From ceb85d30001faaaee311ac6dd145e10899490e6b Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:19:18 +0100 Subject: [PATCH 01/32] Plan bounded client command authorization agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol From 6ea439e35bb1318d9ce3138c38e29034a21958f6 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:26:37 +0100 Subject: [PATCH 02/32] Prepare isolated browser P-256 identity storage and signing capability agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/client-control/identity.ts | 150 ++++++++++++++++++ .../src/client-control/identity.unit.test.ts | 81 ++++++++++ 2 files changed, 231 insertions(+) create mode 100644 apps/fractal-web/src/client-control/identity.ts create mode 100644 apps/fractal-web/src/client-control/identity.unit.test.ts diff --git a/apps/fractal-web/src/client-control/identity.ts b/apps/fractal-web/src/client-control/identity.ts new file mode 100644 index 00000000000..38683400c0f --- /dev/null +++ b/apps/fractal-web/src/client-control/identity.ts @@ -0,0 +1,150 @@ +// This module is an internal capability, never a window/debug/control API. Obtaining a key +// does not enable client control or establish pairing/enrollment/grant authority. +const databaseName = 'fractal-client-control-identity-v1' +const storeName = 'identity' +const identityKey = 'p256' + +export type ClientControlIdentityErrorCode = + | 'unavailable' + | 'storage-blocked' + | 'storage-failed' + | 'invalid-stored-identity' + | 'generation-failed' + | 'export-failed' + | 'signing-failed' + +export class ClientControlIdentityError extends Error { + readonly _tag = 'ClientControlIdentityError' + + constructor(readonly code: ClientControlIdentityErrorCode, message: string, cause?: unknown) { + super(message, { cause }) + this.name = 'ClientControlIdentityError' + } +} + +export interface ClientControlIdentity { + /** DER SubjectPublicKeyInfo bytes; pairing's eventual representation is not decided here. */ + exportPublicKey(): Promise + /** + * Signs caller-supplied canonical bytes, without encoding or constructing a device.result. + * The caller must enforce enrollment, foreground, command and active-grant checks first. + * Returns WebCrypto's native signature bytes; no daemon serialization is assumed. + */ + signCanonicalBytes(bytes: Uint8Array): Promise +} + +/** Low-level key generation used by persistence and real-WebCrypto tests; never global. */ +export const generateClientControlKeyPair = async (subtle: SubtleCrypto = globalThis.crypto.subtle): Promise => { + try { + // WebCrypto keeps the public half exportable while making the private half non-extractable. + return await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']) + } catch (cause) { + throw new ClientControlIdentityError('generation-failed', 'Could not generate the client-control identity', cause) + } +} + +type StoredIdentity = { version: 1; privateKey: CryptoKey; publicKey: CryptoKey } + +const isP256Key = (key: unknown): key is CryptoKey => + key instanceof CryptoKey && key.algorithm.name === 'ECDSA' + && 'namedCurve' in key.algorithm && key.algorithm.namedCurve === 'P-256' + +const isStoredIdentity = (value: unknown): value is StoredIdentity => { + if (typeof value !== 'object' || value === null || !('version' in value) || value.version !== 1 + || !('privateKey' in value) || !('publicKey' in value)) return false + return isP256Key(value.privateKey) && value.privateKey.type === 'private' + && !value.privateKey.extractable && value.privateKey.usages.length === 1 && value.privateKey.usages[0] === 'sign' + && isP256Key(value.publicKey) && value.publicKey.type === 'public' + && value.publicKey.extractable && value.publicKey.usages.length === 1 && value.publicKey.usages[0] === 'verify' +} + +const openDatabase = (factory: IDBFactory): Promise => { + const { promise, resolve, reject } = Promise.withResolvers() + const request = factory.open(databaseName, 1) + let rejected = false + request.onupgradeneeded = () => { request.result.createObjectStore(storeName) } + request.onblocked = () => { + rejected = true + reject(new ClientControlIdentityError('storage-blocked', 'Client-control identity storage is blocked by another tab')) + } + request.onerror = () => reject(new ClientControlIdentityError('storage-failed', 'Could not open client-control identity storage', request.error)) + request.onsuccess = () => { + const database = request.result + if (rejected) { database.close(); return } + database.onversionchange = () => database.close() + resolve(database) + } + return promise +} + +// The read and conditional add share ONE readwrite transaction. IndexedDB serializes these +// transactions across connections/tabs; losing candidates are discarded, never replacing a key. +// Generate outside the transaction: awaiting WebCrypto inside it would let it auto-commit. +const readOrInsert = (database: IDBDatabase, candidate?: CryptoKeyPair): Promise => { + const { promise, resolve, reject } = Promise.withResolvers() + const transaction = database.transaction(storeName, candidate === undefined ? 'readonly' : 'readwrite') + const store = transaction.objectStore(storeName) + const request = store.get(identityKey) + let identity: StoredIdentity | undefined + let failure: ClientControlIdentityError | undefined + transaction.oncomplete = () => resolve(identity) + transaction.onabort = () => reject(failure ?? new ClientControlIdentityError('storage-failed', 'Could not persist the client-control identity', transaction.error)) + request.onsuccess = () => { + const value: unknown = request.result + if (value !== undefined) { + if (!isStoredIdentity(value)) { + failure = new ClientControlIdentityError('invalid-stored-identity', 'Stored client-control identity is invalid; refusing to replace it') + transaction.abort() + return + } + identity = value + } else if (candidate !== undefined) { + identity = { version: 1, privateKey: candidate.privateKey, publicKey: candidate.publicKey } + try { + store.add(identity, identityKey) + } catch (cause) { + failure = new ClientControlIdentityError('storage-failed', 'Could not store the non-extractable client-control key', cause) + transaction.abort() + } + } + } + return promise +} + +/** + * Retrieves or atomically creates this origin's identity. Reloads and concurrent tabs use + * the committed key, not a tab-local cache. Each call closes its database connection. + * Storage failures never fall back to an ephemeral key or silently rotate an existing key. + * Clearing site data loses the key and requires new pairing/enrollment; revoke is NOT key deletion. + * Canonical signed fields/bytes and pairing integration remain the daemon contract's responsibility. + */ +export const getOrCreateClientControlIdentity = async (): Promise => { + if (globalThis.crypto?.subtle === undefined || globalThis.indexedDB === undefined) { + throw new ClientControlIdentityError('unavailable', 'Client-control identity requires secure-context WebCrypto and IndexedDB') + } + const subtle = globalThis.crypto.subtle + let database: IDBDatabase | undefined + try { + database = await openDatabase(globalThis.indexedDB) + const stored = await readOrInsert(database) + ?? await readOrInsert(database, await generateClientControlKeyPair(subtle)) + if (stored === undefined) { + throw new ClientControlIdentityError('storage-failed', 'Client-control identity transaction committed without a key') + } + return Object.freeze({ + exportPublicKey: async (): Promise => { + try { return await subtle.exportKey('spki', stored.publicKey) } + catch (cause) { throw new ClientControlIdentityError('export-failed', 'Could not export the client-control public key', cause) } + }, + signCanonicalBytes: async (bytes: Uint8Array): Promise => { + try { return await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, stored.privateKey, bytes) } + catch (cause) { throw new ClientControlIdentityError('signing-failed', 'Could not sign the supplied canonical bytes', cause) } + }, + }) + } catch (cause) { + if (cause instanceof ClientControlIdentityError) throw cause + throw new ClientControlIdentityError('storage-failed', 'Could not access client-control identity storage', cause) + } finally { + database?.close() + } +} diff --git a/apps/fractal-web/src/client-control/identity.unit.test.ts b/apps/fractal-web/src/client-control/identity.unit.test.ts new file mode 100644 index 00000000000..6221fa25cd2 --- /dev/null +++ b/apps/fractal-web/src/client-control/identity.unit.test.ts @@ -0,0 +1,81 @@ +// @vitest-environment node +import { IDBFactory } from 'fake-indexeddb' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ClientControlIdentityError, + generateClientControlKeyPair, + getOrCreateClientControlIdentity, +} from './identity.ts' + +// Crypto is the runtime's REAL WebCrypto, never a mock. Only IndexedDB uses the +// in-memory platform implementation; these are not browser persistence receipts. +const subtle = globalThis.crypto.subtle +const bytes = new TextEncoder().encode('caller-supplied canonical bytes; not a device.result contract') +const algorithm = { name: 'ECDSA', hash: 'SHA-256' } + +beforeEach(() => { vi.stubGlobal('indexedDB', new IDBFactory()) }) +afterEach(() => { vi.unstubAllGlobals() }) + +describe('client-control P-256 identity', () => { + it('generates a non-extractable signing key and an exportable verification key', async () => { + const keys = await generateClientControlKeyPair() + expect(keys.privateKey.type).toBe('private') + expect(keys.privateKey.extractable).toBe(false) + expect(keys.privateKey.algorithm).toMatchObject({ name: 'ECDSA', namedCurve: 'P-256' }) + expect(keys.privateKey.usages).toEqual(['sign']) + await expect(subtle.exportKey('pkcs8', keys.privateKey)).rejects.toThrow() + await expect(subtle.exportKey('jwk', keys.privateKey)).rejects.toThrow() + expect(keys.publicKey.extractable).toBe(true) + const exported = await subtle.exportKey('spki', keys.publicKey) + const imported = await subtle.importKey('spki', exported, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) + const signature = await subtle.sign(algorithm, keys.privateKey, bytes) + expect(await subtle.verify(algorithm, imported, signature, bytes)).toBe(true) + }) + + it('exports its public key and signs exactly the supplied bytes', async () => { + const identity = await getOrCreateClientControlIdentity() + const key = await subtle.importKey('spki', await identity.exportPublicKey(), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) + const signature = await identity.signCanonicalBytes(bytes) + expect(await subtle.verify(algorithm, key, signature, bytes)).toBe(true) + expect(await subtle.verify(algorithm, key, signature, new TextEncoder().encode('different bytes'))).toBe(false) + const other = await generateClientControlKeyPair() + expect(await subtle.verify(algorithm, other.publicKey, signature, bytes)).toBe(false) + // No signature length/serialization assertion: daemon canonical encoding is unresolved. + }) + + it('retains the committed identity across independent acquisitions without a key cache', async () => { + const initial = await getOrCreateClientControlIdentity() + const publicKey = await initial.exportPublicKey() + const reloaded = await getOrCreateClientControlIdentity() + expect(new Uint8Array(await reloaded.exportPublicKey())).toEqual(new Uint8Array(publicKey)) + const verifier = await subtle.importKey('spki', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) + expect(await subtle.verify(algorithm, verifier, await reloaded.signCanonicalBytes(bytes), bytes)).toBe(true) + }) + + it('returns one committed key to concurrent first acquisitions', async () => { + const identities = await Promise.all(Array.from({ length: 8 }, () => getOrCreateClientControlIdentity())) + const publicKeys = await Promise.all(identities.map(identity => identity.exportPublicKey())) + for (const publicKey of publicKeys) { + expect(new Uint8Array(publicKey)).toEqual(new Uint8Array(publicKeys[0]!)) + } + const verifier = await subtle.importKey('spki', publicKeys[0]!, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) + for (const identity of identities) { + expect(await subtle.verify(algorithm, verifier, await identity.signCanonicalBytes(bytes), bytes)).toBe(true) + } + const later = await getOrCreateClientControlIdentity() + expect(new Uint8Array(await later.exportPublicKey())).toEqual(new Uint8Array(publicKeys[0]!)) + }) + + it('fails closed without IndexedDB instead of creating an ephemeral identity', async () => { + vi.stubGlobal('indexedDB', undefined) + await expect(getOrCreateClientControlIdentity()).rejects.toMatchObject({ + _tag: 'ClientControlIdentityError', code: 'unavailable', + }) + }) + + it('fails closed without secure-context WebCrypto', async () => { + vi.stubGlobal('crypto', undefined) + await expect(getOrCreateClientControlIdentity()).rejects.toBeInstanceOf(ClientControlIdentityError) + await expect(getOrCreateClientControlIdentity()).rejects.toMatchObject({ code: 'unavailable' }) + }) +}) From b49c18cd12b09b00638cbf474cf4d6cd1540563e Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:26:44 +0100 Subject: [PATCH 03/32] Prepare foreground, read-advancement and enrollment-revoke UI boundaries agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- ...lientControlIndicator.integration.test.tsx | 43 +++++++++++++++++ .../client-control/ClientControlIndicator.tsx | 47 +++++++++++++++++++ .../src/client-control/foreground.ts | 6 +++ .../client-control/foreground.unit.test.ts | 21 +++++++++ .../src/client-control/navigation.ts | 21 +++++++++ .../src/client-control/readAdvancement.ts | 28 +++++++++++ .../readAdvancement.unit.test.ts | 40 ++++++++++++++++ 7 files changed, 206 insertions(+) create mode 100644 apps/fractal-web/src/client-control/ClientControlIndicator.integration.test.tsx create mode 100644 apps/fractal-web/src/client-control/ClientControlIndicator.tsx create mode 100644 apps/fractal-web/src/client-control/foreground.ts create mode 100644 apps/fractal-web/src/client-control/foreground.unit.test.ts create mode 100644 apps/fractal-web/src/client-control/navigation.ts create mode 100644 apps/fractal-web/src/client-control/readAdvancement.ts create mode 100644 apps/fractal-web/src/client-control/readAdvancement.unit.test.ts diff --git a/apps/fractal-web/src/client-control/ClientControlIndicator.integration.test.tsx b/apps/fractal-web/src/client-control/ClientControlIndicator.integration.test.tsx new file mode 100644 index 00000000000..4f78c60654d --- /dev/null +++ b/apps/fractal-web/src/client-control/ClientControlIndicator.integration.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import * as React from 'react' +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, expect, it, vi } from 'vitest' +import { ClientControlIndicator, type ClientControlIndicatorState } from './ClientControlIndicator.tsx' + +vi.mock('@stylexjs/stylex', () => import('../testStylex.ts')) + +const host = document.createElement('div') +const root = createRoot(host) +afterEach(async () => { await act(async () => root.render(null)); vi.unstubAllGlobals() }) + +const render = async (state: ClientControlIndicatorState, onRevokeEnrollment = () => {}) => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + await act(async () => root.render()) +} + +it('renders nothing when no grant is active', async () => { + await render({ _tag: 'Inactive' }) + expect(host.childElementCount).toBe(0) +}) + +it('names the run and invokes enrollment revoke rather than hiding the grant optimistically', async () => { + let revokes = 0 + await render({ _tag: 'Active', runId: 'mission-run/fixture' }, () => { revokes += 1 }) + expect(host.querySelector('[data-client-control-run]')?.getAttribute('data-client-control-run')).toBe('mission-run/fixture') + expect(host.querySelector('code')?.textContent).toBe('mission-run/fixture') + const button = host.querySelector('button')! + expect(button.textContent).toBe('Revoke test enrollment') + await act(async () => button.click()) + expect(revokes).toBe(1) + expect(host.querySelector('[aria-label="Test client control"]')).not.toBeNull() +}) + +it('keeps the run visible during revocation and explains an unconfirmed revoke', async () => { + await render({ _tag: 'Revoking', runId: 'mission-run/fixture' }) + expect(host.querySelector('button')?.disabled).toBe(true) + expect(host.querySelector('code')?.textContent).toBe('mission-run/fixture') + await render({ _tag: 'RevokeFailed', runId: 'mission-run/fixture' }) + expect(host.querySelector('[role="alert"]')?.textContent).toContain('not confirmed') + expect(host.querySelector('button')?.disabled).toBe(false) +}) diff --git a/apps/fractal-web/src/client-control/ClientControlIndicator.tsx b/apps/fractal-web/src/client-control/ClientControlIndicator.tsx new file mode 100644 index 00000000000..30a4549075e --- /dev/null +++ b/apps/fractal-web/src/client-control/ClientControlIndicator.tsx @@ -0,0 +1,47 @@ +import * as Aria from 'react-aria-components' +import * as stylex from '@stylexjs/stylex' +import { borderVars, interactionVars, statusVars, surfaceVars, textVars, typeVars } from '../../../../packages/fractal-ui/src/assistant-ui/composition-tokens.stylex.ts' + +/** Controller-owned state; this component cannot create or extend authority. */ +export type ClientControlIndicatorState = + | { readonly _tag: 'Inactive' } + | { readonly _tag: 'Active'; readonly runId: string } + | { readonly _tag: 'Revoking'; readonly runId: string } + | { readonly _tag: 'RevokeFailed'; readonly runId: string } + +export const ClientControlIndicator = ({ state, onRevokeEnrollment }: { + readonly state: ClientControlIndicatorState + /** End the enrollment and all grants, never just the displayed run's grant. */ + readonly onRevokeEnrollment: () => void +}) => { + if (state._tag === 'Inactive') return null + return
+ + Test control {state.runId} + + + {state._tag === 'Revoking' ? 'Revoking enrollment…' : 'Revoke test enrollment'} + + {state._tag === 'RevokeFailed' && + Enrollment revoke was not confirmed. Check the connection and try again. + } +
+} + +const styles = stylex.create({ + indicator: { + display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 8, + paddingBlock: 8, paddingInline: 12, backgroundColor: surfaceVars.raised, + color: textVars.fg, borderBottomWidth: 1, borderBottomStyle: 'solid', borderBottomColor: borderVars.borderStrong, + fontFamily: typeVars.fontSans, fontSize: typeVars.metaSize, lineHeight: typeVars.metaLeading, + }, + description: { display: 'flex', alignItems: 'baseline', flexWrap: 'wrap', gap: 8, minWidth: 0 }, + run: { fontFamily: typeVars.fontMono, overflowWrap: 'anywhere', color: statusVars.attention }, + revoke: { + flexShrink: 0, paddingBlock: 4, paddingInline: 8, cursor: 'pointer', color: textVars.fg, + backgroundColor: surfaceVars.raised, borderWidth: 1, borderStyle: 'solid', borderColor: borderVars.borderStrong, + ':focus-visible': { outlineWidth: 2, outlineStyle: 'solid', outlineColor: interactionVars.focusRing, outlineOffset: 2 }, + ':disabled': { cursor: 'default', opacity: 0.6 }, + }, + error: { flexBasis: '100%', color: statusVars.dangerFg }, +}) diff --git a/apps/fractal-web/src/client-control/foreground.ts b/apps/fractal-web/src/client-control/foreground.ts new file mode 100644 index 00000000000..464b25d59ec --- /dev/null +++ b/apps/fractal-web/src/client-control/foreground.ts @@ -0,0 +1,6 @@ +/** Browser focus and visibility are independent: a visible background tab must not execute. */ +export const isClientControlForeground = (document: Pick): boolean => + document.visibilityState === 'visible' && document.hasFocus() + +/** Re-read immediately before navigation and again before signing; never cache this verdict. */ +export const browserClientControlForeground = (): boolean => isClientControlForeground(document) diff --git a/apps/fractal-web/src/client-control/foreground.unit.test.ts b/apps/fractal-web/src/client-control/foreground.unit.test.ts new file mode 100644 index 00000000000..f5e2a39d8f3 --- /dev/null +++ b/apps/fractal-web/src/client-control/foreground.unit.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { isClientControlForeground } from './foreground.ts' + +describe('client-control foreground', () => { + it.each([ + ['visible', true, true], + ['visible', false, false], + ['hidden', true, false], + ['hidden', false, false], + ] as const)('visibility=%s focus=%s permits=%s', (visibilityState, focus, expected) => { + expect(isClientControlForeground({ visibilityState, hasFocus: () => focus })).toBe(expected) + }) + + it('does not retain a previous focus verdict', () => { + let focused = true + const document = { visibilityState: 'visible' as const, hasFocus: () => focused } + expect(isClientControlForeground(document)).toBe(true) + focused = false + expect(isClientControlForeground(document)).toBe(false) + }) +}) diff --git a/apps/fractal-web/src/client-control/navigation.ts b/apps/fractal-web/src/client-control/navigation.ts new file mode 100644 index 00000000000..286c6921fb8 --- /dev/null +++ b/apps/fractal-web/src/client-control/navigation.ts @@ -0,0 +1,21 @@ +/** App destinations, not device.command wire types. The transport must decode its generated screen first. */ +export type ClientControlRouteTarget = + | { readonly _tag: 'Inbox' } + | { readonly _tag: 'Conversation'; readonly agent: `agent/${string}` } + +export interface ClientControlNavigation { + /** Resolve through the app's subject model; no test-only renderer or fixtureSource. */ + readonly href: (target: ClientControlRouteTarget) => string + readonly navigate: (href: string) => void +} + +/** + * The integration host supplies subjectPath/subjectHref from workspace/subject.ts. Keeping its + * routing codec authoritative prevents the control transport growing a second URL convention. + */ +export const navigateClientControlRoute = ( + target: ClientControlRouteTarget, + navigation: ClientControlNavigation, +): void => { + navigation.navigate(navigation.href(target)) +} diff --git a/apps/fractal-web/src/client-control/readAdvancement.ts b/apps/fractal-web/src/client-control/readAdvancement.ts new file mode 100644 index 00000000000..56507775c28 --- /dev/null +++ b/apps/fractal-web/src/client-control/readAdvancement.ts @@ -0,0 +1,28 @@ +/** + * Shared boundary for browser-local positions and daemon synchronization. The caller is the + * pairing-authority adapter, never a URL parameter, browser preference or environment flag. + * An enrollment ending does not turn the isolated test browser into an ordinary reader. + */ +export interface ReadAdvancementPolicy { + readonly mayAdvance: () => boolean + readonly prohibitForTestEnrollment: () => void +} + +export const createReadAdvancementPolicy = (): ReadAdvancementPolicy => { + let prohibited = false + return { + mayAdvance: () => !prohibited, + prohibitForTestEnrollment: () => { prohibited = true }, + } +} + +/** + * Wrap the store's advance implementation, not its UI callers. Keep persistence and any outbound + * synchronization inside `advance`: suppressing only onSeen would leave explicit mark-read writable. + * The policy is checked at execution time, so already-created callbacks cannot bypass enrollment. + */ +export const gateReadAdvancement = ( + policy: Pick, + advance: (...args: TArgs) => TResult, +): ((...args: TArgs) => TResult | undefined) => (...args) => + policy.mayAdvance() ? advance(...args) : undefined diff --git a/apps/fractal-web/src/client-control/readAdvancement.unit.test.ts b/apps/fractal-web/src/client-control/readAdvancement.unit.test.ts new file mode 100644 index 00000000000..20e1151e731 --- /dev/null +++ b/apps/fractal-web/src/client-control/readAdvancement.unit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { createReadAdvancementPolicy, gateReadAdvancement } from './readAdvancement.ts' + +describe('read advancement boundary', () => { + it('leaves an ordinary store and its return value unchanged', () => { + const positions = new Map() + const policy = createReadAdvancementPolicy() + const advance = gateReadAdvancement(policy, (id: string, position: number) => { + positions.set(id, position) + return position + }) + expect(advance('message/seed', 100)).toBe(100) + expect(positions.get('message/seed')).toBe(100) + }) + + it('suppresses persistence AND outbound sync at the store boundary', () => { + const persisted: number[] = [] + const synchronized: number[] = [] + const policy = createReadAdvancementPolicy() + const advance = gateReadAdvancement(policy, (position: number) => { + persisted.push(position) + synchronized.push(position) + }) + // Both automatic seen and explicit mark-read retain the same store callback. + const onSeen = () => advance(100) + const markRead = () => advance(200) + policy.prohibitForTestEnrollment() + onSeen() + markRead() + expect(persisted).toEqual([]) + expect(synchronized).toEqual([]) + }) + + it('never re-enables advancement when test enrollment ends', () => { + const policy = createReadAdvancementPolicy() + policy.prohibitForTestEnrollment() + policy.prohibitForTestEnrollment() + expect(policy.mayAdvance()).toBe(false) + }) +}) From 8cc3e4fb1ef2ba309acffb26bc456eac5b84f316 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:26:50 +0100 Subject: [PATCH 04/32] Add strict fixture boundary and independent DOM evidence observer agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/client-control/fixtureDomEvidence.ts | 106 ++++++++++++++++++ .../fixtureDomEvidence.unit.test.ts | 31 +++++ .../src/client-control/fixtureGuard.ts | 49 ++++++++ .../client-control/fixtureGuard.unit.test.ts | 57 ++++++++++ 4 files changed, 243 insertions(+) create mode 100644 apps/fractal-web/src/client-control/fixtureDomEvidence.ts create mode 100644 apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts create mode 100644 apps/fractal-web/src/client-control/fixtureGuard.ts create mode 100644 apps/fractal-web/src/client-control/fixtureGuard.unit.test.ts diff --git a/apps/fractal-web/src/client-control/fixtureDomEvidence.ts b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts new file mode 100644 index 00000000000..9e758e09be0 --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts @@ -0,0 +1,106 @@ +import { guardFixtureBoundary, type FixtureGuardInput } from './fixtureGuard.ts' + +/** Explicit adapter projection of VERIFIED setup output, not a daemon wire schema. */ +export interface FixtureSeedExpectation { + readonly itemId: string + readonly title: string + readonly bodySha256: string + readonly subjectIds: readonly string[] +} + +/** The minimal real Playwright Page/Locator surface; no browser dependency in the app bundle. */ +export interface ObservationPage { + readonly url: () => string + readonly locator: (selector: string) => { + readonly evaluateAll: (read: (elements: Element[], argument: TArg) => T, argument: TArg) => Promise + } +} + +export interface FixtureDomEvidence { + readonly format: 'client-control.dom-observation.v1' + readonly commit: string + readonly origin: string + readonly observedAt: string + readonly itemId: string + readonly title: string + readonly renderedBodySha256: string + readonly renderedItemIds: readonly string[] + readonly titleMatches: boolean + readonly bodyMatches: boolean + readonly catalogMatches: boolean + readonly passed: boolean + /** Independent evidence only. Not a device.result, signed receipt, or complete journey. */ + readonly status: 'unsigned-independent-observation' +} + +/** Pure comparison shared by the observer and unit tests. No client-reported state is accepted. */ +export const compareFixtureDom = (seed: FixtureSeedExpectation, observed: { + readonly title: string + readonly bodySha256: string + readonly itemIds: readonly string[] +}): { readonly titleMatches: boolean; readonly bodyMatches: boolean; readonly catalogMatches: boolean; readonly passed: boolean } => { + const allowed = new Set(seed.subjectIds) + const titleMatches = observed.title === seed.title + const bodyMatches = observed.bodySha256 === seed.bodySha256 + const catalogMatches = observed.itemIds.includes(seed.itemId) && observed.itemIds.every(id => allowed.has(id)) + return { titleMatches, bodyMatches, catalogMatches, passed: titleMatches && bodyMatches && catalogMatches } +} + +/** + * Call AFTER granted navigation on the real app in a fresh, dedicated Playwright context. + * The runner owns context creation, verified fixture attestation, network capture and granted + * commands. This function never navigates, pairs, reads credentials, mutates state, or signs. + * Selectors identify the rendered title/body INSIDE the seed item, not hidden report metadata. + * Digest is SHA-256 of exact UTF-8 DOM innerText, without trimming/normalizing. A manifest hashing + * source Markdown instead needs an authoritative rendered-text expectation before this can pass. + * Writes both passing and failing evidence; a false `passed` MUST fail the enclosing journey. + */ +export const observeFixtureDom = async (input: { + readonly boundary: FixtureGuardInput + /** Full commit of a clean exact candidate, independently established by the runner. */ + readonly commit: string + readonly page: ObservationPage + readonly seed: FixtureSeedExpectation + readonly titleSelector: string + readonly bodySelector: string + readonly writeEvidence: (evidence: FixtureDomEvidence) => Promise +}): Promise => { + const boundary = guardFixtureBoundary(input.boundary) + if (!/^[0-9a-f]{40}$/.test(input.commit)) throw new TypeError('A full exact-candidate commit is required') + if (!/^[0-9a-f]{64}$/.test(input.seed.bodySha256) || input.seed.title === '' || input.seed.itemId === '' || + !input.seed.subjectIds.includes(input.seed.itemId)) throw new TypeError('A verified seed and complete subject catalog are required') + if (new URL(input.page.url()).origin !== boundary.origin || new URL(input.page.url()).pathname.includes('embed')) + throw new TypeError('Observation must remain on the dedicated real-app origin, never embed') + // One DOM read avoids mixing title, body and catalog from different renders. + const snapshots = await input.page.locator('html').evaluateAll((elements, selection) => elements.map(element => { + const visible = (node: Element): node is HTMLElement => node instanceof HTMLElement && node.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) + const items = Array.from(element.querySelectorAll('[data-item-id]')).filter(visible) + const seeds = items.filter(node => node.getAttribute('data-item-id') === selection.itemId) + // Structural wrappers can repeat the id; choose the innermost content owner. + const owners = seeds.filter(node => !seeds.some(other => other !== node && node.contains(other))) + const owner = owners.length === 1 ? owners[0] : undefined + return { + titles: owner === undefined ? [] : Array.from(owner.querySelectorAll(selection.titleSelector)).filter(visible).map(node => node.innerText), + bodies: owner === undefined ? [] : Array.from(owner.querySelectorAll(selection.bodySelector)).filter(visible).map(node => node.innerText), + itemIds: items.flatMap(node => { + const id = node.getAttribute('data-item-id') + return id === null ? [] : [id] + }), + } + }), { itemId: input.seed.itemId, titleSelector: input.titleSelector, bodySelector: input.bodySelector }) + if (snapshots.length !== 1 || snapshots[0]!.titles.length !== 1 || snapshots[0]!.bodies.length !== 1) + throw new TypeError('Exactly one visible seed title and body in a single rendered app document are required') + const title = snapshots[0]!.titles[0]! + const body = snapshots[0]!.bodies[0]! + if (new URL(input.page.url()).origin !== boundary.origin) throw new TypeError('Origin changed during observation') + const renderedBodySha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))), byte => byte.toString(16).padStart(2, '0')).join('') + const renderedItemIds = [...new Set(snapshots[0]!.itemIds)] + const evidence: FixtureDomEvidence = { + format: 'client-control.dom-observation.v1', commit: input.commit, origin: boundary.origin, + observedAt: new Date().toISOString(), itemId: input.seed.itemId, title, renderedBodySha256, renderedItemIds, + ...compareFixtureDom(input.seed, { title, bodySha256: renderedBodySha256, itemIds: renderedItemIds }), + status: 'unsigned-independent-observation', + } + await input.writeEvidence(evidence) + return evidence +} diff --git a/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts b/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts new file mode 100644 index 00000000000..30d31e60a3e --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from '@effect/vitest' +import { expect } from 'vitest' +import { compareFixtureDom, type FixtureSeedExpectation } from './fixtureDomEvidence.ts' + +const seed: FixtureSeedExpectation = { + itemId: 'message/fixture-seed', title: 'client-control fixture 0001', + bodySha256: 'a'.repeat(64), subjectIds: ['message/fixture-seed', 'message/fixture-other'], +} +const observation = { title: seed.title, bodySha256: seed.bodySha256, itemIds: [seed.itemId] } + +describe('independent rendered fixture comparison', () => { + it('requires exact title, exact body digest and manifest membership', () => { + expect(compareFixtureDom(seed, observation)).toEqual({ titleMatches: true, bodyMatches: true, catalogMatches: true, passed: true }) + }) + + it('does not normalize title or accept a client-reported digest in place of a differing DOM digest', () => { + expect(compareFixtureDom(seed, { ...observation, title: `${seed.title} ` }).passed).toBe(false) + expect(compareFixtureDom(seed, { ...observation, bodySha256: 'b'.repeat(64) }).passed).toBe(false) + }) + + it('refuses a matching seed alongside any item outside the actual catalog', () => { + expect(compareFixtureDom(seed, { ...observation, itemIds: [seed.itemId, 'message/outside-catalog'] })).toEqual({ + titleMatches: true, bodyMatches: true, catalogMatches: false, passed: false, + }) + }) + + it('requires the seed to be rendered even when all other visible ids are allowed', () => { + expect(compareFixtureDom(seed, { ...observation, itemIds: [] }).passed).toBe(false) + expect(compareFixtureDom(seed, { ...observation, itemIds: ['message/fixture-other'] }).passed).toBe(false) + }) +}) diff --git a/apps/fractal-web/src/client-control/fixtureGuard.ts b/apps/fractal-web/src/client-control/fixtureGuard.ts new file mode 100644 index 00000000000..6a862d0e596 --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureGuard.ts @@ -0,0 +1,49 @@ +/** Local runner configuration, NOT daemon attestation or enrollment authority. */ +export interface FixtureGuardInput { + readonly origin: string + /** Origin reserved by the approved bootstrap for this isolated browser identity. */ + readonly approvedOrigin: string + readonly normalOrigins: readonly string[] + readonly gateway: string | undefined + /** All paths must be realpath-resolved by the runner before opening any connection. */ + readonly resolvedGatewayPath: string + readonly approvedClientSocketPath: string + readonly approvedInstanceRoot: string + readonly normalGatewayPaths: readonly string[] +} + +export interface FixtureBoundary { + readonly origin: string + readonly socketPath: string + readonly instance: 'client-control-fixture' +} + +const absoluteCanonicalPath = (path: string): boolean => path.startsWith('/') && + !path.includes('\\') && !/[\s\0?#%]/.test(path) && + path.split('/').slice(1).every(part => part !== '' && part !== '.' && part !== '..') + +/** + * Refuses ambiguous origins, privileged sockets and non-fixture paths before browser/server startup. + * The approved paths must come from independently verified setup output, never browser storage or + * URL parameters. Realpath resolution is a caller obligation: lexical paths cannot detect symlinks. + * This cannot prove daemon local-only mode, fixture catalog, or pairing enrollment. + */ +export const guardFixtureBoundary = (input: FixtureGuardInput): FixtureBoundary => { + const origin = new URL(input.origin) + if (origin.origin !== input.origin || origin.protocol !== 'http:' || + origin.hostname !== '127.0.0.1' || origin.port === '' || + input.origin !== input.approvedOrigin || input.normalOrigins.includes(input.origin)) + throw new TypeError('A dedicated exact IPv4 loopback HTTP origin with an explicit port is required') + + const root = input.approvedInstanceRoot + if (!absoluteCanonicalPath(root) || root.split('/').at(-1) !== 'client-control-fixture') + throw new TypeError('An independently approved fixture instance root is required') + const socket = input.gateway?.replace(/^unix:/, '') + if (socket === undefined || !absoluteCanonicalPath(socket) || + socket !== input.resolvedGatewayPath || socket !== input.approvedClientSocketPath || + !socket.startsWith(`${root}/`) || input.normalGatewayPaths.includes(socket) || + !['st3-client.sock', 'fractal-client.sock'].includes(socket.split('/').at(-1) ?? '')) + throw new TypeError('WF_ST_GATEWAY must be the resolved approved fixture instance client socket') + + return { origin: input.origin, socketPath: socket, instance: 'client-control-fixture' } +} diff --git a/apps/fractal-web/src/client-control/fixtureGuard.unit.test.ts b/apps/fractal-web/src/client-control/fixtureGuard.unit.test.ts new file mode 100644 index 00000000000..771d7a757e2 --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureGuard.unit.test.ts @@ -0,0 +1,57 @@ +import { describe, it } from '@effect/vitest' +import { expect } from 'vitest' +import { guardFixtureBoundary, type FixtureGuardInput } from './fixtureGuard.ts' + +const approved: FixtureGuardInput = { + origin: 'http://127.0.0.1:43127', approvedOrigin: 'http://127.0.0.1:43127', + normalOrigins: ['http://127.0.0.1:5173'], + gateway: 'unix:/isolated/client-control-fixture/run/fractal-client.sock', + resolvedGatewayPath: '/isolated/client-control-fixture/run/fractal-client.sock', + approvedClientSocketPath: '/isolated/client-control-fixture/run/fractal-client.sock', + approvedInstanceRoot: '/isolated/client-control-fixture', + normalGatewayPaths: ['/run/fractal-client.sock'], +} + +describe('fixture boundary (configuration only)', () => { + it('accepts only the reserved exact origin and resolved approved client socket', () => { + expect(guardFixtureBoundary(approved)).toEqual({ + origin: approved.origin, socketPath: approved.resolvedGatewayPath, instance: 'client-control-fixture', + }) + expect(guardFixtureBoundary({ ...approved, gateway: approved.resolvedGatewayPath }).socketPath).toBe(approved.resolvedGatewayPath) + }) + + it.each([ + 'http://localhost:43127', 'http://[::1]:43127', 'http://0.0.0.0:43127', + 'https://127.0.0.1:43127', 'http://127.0.0.1', 'http://127.0.0.1:43127/', + 'http://127.0.0.1:43127?fixture=1', 'http://127.0.0.1:43127#fixture', + 'http://user@127.0.0.1:43127', 'http://127.0.0.2:43127', + ])('refuses nonexact or nonapproved origin %s', origin => { + expect(() => guardFixtureBoundary({ ...approved, origin })).toThrow() + }) + + it('refuses a reserved origin also used by a normal session', () => { + expect(() => guardFixtureBoundary({ ...approved, normalOrigins: [approved.origin] })).toThrow() + }) + + it.each([ + undefined, '', '/run/fractal-client.sock', 'unix:/isolated/client-control-fixture/run/api.sock', + 'unix:/isolated/client-control-fixture/run/../fractal-client.sock', + 'unix:/isolated/client-control-fixture//run/fractal-client.sock', + 'unix:/isolated/client-control-fixture/run/fractal-client.sock?fixture=1', + 'unix:/isolated/client-control-fixture-other/run/fractal-client.sock', + 'https://127.0.0.1:43127', + ])('refuses absent, privileged, ambiguous or non-fixture gateway %s', gateway => { + expect(() => guardFixtureBoundary({ ...approved, gateway })).toThrow() + }) + + it('rejects symlink resolution disagreement and normal gateway aliases', () => { + expect(() => guardFixtureBoundary({ ...approved, resolvedGatewayPath: '/run/fractal-client.sock' })).toThrow() + expect(() => guardFixtureBoundary({ ...approved, normalGatewayPaths: [approved.resolvedGatewayPath] })).toThrow() + }) + + it('rejects an unrelated root even when all socket fields agree', () => { + const socket = '/isolated/ordinary/run/fractal-client.sock' + expect(() => guardFixtureBoundary({ ...approved, approvedInstanceRoot: '/isolated/ordinary', + gateway: socket, resolvedGatewayPath: socket, approvedClientSocketPath: socket })).toThrow() + }) +}) From a0631c8773daa655ea04a2c2e75e219b54087a51 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:26:57 +0100 Subject: [PATCH 05/32] Document client-control source dependencies and deferred authority integration agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- RESUME-CCWeb.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 RESUME-CCWeb.md diff --git a/RESUME-CCWeb.md b/RESUME-CCWeb.md new file mode 100644 index 00000000000..20d259cf34b --- /dev/null +++ b/RESUME-CCWeb.md @@ -0,0 +1,62 @@ +# Client-control web source preparation + +## Scope and status + +Wire-independent source preparation only, based on origin/main d7aa0be1db. The parent explicitly deferred application activation and end-to-end integration until the authoritative contracts and route/store dependencies below exist. **Test mode remains disabled in the application.** No existing application entrypoint, pairing flow, normal session or read store is modified. No pairing, enrollment, grant, fixture instance or live daemon was created or touched. This is not a claimed mission step. + +## Daemon handle and required dependencies + +Canonical daemon source: [compoundingtech/fractal#6274](https://github.com/compoundingtech/fractal/pull/6274), branch `schickling-assistant/2026-11-10-client-control-daemon`, planning head `ceb85d30001faaaee311ac6dd145e10899490e6b`. The PR body was read. Its ordered plan is: + +1. #6274: D-1/D-2 vocabulary, D-4 grant types, D-6 canonical purpose-specific results and shared fixed-key positive/negative golden-vector JSON; A-1 generated contracts and A-2 canonical signature tests. Canonical form/vector source was not yet published when this preparation was handed off. Browser conformance tests must READ the eventual repository vector file, never duplicate its fields or bytes. +2. Enrollment readback/revoke PR (handle pending): D-3 enrollment of an existing client-generated pairing/key and authoritative revocation. +3. Grant subscription PR (handle pending): D-4 mission/claimed-owner binding, active grant lifecycle delivered on the session-scoped commands subscription, admission restrictions. +4. Verification/receipt PR (handle pending): D-6 result verification and D-7 exactly one durable terminal receipt. +5. Workspace-model/routes group: `cac380ac98`, `df082cd9e8`, `349b0da2aa`, `5a107113bd`. Read-only inspection of the integration source found `workspace/subject.ts`: Inbox -> `/w/inbox`; Conversation -> `/w/`; Alert -> `/w/inbox/`. `workspace/useWorkspace.ts` projects subject routing; `workspace/surfaces.tsx` renders it. No commits were cherry-picked. +6. State-cells read positions: `9e66194639`, `0d21c1286b`, `45710bb1d5`. The integration source writes browser-local positions through `state/cells/readPositions.ts` (`advance`), called from both `onSeen` and `useMarkRead` in `web/readPosition.ts`. Suppression must wrap that store boundary, not UI call sites. +7. #6108 read-position synchronization: branch `schickling-assistant/2026-11-08-read-position-sync`, inspected at `01fd722d1cdfc4beb916817ab9dc68f0307b437b`. Its web delta only adds lastProseAt projection/types; no web readPosition.ts or daemon synchronization caller exists there. The eventual daemon sync writer must remain inside the same gated advancement boundary as local persistence. + +Earlier searches found only the old `agent/client-controls` branch (behind main, no new delta); #6274 supersedes that initial NOT FOUND result. + +## Implemented source and integration contracts + +All modules are under `apps/fractal-web/src/client-control/`: + +- `identity.ts`: real WebCrypto P-256 private key, non-extractable, persisted via IndexedDB structured clone. Atomic readwrite read/conditional-add resolves concurrent acquisition without replacing a committed key. No ephemeral fallback. `getOrCreateClientControlIdentity()` exposes public SPKI export and ECDSA/SHA-256 signing of supplied canonical bytes; neither public-key wire encoding nor result serialization is invented. Key access is internal, never attached to window. Clearing site data loses identity and requires fresh pairing/enrollment; deleting a key is not revocation. +- `foreground.ts`: visible AND document focus; callers must re-evaluate before navigation and signing. +- `navigation.ts`: typed app destinations plus a host-supplied authoritative subject-to-href resolver. No alternate renderer or second routing codec. +- `readAdvancement.ts`: a sticky page-lifetime prohibition and execution-time `gateReadAdvancement`. The eventual authority adapter prohibits writes before test enrollment permits rendering; store advance wraps local persistence and outbound synchronization together. Ending enrollment never re-enables writes in that isolated page. This boundary is not yet wired because the store does not exist on this branch's base. +- `ClientControlIndicator.tsx`: run id, explicit enrollment-revoke action, pending and unconfirmed-revoke states. Controller owns authority and async state; the component does not optimistically hide the grant. Not mounted before authoritative enrollment/grant delivery exists. +- `fixtureGuard.ts`: strict exact dedicated IPv4 loopback origin plus independently approved, realpath-resolved fixture instance client socket and normal-origin/socket deny lists. Configuration guard only, not daemon attestation or enrollment authority. +- `fixtureDomEvidence.ts`: independent real Playwright-compatible DOM observer, never client-reported state. Compares seed title and exact UTF-8 rendered innerText SHA-256 and checks visible data-item-id values against verified actual subject catalog. Awaits evidence persistence for both comparison outcomes; records full exact-candidate commit, origin, time, comparisons and unsigned observation status. Caller must fail the journey on `passed: false`. +- Colocated tests cover real key signing/verification and storage acquisition, foreground matrix, local+sync suppression, revoke UI states, fixture refusals and independent DOM comparison. No assumed signing bytes are pinned or presented as conformance. + +## ASSUMPTIONS / unresolved contracts + +No guessed wire fields are implemented. All following mappings remain unresolved until their owners publish canonical source: + +- Pairing-bound authoritative test-enrollment readback and key/public-key binding; browser requests or flags cannot enable mode on a normal pairing. +- Grant start/end/revoke event envelope and command device/session/grant/run/id/kind/window fields. +- Generated visible-state schema and the exact purpose-first claim-fields signing lines, ordering/escaping, JCS vectors and signature serialization. +- device.result submission envelope and enrollment-revoke operation. +- Fixture local-only identity attestation, actual manifest/readback interfaces and authorized bootstrap flow. +- Mapping an optional Conversation sessionId through the integration router; no guessed query parameter is added. +- Rendered body text expectation when fixture source is Markdown: observer hashes exact DOM innerText, never invents normalization of source Markdown. + +Local types in this directory are internal module/observer inputs, NOT daemon wire schemas. There is no enabled test-client adapter, command subscription, generated visible-state capture, JCS/result conformance test or complete `.pw.test.ts` journey yet; those were explicitly deferred by the parent rather than fabricated. + +## Proof policy and next executable step + +NOT RUN: builds, typechecks, unit/integration tests, linters, formatters, Storybook, Playwright, Rust, runtime servers, live setup and browser captures. Source regression evidence belongs to hosted CI only; hosted CI is NEVER journey acceptance. The parent opens the draft PR and arranges hosted checks. + +Exact next executable source-regression step on the approved hosted runner, from `apps/fractal-web`: + +```sh +pnpm test -- src/client-control +``` + +Then hosted typecheck/build/format gates apply normally. Do not run those on the source-editing machine. + +Journey acceptance remains a LOCAL exact-candidate Playwright/DOM run against the approved real fixture after authorized setup/bootstrap/enrollment, not a fake fixtureSource/embed path. The future runner must supply: exact clean commit; approved unused dedicated `http://127.0.0.1:` origin; `WF_ST_GATEWAY` equal to the independently approved fixture instance CLIENT socket; realpath-resolved instance root/socket; normal-origin/socket deny lists; approved fixture-only bearer/bootstrap; verified actual fixture manifest; selectors for real visible seed title/body; durable evidence writer for later authorized signing. It must guard BEFORE server/browser connections, verify daemon fixture/local-only identity independently, create a fresh dedicated browser context, capture every pre-render HTTP/collections payload and execute the full grant/refusal/not-foreground/revoke/read-state/receipt checks. The observer alone is not a journey or signed receipt. + +No `.pw.test.ts` with invented protocol, no fixture setup, no pairing credentials and no normal data are included. Canonical vectors and the dependent application/store/authority contracts are the exact next source prerequisites. From 7ec3bd8d0ee81ff1106ca642fb1193d4e83ded44 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:28:34 +0100 Subject: [PATCH 06/32] Keep scratch handoff untracked and document client-control module contracts agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- RESUME-CCWeb.md | 62 ------------------- apps/fractal-web/src/client-control/README.md | 41 ++++++++++++ 2 files changed, 41 insertions(+), 62 deletions(-) delete mode 100644 RESUME-CCWeb.md create mode 100644 apps/fractal-web/src/client-control/README.md diff --git a/RESUME-CCWeb.md b/RESUME-CCWeb.md deleted file mode 100644 index 20d259cf34b..00000000000 --- a/RESUME-CCWeb.md +++ /dev/null @@ -1,62 +0,0 @@ -# Client-control web source preparation - -## Scope and status - -Wire-independent source preparation only, based on origin/main d7aa0be1db. The parent explicitly deferred application activation and end-to-end integration until the authoritative contracts and route/store dependencies below exist. **Test mode remains disabled in the application.** No existing application entrypoint, pairing flow, normal session or read store is modified. No pairing, enrollment, grant, fixture instance or live daemon was created or touched. This is not a claimed mission step. - -## Daemon handle and required dependencies - -Canonical daemon source: [compoundingtech/fractal#6274](https://github.com/compoundingtech/fractal/pull/6274), branch `schickling-assistant/2026-11-10-client-control-daemon`, planning head `ceb85d30001faaaee311ac6dd145e10899490e6b`. The PR body was read. Its ordered plan is: - -1. #6274: D-1/D-2 vocabulary, D-4 grant types, D-6 canonical purpose-specific results and shared fixed-key positive/negative golden-vector JSON; A-1 generated contracts and A-2 canonical signature tests. Canonical form/vector source was not yet published when this preparation was handed off. Browser conformance tests must READ the eventual repository vector file, never duplicate its fields or bytes. -2. Enrollment readback/revoke PR (handle pending): D-3 enrollment of an existing client-generated pairing/key and authoritative revocation. -3. Grant subscription PR (handle pending): D-4 mission/claimed-owner binding, active grant lifecycle delivered on the session-scoped commands subscription, admission restrictions. -4. Verification/receipt PR (handle pending): D-6 result verification and D-7 exactly one durable terminal receipt. -5. Workspace-model/routes group: `cac380ac98`, `df082cd9e8`, `349b0da2aa`, `5a107113bd`. Read-only inspection of the integration source found `workspace/subject.ts`: Inbox -> `/w/inbox`; Conversation -> `/w/`; Alert -> `/w/inbox/`. `workspace/useWorkspace.ts` projects subject routing; `workspace/surfaces.tsx` renders it. No commits were cherry-picked. -6. State-cells read positions: `9e66194639`, `0d21c1286b`, `45710bb1d5`. The integration source writes browser-local positions through `state/cells/readPositions.ts` (`advance`), called from both `onSeen` and `useMarkRead` in `web/readPosition.ts`. Suppression must wrap that store boundary, not UI call sites. -7. #6108 read-position synchronization: branch `schickling-assistant/2026-11-08-read-position-sync`, inspected at `01fd722d1cdfc4beb916817ab9dc68f0307b437b`. Its web delta only adds lastProseAt projection/types; no web readPosition.ts or daemon synchronization caller exists there. The eventual daemon sync writer must remain inside the same gated advancement boundary as local persistence. - -Earlier searches found only the old `agent/client-controls` branch (behind main, no new delta); #6274 supersedes that initial NOT FOUND result. - -## Implemented source and integration contracts - -All modules are under `apps/fractal-web/src/client-control/`: - -- `identity.ts`: real WebCrypto P-256 private key, non-extractable, persisted via IndexedDB structured clone. Atomic readwrite read/conditional-add resolves concurrent acquisition without replacing a committed key. No ephemeral fallback. `getOrCreateClientControlIdentity()` exposes public SPKI export and ECDSA/SHA-256 signing of supplied canonical bytes; neither public-key wire encoding nor result serialization is invented. Key access is internal, never attached to window. Clearing site data loses identity and requires fresh pairing/enrollment; deleting a key is not revocation. -- `foreground.ts`: visible AND document focus; callers must re-evaluate before navigation and signing. -- `navigation.ts`: typed app destinations plus a host-supplied authoritative subject-to-href resolver. No alternate renderer or second routing codec. -- `readAdvancement.ts`: a sticky page-lifetime prohibition and execution-time `gateReadAdvancement`. The eventual authority adapter prohibits writes before test enrollment permits rendering; store advance wraps local persistence and outbound synchronization together. Ending enrollment never re-enables writes in that isolated page. This boundary is not yet wired because the store does not exist on this branch's base. -- `ClientControlIndicator.tsx`: run id, explicit enrollment-revoke action, pending and unconfirmed-revoke states. Controller owns authority and async state; the component does not optimistically hide the grant. Not mounted before authoritative enrollment/grant delivery exists. -- `fixtureGuard.ts`: strict exact dedicated IPv4 loopback origin plus independently approved, realpath-resolved fixture instance client socket and normal-origin/socket deny lists. Configuration guard only, not daemon attestation or enrollment authority. -- `fixtureDomEvidence.ts`: independent real Playwright-compatible DOM observer, never client-reported state. Compares seed title and exact UTF-8 rendered innerText SHA-256 and checks visible data-item-id values against verified actual subject catalog. Awaits evidence persistence for both comparison outcomes; records full exact-candidate commit, origin, time, comparisons and unsigned observation status. Caller must fail the journey on `passed: false`. -- Colocated tests cover real key signing/verification and storage acquisition, foreground matrix, local+sync suppression, revoke UI states, fixture refusals and independent DOM comparison. No assumed signing bytes are pinned or presented as conformance. - -## ASSUMPTIONS / unresolved contracts - -No guessed wire fields are implemented. All following mappings remain unresolved until their owners publish canonical source: - -- Pairing-bound authoritative test-enrollment readback and key/public-key binding; browser requests or flags cannot enable mode on a normal pairing. -- Grant start/end/revoke event envelope and command device/session/grant/run/id/kind/window fields. -- Generated visible-state schema and the exact purpose-first claim-fields signing lines, ordering/escaping, JCS vectors and signature serialization. -- device.result submission envelope and enrollment-revoke operation. -- Fixture local-only identity attestation, actual manifest/readback interfaces and authorized bootstrap flow. -- Mapping an optional Conversation sessionId through the integration router; no guessed query parameter is added. -- Rendered body text expectation when fixture source is Markdown: observer hashes exact DOM innerText, never invents normalization of source Markdown. - -Local types in this directory are internal module/observer inputs, NOT daemon wire schemas. There is no enabled test-client adapter, command subscription, generated visible-state capture, JCS/result conformance test or complete `.pw.test.ts` journey yet; those were explicitly deferred by the parent rather than fabricated. - -## Proof policy and next executable step - -NOT RUN: builds, typechecks, unit/integration tests, linters, formatters, Storybook, Playwright, Rust, runtime servers, live setup and browser captures. Source regression evidence belongs to hosted CI only; hosted CI is NEVER journey acceptance. The parent opens the draft PR and arranges hosted checks. - -Exact next executable source-regression step on the approved hosted runner, from `apps/fractal-web`: - -```sh -pnpm test -- src/client-control -``` - -Then hosted typecheck/build/format gates apply normally. Do not run those on the source-editing machine. - -Journey acceptance remains a LOCAL exact-candidate Playwright/DOM run against the approved real fixture after authorized setup/bootstrap/enrollment, not a fake fixtureSource/embed path. The future runner must supply: exact clean commit; approved unused dedicated `http://127.0.0.1:` origin; `WF_ST_GATEWAY` equal to the independently approved fixture instance CLIENT socket; realpath-resolved instance root/socket; normal-origin/socket deny lists; approved fixture-only bearer/bootstrap; verified actual fixture manifest; selectors for real visible seed title/body; durable evidence writer for later authorized signing. It must guard BEFORE server/browser connections, verify daemon fixture/local-only identity independently, create a fresh dedicated browser context, capture every pre-render HTTP/collections payload and execute the full grant/refusal/not-foreground/revoke/read-state/receipt checks. The observer alone is not a journey or signed receipt. - -No `.pw.test.ts` with invented protocol, no fixture setup, no pairing credentials and no normal data are included. Canonical vectors and the dependent application/store/authority contracts are the exact next source prerequisites. diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md new file mode 100644 index 00000000000..4463f581abe --- /dev/null +++ b/apps/fractal-web/src/client-control/README.md @@ -0,0 +1,41 @@ +# Client-control web modules + +Wire-independent source preparation only. **Application test mode remains disabled.** These modules do not change existing entrypoints, pairings or read stores, and do not create enrollment or grant authority. + +## APIs + +- `identity.ts`: `getOrCreateClientControlIdentity()` atomically acquires an origin-local IndexedDB P-256 identity. The private key is non-extractable; public SPKI export and supplied-byte ECDSA/SHA-256 signing remain internal capabilities. Storage failures never replace a key or fall back to ephemeral identity. Key deletion is not enrollment revocation. +- `foreground.ts`: visible AND focused. Re-evaluate before navigation and signing. +- `navigation.ts`: typed app destinations; the host supplies its authoritative subject-to-href resolver, not an alternate renderer or routing codec. +- `readAdvancement.ts`: sticky page-lifetime prohibition checked by `gateReadAdvancement` at execution time. Wrap the store's entire advance implementation, including persistence and outbound synchronization, rather than individual UI callers. Enrollment ending must not re-enable writes. +- `ClientControlIndicator.tsx`: controller-owned run indicator and enrollment-revoke action with pending/unconfirmed states. The component does not create authority or optimistically hide an active grant. +- `fixtureGuard.ts`: requires the exact approved dedicated loopback origin and realpath-resolved fixture instance client socket, with normal-origin/socket deny lists. This is configuration validation, not daemon attestation or enrollment authority. +- `fixtureDomEvidence.ts`: independent real Playwright-compatible DOM observation, comparing seed title, exact UTF-8 rendered innerText SHA-256 and visible item IDs against the verified actual subject catalog. Persists unsigned observations with exact candidate commit and origin; the runner must fail on `passed: false`. + +Colocated tests cover crypto/storage, foreground, suppression, indicator states, fixture refusals and independent DOM comparisons. Internal module inputs are not daemon wire schemas. + +## Integration dependencies + +1. [Canonical daemon contract #6274](https://github.com/compoundingtech/fractal/pull/6274): vocabulary, grant types, generated visible state, purpose-specific result signing and shared fixed-key positive/negative golden vectors. Inspected planning head: `ceb85d30001faaaee311ac6dd145e10899490e6b`. Conformance tests must read the eventual repository vector file, never duplicate its fields or bytes. +2. Subsequent enrollment readback/revoke and grant-subscription PRs: authoritative pairing/key enrollment and session-scoped grant lifecycle. Their handles remain pending. The subsequent verification/receipt PR supplies result verification and durable terminal receipts. +3. Workspace-model/routes group: `cac380ac98`, `df082cd9e8`, `349b0da2aa`, `5a107113bd`. Its `workspace/subject.ts` resolves Inbox to `/w/inbox` and Conversation to `/w/`; `workspace/useWorkspace.ts` projects routing and `workspace/surfaces.tsx` renders it. None was cherry-picked here. +4. State-cells read-position group: `9e66194639`, `0d21c1286b`, `45710bb1d5`. Wrap `state/cells/readPositions.ts` store `advance`, used by both `onSeen` and `useMarkRead` in `web/readPosition.ts`. +5. [Read-position synchronization #6108](https://github.com/compoundingtech/fractal/pull/6108), inspected at `01fd722d1cdfc4beb916817ab9dc68f0307b437b`: its web delta adds lastProseAt projection/types, not a web synchronization caller. The eventual outbound writer belongs inside the same gated advancement boundary as browser-local persistence. + +## Open contracts + +No guessed wire fields or signing bytes are implemented. Pending contracts include pairing-bound enrollment readback and public-key binding; grant start/end/revoke events and command identity/window fields; generated visible state, canonical purpose-first signing lines, JCS vectors and signature/result serialization; enrollment-revoke operation; fixture local-only attestation, manifest/readback and authorized bootstrap; optional Conversation sessionId routing; and rendered-body expectations when fixture source is Markdown. The observer does not invent Markdown normalization. + +Activation, command subscription, generated capture/signing, actual store wrapping and the complete journey await those contracts. Browser flags or preferences must never enable test mode on a normal pairing. + +## Proof limits + +No builds, typechecks, tests, linters, formatters, Storybook, Playwright, Rust, servers or browser captures were run during source preparation. No live setup, pairing, enrollment, grant or daemon operation was performed. + +Next source-regression command on the approved hosted runner, from `apps/fractal-web`: + +```sh +pnpm test -- src/client-control +``` + +Hosted CI provides source regression evidence only, never journey acceptance. Journey acceptance requires a LOCAL exact-candidate real-fixture Playwright/DOM run after authorized setup/bootstrap/enrollment. Its runner must guard before opening connections; independently verify the fixture/local-only daemon identity; supply the exact clean commit, approved unused loopback origin, fixture-only `WF_ST_GATEWAY` client socket and approved bootstrap; create a fresh dedicated browser context; capture every pre-render HTTP/collections payload; use the actual fixture manifest and real DOM selectors; persist evidence for authorized signing; and execute grant/refusal/not-foreground/revoke/read-state/receipt checks. Neither fixtureSource/embed rendering nor this independent observer alone is journey proof. From b6c0d55d9325ec53f5285ac30b4ff38707cd22c8 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:39:40 +0100 Subject: [PATCH 07/32] Define client control canonical source and signed vector inputs Source-only draft: generated outputs pending admitted codegen; not consumer-ready. agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../ClientControlTests.swift | 37 ++ .../fractal-client/client-control.test.cjs | 43 ++ clients/typescript/fractal-client/index.ts | 1 + .../typescript/fractal-client/package.json | 4 +- crates/fractal-client-codegen/src/main.rs | 8 + .../templates/Client.swift.in | 1 + .../templates/Client.ts.in | 4 + .../templates/ClientControl.swift.in | 117 ++++ .../templates/ClientControl.ts.in | 50 ++ .../templates/Models.swift.in | 28 +- .../client-control-v1.golden.json.in | 501 ++++++++++++++++++ .../templates/client_control.rs.in | 124 +++++ .../templates/generated.rs.in | 23 + .../templates/lib.rs.in | 5 + .../tests/client_control_golden.rs | 30 ++ crates/fractal-schema/src/lib.rs | 1 + docs/st3/client-v0/device-commands.md | 96 ++++ .../client-v0/schemas/client-v0.schema.json | 58 +- .../client-control-canonical-v1.json | 11 + 19 files changed, 1130 insertions(+), 12 deletions(-) create mode 100644 clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift create mode 100644 clients/typescript/fractal-client/client-control.test.cjs create mode 100644 crates/fractal-client-codegen/templates/ClientControl.swift.in create mode 100644 crates/fractal-client-codegen/templates/ClientControl.ts.in create mode 100644 crates/fractal-client-codegen/templates/client-control-v1.golden.json.in create mode 100644 crates/fractal-client-codegen/templates/client_control.rs.in create mode 100644 crates/fractal-client/tests/client_control_golden.rs create mode 100644 release-notes/client-control-canonical-v1.json diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift new file mode 100644 index 00000000000..91310745054 --- /dev/null +++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift @@ -0,0 +1,37 @@ +import Foundation +import CryptoKit +import XCTest +@testable import FractalClient + +final class ClientControlTests: XCTestCase { + private func base64url(_ value: String) throws -> Data { + let padded = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + return try XCTUnwrap(Data(base64Encoded: padded + String(repeating: "=", count: (4 - padded.count % 4) % 4))) + } + private func binding(_ object: Any) throws -> ClientResultBinding { + try JSONDecoder().decode(ClientResultBinding.self, from: JSONSerialization.data(withJSONObject: object)) + } + func testSharedCanonicalAndSignatureVectors() throws { + var root = URL(fileURLWithPath: #filePath) + for _ in 0..<6 { root.deleteLastPathComponent() } + let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: root.appendingPathComponent("fixtures/clients/client-control-v1.golden.json"))) as? [String: Any]) + let publicKey = try P256.Signing.PublicKey(x963Representation: base64url(XCTUnwrap(fixture["public_key_sec1_base64url"] as? String))) + let message = Data(try XCTUnwrap(fixture["message_domain_bytes"] as? String).utf8) + let messageSignature = try P256.Signing.ECDSASignature(rawRepresentation: base64url(XCTUnwrap(fixture["message_domain_signature_p1363_base64url"] as? String))) + XCTAssertTrue(publicKey.isValidSignature(messageSignature, for: message)) + for vector in try XCTUnwrap(fixture["vectors"] as? [[String: Any]]) { + let input = try binding(XCTUnwrap(vector["input"])) + XCTAssertEqual(visibleStateCanonicalJson(input.visible_state), vector["canonical_visible_state"] as? String) + XCTAssertEqual(visibleStateDigest(input.visible_state), vector["visible_state_sha256"] as? String) + let bytes = clientResultCanonicalBytes(input) + XCTAssertEqual(bytes, Data(try XCTUnwrap(vector["canonical_bytes"] as? String).utf8)) + let signature = try P256.Signing.ECDSASignature(rawRepresentation: base64url(XCTUnwrap(vector["signature_p1363_base64url"] as? String))) + XCTAssertTrue(publicKey.isValidSignature(signature, for: bytes)) + for changed in try XCTUnwrap(vector["negative_bindings"] as? [Any]) { + XCTAssertFalse(publicKey.isValidSignature(signature, for: clientResultCanonicalBytes(try binding(changed)))) + } + XCTAssertFalse(publicKey.isValidSignature(signature, for: message)) + XCTAssertFalse(publicKey.isValidSignature(messageSignature, for: bytes)) + } + } +} diff --git a/clients/typescript/fractal-client/client-control.test.cjs b/clients/typescript/fractal-client/client-control.test.cjs new file mode 100644 index 00000000000..b6bfc83df55 --- /dev/null +++ b/clients/typescript/fractal-client/client-control.test.cjs @@ -0,0 +1,43 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); +const { webcrypto, createPublicKey, verify } = require('node:crypto'); +const Module = require('node:module'); +const filename = path.join(__dirname, 'ClientControl.generated.ts'); +const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } +}).outputText; +const loaded = new Module(filename, module); +loaded.filename = filename; +loaded.paths = module.paths; +loaded._compile(source, filename); +const { visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } = loaded.exports; +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../fixtures/clients/client-control-v1.golden.json'), 'utf8')); +if (!globalThis.crypto) globalThis.crypto = webcrypto; +const point = Buffer.from(fixture.public_key_sec1_base64url, 'base64url'); +const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }, format: 'jwk' }); +function verifies(bytes, signature) { + return verify('sha256', bytes, { key, dsaEncoding: 'ieee-p1363' }, signature); +} +test('all clients share canonical bytes, digest and purpose-specific fixed-key signatures', async () => { + const message = Buffer.from(fixture.message_domain_bytes); + const messageSignature = Buffer.from(fixture.message_domain_signature_p1363_base64url, 'base64url'); + assert.ok(verifies(message, messageSignature)); + for (const vector of fixture.vectors) { + assert.equal(visibleStateCanonicalJson(vector.input.visible_state), vector.canonical_visible_state); + assert.equal(await visibleStateDigest(vector.input.visible_state), vector.visible_state_sha256); + const bytes = await clientResultCanonicalBytes(vector.input); + assert.equal(Buffer.from(bytes).toString('utf8'), vector.canonical_bytes); + const signature = Buffer.from(vector.signature_p1363_base64url, 'base64url'); + assert.ok(verifies(bytes, signature)); + for (const changed of vector.negative_bindings) assert.equal(verifies(await clientResultCanonicalBytes(changed), signature), false); + assert.equal(verifies(message, signature), false); + assert.equal(verifies(bytes, messageSignature), false); + } +}); +test('JCS refuses lone surrogate strings', () => { + const state = { ...fixture.vectors[0].input.visible_state, route: '\ud800' }; + assert.throws(() => visibleStateCanonicalJson(state), /Invalid Unicode/); +}); diff --git a/clients/typescript/fractal-client/index.ts b/clients/typescript/fractal-client/index.ts index 896488ca4e6..f04e3c9d52a 100644 --- a/clients/typescript/fractal-client/index.ts +++ b/clients/typescript/fractal-client/index.ts @@ -2,3 +2,4 @@ export * from './Models.generated.ts'; export * from './Client.generated.ts'; export * from './errors.ts'; export * from './subagent-drivers.ts'; +export { CLIENT_RESULT_PURPOSE, CLIENT_RESULT_SIGNED_FIELDS, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts'; diff --git a/clients/typescript/fractal-client/package.json b/clients/typescript/fractal-client/package.json index c7ec3e82f32..c5895ed3e32 100644 --- a/clients/typescript/fractal-client/package.json +++ b/clients/typescript/fractal-client/package.json @@ -5,8 +5,8 @@ "license": "MIT", "type": "module", "scripts": { - "test": "node --test contract.test.cjs schema.test.cjs fabric.test.cjs", - "test:contract": "node --test contract.test.cjs", + "test": "node --test contract.test.cjs schema.test.cjs fabric.test.cjs client-control.test.cjs", + "test:contract": "node --test contract.test.cjs client-control.test.cjs", "test:schema": "node --test schema.test.cjs", "typecheck": "pnpm run typecheck:client && pnpm run typecheck:schema", "typecheck:client": "tsc -p tsconfig.json", diff --git a/crates/fractal-client-codegen/src/main.rs b/crates/fractal-client-codegen/src/main.rs index 516ed9a5bd0..f1bb4a0c762 100644 --- a/crates/fractal-client-codegen/src/main.rs +++ b/crates/fractal-client-codegen/src/main.rs @@ -23,6 +23,14 @@ fn main() -> Result<()> { .skip(1) .any(|argument| argument == "--check"); let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + for (path, template) in [ + ("crates/fractal-schema/src/client_control.rs", include_str!("../templates/client_control.rs.in")), + ("clients/typescript/fractal-client/ClientControl.generated.ts", include_str!("../templates/ClientControl.ts.in")), + ("clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift", include_str!("../templates/ClientControl.swift.in")), + ("fixtures/clients/client-control-v1.golden.json", include_str!("../templates/client-control-v1.golden.json.in")), + ] { + output(&root.join(path), template, check)?; + } let operations_path = root.join("docs/st3/client-v0/schemas/operations.json"); let schema_path = root.join("docs/st3/client-v0/schemas/client-v0.schema.json"); let operations_bytes = std::fs::read(&operations_path).context("read operations manifest")?; diff --git a/crates/fractal-client-codegen/templates/Client.swift.in b/crates/fractal-client-codegen/templates/Client.swift.in index cf0251cacd9..3a84628d3f2 100644 --- a/crates/fractal-client-codegen/templates/Client.swift.in +++ b/crates/fractal-client-codegen/templates/Client.swift.in @@ -295,6 +295,7 @@ public actor FractalClient { /// Issue one expiring foreground command, without durable receipt or replay. public func deviceCommand(_ command: DeviceCommandRequest) async throws -> Envelope { try await post("v1/client/device-commands", command) } public func deviceResult(_ result: DeviceResult) async throws -> Envelope { try await post("v1/client/device-results", result) } + public func clientTestRevoke(device: String) async throws -> JSONValue { try await post("v1/client/test-clients/revoke", ["device": device]) } public func beginPairing(deviceName: String, personID: String) async throws -> Envelope { try await post("v1/client/pairings", PersonPairingBegin(apiVersion: fractalClientAPIVersion, deviceName: deviceName, personID: personID)) } public func beginPairing(_ request: PairingBegin) async throws -> Envelope { try await post("v1/client/pairings", request) } diff --git a/crates/fractal-client-codegen/templates/Client.ts.in b/crates/fractal-client-codegen/templates/Client.ts.in index ab5e2df734b..904d8385950 100644 --- a/crates/fractal-client-codegen/templates/Client.ts.in +++ b/crates/fractal-client-codegen/templates/Client.ts.in @@ -486,6 +486,10 @@ export class FractalClient { return this.request('POST', '/v1/client/device-results', result, undefined, undefined, requestOptions); } + async clientTestRevoke(device: string, requestOptions: RequestOptions = {}): Promise { + return this.request('POST', '/v1/client/test-clients/revoke', { device }, undefined, undefined, requestOptions); + } + async beginPairing(request: PairingBegin, requestOptions: RequestOptions = {}): Promise> { return this.request('POST', '/v1/client/pairings', request, undefined, undefined, requestOptions); } diff --git a/crates/fractal-client-codegen/templates/ClientControl.swift.in b/crates/fractal-client-codegen/templates/ClientControl.swift.in new file mode 100644 index 00000000000..f32686b92fb --- /dev/null +++ b/crates/fractal-client-codegen/templates/ClientControl.swift.in @@ -0,0 +1,117 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.swift.in. +import Foundation +import CryptoKit + +public let clientResultPurpose = "client.device-result.v1" +public let clientResultSignedFields = ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state_sha256"] +public enum ClientControlRefusal: String, Codable, Sendable { + case deviceNotTestClient = "device-not-test-client", grantMissing = "grant-missing", grantExpired = "grant-expired" + case screenNotGranted = "screen-not-granted", verbNotGranted = "verb-not-granted", budgetExhausted = "budget-exhausted", actorNotGranted = "actor-not-granted" +} +public struct ClientTestEnrollment: Codable, Sendable { + public var device: String; public var kind: String; public var enrolledBy: String + public var consentRef: String; public var packetDigest: String; public var host: String; public var instance: String + public var pairingSubject: String; public var publicKey: String; public var session: String +} +public struct ClientTestRevocation: Codable, Sendable { + public var device: String; public var enrollmentId: String; public var revokedBy: String +} +public struct ClientCommandReceipt: Codable, Sendable { + public var commandId: String; public var terminalState: DeviceCommandOutcome + public var issuer: String; public var device: String; public var session: String + public var grantId: String; public var runId: String; public var commandKind: String + public var verifiedResultSignature: DeviceSignature?; public var signedResult: ClientResultBinding? + private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, verifiedResultSignature, signedResult } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(commandId, forKey: .commandId); try box.encode(terminalState, forKey: .terminalState) + try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encode(session, forKey: .session) + try box.encode(grantId, forKey: .grantId); try box.encode(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind) + if let verifiedResultSignature { try box.encode(verifiedResultSignature, forKey: .verifiedResultSignature) } + else { try box.encodeNil(forKey: .verifiedResultSignature) } + if let signedResult { try box.encode(signedResult, forKey: .signedResult) } + else { try box.encodeNil(forKey: .signedResult) } + } +} +public struct ClientVisibleItem: Codable, Sendable, Equatable { + public var id: String; public var title: String; public var body: String + public init(id: String, title: String, body: String) { self.id = id; self.title = title; self.body = body } +} +public struct ClientVisibleState: Codable, Sendable, Equatable { + public var route: String; public var screen: String; public var visible_item_ids: [String]; public var opened_item: ClientVisibleItem? + public init(route: String, screen: String, visible_item_ids: [String], opened_item: ClientVisibleItem?) { + self.route = route; self.screen = screen; self.visible_item_ids = visible_item_ids; self.opened_item = opened_item + } + private enum CodingKeys: String, CodingKey { case route, screen, visible_item_ids, opened_item } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(route, forKey: .route); try box.encode(screen, forKey: .screen) + try box.encode(visible_item_ids, forKey: .visible_item_ids) + if let opened_item { try box.encode(opened_item, forKey: .opened_item) } + else { try box.encodeNil(forKey: .opened_item) } + } +} +public struct ClientResultBinding: Codable, Sendable, Equatable { + public var purpose: String; public var command_kind: String; public var device: String; public var session: String + public var grant_id: String; public var run_id: String; public var command_id: String; public var outcome: String + public var error_code: String?; public var visible_state: ClientVisibleState + public init(purpose: String = clientResultPurpose, command_kind: String, device: String, session: String, grant_id: String, run_id: String, command_id: String, outcome: String, error_code: String?, visible_state: ClientVisibleState) { + self.purpose = purpose; self.command_kind = command_kind; self.device = device; self.session = session + self.grant_id = grant_id; self.run_id = run_id; self.command_id = command_id; self.outcome = outcome + self.error_code = error_code; self.visible_state = visible_state + } + private enum CodingKeys: String, CodingKey { + case purpose, command_kind, device, session, grant_id, run_id, command_id, outcome, error_code, visible_state + } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(purpose, forKey: .purpose); try box.encode(command_kind, forKey: .command_kind) + try box.encode(device, forKey: .device); try box.encode(session, forKey: .session) + try box.encode(grant_id, forKey: .grant_id); try box.encode(run_id, forKey: .run_id) + try box.encode(command_id, forKey: .command_id); try box.encode(outcome, forKey: .outcome) + if let error_code { try box.encode(error_code, forKey: .error_code) } + else { try box.encodeNil(forKey: .error_code) } + try box.encode(visible_state, forKey: .visible_state) + } +} +public struct ClientControlGrant: Codable, Sendable, Equatable { + public var grant_id: String; public var run_id: String; public var device: String; public var session: String; public var actor_agent: String + public var verbs: [String]; public var screens: [String]; public var max_commands: UInt64; public var expires_at: String; public var state: String +} +private func clientResultQuote(_ text: String) -> String { + var result = "\"" + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x22: result += "\\\"" + case 0x5c: result += "\\\\" + case 8: result += "\\b" + case 9: result += "\\t" + case 10: result += "\\n" + case 12: result += "\\f" + case 13: result += "\\r" + case 0...31: result += String(format: "\\u%04x", scalar.value) + default: result.unicodeScalars.append(scalar) + } + } + return result + "\"" +} +/// RFC 8785 JCS for the generated closed string-only visible-state schema. +public func visibleStateCanonicalJson(_ state: ClientVisibleState) -> String { + let item = state.opened_item.map { "{\"body\":" + clientResultQuote($0.body) + ",\"id\":" + clientResultQuote($0.id) + ",\"title\":" + clientResultQuote($0.title) + "}" } ?? "null" + return "{\"opened_item\":" + item + ",\"route\":" + clientResultQuote(state.route) + ",\"screen\":" + clientResultQuote(state.screen) + ",\"visible_item_ids\":[" + state.visible_item_ids.map(clientResultQuote).joined(separator: ",") + "]}" +} +public func visibleStateDigest(_ state: ClientVisibleState) -> String { + SHA256.hash(data: Data(visibleStateCanonicalJson(state).utf8)).map { String(format: "%02x", $0) }.joined() +} +/// Sign with P256.Signing.PrivateKey.signature(for:).rawRepresentation (r||s). +/// This domain is never message.send authorization. Enforce active grant first. +public func clientResultCanonicalBytes(_ binding: ClientResultBinding) -> Data { + let fields: [(String, String?)] = [ + ("purpose", binding.purpose), ("command_kind", binding.command_kind), ("device", binding.device), + ("session", binding.session), ("grant_id", binding.grant_id), ("run_id", binding.run_id), + ("command_id", binding.command_id), ("outcome", binding.outcome), ("error_code", binding.error_code), + ("visible_state_sha256", visibleStateDigest(binding.visible_state)) + ] + return Data((["smallclaims-claim-fields-v1"] + fields.map { $0.0 + "=" + ($0.1.map(clientResultQuote) ?? "null") }).joined(separator: "\n").utf8) +} diff --git a/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in new file mode 100644 index 00000000000..9c2290396cd --- /dev/null +++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in @@ -0,0 +1,50 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.ts.in. +export const CLIENT_RESULT_PURPOSE = 'client.device-result.v1'; +export const CLIENT_RESULT_SIGNED_FIELDS = ['purpose', 'command_kind', 'device', 'session', 'grant_id', 'run_id', 'command_id', 'outcome', 'error_code', 'visible_state_sha256'] as const; +export interface ClientVisibleItem { id: string; title: string; body: string } +export interface ClientVisibleState { route: string; screen: string; visible_item_ids: string[]; opened_item: ClientVisibleItem | null } +export interface ClientResultBinding { + purpose: string; command_kind: string; device: string; session: string; + grant_id: string; run_id: string; command_id: string; outcome: string; + error_code: string | null; visible_state: ClientVisibleState; +} +export interface ClientControlGrant { + grant_id: string; run_id: string; device: string; session: string; actor_agent: string; + verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string; +} +function quote(value: string): string { + // RFC 8785 requires rejection of lone surrogates rather than replacement. + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('Invalid Unicode in client result'); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new Error('Invalid Unicode in client result'); + } + return JSON.stringify(value); +} +/** RFC 8785 JCS for the generated closed string-only visible-state schema. */ +export function visibleStateCanonicalJson(state: ClientVisibleState): string { + const item = state.opened_item; + return '{"opened_item":' + (item === null ? 'null' : '{"body":' + quote(item.body) + ',"id":' + quote(item.id) + ',"title":' + quote(item.title) + '}') + + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen) + + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}'; +} +export async function visibleStateDigest(state: ClientVisibleState): Promise { + const bytes = new TextEncoder().encode(visibleStateCanonicalJson(state)); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); +} +/** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding. + * Never use this helper as message.send authorization. Enforce active grant first. */ +export async function clientResultCanonicalBytes(binding: ClientResultBinding): Promise { + const digest = await visibleStateDigest(binding.visible_state); + const fields: [string, string | null][] = [ + ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device], + ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id], + ['command_id', binding.command_id], ['outcome', binding.outcome], ['error_code', binding.error_code], + ['visible_state_sha256', digest], + ]; + return new TextEncoder().encode(['smallclaims-claim-fields-v1', ...fields.map(([k, v]) => k + '=' + (v === null ? 'null' : quote(v)))].join('\n')); +} diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index f67d5ffb1f3..c6d43fa6a5e 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -791,20 +791,23 @@ public struct DeviceCommandRequest: Codable, Sendable { public let apiVersion, device: String public let command: PhoneCommand public let ttlMS: UInt64? - public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, apiVersion: String = "st3.client.v0") { - self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS + public let grantID: String? + public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, grantID: String? = nil, apiVersion: String = "st3.client.v0") { + self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS; self.grantID = grantID } - private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms" } + private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms", grantID = "grant_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) apiVersion = try box.decode(String.self, forKey: .apiVersion) device = try box.decode(String.self, forKey: .device) ttlMS = try box.decodeIfPresent(UInt64.self, forKey: .ttlMS) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID) command = try PhoneCommand(from: decoder) } public func encode(to encoder: Encoder) throws { var box = encoder.container(keyedBy: Keys.self) try box.encode(apiVersion, forKey: .apiVersion); try box.encode(device, forKey: .device) + try box.encodeIfPresent(grantID, forKey: .grantID) try box.encodeIfPresent(ttlMS, forKey: .ttlMS); try command.encode(to: encoder) } } @@ -813,10 +816,12 @@ public struct DeviceResult: Codable, Sendable { public let outcome: DeviceCommandOutcome public let result: JSONValue? public let errorCode: String? - public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil) { - self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode + public let binding: ClientResultBinding? + public let signature: DeviceSignature? + public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil, binding: ClientResultBinding? = nil, signature: DeviceSignature? = nil) { + self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode; self.binding = binding; self.signature = signature } - enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code" } + enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code", binding, signature } } public struct DeviceCommand: Codable, Sendable, Identifiable { public let id, kind, revision, updatedAt, deviceID, sessionActor, issuedBy, issuedAt, expiresAt: String @@ -824,9 +829,14 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { public let state: DeviceCommandState public let result: JSONValue? public let errorCode: String? + public let grantID, runID: String? + public let controlGrant: ClientControlGrant? + public let receiptClaimID: String? private enum Keys: String, CodingKey { case id, kind, revision, updatedAt = "updated_at", deviceID = "device_id", sessionActor = "session_actor" case issuedBy = "issued_by", issuedAt = "issued_at", expiresAt = "expires_at", state, result, errorCode = "error_code" + case grantID = "grant_id", runID = "run_id", controlGrant = "control_grant" + case receiptClaimID = "receipt_claim_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) @@ -836,6 +846,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { issuedBy = try box.decode(String.self, forKey: .issuedBy); issuedAt = try box.decode(String.self, forKey: .issuedAt) expiresAt = try box.decode(String.self, forKey: .expiresAt); state = try box.decode(DeviceCommandState.self, forKey: .state) result = try box.decodeIfPresent(JSONValue.self, forKey: .result); errorCode = try box.decodeIfPresent(String.self, forKey: .errorCode) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID); runID = try box.decodeIfPresent(String.self, forKey: .runID) + controlGrant = try box.decodeIfPresent(ClientControlGrant.self, forKey: .controlGrant) + receiptClaimID = try box.decodeIfPresent(String.self, forKey: .receiptClaimID) command = try PhoneCommand(from: decoder, resource: true) } public func encode(to encoder: Encoder) throws { @@ -846,6 +859,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { try box.encode(issuedBy, forKey: .issuedBy); try box.encode(issuedAt, forKey: .issuedAt) try box.encode(expiresAt, forKey: .expiresAt); try box.encode(state, forKey: .state) try box.encodeIfPresent(result, forKey: .result); try box.encodeIfPresent(errorCode, forKey: .errorCode) + try box.encodeIfPresent(grantID, forKey: .grantID); try box.encodeIfPresent(runID, forKey: .runID) + try box.encodeIfPresent(controlGrant, forKey: .controlGrant) + try box.encodeIfPresent(receiptClaimID, forKey: .receiptClaimID) try command.encode(to: encoder, resource: true) } } diff --git a/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in new file mode 100644 index 00000000000..62e50fd522c --- /dev/null +++ b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in @@ -0,0 +1,501 @@ +{ + "_generated": "cargo run -p fractal-client-codegen; source templates/client-control-v1.golden.json.in; fixed PUBLIC TEST key scalar 1, nonce scalar 2, never use for enrollment", + "format": "client-control-v1", + "public_key_sec1_base64url": "BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU", + "private_scalar_hex_for_tests": "0000000000000000000000000000000000000000000000000000000000000001", + "message_domain_bytes": "smallclaims-claim-fields-v1\nmessage/test\nmessage.sent\nperson/client-control-fixture\ncontent=\"hello\"", + "message_domain_signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXgRe11q295ks9iyv8QCIwcWwVU7WhRTUdQe_JG0MhGy1w", + "vectors": [ + { + "name": "inbox", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"message/seed\"]}", + "visible_state_sha256": "f1b171d18c588f8d7bf6df15c3b2189632e6b2192faf64be714ea0e93b1e40fa", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"f1b171d18c588f8d7bf6df15c3b2189632e6b2192faf64be714ea0e93b1e40fa\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXhyH3-4fQmuiZMfjBipdGKJyo7H1eIQ4nJVLpk44Q-3LA", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "NowRoot", + "visible_item_ids": [ + "message/seed" + ], + "opened_item": null + } + } + ] + }, + { + "name": "unicode-opened-item", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/

\",\"id\":\"message/seed\",\"title\":\"Seed é 😀\"},\"route\":\"/inbox/message/seed\",\"screen\":\"Conversation\",\"visible_item_ids\":[\"message/seed\",\"message/é😀\"]}", + "visible_state_sha256": "000b60d79d2f6dd48278100222bc4d479adc8e530e922264c1a69bcdb3749220", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"000b60d79d2f6dd48278100222bc4d479adc8e530e922264c1a69bcdb3749220\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXjB9ZRAQfZ5iiyRkjiPK6RlzvDHFcq0ODjctNAo_Bb3lQ", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/inbox/message/seed", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "Conversation", + "visible_item_ids": [ + "message/seed", + "message/é😀" + ], + "opened_item": { + "id": "message/seed", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + } + ] + } + ] +} diff --git a/crates/fractal-client-codegen/templates/client_control.rs.in b/crates/fractal-client-codegen/templates/client_control.rs.in new file mode 100644 index 00000000000..71ed47bce55 --- /dev/null +++ b/crates/fractal-client-codegen/templates/client_control.rs.in @@ -0,0 +1,124 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/client_control.rs.in. +//! Purpose-specific client results. This is not a message signing authorization. +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +pub const CLIENT_RESULT_PURPOSE: &str = "client.device-result.v1"; +pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[ + "purpose", "command_kind", "device", "session", "grant_id", "run_id", + "command_id", "outcome", "error_code", "visible_state_sha256", +]; +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ClientControlRefusal { + DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted, + VerbNotGranted, BudgetExhausted, ActorNotGranted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestEnrollment { + pub device: String, pub kind: String, pub enrolled_by: String, + pub consent_ref: String, pub packet_digest: String, pub host: String, + pub instance: String, pub pairing_subject: String, pub public_key: String, pub session: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestRevocation { + pub device: String, pub enrollment_id: String, pub revoked_by: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleItem { pub id: String, pub title: String, pub body: String } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleState { + pub route: String, + pub screen: String, + pub visible_item_ids: Vec, + pub opened_item: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientResultBinding { + pub purpose: String, + pub command_kind: String, + pub device: String, + pub session: String, + pub grant_id: String, + pub run_id: String, + pub command_id: String, + pub outcome: String, + pub error_code: Option, + pub visible_state: ClientVisibleState, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientControlGrant { + pub grant_id: String, + pub run_id: String, + pub device: String, + pub session: String, + pub actor_agent: String, + pub verbs: Vec, + pub screens: Vec, + pub max_commands: u64, + pub expires_at: String, + pub state: String, +} +/// RFC 8785 JCS for this closed, string-only schema. Keys are emitted in ASCII +/// order explicitly, independent of serde_json's preserve_order feature. +/// There are no numbers or arbitrary object keys in this type. +pub fn visible_state_canonical_json(state: &ClientVisibleState) -> Result { + #[derive(Serialize)] + struct Item<'a> { body: &'a str, id: &'a str, title: &'a str } + #[derive(Serialize)] + struct State<'a> { opened_item: Option>, route: &'a str, screen: &'a str, visible_item_ids: &'a [String] } + serde_json::to_string(&State { + opened_item: state.opened_item.as_ref().map(|item| Item { body: &item.body, id: &item.id, title: &item.title }), + route: &state.route, screen: &state.screen, visible_item_ids: &state.visible_item_ids, + }) +} +pub fn visible_state_digest(state: &ClientVisibleState) -> Result { + Ok(hex::encode(Sha256::digest(visible_state_canonical_json(state)?.as_bytes()))) +} +/// Purpose is the first fields-v1 field. Values are JSON, not raw lines, so +/// embedded newlines cannot inject fields. No trailing newline is signed. +/// Callers MUST enforce the purpose constant, enrolled key, grant and windows. +pub fn client_result_canonical_bytes(binding: &ClientResultBinding) -> Result, serde_json::Error> { + let digest = visible_state_digest(&binding.visible_state)?; + let mut lines = vec!["smallclaims-claim-fields-v1".to_owned()]; + for (name, value) in [ + ("purpose", Some(binding.purpose.as_str())), + ("command_kind", Some(binding.command_kind.as_str())), + ("device", Some(binding.device.as_str())), + ("session", Some(binding.session.as_str())), + ("grant_id", Some(binding.grant_id.as_str())), + ("run_id", Some(binding.run_id.as_str())), + ("command_id", Some(binding.command_id.as_str())), + ("outcome", Some(binding.outcome.as_str())), + ("error_code", binding.error_code.as_deref()), + ("visible_state_sha256", Some(digest.as_str())), + ] { lines.push(format!("{name}={}", serde_json::to_string(&value)?)); } + Ok(lines.join("\n").into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shared_golden_vectors() { + let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap(); + for vector in fixture["vectors"].as_array().unwrap() { + let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap(); + assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap()); + assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap()); + let bytes = client_result_canonical_bytes(&binding).unwrap(); + assert_eq!(bytes, vector["canonical_bytes"].as_str().unwrap().as_bytes()); + for mutation in vector["negative_bindings"].as_array().unwrap() { + let changed: ClientResultBinding = serde_json::from_value(mutation.clone()).unwrap(); + assert_ne!(bytes, client_result_canonical_bytes(&changed).unwrap()); + } + } + } +} diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index a2a87f21f7d..461e5626ed4 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -5263,6 +5263,7 @@ pub enum PhoneCommand { pub struct DeviceCommandRequest { pub api_version: String, pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option, #[serde(flatten)] pub command: PhoneCommand, #[serde(default, skip_serializing_if = "Option::is_none")] pub ttl_ms: Option, } @@ -5286,6 +5287,24 @@ pub struct DeviceResult { pub outcome: DeviceCommandOutcome, #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientCommandReceipt { + pub command_id: String, + pub terminal_state: DeviceCommandOutcome, + pub issuer: String, + pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option, + pub command_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub screen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option, + pub verified_result_signature: Option, + pub signed_result: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct DeviceCommand { @@ -5296,6 +5315,10 @@ pub struct DeviceCommand { pub issued_by: String, pub issued_at: String, pub expires_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub control_grant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub receipt_claim_id: Option, pub state: DeviceCommandState, #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in index cb69c4c2fae..70d437ebf3d 100644 --- a/crates/fractal-client-codegen/templates/lib.rs.in +++ b/crates/fractal-client-codegen/templates/lib.rs.in @@ -17,6 +17,7 @@ pub mod propagation; pub mod scenarios; pub use contract::*; pub use generated::*; +pub use fractal_schema::client_control::*; mod identities; pub use identities::*; @@ -2023,6 +2024,10 @@ impl Client { self.post("/v1/client/device-results", result).await } + pub async fn client_test_revoke(&self, device: &str) -> Result { + self.post("/v1/client/test-clients/revoke", &serde_json::json!({"device": device})).await + } + pub async fn pairing_begin( &self, request: &PairingBegin, diff --git a/crates/fractal-client/tests/client_control_golden.rs b/crates/fractal-client/tests/client_control_golden.rs new file mode 100644 index 00000000000..c0b5c76248a --- /dev/null +++ b/crates/fractal-client/tests/client_control_golden.rs @@ -0,0 +1,30 @@ +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use fractal_client::{ClientResultBinding, client_result_canonical_bytes, visible_state_canonical_json, visible_state_digest}; +use ring::signature::{ECDSA_P256_SHA256_FIXED, UnparsedPublicKey}; + +#[test] +fn all_clients_share_canonical_bytes_and_p256_signatures() { + let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap(); + let key = URL_SAFE_NO_PAD.decode(fixture["public_key_sec1_base64url"].as_str().unwrap()).unwrap(); + let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, &key); + for vector in fixture["vectors"].as_array().unwrap() { + let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap(); + assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap()); + assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap()); + let canonical = client_result_canonical_bytes(&binding).unwrap(); + assert_eq!(canonical, vector["canonical_bytes"].as_str().unwrap().as_bytes()); + let signature = URL_SAFE_NO_PAD.decode(vector["signature_p1363_base64url"].as_str().unwrap()).unwrap(); + verifier.verify(&canonical, &signature).unwrap(); + for negative in vector["negative_bindings"].as_array().unwrap() { + let changed: ClientResultBinding = serde_json::from_value(negative.clone()).unwrap(); + assert!(verifier.verify(&client_result_canonical_bytes(&changed).unwrap(), &signature).is_err()); + } + // message.send's ordinary fields-v1 domain cannot consume a result signature. + let message = fixture["message_domain_bytes"].as_str().unwrap().as_bytes(); + assert!(verifier.verify(message, &signature).is_err()); + // The exact same enrolled test key signed this message-domain vector. + let message_signature = URL_SAFE_NO_PAD.decode(fixture["message_domain_signature_p1363_base64url"].as_str().unwrap()).unwrap(); + verifier.verify(message, &message_signature).unwrap(); + assert!(verifier.verify(&canonical, &message_signature).is_err()); + } +} diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs index 8b822a5387e..82eeaaf63d8 100644 --- a/crates/fractal-schema/src/lib.rs +++ b/crates/fractal-schema/src/lib.rs @@ -1,5 +1,6 @@ //! The authoritative st3 subject, resource, and claim registry. +pub mod client_control; pub mod arrangements; pub mod custom; pub mod accountable; diff --git a/docs/st3/client-v0/device-commands.md b/docs/st3/client-v0/device-commands.md index 8067214e7a7..655f8276959 100644 --- a/docs/st3/client-v0/device-commands.md +++ b/docs/st3/client-v0/device-commands.md @@ -127,3 +127,99 @@ available for five minutes of ephemeral correlation before eviction. The process discard a retained outcome early to make room. A daemon restart or eviction makes outcomes unavailable. Expired commands are never executed on reconnection. The normal envelope's snapshot describes the response context, not durable storage of the command. + +## Isolated client-control v1 signed results + +The generated client-control contract uses the same `phone.inspect` and +`phone.navigate` verbs on iOS, web and TUI. `phone.message.read` is excluded from +test grants. A granted request adds `grant_id`; its command carries `grant_id`, +`run_id` and `control_grant` (including the pairing `session` actor). Ordinary +ungranted reports retain the preceding wire shape. + +A granted `DeviceResult` adds `binding: ClientResultBinding` and the existing +`signature: DeviceSignature` envelope. The binding contains `purpose`, +`command_kind`, `device`, `session`, `grant_id`, `run_id`, `command_id`, +`outcome`, nullable `error_code`, and `visible_state`. Outer command, outcome and +error must agree with the binding; any outer `result` must equal `visible_state`. +The closed visible-state type has `route`, `screen`, `visible_item_ids` and +nullable `opened_item: {id, title, body}`. Arrays retain display order. + +`visible_state_sha256` is lowercase SHA-256 of UTF-8 RFC 8785 JCS JSON. The +closed type admits only strings, null, arrays and fixed ASCII object keys: +there is no floating-point normalization or arbitrary-key sorting ambiguity. +No Unicode normalization is applied, lone surrogates are invalid, and slash, +non-ASCII and U+2028/U+2029 are not escaped. + +Sign UTF-8 newline-joined lines below, **without a trailing newline**. Every +right-hand value is its compact JSON encoding, including quotes for strings. +The purpose is the first signed field, and the field order is normative: + +```text +smallclaims-claim-fields-v1 +purpose="client.device-result.v1" +command_kind="phone.inspect" +device="device/example" +session="client-session/example" +grant_id="client-control-grant/example" +run_id="mission-run/example" +command_id="device-command/example" +outcome="completed" +error_code=null +visible_state_sha256="" +``` + +Use P-256 ECDSA with SHA-256; signature encoding is 64-byte IEEE P1363 `r||s`, +base64url without padding, not ASN.1 DER. The envelope format is `fields-v1` and +`signed_fields` is exactly the ordered list above. The enrolled pairing key is +the authority, never a caller-selected envelope key. Envelope `nonce` and +`signed_at_unix_ms` are not signed or freshness authority: daemon time must be +within both command and grant windows, and a command accepts only one result. +This signature is purpose-specific and never authorizes `message.send` or a +read-state mutation; fixture pairings must not acquire `control.messages`. + +The source templates live in `crates/fractal-client-codegen/templates`; regenerate +all outputs with `cargo run -p fractal-client-codegen`. Rust exports canonical +helpers through `fractal_schema::client_control` and `fractal_client`; TypeScript +uses `ClientControl.generated.ts`, and Swift uses `ClientControl.generated.swift`. +All three test suites read the same +`fixtures/clients/client-control-v1.golden.json`, including fixed-public-test-key +signatures and negative bindings for grant/run/command/purpose/kind/device/session, +outcome, error and visible-state tampering. The fixture scalar and nonce are public +test data, never enrollment keys. + +## Fixture enrollment and revocation + +Enrollment is a privileged local Unix-socket operation on the local-only +`client-control-fixture` instance owned by `person/client-control-fixture`. +The socket peer must have the daemon's UID. A remote bearer credential, +request header, ordinary pairing, or main-instance operator cannot enroll. +Each isolated client first generates its own P-256 key and uses the existing +pairing flow. Enrollment binds the exact existing pairing subject, device and +public key; it never generates keys or grants principal signing authority. +Pairing scopes must include `read.projections` and `control.commands`, with +only `read.*` scopes otherwise; `control.messages` is forbidden. + +The enrollment request records `device`, `pairing_subject`, `public_key`, +`kind` (`ios`, `web`, `tui`), `host`, `instance`, `consent_ref` and +`packet_digest`. The instance is exactly `client-control-fixture`. +`consent_ref` must name the native ask `step-run/ask-…/ask`; `packet_digest` +is the lowercase 64-hex SHA256 of the reviewed enrollment packet and input +manifest. These become `consentRef` and `packetDigest` in the persisted +`custom.client.test-enrolled` claim alongside the bound host, instance and kind. +Invalid ask IDs return `consent-ref-invalid`; malformed digests return +`consent-packet-invalid`; reuse across devices, kinds or re-enrollment returns +`consent-ref-reused`. Re-enrollment or changed scope requires a fresh ask. + +Approval verification is an external mission gate: before the local call, +verify the native ask was answered by `person/schickling` with explicit approval +of the matching packet digest, kind, host and instance. Silence is not approval. +The privileged operator attests that verified gate; the fixture daemon never +connects to the main daemon or imports its person/ask claims. A signed exported +consent receipt is not part of this protocol. + +The exact paired client's revoke control, a local agent, or the daemon-host +operator appends `custom.client.test-revoked` naming the enrollment claim ID. +This persisted withdrawal ends every grant bound to that enrollment, regardless +of timestamps; new enrollment cannot resurrect old grants. Grant issue, command +admission and result acceptance reload enrollment and pairing authority. +Operators can revoke even after the pairing expires. diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 3daf13f9002..560dd9890b9 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -457,16 +457,64 @@ "DeviceCommandRequest": { "description": "One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device.", "oneOf": [ - {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.inspect"}, "args": {"$ref": "#/$defs/PhoneInspectArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, - {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.navigate"}, "args": {"$ref": "#/$defs/PhoneNavigateArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, - {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.message.read"}, "args": {"$ref": "#/$defs/PhoneMessageReadArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}} + {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.inspect"}, "args": {"$ref": "#/$defs/PhoneInspectArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, + {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.navigate"}, "args": {"$ref": "#/$defs/PhoneNavigateArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}, + {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.message.read"}, "args": {"$ref": "#/$defs/PhoneMessageReadArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}} ] }, "DeviceCommandOutcome": {"enum": ["completed", "not-foreground", "expired", "refused", "failed"]}, "DeviceCommandState": {"enum": ["pending", "delivered", "completed", "not-foreground", "expired", "refused", "failed"]}, + "ClientVisibleItem": { + "type": "object", "additionalProperties": false, "required": ["id", "title", "body"], + "properties": {"id": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}} + }, + "ClientVisibleState": { + "type": "object", "additionalProperties": false, "required": ["route", "screen", "visible_item_ids", "opened_item"], + "properties": {"route": {"type": "string"}, "screen": {"type": "string"}, "visible_item_ids": {"type": "array", "items": {"type": "string"}}, "opened_item": {"anyOf": [{"$ref": "#/$defs/ClientVisibleItem"}, {"type": "null"}]}} + }, + "ClientResultBinding": { + "type": "object", "additionalProperties": false, + "required": ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state"], + "properties": { + "purpose": {"const": "client.device-result.v1"}, "command_kind": {"enum": ["phone.inspect", "phone.navigate"]}, + "device": {"type": "string"}, "session": {"type": "string"}, "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "command_id": {"type": "string"}, + "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "error_code": {"type": ["string", "null"]}, "visible_state": {"$ref": "#/$defs/ClientVisibleState"} + } + }, + "ClientControlGrant": { + "type": "object", "additionalProperties": false, + "required": ["grant_id", "run_id", "device", "session", "actor_agent", "verbs", "screens", "max_commands", "expires_at", "state"], + "properties": { + "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "device": {"type": "string"}, "session": {"type": "string"}, "actor_agent": {"type": "string"}, + "verbs": {"type": "array", "items": {"type": "string"}}, "screens": {"type": "array", "items": {"type": "string"}}, + "max_commands": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, "expires_at": {"$ref": "#/$defs/Timestamp"}, "state": {"type": "string"} + } + }, + "ClientControlRefusal": {"enum": ["device-not-test-client", "grant-missing", "grant-expired", "screen-not-granted", "verb-not-granted", "budget-exhausted", "actor-not-granted"]}, + "ClientTestEnrollment": { + "type": "object", "additionalProperties": false, + "required": ["device", "kind", "enrolledBy", "consentRef", "packetDigest", "host", "instance", "pairingSubject", "publicKey", "session"], + "properties": { + "device": {"type": "string"}, "kind": {"enum": ["ios", "web", "tui"]}, "enrolledBy": {"type": "string"}, "consentRef": {"type": "string"}, + "packetDigest": {"type": "string"}, "host": {"type": "string"}, "instance": {"type": "string"}, "pairingSubject": {"type": "string"}, "publicKey": {"type": "string"}, "session": {"type": "string"} + } + }, + "ClientTestRevocation": { + "type": "object", "additionalProperties": false, "required": ["device", "enrollmentId", "revokedBy"], + "properties": {"device": {"type": "string"}, "enrollmentId": {"type": "string"}, "revokedBy": {"type": "string"}} + }, + "ClientCommandReceipt": { + "type": "object", "additionalProperties": false, "required": ["commandId", "terminalState", "issuer", "device", "commandKind", "verifiedResultSignature", "signedResult"], + "properties": { + "commandId": {"type": "string"}, "terminalState": {"$ref": "#/$defs/DeviceCommandOutcome"}, "issuer": {"type": "string"}, "device": {"type": "string"}, + "session": {"type": "string"}, "grantId": {"type": "string"}, "runId": {"type": "string"}, "commandKind": {"type": "string"}, + "screen": {"type": "string"}, "reason": {"$ref": "#/$defs/ClientControlRefusal"}, + "verifiedResultSignature": {"anyOf": [{"$ref": "#/$defs/DeviceSignature"}, {"type": "null"}]}, "signedResult": {"anyOf": [{"$ref": "#/$defs/ClientResultBinding"}, {"type": "null"}]} + } + }, "DeviceResult": { "type": "object", "additionalProperties": false, "required": ["command_id", "outcome"], - "properties": {"command_id": {"type": "string", "pattern": "^device-command/.+"}, "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}}, + "properties": {"command_id": {"type": "string", "pattern": "^device-command/.+"}, "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}, "binding": {"$ref": "#/$defs/ClientResultBinding"}, "signature": {"$ref": "#/$defs/DeviceSignature"}}, "description": "Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry." }, "DeviceCommand": { @@ -481,6 +529,8 @@ "device_id": {"type": "string", "pattern": "^device/.+"}, "session_actor": {"$ref": "#/$defs/Id"}, "issued_by": {"$ref": "#/$defs/Id"}, "issued_at": {"$ref": "#/$defs/Timestamp"}, "expires_at": {"$ref": "#/$defs/Timestamp"}, + "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "control_grant": {"$ref": "#/$defs/ClientControlGrant"}, + "receipt_claim_id": {"type": "string"}, "state": {"$ref": "#/$defs/DeviceCommandState"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"} }, "oneOf": [ diff --git a/release-notes/client-control-canonical-v1.json b/release-notes/client-control-canonical-v1.json new file mode 100644 index 00000000000..70114afc2b1 --- /dev/null +++ b/release-notes/client-control-canonical-v1.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Generate one purpose-specific canonical client-control result format for Rust, TypeScript and Swift", + "replay": {"status":"none","detail":"Canonical helpers and wire models do not change store replay or migrate existing claims."}, + "schema": {"status":"changed","detail":"Adds closed visible-state, signed-result binding and per-run control-grant wire models. Granted commands carry grant/run metadata; result requests optionally carry a binding and existing DeviceSignature envelope."}, + "rules": {"status":"changed","detail":"The client.device-result.v1 domain binds command kind, enrolled device and pairing session, grant, run, command id, outcome, error and RFC 8785 visible-state digest. It is never message.send authority and requires no control.messages scope."}, + "compatibility": {"status":"changed","detail":"The existing phone.inspect and phone.navigate verb names are shared by all test-client kinds. Legacy ungranted request/result shapes remain decodable. All clients share fixed-key golden vectors and cross-domain rejection tests."}, + "downtime": {"status":"none","detail":"Source-only contract generation requires no live setup, enrollment, daemon or pairing changes."}, + "manual": {"status":"none","detail":"Regenerate checked-in canonical outputs with cargo run -p fractal-client-codegen. Fixture key scalar and signing nonce are public test material, never production identities."}, + "recovery": {"status":"none","detail":"No persisted data or runtime state is changed by canonical source generation."} +} From 6ad7f674d4503ce4dddd2186c66943c9a1b08751 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:40:38 +0100 Subject: [PATCH 08/32] Complete canonical enrollment and receipt wire models Generated outputs remain pending admitted codegen; not consumer-ready. agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .gitattributes | 2 ++ .../templates/ClientControl.swift.in | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.gitattributes b/.gitattributes index f5cc60d6f29..1579104b390 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,8 @@ crates/fractal-client/src/generated.rs linguist-generated=true crates/fractal-client/src/contract.rs linguist-generated=true crates/fractal-client/src/lib.rs linguist-generated=true crates/fractal-client/src/notice_descriptors.generated.rs linguist-generated=true +crates/fractal-schema/src/client_control.rs linguist-generated=true +fixtures/clients/client-control-v1.golden.json linguist-generated=true clients/typescript/fractal-client/*.generated.ts linguist-generated=true clients/swift/FractalClient/Sources/FractalClient/*.generated.swift linguist-generated=true clients/swift/FractalClient/Sources/FractalClient/Models.swift linguist-generated=true diff --git a/crates/fractal-client-codegen/templates/ClientControl.swift.in b/crates/fractal-client-codegen/templates/ClientControl.swift.in index f32686b92fb..0c173682ac7 100644 --- a/crates/fractal-client-codegen/templates/ClientControl.swift.in +++ b/crates/fractal-client-codegen/templates/ClientControl.swift.in @@ -19,15 +19,17 @@ public struct ClientTestRevocation: Codable, Sendable { } public struct ClientCommandReceipt: Codable, Sendable { public var commandId: String; public var terminalState: DeviceCommandOutcome - public var issuer: String; public var device: String; public var session: String - public var grantId: String; public var runId: String; public var commandKind: String + public var issuer: String; public var device: String; public var session: String? + public var grantId: String?; public var runId: String?; public var commandKind: String + public var screen: String?; public var reason: ClientControlRefusal? public var verifiedResultSignature: DeviceSignature?; public var signedResult: ClientResultBinding? - private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, verifiedResultSignature, signedResult } + private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, screen, reason, verifiedResultSignature, signedResult } public func encode(to encoder: Encoder) throws { var box = encoder.container(keyedBy: CodingKeys.self) try box.encode(commandId, forKey: .commandId); try box.encode(terminalState, forKey: .terminalState) - try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encode(session, forKey: .session) - try box.encode(grantId, forKey: .grantId); try box.encode(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind) + try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encodeIfPresent(session, forKey: .session) + try box.encodeIfPresent(grantId, forKey: .grantId); try box.encodeIfPresent(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind) + try box.encodeIfPresent(screen, forKey: .screen); try box.encodeIfPresent(reason, forKey: .reason) if let verifiedResultSignature { try box.encode(verifiedResultSignature, forKey: .verifiedResultSignature) } else { try box.encodeNil(forKey: .verifiedResultSignature) } if let signedResult { try box.encode(signedResult, forKey: .signedResult) } From f09c793e61bd0355aac649927433f36f39d2a109 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:41:17 +0100 Subject: [PATCH 09/32] Align client-control integration notes with published canonical source agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 8 ++++++-- apps/fractal-web/src/client-control/identity.ts | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 4463f581abe..9c19c6ab70a 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -16,15 +16,19 @@ Colocated tests cover crypto/storage, foreground, suppression, indicator states, ## Integration dependencies -1. [Canonical daemon contract #6274](https://github.com/compoundingtech/fractal/pull/6274): vocabulary, grant types, generated visible state, purpose-specific result signing and shared fixed-key positive/negative golden vectors. Inspected planning head: `ceb85d30001faaaee311ac6dd145e10899490e6b`. Conformance tests must read the eventual repository vector file, never duplicate its fields or bytes. +1. [Canonical daemon contract #6274](https://github.com/compoundingtech/fractal/pull/6274): inspected source head `b6c0d55d9325ec53f5285ac30b4ff38707cd22c8`, including `docs/st3/client-v0/device-commands.md`, `crates/fractal-client-codegen/templates/ClientControl.ts.in` and the fixed-key vector input template. This head is **NOT consumer-ready**: generated outputs and approved computed vectors await the announced codegen commit. Future conformance tests must read `fixtures/clients/client-control-v1.golden.json` from the repository, never duplicate its fields or bytes. 2. Subsequent enrollment readback/revoke and grant-subscription PRs: authoritative pairing/key enrollment and session-scoped grant lifecycle. Their handles remain pending. The subsequent verification/receipt PR supplies result verification and durable terminal receipts. 3. Workspace-model/routes group: `cac380ac98`, `df082cd9e8`, `349b0da2aa`, `5a107113bd`. Its `workspace/subject.ts` resolves Inbox to `/w/inbox` and Conversation to `/w/`; `workspace/useWorkspace.ts` projects routing and `workspace/surfaces.tsx` renders it. None was cherry-picked here. 4. State-cells read-position group: `9e66194639`, `0d21c1286b`, `45710bb1d5`. Wrap `state/cells/readPositions.ts` store `advance`, used by both `onSeen` and `useMarkRead` in `web/readPosition.ts`. 5. [Read-position synchronization #6108](https://github.com/compoundingtech/fractal/pull/6108), inspected at `01fd722d1cdfc4beb916817ab9dc68f0307b437b`: its web delta adds lastProseAt projection/types, not a web synchronization caller. The eventual outbound writer belongs inside the same gated advancement boundary as browser-local persistence. +## Resolved source contract; consumption still blocked + +#6274 now specifies the existing phone.inspect/phone.navigate vocabulary, generated visible-state and result-binding/grant types, purpose-first claim-fields canonicalization over the JCS visible-state digest, and P-256/SHA-256 IEEE P1363 signatures with unpadded base64url envelope serialization. The signing interface deliberately remains supplied-byte-only: the future adapter must consume `ClientControl.generated.ts` helpers/types rather than reproduce template fields locally. Native WebCrypto signature bytes are compatible with the specified P1363 format; envelope construction belongs to that adapter, not the identity store. These are source-contract resolutions, not conformance or consumer-readiness claims. + ## Open contracts -No guessed wire fields or signing bytes are implemented. Pending contracts include pairing-bound enrollment readback and public-key binding; grant start/end/revoke events and command identity/window fields; generated visible state, canonical purpose-first signing lines, JCS vectors and signature/result serialization; enrollment-revoke operation; fixture local-only attestation, manifest/readback and authorized bootstrap; optional Conversation sessionId routing; and rendered-body expectations when fixture source is Markdown. The observer does not invent Markdown normalization. +No guessed wire fields or signing bytes are implemented. Still pending: consumer-ready generated outputs and approved computed golden vectors; authoritative enrollment readback and pairing/public-key binding adapter; grant start/end/revoke delivery and its executable authority semantics; enrollment-revoke operation; fixture local-only attestation, manifest/readback and authorized bootstrap; optional Conversation sessionId routing; and rendered-body expectations when fixture source is Markdown. The published enrollment documentation describes local authority constraints, but does not yet supply the browser readback/revoke integration. The observer does not invent Markdown normalization. Activation, command subscription, generated capture/signing, actual store wrapping and the complete journey await those contracts. Browser flags or preferences must never enable test mode on a normal pairing. diff --git a/apps/fractal-web/src/client-control/identity.ts b/apps/fractal-web/src/client-control/identity.ts index 38683400c0f..9323d7c7457 100644 --- a/apps/fractal-web/src/client-control/identity.ts +++ b/apps/fractal-web/src/client-control/identity.ts @@ -26,9 +26,10 @@ export interface ClientControlIdentity { /** DER SubjectPublicKeyInfo bytes; pairing's eventual representation is not decided here. */ exportPublicKey(): Promise /** - * Signs caller-supplied canonical bytes, without encoding or constructing a device.result. + * Signs bytes supplied by the generated client-result canonical helper; never reconstruct fields here. * The caller must enforce enrollment, foreground, command and active-grant checks first. - * Returns WebCrypto's native signature bytes; no daemon serialization is assumed. + * Returns native P1363 signature bytes. The generated-contract adapter owns base64url/envelope + * serialization; #6274's source contract is known but its generated outputs are not consumer-ready. */ signCanonicalBytes(bytes: Uint8Array): Promise } From 7770fd0a8eea0c79f786133b1f6c7f05ee2e1e58 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:48:04 +0100 Subject: [PATCH 10/32] Record main-first canonical dependency consumption for web client control agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 9c19c6ab70a..9162c241562 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -22,6 +22,8 @@ Colocated tests cover crypto/storage, foreground, suppression, indicator states, 4. State-cells read-position group: `9e66194639`, `0d21c1286b`, `45710bb1d5`. Wrap `state/cells/readPositions.ts` store `advance`, used by both `onSeen` and `useMarkRead` in `web/readPosition.ts`. 5. [Read-position synchronization #6108](https://github.com/compoundingtech/fractal/pull/6108), inspected at `01fd722d1cdfc4beb916817ab9dc68f0307b437b`: its web delta adds lastProseAt projection/types, not a web synchronization caller. The eventual outbound writer belongs inside the same gated advancement boundary as browser-local persistence. +#6278 stays based on origin/main and consumes #6274's generated TS module and shared vector file after they land on main. + ## Resolved source contract; consumption still blocked #6274 now specifies the existing phone.inspect/phone.navigate vocabulary, generated visible-state and result-binding/grant types, purpose-first claim-fields canonicalization over the JCS visible-state digest, and P-256/SHA-256 IEEE P1363 signatures with unpadded base64url envelope serialization. The signing interface deliberately remains supplied-byte-only: the future adapter must consume `ClientControl.generated.ts` helpers/types rather than reproduce template fields locally. Native WebCrypto signature bytes are compatible with the specified P1363 format; envelope construction belongs to that adapter, not the identity store. These are source-contract resolutions, not conformance or consumer-readiness claims. From 5a16fb5ca454a0dd75154bcbcff42440021c98f5 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:21:03 +0100 Subject: [PATCH 11/32] Restrict client-control journey to native asks with no-answer and grant guards agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../src/client-control/fixtureDomEvidence.ts | 65 +++++++++------- .../fixtureDomEvidence.unit.test.ts | 18 ++--- .../client-control/fixtureNoAnswerGuard.ts | 39 ++++++++++ .../fixtureNoAnswerGuard.unit.test.ts | 26 +++++++ .../src/client-control/fixtureText.ts | 8 ++ .../client-control/fixtureText.unit.test.ts | 10 +++ .../src/client-control/grantBoundary.ts | 54 +++++++++++++ .../client-control/grantBoundary.unit.test.ts | 77 +++++++++++++++++++ .../src/client-control/navigation.ts | 4 +- 9 files changed, 262 insertions(+), 39 deletions(-) create mode 100644 apps/fractal-web/src/client-control/fixtureNoAnswerGuard.ts create mode 100644 apps/fractal-web/src/client-control/fixtureNoAnswerGuard.unit.test.ts create mode 100644 apps/fractal-web/src/client-control/fixtureText.ts create mode 100644 apps/fractal-web/src/client-control/fixtureText.unit.test.ts create mode 100644 apps/fractal-web/src/client-control/grantBoundary.ts create mode 100644 apps/fractal-web/src/client-control/grantBoundary.unit.test.ts diff --git a/apps/fractal-web/src/client-control/fixtureDomEvidence.ts b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts index 9e758e09be0..7cdf5a538a8 100644 --- a/apps/fractal-web/src/client-control/fixtureDomEvidence.ts +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts @@ -1,10 +1,13 @@ import { guardFixtureBoundary, type FixtureGuardInput } from './fixtureGuard.ts' +import type { FixtureNoAnswerGuard } from './fixtureNoAnswerGuard.ts' +import { isFixturePlainText } from './fixtureText.ts' -/** Explicit adapter projection of VERIFIED setup output, not a daemon wire schema. */ +/** Verified manifest projection of one open native ask, not a daemon wire schema. */ export interface FixtureSeedExpectation { - readonly itemId: string + readonly askId: string readonly title: string - readonly bodySha256: string + /** SHA-256 of the rendered request.question text, never answer text or conversation content. */ + readonly questionTextSha256: string readonly subjectIds: readonly string[] } @@ -21,12 +24,12 @@ export interface FixtureDomEvidence { readonly commit: string readonly origin: string readonly observedAt: string - readonly itemId: string + readonly askId: string readonly title: string - readonly renderedBodySha256: string + readonly renderedQuestionTextSha256: string readonly renderedItemIds: readonly string[] readonly titleMatches: boolean - readonly bodyMatches: boolean + readonly questionMatches: boolean readonly catalogMatches: boolean readonly passed: boolean /** Independent evidence only. Not a device.result, signed receipt, or complete journey. */ @@ -36,23 +39,23 @@ export interface FixtureDomEvidence { /** Pure comparison shared by the observer and unit tests. No client-reported state is accepted. */ export const compareFixtureDom = (seed: FixtureSeedExpectation, observed: { readonly title: string - readonly bodySha256: string + readonly questionTextSha256: string readonly itemIds: readonly string[] -}): { readonly titleMatches: boolean; readonly bodyMatches: boolean; readonly catalogMatches: boolean; readonly passed: boolean } => { +}): { readonly titleMatches: boolean; readonly questionMatches: boolean; readonly catalogMatches: boolean; readonly passed: boolean } => { const allowed = new Set(seed.subjectIds) const titleMatches = observed.title === seed.title - const bodyMatches = observed.bodySha256 === seed.bodySha256 - const catalogMatches = observed.itemIds.includes(seed.itemId) && observed.itemIds.every(id => allowed.has(id)) - return { titleMatches, bodyMatches, catalogMatches, passed: titleMatches && bodyMatches && catalogMatches } + const questionMatches = observed.questionTextSha256 === seed.questionTextSha256 + const catalogMatches = observed.itemIds.includes(seed.askId) && observed.itemIds.every(id => allowed.has(id)) + return { titleMatches, questionMatches, catalogMatches, passed: titleMatches && questionMatches && catalogMatches } } /** * Call AFTER granted navigation on the real app in a fresh, dedicated Playwright context. * The runner owns context creation, verified fixture attestation, network capture and granted * commands. This function never navigates, pairs, reads credentials, mutates state, or signs. - * Selectors identify the rendered title/body INSIDE the seed item, not hidden report metadata. - * Digest is SHA-256 of exact UTF-8 DOM innerText, without trimming/normalizing. A manifest hashing - * source Markdown instead needs an authoritative rendered-text expectation before this can pass. + * Selectors identify the ask title and rendered request.question INSIDE the expanded native ask. + * Digest is SHA-256 of exact UTF-8 DOM innerText, without trimming/normalizing. Never press answer + * controls: install the no-answer guard before navigation and assert it again at journey end. * Writes both passing and failing evidence; a false `passed` MUST fail the enclosing journey. */ export const observeFixtureDom = async (input: { @@ -62,43 +65,49 @@ export const observeFixtureDom = async (input: { readonly page: ObservationPage readonly seed: FixtureSeedExpectation readonly titleSelector: string - readonly bodySelector: string + readonly questionSelector: string + readonly noAnswerGuard: FixtureNoAnswerGuard readonly writeEvidence: (evidence: FixtureDomEvidence) => Promise }): Promise => { const boundary = guardFixtureBoundary(input.boundary) + input.noAnswerGuard.assertNoAnswers() if (!/^[0-9a-f]{40}$/.test(input.commit)) throw new TypeError('A full exact-candidate commit is required') - if (!/^[0-9a-f]{64}$/.test(input.seed.bodySha256) || input.seed.title === '' || input.seed.itemId === '' || - !input.seed.subjectIds.includes(input.seed.itemId)) throw new TypeError('A verified seed and complete subject catalog are required') + if (!/^[0-9a-f]{64}$/.test(input.seed.questionTextSha256) || !isFixturePlainText(input.seed.title) || input.seed.askId === '' || + !input.seed.subjectIds.includes(input.seed.askId)) throw new TypeError('A verified open native ask and complete subject catalog are required') if (new URL(input.page.url()).origin !== boundary.origin || new URL(input.page.url()).pathname.includes('embed')) throw new TypeError('Observation must remain on the dedicated real-app origin, never embed') // One DOM read avoids mixing title, body and catalog from different renders. const snapshots = await input.page.locator('html').evaluateAll((elements, selection) => elements.map(element => { const visible = (node: Element): node is HTMLElement => node instanceof HTMLElement && node.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) const items = Array.from(element.querySelectorAll('[data-item-id]')).filter(visible) - const seeds = items.filter(node => node.getAttribute('data-item-id') === selection.itemId) + const seeds = items.filter(node => node.getAttribute('data-item-id') === selection.askId) // Structural wrappers can repeat the id; choose the innermost content owner. const owners = seeds.filter(node => !seeds.some(other => other !== node && node.contains(other))) const owner = owners.length === 1 ? owners[0] : undefined return { - titles: owner === undefined ? [] : Array.from(owner.querySelectorAll(selection.titleSelector)).filter(visible).map(node => node.innerText), - bodies: owner === undefined ? [] : Array.from(owner.querySelectorAll(selection.bodySelector)).filter(visible).map(node => node.innerText), + titles: owner === undefined ? [] : Array.from(owner.querySelectorAll(selection.titleSelector)).filter(visible).map(node => ({ text: node.innerText, leaf: node.childElementCount === 0 })), + questions: owner === undefined ? [] : Array.from(owner.querySelectorAll(selection.questionSelector)).filter(visible).map(node => ({ text: node.innerText, leaf: node.childElementCount === 0 })), itemIds: items.flatMap(node => { const id = node.getAttribute('data-item-id') return id === null ? [] : [id] }), } - }), { itemId: input.seed.itemId, titleSelector: input.titleSelector, bodySelector: input.bodySelector }) - if (snapshots.length !== 1 || snapshots[0]!.titles.length !== 1 || snapshots[0]!.bodies.length !== 1) - throw new TypeError('Exactly one visible seed title and body in a single rendered app document are required') - const title = snapshots[0]!.titles[0]! - const body = snapshots[0]!.bodies[0]! + }), { askId: input.seed.askId, titleSelector: input.titleSelector, questionSelector: input.questionSelector }) + if (snapshots.length !== 1 || snapshots[0]!.titles.length !== 1 || snapshots[0]!.questions.length !== 1 || + !snapshots[0]!.titles[0]!.leaf || !snapshots[0]!.questions[0]!.leaf) + throw new TypeError('Ask title and question must each render as exactly one unsplit text element') + const title = snapshots[0]!.titles[0]!.text + const question = snapshots[0]!.questions[0]!.text + if (!isFixturePlainText(title) || !isFixturePlainText(question)) + throw new TypeError('Rendered ask title and question must preserve the approved plain fixture text') if (new URL(input.page.url()).origin !== boundary.origin) throw new TypeError('Origin changed during observation') - const renderedBodySha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))), byte => byte.toString(16).padStart(2, '0')).join('') + const renderedQuestionTextSha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(question))), byte => byte.toString(16).padStart(2, '0')).join('') const renderedItemIds = [...new Set(snapshots[0]!.itemIds)] + input.noAnswerGuard.assertNoAnswers() const evidence: FixtureDomEvidence = { format: 'client-control.dom-observation.v1', commit: input.commit, origin: boundary.origin, - observedAt: new Date().toISOString(), itemId: input.seed.itemId, title, renderedBodySha256, renderedItemIds, - ...compareFixtureDom(input.seed, { title, bodySha256: renderedBodySha256, itemIds: renderedItemIds }), + observedAt: new Date().toISOString(), askId: input.seed.askId, title, renderedQuestionTextSha256, renderedItemIds, + ...compareFixtureDom(input.seed, { title, questionTextSha256: renderedQuestionTextSha256, itemIds: renderedItemIds }), status: 'unsigned-independent-observation', } await input.writeEvidence(evidence) diff --git a/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts b/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts index 30d31e60a3e..a91d20e4ed7 100644 --- a/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts @@ -3,29 +3,29 @@ import { expect } from 'vitest' import { compareFixtureDom, type FixtureSeedExpectation } from './fixtureDomEvidence.ts' const seed: FixtureSeedExpectation = { - itemId: 'message/fixture-seed', title: 'client-control fixture 0001', - bodySha256: 'a'.repeat(64), subjectIds: ['message/fixture-seed', 'message/fixture-other'], + askId: 'attention/unit-seed', title: 'Unit ask title', + questionTextSha256: 'a'.repeat(64), subjectIds: ['attention/unit-seed', 'attention/unit-other'], } -const observation = { title: seed.title, bodySha256: seed.bodySha256, itemIds: [seed.itemId] } +const observation = { title: seed.title, questionTextSha256: seed.questionTextSha256, itemIds: [seed.askId] } describe('independent rendered fixture comparison', () => { - it('requires exact title, exact body digest and manifest membership', () => { - expect(compareFixtureDom(seed, observation)).toEqual({ titleMatches: true, bodyMatches: true, catalogMatches: true, passed: true }) + it('requires exact ask title, exact question-text digest and manifest membership', () => { + expect(compareFixtureDom(seed, observation)).toEqual({ titleMatches: true, questionMatches: true, catalogMatches: true, passed: true }) }) it('does not normalize title or accept a client-reported digest in place of a differing DOM digest', () => { expect(compareFixtureDom(seed, { ...observation, title: `${seed.title} ` }).passed).toBe(false) - expect(compareFixtureDom(seed, { ...observation, bodySha256: 'b'.repeat(64) }).passed).toBe(false) + expect(compareFixtureDom(seed, { ...observation, questionTextSha256: 'b'.repeat(64) }).passed).toBe(false) }) it('refuses a matching seed alongside any item outside the actual catalog', () => { - expect(compareFixtureDom(seed, { ...observation, itemIds: [seed.itemId, 'message/outside-catalog'] })).toEqual({ - titleMatches: true, bodyMatches: true, catalogMatches: false, passed: false, + expect(compareFixtureDom(seed, { ...observation, itemIds: [seed.askId, 'attention/outside-catalog'] })).toEqual({ + titleMatches: true, questionMatches: true, catalogMatches: false, passed: false, }) }) it('requires the seed to be rendered even when all other visible ids are allowed', () => { expect(compareFixtureDom(seed, { ...observation, itemIds: [] }).passed).toBe(false) - expect(compareFixtureDom(seed, { ...observation, itemIds: ['message/fixture-other'] }).passed).toBe(false) + expect(compareFixtureDom(seed, { ...observation, itemIds: ['attention/unit-other'] }).passed).toBe(false) }) }) diff --git a/apps/fractal-web/src/client-control/fixtureNoAnswerGuard.ts b/apps/fractal-web/src/client-control/fixtureNoAnswerGuard.ts new file mode 100644 index 00000000000..6ddf3f1f05e --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureNoAnswerGuard.ts @@ -0,0 +1,39 @@ +/** Install on the dedicated Playwright context BEFORE opening the real app. */ +export interface FixtureActionRoute { + readonly request: () => { readonly url: () => string; readonly method: () => string } + readonly abort: (errorCode: 'blockedbyclient') => Promise + readonly continue: () => Promise +} +export interface FixtureActionContext { + readonly route: (pattern: string, handler: (route: FixtureActionRoute) => Promise) => Promise +} +export interface FixtureNoAnswerGuard { + /** Assert after observation AND again at journey end; attempted answers fail even when blocked. */ + readonly assertNoAnswers: () => void +} + +/** AnswerAlert sends every native answer through this generated client's mutation endpoint. */ +export const isFixtureAnswerMutation = (url: string, method: string): boolean => + new URL(url).pathname === '/v1/client/actions' && method !== 'GET' && method !== 'HEAD' + +/** + * Fail closed for ALL subject-action writes, not only a particular ask id or answer choice. + * The journey may expand an ask locally, but must never press answer controls. This guard + * prevents accidental answers from reaching the daemon and makes any attempt fail acceptance. + * Device-result submission uses a separate endpoint and is not suppressed. + */ +export const installFixtureNoAnswerGuard = async (context: FixtureActionContext): Promise => { + let attemptedAnswers = 0 + await context.route('**/v1/client/actions**', async route => { + const request = route.request() + if (isFixtureAnswerMutation(request.url(), request.method())) { + attemptedAnswers += 1 + await route.abort('blockedbyclient') + } else await route.continue() + }) + return { + assertNoAnswers: () => { + if (attemptedAnswers !== 0) throw new TypeError('Fixture journey attempted an answer or other subject action') + }, + } +} diff --git a/apps/fractal-web/src/client-control/fixtureNoAnswerGuard.unit.test.ts b/apps/fractal-web/src/client-control/fixtureNoAnswerGuard.unit.test.ts new file mode 100644 index 00000000000..f3878bd50fd --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureNoAnswerGuard.unit.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest' +import { installFixtureNoAnswerGuard, isFixtureAnswerMutation, type FixtureActionRoute } from './fixtureNoAnswerGuard.ts' + +it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('blocks %s subject actions independently of ask id or answer payload', method => { + expect(isFixtureAnswerMutation('http://127.0.0.1:7411/v1/client/actions?trace=unit', method)).toBe(true) +}) + +it('does not mistake inspect delivery or signed results for answers', () => { + expect(isFixtureAnswerMutation('http://127.0.0.1:7411/v1/client/actions', 'GET')).toBe(false) + expect(isFixtureAnswerMutation('http://127.0.0.1:7411/v1/client/device-results', 'POST')).toBe(false) +}) + +it('asserts failed acceptance even when the attempted action was successfully blocked', async () => { + let installed: ((route: FixtureActionRoute) => Promise) | undefined + const guard = await installFixtureNoAnswerGuard({ route: async (_pattern, handler) => { installed = handler } }) + guard.assertNoAnswers() + let aborted = false + let continued = false + await installed!({ + request: () => ({ url: () => 'http://127.0.0.1:7411/v1/client/actions', method: () => 'POST' }), + abort: async () => { aborted = true }, continue: async () => { continued = true }, + }) + expect(aborted).toBe(true) + expect(continued).toBe(false) + expect(() => guard.assertNoAnswers()).toThrow('attempted an answer') +}) diff --git a/apps/fractal-web/src/client-control/fixtureText.ts b/apps/fractal-web/src/client-control/fixtureText.ts new file mode 100644 index 00000000000..bb598325e42 --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureText.ts @@ -0,0 +1,8 @@ +import { Option, Schema } from 'effect' + +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) + +/** Final fixture-source restriction for BOTH ask title and request.question. No normalization. */ +export const isFixturePlainText = (text: string): boolean => text.length > 0 && text.length <= 60 + && /^[\x20-\x7e]+$/.test(text) && text.trim() === text && !text.includes(' ') + && !/[*_#\[\]`>|]/.test(text) && Option.isNone(decodeJson(text)) diff --git a/apps/fractal-web/src/client-control/fixtureText.unit.test.ts b/apps/fractal-web/src/client-control/fixtureText.unit.test.ts new file mode 100644 index 00000000000..62d92dd1a91 --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureText.unit.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { isFixturePlainText } from './fixtureText.ts' + +it.each(['client-control fixture 0001', 'Plain ask title', 'A'.repeat(60)])('accepts exact plain text: %s', text => { + expect(isFixturePlainText(text)).toBe(true) +}) + +it.each(['', 'A'.repeat(61), ' leading', 'trailing ', 'two spaces', 'line\nbreak', 'tab\there', 'non ASCII é', + '*bold*', '_emphasis_', '# heading', '[link]', '`code`', '> quote', 'a|b', '{"a":1}', 'true', '123', '"JSON string"']) + ('rejects non-fixture text: %s', text => { expect(isFixturePlainText(text)).toBe(false) }) diff --git a/apps/fractal-web/src/client-control/grantBoundary.ts b/apps/fractal-web/src/client-control/grantBoundary.ts new file mode 100644 index 00000000000..ad0fd803903 --- /dev/null +++ b/apps/fractal-web/src/client-control/grantBoundary.ts @@ -0,0 +1,54 @@ +import type { ClientControlRouteTarget } from './navigation.ts' + +/** Internal adapter inputs, NOT copied wire types. Decode generated commands before this boundary. */ +export interface ClientControlIntent { + readonly verb: string + readonly screenName?: string + readonly targetAskId?: string +} +export interface ClientControlPermission { + /** Authoritative adapter returns nothing unless identity/session/run/grant binding is valid. */ + readonly verbs: ReadonlySet + readonly screens: ReadonlySet +} + +const destinationFor = (intent: ClientControlIntent): ClientControlRouteTarget | undefined => { + if (intent.screenName === 'NowRoot' && intent.targetAskId === undefined) return { _tag: 'NowRoot' } + if (intent.screenName === 'Attention' && intent.targetAskId !== undefined && intent.targetAskId !== '') + return { _tag: 'Attention', askId: intent.targetAskId } + return undefined +} + +/** + * Narrow handler boundary: there is deliberately no answer/close/read-state callback or arbitrary + * DOM activation capability. Only granted inspect and the final NowRoot/Attention route set exist. + * The later generated-wire adapter owns identity/session/run/window validation and permission revocation. + */ +export const handleGrantedClientControlIntent = async (input: { + readonly currentScreen: () => string + readonly intent: ClientControlIntent + readonly permission: () => ClientControlPermission | undefined + readonly foreground: () => boolean + readonly navigate: (target: ClientControlRouteTarget) => Promise + readonly signVisibleState: () => Promise +}): Promise<'Refused' | 'NotForeground' | 'Applied'> => { + const intent = input.intent + if (intent.verb !== 'phone.inspect' && intent.verb !== 'phone.navigate') return 'Refused' + const destination = intent.verb === 'phone.navigate' ? destinationFor(intent) : undefined + if (intent.verb === 'phone.navigate' && destination === undefined) return 'Refused' + if (intent.verb === 'phone.inspect' && (intent.screenName !== undefined || intent.targetAskId !== undefined)) return 'Refused' + const allowed = () => { + const permission = input.permission() + return permission !== undefined && permission.verbs.has(intent.verb) + && permission.screens.has(destination?._tag ?? input.currentScreen()) + } + if (!allowed()) return 'Refused' + if (!input.foreground()) return 'NotForeground' + if (destination !== undefined) await input.navigate(destination) + // Navigation may yield; revoked authority or lost foreground must never sign afterward. + if (!input.permission()?.screens.has(input.currentScreen())) return 'Refused' + if (!allowed()) return 'Refused' + if (!input.foreground()) return 'NotForeground' + await input.signVisibleState() + return 'Applied' +} diff --git a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts new file mode 100644 index 00000000000..2380b7e1f4d --- /dev/null +++ b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { handleGrantedClientControlIntent, type ClientControlIntent, type ClientControlPermission } from './grantBoundary.ts' +import type { ClientControlRouteTarget } from './navigation.ts' + +const permission: ClientControlPermission = { + verbs: new Set(['phone.inspect', 'phone.navigate']), screens: new Set(['NowRoot', 'Attention']), +} + +const exercise = async (intent: ClientControlIntent, grant: ClientControlPermission | undefined = permission) => { + const navigations: ClientControlRouteTarget[] = [] + let signatures = 0 + const result = await handleGrantedClientControlIntent({ + intent, permission: () => grant, foreground: () => true, + currentScreen: () => intent.screenName === 'Attention' ? 'Attention' : 'NowRoot', + navigate: async target => { navigations.push(target) }, + signVisibleState: async () => { signatures += 1 }, + }) + return { result, navigations, signatures } +} + +describe('client-control grant-check handler boundary', () => { + it('refuses Conversation under a NowRoot/Attention grant without navigating or signing', async () => { + expect(await exercise({ verb: 'phone.navigate', screenName: 'Conversation' })).toEqual({ + result: 'Refused', navigations: [], signatures: 0, + }) + }) + + it.each(['work.done', 'prompt.respond', 'attention.answer', 'attention.close', 'phone.message.read', 'read-position.advance'])( + 'refuses %s without navigation or a signed result', async verb => { + // Even a malformed over-broad grant cannot turn the narrow handler into a write executor. + const grant = { ...permission, verbs: new Set([...permission.verbs, verb]) } + expect(await exercise({ verb }, grant)).toEqual({ result: 'Refused', navigations: [], signatures: 0 }) + }, + ) + + it('only exposes local routing and inspect/sign, never an answer-control activation capability', async () => { + expect(await exercise({ verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/unit-ask' })).toEqual({ + result: 'Applied', navigations: [{ _tag: 'Attention', askId: 'attention/unit-ask' }], signatures: 1, + }) + expect(await exercise({ verb: 'phone.inspect' })).toEqual({ result: 'Applied', navigations: [], signatures: 1 }) + }) + + it('refuses a grant that omits the destination', async () => { + expect(await exercise({ verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/unit-ask' }, + { ...permission, screens: new Set(['NowRoot']) })).toEqual({ result: 'Refused', navigations: [], signatures: 0 }) + }) + + it.each([ + [undefined, true, 'Refused'], + [permission, false, 'NotForeground'], + [{ ...permission, verbs: new Set() }, true, 'Refused'], + ] as const)('does not act without permission or foreground', async (grant, foreground, expected) => { + const calls: string[] = [] + const result = await handleGrantedClientControlIntent({ + intent: { verb: 'phone.navigate', screenName: 'NowRoot' }, permission: () => grant, + foreground: () => foreground, currentScreen: () => 'NowRoot', + navigate: async () => { calls.push('navigate') }, signVisibleState: async () => { calls.push('sign') }, + }) + expect(result).toBe(expected) + expect(calls).toEqual([]) + }) + + it('does not sign after grant loss during navigation', async () => { + let active: ClientControlPermission | undefined = permission + let signatures = 0 + const result = await handleGrantedClientControlIntent({ + intent: { verb: 'phone.navigate', screenName: 'NowRoot' }, permission: () => active, foreground: () => true, + currentScreen: () => 'NowRoot', + navigate: async () => { active = undefined }, signVisibleState: async () => { signatures += 1 }, + }) + expect(result).toBe('Refused') + expect(signatures).toBe(0) + }) +}) + +// These assertions run against the wire-independent handler. Generated command decoding, +// identity/session/run/window authority and canonical signing conformance remain pending #6274. diff --git a/apps/fractal-web/src/client-control/navigation.ts b/apps/fractal-web/src/client-control/navigation.ts index 286c6921fb8..710828d893c 100644 --- a/apps/fractal-web/src/client-control/navigation.ts +++ b/apps/fractal-web/src/client-control/navigation.ts @@ -1,7 +1,7 @@ /** App destinations, not device.command wire types. The transport must decode its generated screen first. */ export type ClientControlRouteTarget = - | { readonly _tag: 'Inbox' } - | { readonly _tag: 'Conversation'; readonly agent: `agent/${string}` } + | { readonly _tag: 'NowRoot' } + | { readonly _tag: 'Attention'; readonly askId: string } export interface ClientControlNavigation { /** Resolve through the app's subject model; no test-only renderer or fixtureSource. */ From 88adf9cfc9c8d391fb6c0dc5244b918a7ae0fccb Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:21:20 +0100 Subject: [PATCH 12/32] Expose native ask text for independent DOM proof and cover shipped rendering agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 19 ++++++++---- .../alerts/AnswerAlert.integration.test.tsx | 30 +++++++++++++++++++ .../src/conversation/alerts/AnswerAlert.tsx | 6 ++-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 9162c241562..5033d8d3a59 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -6,19 +6,22 @@ Wire-independent source preparation only. **Application test mode remains disabl - `identity.ts`: `getOrCreateClientControlIdentity()` atomically acquires an origin-local IndexedDB P-256 identity. The private key is non-extractable; public SPKI export and supplied-byte ECDSA/SHA-256 signing remain internal capabilities. Storage failures never replace a key or fall back to ephemeral identity. Key deletion is not enrollment revocation. - `foreground.ts`: visible AND focused. Re-evaluate before navigation and signing. -- `navigation.ts`: typed app destinations; the host supplies its authoritative subject-to-href resolver, not an alternate renderer or routing codec. +- `navigation.ts`: only NowRoot and Attention(ask id) destinations; the host supplies its authoritative subject-to-href resolver. Conversation is outside the journey and refused locally. +- `grantBoundary.ts`: wire-independent handler allowing only granted inspect or NowRoot/Attention navigation. There is no answer/close/read-state or DOM activation callback. It rechecks permission and foreground before signing after navigation. Unit assertions cover Conversation and mutation refusals with no navigation/signing; generated decoding, identity/session/run/window validation and actual canonical signatures remain pending. - `readAdvancement.ts`: sticky page-lifetime prohibition checked by `gateReadAdvancement` at execution time. Wrap the store's entire advance implementation, including persistence and outbound synchronization, rather than individual UI callers. Enrollment ending must not re-enable writes. - `ClientControlIndicator.tsx`: controller-owned run indicator and enrollment-revoke action with pending/unconfirmed states. The component does not create authority or optimistically hide an active grant. - `fixtureGuard.ts`: requires the exact approved dedicated loopback origin and realpath-resolved fixture instance client socket, with normal-origin/socket deny lists. This is configuration validation, not daemon attestation or enrollment authority. -- `fixtureDomEvidence.ts`: independent real Playwright-compatible DOM observation, comparing seed title, exact UTF-8 rendered innerText SHA-256 and visible item IDs against the verified actual subject catalog. Persists unsigned observations with exact candidate commit and origin; the runner must fail on `passed: false`. +- `fixtureDomEvidence.ts`: independent real Playwright-compatible DOM observation of the open native ask's title and request.question text. The digest is lowercase SHA-256 of exact UTF-8 question text. Each must render as one unsplit leaf element; altered text/digest or IDs outside the verified actual catalog fail observation. Persists unsigned observations with exact candidate commit and origin. +- `fixtureText.ts`: both title and question must be printable ASCII, at most 60 characters, no Markdown syntax characters (asterisk, underscore, hash, square brackets, backtick, greater-than or vertical bar), JSON, or leading/trailing/repeated whitespace. The title comes from the verified fixture manifest, never a hard-coded value. +- `fixtureNoAnswerGuard.ts`: install on the dedicated Playwright context before opening the app; it blocks all subject-action mutation requests and fails assertions if any were attempted, even when successfully blocked. The observer asserts the guard before and after capture; the journey must assert it again at completion and must never press answer controls. -Colocated tests cover crypto/storage, foreground, suppression, indicator states, fixture refusals and independent DOM comparisons. Internal module inputs are not daemon wire schemas. +Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Internal module inputs are not daemon wire schemas. ## Integration dependencies 1. [Canonical daemon contract #6274](https://github.com/compoundingtech/fractal/pull/6274): inspected source head `b6c0d55d9325ec53f5285ac30b4ff38707cd22c8`, including `docs/st3/client-v0/device-commands.md`, `crates/fractal-client-codegen/templates/ClientControl.ts.in` and the fixed-key vector input template. This head is **NOT consumer-ready**: generated outputs and approved computed vectors await the announced codegen commit. Future conformance tests must read `fixtures/clients/client-control-v1.golden.json` from the repository, never duplicate its fields or bytes. 2. Subsequent enrollment readback/revoke and grant-subscription PRs: authoritative pairing/key enrollment and session-scoped grant lifecycle. Their handles remain pending. The subsequent verification/receipt PR supplies result verification and durable terminal receipts. -3. Workspace-model/routes group: `cac380ac98`, `df082cd9e8`, `349b0da2aa`, `5a107113bd`. Its `workspace/subject.ts` resolves Inbox to `/w/inbox` and Conversation to `/w/`; `workspace/useWorkspace.ts` projects routing and `workspace/surfaces.tsx` renders it. None was cherry-picked here. +3. Workspace-model/routes group: `cac380ac98`, `df082cd9e8`, `349b0da2aa`, `5a107113bd`. Its `workspace/subject.ts` resolves Inbox to `/w/inbox` and Alert to `/w/inbox/`; `workspace/useWorkspace.ts` projects routing and `workspace/surfaces.tsx` renders it. None was cherry-picked here. 4. State-cells read-position group: `9e66194639`, `0d21c1286b`, `45710bb1d5`. Wrap `state/cells/readPositions.ts` store `advance`, used by both `onSeen` and `useMarkRead` in `web/readPosition.ts`. 5. [Read-position synchronization #6108](https://github.com/compoundingtech/fractal/pull/6108), inspected at `01fd722d1cdfc4beb916817ab9dc68f0307b437b`: its web delta adds lastProseAt projection/types, not a web synchronization caller. The eventual outbound writer belongs inside the same gated advancement boundary as browser-local persistence. @@ -30,10 +33,14 @@ Colocated tests cover crypto/storage, foreground, suppression, indicator states, ## Open contracts -No guessed wire fields or signing bytes are implemented. Still pending: consumer-ready generated outputs and approved computed golden vectors; authoritative enrollment readback and pairing/public-key binding adapter; grant start/end/revoke delivery and its executable authority semantics; enrollment-revoke operation; fixture local-only attestation, manifest/readback and authorized bootstrap; optional Conversation sessionId routing; and rendered-body expectations when fixture source is Markdown. The published enrollment documentation describes local authority constraints, but does not yet supply the browser readback/revoke integration. The observer does not invent Markdown normalization. +No guessed wire fields or signing bytes are implemented. Still pending: consumer-ready generated outputs and approved computed golden vectors; authoritative enrollment readback and pairing/public-key binding adapter; grant start/end/revoke delivery and its executable authority semantics; enrollment-revoke operation; fixture local-only attestation, manifest/readback and authorized bootstrap. The published enrollment documentation describes local authority constraints, but does not yet supply the browser readback/revoke integration. Activation, command subscription, generated capture/signing, actual store wrapping and the complete journey await those contracts. Browser flags or preferences must never enable test mode on a normal pairing. +## Final seed and route ruling + +The seed is one OPEN native ask (attention_kind person-step, presentation.kind request), with request.question carrying the seeded text. The journey is NowRoot -> that ask's Attention screen -> inspect; its grant screens are NowRoot and Attention ONLY. Opening is the real AlertsTab local expansion through AgentAlerts/AnswerAlert, never an answer. The independent observer compares the manifest-supplied ask title and exact question-text digest using `[data-ask-title]` and `[data-ask-question] p` inside the ask's `data-item-id`. Fixture setup owns proving the ask remains open/unread before and after; the web journey never answers, closes or advances read state. + ## Proof limits No builds, typechecks, tests, linters, formatters, Storybook, Playwright, Rust, servers or browser captures were run during source preparation. No live setup, pairing, enrollment, grant or daemon operation was performed. @@ -41,7 +48,7 @@ No builds, typechecks, tests, linters, formatters, Storybook, Playwright, Rust, Next source-regression command on the approved hosted runner, from `apps/fractal-web`: ```sh -pnpm test -- src/client-control +pnpm test -- src/client-control src/conversation/alerts/AnswerAlert.integration.test.tsx ``` Hosted CI provides source regression evidence only, never journey acceptance. Journey acceptance requires a LOCAL exact-candidate real-fixture Playwright/DOM run after authorized setup/bootstrap/enrollment. Its runner must guard before opening connections; independently verify the fixture/local-only daemon identity; supply the exact clean commit, approved unused loopback origin, fixture-only `WF_ST_GATEWAY` client socket and approved bootstrap; create a fresh dedicated browser context; capture every pre-render HTTP/collections payload; use the actual fixture manifest and real DOM selectors; persist evidence for authorized signing; and execute grant/refusal/not-foreground/revoke/read-state/receipt checks. Neither fixtureSource/embed rendering nor this independent observer alone is journey proof. diff --git a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx index 6c184eda9b1..ff433b7a6c5 100644 --- a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx +++ b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx @@ -15,6 +15,7 @@ vi.mock('@stylexjs/stylex', () => ({ import { AnswerAlert } from './AnswerAlert.tsx' import type { AlertAction } from './alert-action-port.ts' +import { isFixturePlainText } from '../../client-control/fixtureText.ts' const alert = { id: 'attention/ask', @@ -117,3 +118,32 @@ it('sends a request for changes only with the person\'s words', async () => { await act(async () => button(container, 'Send')!.click()) expect(onAct.mock.calls[0]![1]).toEqual({ type: 'work.done', summary: 'Rename the flag.', answer: { id: 'change', text: 'Rename the flag.' } }) }) + +it.each([ + ['A short native ask', 'client-control fixture 0001'], + ['Plain ASCII punctuation: yes?', 'Read this single paragraph.'], + ['T'.repeat(60), 'Q'.repeat(60)], +])('preserves approved plain ask text as one unsplit element: %s', async (title, question) => { + expect(isFixturePlainText(title)).toBe(true) + expect(isFixturePlainText(question)).toBe(true) + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + cleanup = () => { act(() => root.unmount()); container.remove() } + let answers = 0 + await act(async () => root.render( { answers += 1; return { _tag: 'Sent' } }} + />)) + const titles = container.querySelectorAll('[data-ask-title]') + const questions = container.querySelectorAll('[data-ask-question] p') + expect(titles).toHaveLength(1) + expect(questions).toHaveLength(1) + expect(titles[0]!.childElementCount).toBe(0) + expect(questions[0]!.childElementCount).toBe(0) + expect(titles[0]!.textContent).toBe(title) + expect(questions[0]!.textContent).toBe(question) + // Observe only: answer buttons are deliberately never pressed. + expect(answers).toBe(0) +}) diff --git a/apps/fractal-web/src/conversation/alerts/AnswerAlert.tsx b/apps/fractal-web/src/conversation/alerts/AnswerAlert.tsx index add4266d872..b08d1101e85 100644 --- a/apps/fractal-web/src/conversation/alerts/AnswerAlert.tsx +++ b/apps/fractal-web/src/conversation/alerts/AnswerAlert.tsx @@ -171,7 +171,7 @@ const styles = stylex.create({ /** A request's question: a JSON report as its telling fields, anything else as Markdown. */ const Question = ({ text }: { readonly text: string }): React.ReactNode => { const shown = report(text) - if (shown === null) return
+ if (shown === null) return
return (
{shown.before ? : null} @@ -313,8 +313,8 @@ export const AnswerAlert = ({ now, ) return ( -
-
◆ {alert.title}
+
+
{alert.title}
{KIND_WORDS[alert.attention_kind] ?? 'Alert'} · from {from} {step ? ` · step ${step}` : ''} · waited {ago(DateTime.formatIso(alert.requested_at), now)} From 9ac7024e8e34900795c07686d503aa7daa9aed83 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:24:05 +0100 Subject: [PATCH 13/32] Bind client control to the seeded Attention ask journey Source-only contract: optional grant item_id and NowRoot/Attention signed vectors. Generated outputs pending admitted codegen. agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../templates/ClientControl.swift.in | 1 + .../templates/ClientControl.ts.in | 1 + .../client-control-v1.golden.json.in | 124 +++++++++--------- .../templates/client_control.rs.in | 1 + .../client-v0/schemas/client-v0.schema.json | 1 + 5 files changed, 66 insertions(+), 62 deletions(-) diff --git a/crates/fractal-client-codegen/templates/ClientControl.swift.in b/crates/fractal-client-codegen/templates/ClientControl.swift.in index 0c173682ac7..4f6ed189984 100644 --- a/crates/fractal-client-codegen/templates/ClientControl.swift.in +++ b/crates/fractal-client-codegen/templates/ClientControl.swift.in @@ -80,6 +80,7 @@ public struct ClientResultBinding: Codable, Sendable, Equatable { public struct ClientControlGrant: Codable, Sendable, Equatable { public var grant_id: String; public var run_id: String; public var device: String; public var session: String; public var actor_agent: String public var verbs: [String]; public var screens: [String]; public var max_commands: UInt64; public var expires_at: String; public var state: String + public var item_id: String? } private func clientResultQuote(_ text: String) -> String { var result = "\"" diff --git a/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in index 9c2290396cd..bf8d4fd730d 100644 --- a/crates/fractal-client-codegen/templates/ClientControl.ts.in +++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in @@ -12,6 +12,7 @@ export interface ClientResultBinding { export interface ClientControlGrant { grant_id: string; run_id: string; device: string; session: string; actor_agent: string; verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string; + item_id?: string; } function quote(value: string): string { // RFC 8785 requires rejection of lone surrogates rather than replacement. diff --git a/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in index 62e50fd522c..97bd57cb9ff 100644 --- a/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in +++ b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in @@ -22,15 +22,15 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } }, - "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"message/seed\"]}", - "visible_state_sha256": "f1b171d18c588f8d7bf6df15c3b2189632e6b2192faf64be714ea0e93b1e40fa", - "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"f1b171d18c588f8d7bf6df15c3b2189632e6b2192faf64be714ea0e93b1e40fa\"", - "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXhyH3-4fQmuiZMfjBipdGKJyo7H1eIQ4nJVLpk44Q-3LA", + "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"step-run/ask-golden/ask\"]}", + "visible_state_sha256": "4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXiuu76h6230PGyTZfFujp1lxwC70zSTNzoxlic27FPzmg", "negative_bindings": [ { "purpose": "message.send", @@ -46,7 +46,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -65,7 +65,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -84,7 +84,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -103,7 +103,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -122,7 +122,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -141,7 +141,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -160,7 +160,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -179,7 +179,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -198,7 +198,7 @@ "route": "/inbox", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -217,7 +217,7 @@ "route": "/tampered", "screen": "NowRoot", "visible_item_ids": [ - "message/seed" + "step-run/ask-golden/ask" ], "opened_item": null } @@ -237,23 +237,23 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } } }, - "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/

\",\"id\":\"message/seed\",\"title\":\"Seed é 😀\"},\"route\":\"/inbox/message/seed\",\"screen\":\"Conversation\",\"visible_item_ids\":[\"message/seed\",\"message/é😀\"]}", - "visible_state_sha256": "000b60d79d2f6dd48278100222bc4d479adc8e530e922264c1a69bcdb3749220", - "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"000b60d79d2f6dd48278100222bc4d479adc8e530e922264c1a69bcdb3749220\"", - "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXjB9ZRAQfZ5iiyRkjiPK6RlzvDHFcq0ODjctNAo_Bb3lQ", + "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/

\",\"id\":\"step-run/ask-golden/ask\",\"title\":\"Seed é 😀\"},\"route\":\"/attention/step-run/ask-golden/ask\",\"screen\":\"Attention\",\"visible_item_ids\":[\"step-run/ask-golden/ask\",\"message/é😀\"]}", + "visible_state_sha256": "2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXi45Ckt8qvn0tLGFBvQ7AEZ-86R5CQtnzYJQ5kea6paXQ", "negative_bindings": [ { "purpose": "message.send", @@ -266,14 +266,14 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -290,14 +290,14 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -314,14 +314,14 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -338,14 +338,14 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -362,14 +362,14 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -386,14 +386,14 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -410,14 +410,14 @@ "outcome": "completed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -434,14 +434,14 @@ "outcome": "failed", "error_code": null, "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -458,14 +458,14 @@ "outcome": "completed", "error_code": "tampered", "visible_state": { - "route": "/inbox/message/seed", - "screen": "Conversation", + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } @@ -483,13 +483,13 @@ "error_code": null, "visible_state": { "route": "/tampered", - "screen": "Conversation", + "screen": "Attention", "visible_item_ids": [ - "message/seed", + "step-run/ask-golden/ask", "message/é😀" ], "opened_item": { - "id": "message/seed", + "id": "step-run/ask-golden/ask", "title": "Seed é 😀", "body": "Line 1\n\t\"\\\u0000\u001f<>/

" } diff --git a/crates/fractal-client-codegen/templates/client_control.rs.in b/crates/fractal-client-codegen/templates/client_control.rs.in index 71ed47bce55..cd961871806 100644 --- a/crates/fractal-client-codegen/templates/client_control.rs.in +++ b/crates/fractal-client-codegen/templates/client_control.rs.in @@ -62,6 +62,7 @@ pub struct ClientControlGrant { pub actor_agent: String, pub verbs: Vec, pub screens: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, pub max_commands: u64, pub expires_at: String, pub state: String, diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 560dd9890b9..e4429b1ea3a 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -487,6 +487,7 @@ "properties": { "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "device": {"type": "string"}, "session": {"type": "string"}, "actor_agent": {"type": "string"}, "verbs": {"type": "array", "items": {"type": "string"}}, "screens": {"type": "array", "items": {"type": "string"}}, + "item_id": {"type": "string"}, "max_commands": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, "expires_at": {"$ref": "#/$defs/Timestamp"}, "state": {"type": "string"} } }, From bd1997e7cbdbdb0c5ba6a3a26d564a2b0446c4f1 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:44:30 +0100 Subject: [PATCH 14/32] Add noncreating public identity readback for client evidence --- .../src/client-control/identity.ts | 60 +++++++++++++++++++ .../src/client-control/identity.unit.test.ts | 29 +++++++++ 2 files changed, 89 insertions(+) diff --git a/apps/fractal-web/src/client-control/identity.ts b/apps/fractal-web/src/client-control/identity.ts index 9323d7c7457..584205954ed 100644 --- a/apps/fractal-web/src/client-control/identity.ts +++ b/apps/fractal-web/src/client-control/identity.ts @@ -149,3 +149,63 @@ export const getOrCreateClientControlIdentity = async (): Promise } + +/** + * Secondary identity evidence only. Never calls the creating acquisition path, upgrades storage, + * exports a private key, or deletes anything. Daemon enrollment/signature verification is primary. + */ +export const readExistingPublicKeyPoint = async (): Promise => { + if (globalThis.crypto?.subtle === undefined || globalThis.indexedDB === undefined) { + throw new ClientControlIdentityError('unavailable', 'Identity readback requires WebCrypto and IndexedDB') + } + let database: IDBDatabase | undefined + try { + const { promise, resolve, reject } = Promise.withResolvers() + // No version argument: never upgrade an existing database. + const request = globalThis.indexedDB.open(databaseName) + let absent = false + let blocked = false + request.onupgradeneeded = () => { + absent = true + request.transaction!.abort() + request.result.close() + } + request.onerror = () => { + if (absent) resolve(undefined) + else reject(new ClientControlIdentityError('storage-failed', 'Could not read identity storage', request.error)) + } + request.onblocked = () => { + blocked = true + reject(new ClientControlIdentityError('storage-blocked', 'Identity readback is blocked')) + } + request.onsuccess = () => { + if (blocked) { request.result.close(); return } + request.result.onversionchange = () => request.result.close() + resolve(request.result) + } + database = await promise + if (database === undefined || !database.objectStoreNames.contains(storeName)) return { _tag: 'Absent' } + const stored = await readOrInsert(database) + if (stored === undefined) return { _tag: 'Absent' } + let spki: Uint8Array + try { spki = new Uint8Array(await globalThis.crypto.subtle.exportKey('spki', stored.publicKey)) } + catch (cause) { throw new ClientControlIdentityError('export-failed', 'Could not export the existing public key', cause) } + // DER SPKI for id-ecPublicKey + prime256v1, followed by the uncompressed SEC1 point. + const prefix = [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, + 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00] + if (spki.length !== prefix.length + 65 || !prefix.every((byte, index) => spki[index] === byte) + || spki[prefix.length] !== 0x04) { + throw new ClientControlIdentityError('export-failed', 'Existing public key is not an uncompressed P-256 SPKI') + } + return { _tag: 'Present', point: spki.slice(prefix.length) } + } catch (cause) { + if (cause instanceof ClientControlIdentityError) throw cause + throw new ClientControlIdentityError('storage-failed', 'Could not read the existing identity', cause) + } finally { + database?.close() + } +} diff --git a/apps/fractal-web/src/client-control/identity.unit.test.ts b/apps/fractal-web/src/client-control/identity.unit.test.ts index 6221fa25cd2..0c56c2b21b6 100644 --- a/apps/fractal-web/src/client-control/identity.unit.test.ts +++ b/apps/fractal-web/src/client-control/identity.unit.test.ts @@ -5,6 +5,7 @@ import { ClientControlIdentityError, generateClientControlKeyPair, getOrCreateClientControlIdentity, + readExistingPublicKeyPoint, } from './identity.ts' // Crypto is the runtime's REAL WebCrypto, never a mock. Only IndexedDB uses the @@ -66,6 +67,34 @@ describe('client-control P-256 identity', () => { expect(new Uint8Array(await later.exportPublicKey())).toEqual(new Uint8Array(publicKeys[0]!)) }) + it('reads absent identity without creating a database', async () => { + expect(await indexedDB.databases()).toEqual([]) + expect(await readExistingPublicKeyPoint()).toEqual({ _tag: 'Absent' }) + expect(await indexedDB.databases()).toEqual([]) + }) + + it('reads the existing public SEC1 point without exporting a private key', async () => { + const identity = await getOrCreateClientControlIdentity() + const spki = await identity.exportPublicKey() + const publicKey = await subtle.importKey('spki', spki, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) + const expected = new Uint8Array(await subtle.exportKey('raw', publicKey)) + const exports = vi.spyOn(subtle, 'exportKey') + try { + const result = await readExistingPublicKeyPoint() + expect(result._tag).toBe('Present') + if (result._tag !== 'Present') throw new Error('Existing identity missing') + expect(result.point).toEqual(expected) + expect(result.point.length).toBe(65) + expect(result.point[0]).toBe(0x04) + expect(exports).toHaveBeenCalledTimes(1) + expect(exports.mock.calls[0]![0]).toBe('spki') + expect(exports.mock.calls[0]![1].type).toBe('public') + } finally { + exports.mockRestore() + } + expect(new Uint8Array(await identity.exportPublicKey())).toEqual(new Uint8Array(spki)) + }) + it('fails closed without IndexedDB instead of creating an ephemeral identity', async () => { vi.stubGlobal('indexedDB', undefined) await expect(getOrCreateClientControlIdentity()).rejects.toMatchObject({ From 4619dc87dd9fb5cb20e8c2902029129d060d5c92 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:44:40 +0100 Subject: [PATCH 15/32] Bind web control to the native ask and narrow journey dependencies agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 44 ++++++++++++++----- .../src/client-control/fixtureDomEvidence.ts | 7 +-- .../src/client-control/grantBoundary.ts | 12 +++-- .../client-control/grantBoundary.unit.test.ts | 28 +++++++++--- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 5033d8d3a59..c1461a2fd22 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -6,9 +6,9 @@ Wire-independent source preparation only. **Application test mode remains disabl - `identity.ts`: `getOrCreateClientControlIdentity()` atomically acquires an origin-local IndexedDB P-256 identity. The private key is non-extractable; public SPKI export and supplied-byte ECDSA/SHA-256 signing remain internal capabilities. Storage failures never replace a key or fall back to ephemeral identity. Key deletion is not enrollment revocation. - `foreground.ts`: visible AND focused. Re-evaluate before navigation and signing. -- `navigation.ts`: only NowRoot and Attention(ask id) destinations; the host supplies its authoritative subject-to-href resolver. Conversation is outside the journey and refused locally. -- `grantBoundary.ts`: wire-independent handler allowing only granted inspect or NowRoot/Attention navigation. There is no answer/close/read-state or DOM activation callback. It rechecks permission and foreground before signing after navigation. Unit assertions cover Conversation and mutation refusals with no navigation/signing; generated decoding, identity/session/run/window validation and actual canonical signatures remain pending. -- `readAdvancement.ts`: sticky page-lifetime prohibition checked by `gateReadAdvancement` at execution time. Wrap the store's entire advance implementation, including persistence and outbound synchronization, rather than individual UI callers. Enrollment ending must not re-enable writes. +- `navigation.ts`: only NowRoot and Attention(ask id) destinations; the host supplies its authoritative subject-to-href resolver. +- `grantBoundary.ts`: wire-independent handler allowing only granted inspect or NowRoot/Attention navigation, with Attention restricted to the grant-bound ask. There is no answer/close/read-state or DOM activation callback. It rechecks current target, permission and foreground before signing after navigation. Unit assertions cover nongranted screens, wrong-item and mutation refusals with no navigation/signing; generated decoding, identity/session/run/window validation and actual canonical signatures remain pending. +- `readAdvancement.ts`: future sticky page-lifetime prohibition checked by `gateReadAdvancement` at execution time. The read-position store is absent on this branch's main base, and the Attention route does not use it. This is not a present journey blocker: wrap the entire store advance (persistence and outbound synchronization) when that writer group lands, not individual UI callers. Enrollment ending must not re-enable writes. - `ClientControlIndicator.tsx`: controller-owned run indicator and enrollment-revoke action with pending/unconfirmed states. The component does not create authority or optimistically hide an active grant. - `fixtureGuard.ts`: requires the exact approved dedicated loopback origin and realpath-resolved fixture instance client socket, with normal-origin/socket deny lists. This is configuration validation, not daemon attestation or enrollment authority. - `fixtureDomEvidence.ts`: independent real Playwright-compatible DOM observation of the open native ask's title and request.question text. The digest is lowercase SHA-256 of exact UTF-8 question text. Each must render as one unsplit leaf element; altered text/digest or IDs outside the verified actual catalog fail observation. Persists unsigned observations with exact candidate commit and origin. @@ -19,11 +19,25 @@ Colocated tests cover crypto/storage, foreground, suppression, indicator states, ## Integration dependencies -1. [Canonical daemon contract #6274](https://github.com/compoundingtech/fractal/pull/6274): inspected source head `b6c0d55d9325ec53f5285ac30b4ff38707cd22c8`, including `docs/st3/client-v0/device-commands.md`, `crates/fractal-client-codegen/templates/ClientControl.ts.in` and the fixed-key vector input template. This head is **NOT consumer-ready**: generated outputs and approved computed vectors await the announced codegen commit. Future conformance tests must read `fixtures/clients/client-control-v1.golden.json` from the repository, never duplicate its fields or bytes. -2. Subsequent enrollment readback/revoke and grant-subscription PRs: authoritative pairing/key enrollment and session-scoped grant lifecycle. Their handles remain pending. The subsequent verification/receipt PR supplies result verification and durable terminal receipts. -3. Workspace-model/routes group: `cac380ac98`, `df082cd9e8`, `349b0da2aa`, `5a107113bd`. Its `workspace/subject.ts` resolves Inbox to `/w/inbox` and Alert to `/w/inbox/`; `workspace/useWorkspace.ts` projects routing and `workspace/surfaces.tsx` renders it. None was cherry-picked here. -4. State-cells read-position group: `9e66194639`, `0d21c1286b`, `45710bb1d5`. Wrap `state/cells/readPositions.ts` store `advance`, used by both `onSeen` and `useMarkRead` in `web/readPosition.ts`. -5. [Read-position synchronization #6108](https://github.com/compoundingtech/fractal/pull/6108), inspected at `01fd722d1cdfc4beb916817ab9dc68f0307b437b`: its web delta adds lastProseAt projection/types, not a web synchronization caller. The eventual outbound writer belongs inside the same gated advancement boundary as browser-local persistence. +Dependency decisions below refer to the read-only integration snapshot `ff956ae72b26d92be285b3d7ca245f2afb9e4f0e`; file paths are relative to `apps/fractal-web/src`. KEEP means required behavior, not permission to cherry-pick. No dependency was cherry-picked here. + +| Source | Decision | Source reason | +| --- | --- | --- | +| `cac380ac98` workspace routes | KEEP | `workspace/surfaces.tsx:121-128` renders Inbox and native Alert detail; `workspace/useWorkspace.ts:34-37` opens the URL subject. | +| `df082cd9e8` typed subjects | KEEP | `workspace/subject.ts:81-82,140` maps Inbox and bound Alert to `/w/inbox` and `/w/inbox/`. | +| `349b0da2aa` kit-workspace migration | DROP as an independent journey blocker | `workspace/useWorkspace.ts:9-10,34` uses the host's store; the client-control interface accepts route targets, not kit store APIs. The chosen host may bring this transitively; no separate kit migration is required by the ask. | +| `5a107113bd` split panes | DROP as an independent journey blocker | `workspace/surfaces.tsx:121-128` requires only Inbox and Alert surfaces, not split commands or multiple panes. | +| `37083821b6` native decision-card/detail group | KEEP (newly identified) | `web/AlertDetailsPane.tsx:39-45,78-81` selects the open bound ask from the attention feed and renders shipped AnswerAlert. `conversation/alerts/useAlertAnswers.ts:25-30` reads attention/grants and observes the in-memory answer store; `workspace/registry.ts:76,89-90` assigns both surfaces only the shared attention feed. Reconcile the shipped-renderer DOM markers/tests when this group lands. | +| `9e66194639` read-position cells | DROP from current journey blockers | `state/cells/readPositions.ts:12-17,36-39` stores per-stream prose read positions. Native ask detail reads attention/answer state instead (`web/AlertDetailsPane.tsx:39-45`). | +| `0d21c1286b` read-position clock-skew/remount fix | DROP from current journey blockers | `web/readPosition.ts:45,125` exposes stream seen/mark-read hooks; native ask detail does not invoke them (`web/AlertDetailsPane.tsx:37-60`). | +| `45710bb1d5` last_prose_at unread fix | DROP from current journey blockers | The relevant write boundary remains `state/cells/readPositions.ts:36-39`; neither native ask surface requires it (`workspace/registry.ts:89-90`). The suppression integration must land with any introduced writer, before test-mode rendering can reach it. | +| [#6108 read-position sync](https://github.com/compoundingtech/fractal/pull/6108) | DROP from current journey blockers | Inspected head `01fd722d1cdfc4beb916817ab9dc68f0307b437b` adds lastProseAt projection/types, not a web sync caller. A future writer must share the gated store boundary above. | +| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | KEEP | Inspected source head `6ad7f674d4503ce4dddd2186c66943c9a1b08751`; `crates/fractal-client-codegen/templates/ClientControl.ts.in` is source, not consumer-ready output. Consume generated `ClientControl.generated.ts` and repository `fixtures/clients/client-control-v1.golden.json` after they land, never copy vector fields/bytes. | +| Later enrollment readback/revoke PR | KEEP | Authoritative pairing/key enrollment and enrollment-ending revoke are required before activation; browser flags cannot supply authority. Handle/readback module not yet published. | +| Later grant-subscription PR | KEEP | Commands and grant lifecycle must be session/run/key/item bound; the internal grant permission is not authoritative subscription input. Handle/module pending. | +| Later verification/receipt PR | KEEP for journey acceptance | Actual canonical result verification and durable terminal receipts cannot be established by DOM observations. Handle/module pending. | + +No file:line can be cited for unpublished later PR APIs; their absence remains an explicit integration blocker, not an assumed implementation. #6278 stays based on origin/main and consumes #6274's generated TS module and shared vector file after they land on main. @@ -35,11 +49,19 @@ Colocated tests cover crypto/storage, foreground, suppression, indicator states, No guessed wire fields or signing bytes are implemented. Still pending: consumer-ready generated outputs and approved computed golden vectors; authoritative enrollment readback and pairing/public-key binding adapter; grant start/end/revoke delivery and its executable authority semantics; enrollment-revoke operation; fixture local-only attestation, manifest/readback and authorized bootstrap. The published enrollment documentation describes local authority constraints, but does not yet supply the browser readback/revoke integration. -Activation, command subscription, generated capture/signing, actual store wrapping and the complete journey await those contracts. Browser flags or preferences must never enable test mode on a normal pairing. +Activation, command subscription, generated capture/signing and the complete journey await the KEEP dependencies. Store wrapping is conditional on a read-position writer landing; that guard must land with the writer, not delay the current ask-only journey on a nonexistent store. Browser flags or preferences must never enable test mode on a normal pairing. ## Final seed and route ruling -The seed is one OPEN native ask (attention_kind person-step, presentation.kind request), with request.question carrying the seeded text. The journey is NowRoot -> that ask's Attention screen -> inspect; its grant screens are NowRoot and Attention ONLY. Opening is the real AlertsTab local expansion through AgentAlerts/AnswerAlert, never an answer. The independent observer compares the manifest-supplied ask title and exact question-text digest using `[data-ask-title]` and `[data-ask-question] p` inside the ask's `data-item-id`. Fixture setup owns proving the ask remains open/unread before and after; the web journey never answers, closes or advances read state. +The seed is one OPEN native ask (attention_kind person-step, presentation.kind request), with request.question exactly `client-control fixture 0001` and its title supplied by the fixture manifest. The journey is NowRoot -> that grant-bound ask's Attention screen -> inspect; its grant screens are NowRoot and Attention ONLY. Native detail is `/w/inbox/`, using the attention feed and in-memory answer-state reads, never an answer action. The independent observer compares the manifest-supplied title and exact question-text digest using `[data-ask-title]` and `[data-ask-question] p` inside the ask's `data-item-id`. Fixture setup owns proving the ask remains initially unseen, open and unmutated before and after; the web journey never answers, closes or advances read state. The dedicated browser profile is retained across journeys, never reset to manufacture an unseen state. + +## E3 secondary identity readback (FPL f1b5a6ac) + +`readExistingPublicKeyPoint()` opens `fractal-client-control-identity-v1` without a version, aborts `onupgradeneeded`, and reads `identity.get('p256')` in a readonly transaction. It returns typed absence or the 65-byte uncompressed SEC1 public point extracted from exported P-256 SPKI. It never calls get-or-create, exports a private key, creates a committed database/store/record, or deletes storage. Pairing id is not locally available: this is a declared limitation. + +The FPL f1b5a6ac fingerprint rule is lowercase hex SHA-256 of exactly that 65-byte point (`0x04 || X || Y`), not SPKI bytes. No separate fingerprint canonicalizer is introduced here; consume generated vector coverage when published. PRIMARY proof remains daemon-side: signed results verify against the one enrolled key and the enrollment record remains unchanged. Browser readback is SECONDARY only. + +The E3 exclusion keys are exactly: IndexedDB `fractal-client-control-identity-v1/identity/p256`, cookies `fractal_device` and `fractal_pair`, and `wf.c/session.person`. Preserve the dedicated profile across journeys; these exclusions do not authorize deleting or resetting any other persisted state. ## Proof limits @@ -51,4 +73,4 @@ Next source-regression command on the approved hosted runner, from `apps/fractal pnpm test -- src/client-control src/conversation/alerts/AnswerAlert.integration.test.tsx ``` -Hosted CI provides source regression evidence only, never journey acceptance. Journey acceptance requires a LOCAL exact-candidate real-fixture Playwright/DOM run after authorized setup/bootstrap/enrollment. Its runner must guard before opening connections; independently verify the fixture/local-only daemon identity; supply the exact clean commit, approved unused loopback origin, fixture-only `WF_ST_GATEWAY` client socket and approved bootstrap; create a fresh dedicated browser context; capture every pre-render HTTP/collections payload; use the actual fixture manifest and real DOM selectors; persist evidence for authorized signing; and execute grant/refusal/not-foreground/revoke/read-state/receipt checks. Neither fixtureSource/embed rendering nor this independent observer alone is journey proof. +Hosted CI provides source regression evidence only, never journey acceptance. Journey acceptance requires a LOCAL exact-candidate real-fixture Playwright/DOM run after authorized setup/bootstrap/enrollment. Its runner must guard before opening connections; independently verify fixture/local-only daemon identity; supply the exact clean commit, approved loopback origin, fixture-only `WF_ST_GATEWAY` client socket and approved bootstrap; use the dedicated isolated browser profile/context without resetting it per journey; capture every pre-render HTTP/collections payload; use the actual manifest and real DOM selectors; persist evidence for authorized signing; and execute grant/refusal/not-foreground/revoke/read-state/receipt checks. Neither fixtureSource/embed rendering nor this independent observer alone is journey acceptance or a signed receipt. diff --git a/apps/fractal-web/src/client-control/fixtureDomEvidence.ts b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts index 7cdf5a538a8..7c89c1f907a 100644 --- a/apps/fractal-web/src/client-control/fixtureDomEvidence.ts +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts @@ -6,7 +6,7 @@ import { isFixturePlainText } from './fixtureText.ts' export interface FixtureSeedExpectation { readonly askId: string readonly title: string - /** SHA-256 of the rendered request.question text, never answer text or conversation content. */ + /** SHA-256 of the rendered request.question text, never answer text. */ readonly questionTextSha256: string readonly subjectIds: readonly string[] } @@ -50,8 +50,9 @@ export const compareFixtureDom = (seed: FixtureSeedExpectation, observed: { } /** - * Call AFTER granted navigation on the real app in a fresh, dedicated Playwright context. - * The runner owns context creation, verified fixture attestation, network capture and granted + * Call AFTER granted navigation on the real app in its dedicated, isolated Playwright context. + * Keep the profile across journeys; do not reset storage to manufacture initially unseen state. + * The runner owns context lifecycle, verified fixture attestation, network capture and granted * commands. This function never navigates, pairs, reads credentials, mutates state, or signs. * Selectors identify the ask title and rendered request.question INSIDE the expanded native ask. * Digest is SHA-256 of exact UTF-8 DOM innerText, without trimming/normalizing. Never press answer diff --git a/apps/fractal-web/src/client-control/grantBoundary.ts b/apps/fractal-web/src/client-control/grantBoundary.ts index ad0fd803903..e7cf75bd061 100644 --- a/apps/fractal-web/src/client-control/grantBoundary.ts +++ b/apps/fractal-web/src/client-control/grantBoundary.ts @@ -10,6 +10,8 @@ export interface ClientControlPermission { /** Authoritative adapter returns nothing unless identity/session/run/grant binding is valid. */ readonly verbs: ReadonlySet readonly screens: ReadonlySet + /** Internal authority projection of the grant's bound item, not a wire field name. */ + readonly boundAskId: string } const destinationFor = (intent: ClientControlIntent): ClientControlRouteTarget | undefined => { @@ -25,7 +27,7 @@ const destinationFor = (intent: ClientControlIntent): ClientControlRouteTarget | * The later generated-wire adapter owns identity/session/run/window validation and permission revocation. */ export const handleGrantedClientControlIntent = async (input: { - readonly currentScreen: () => string + readonly currentTarget: () => ClientControlRouteTarget | undefined readonly intent: ClientControlIntent readonly permission: () => ClientControlPermission | undefined readonly foreground: () => boolean @@ -37,16 +39,20 @@ export const handleGrantedClientControlIntent = async (input: { const destination = intent.verb === 'phone.navigate' ? destinationFor(intent) : undefined if (intent.verb === 'phone.navigate' && destination === undefined) return 'Refused' if (intent.verb === 'phone.inspect' && (intent.screenName !== undefined || intent.targetAskId !== undefined)) return 'Refused' + const targetAllowed = (permission: ClientControlPermission, target: ClientControlRouteTarget | undefined) => + target !== undefined && permission.screens.has(target._tag) + && (target._tag !== 'Attention' || target.askId === permission.boundAskId) const allowed = () => { const permission = input.permission() return permission !== undefined && permission.verbs.has(intent.verb) - && permission.screens.has(destination?._tag ?? input.currentScreen()) + && targetAllowed(permission, destination ?? input.currentTarget()) } if (!allowed()) return 'Refused' if (!input.foreground()) return 'NotForeground' if (destination !== undefined) await input.navigate(destination) // Navigation may yield; revoked authority or lost foreground must never sign afterward. - if (!input.permission()?.screens.has(input.currentScreen())) return 'Refused' + const permission = input.permission() + if (permission === undefined || !targetAllowed(permission, input.currentTarget())) return 'Refused' if (!allowed()) return 'Refused' if (!input.foreground()) return 'NotForeground' await input.signVisibleState() diff --git a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts index 2380b7e1f4d..412f19811dd 100644 --- a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts +++ b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts @@ -4,15 +4,21 @@ import type { ClientControlRouteTarget } from './navigation.ts' const permission: ClientControlPermission = { verbs: new Set(['phone.inspect', 'phone.navigate']), screens: new Set(['NowRoot', 'Attention']), + boundAskId: 'attention/unit-ask', } -const exercise = async (intent: ClientControlIntent, grant: ClientControlPermission | undefined = permission) => { +const exercise = async ( + intent: ClientControlIntent, + grant: ClientControlPermission | undefined = permission, + initialTarget: ClientControlRouteTarget = { _tag: 'NowRoot' }, +) => { const navigations: ClientControlRouteTarget[] = [] let signatures = 0 + let currentTarget = initialTarget const result = await handleGrantedClientControlIntent({ intent, permission: () => grant, foreground: () => true, - currentScreen: () => intent.screenName === 'Attention' ? 'Attention' : 'NowRoot', - navigate: async target => { navigations.push(target) }, + currentTarget: () => currentTarget, + navigate: async target => { navigations.push(target); currentTarget = target }, signVisibleState: async () => { signatures += 1 }, }) return { result, navigations, signatures } @@ -45,6 +51,18 @@ describe('client-control grant-check handler boundary', () => { { ...permission, screens: new Set(['NowRoot']) })).toEqual({ result: 'Refused', navigations: [], signatures: 0 }) }) + it('refuses navigation to an ask other than the grant-bound item', async () => { + expect(await exercise({ verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/other-ask' })).toEqual({ + result: 'Refused', navigations: [], signatures: 0, + }) + }) + + it('refuses inspecting an ask other than the grant-bound item', async () => { + expect(await exercise({ verb: 'phone.inspect' }, permission, { _tag: 'Attention', askId: 'attention/other-ask' })).toEqual({ + result: 'Refused', navigations: [], signatures: 0, + }) + }) + it.each([ [undefined, true, 'Refused'], [permission, false, 'NotForeground'], @@ -53,7 +71,7 @@ describe('client-control grant-check handler boundary', () => { const calls: string[] = [] const result = await handleGrantedClientControlIntent({ intent: { verb: 'phone.navigate', screenName: 'NowRoot' }, permission: () => grant, - foreground: () => foreground, currentScreen: () => 'NowRoot', + foreground: () => foreground, currentTarget: () => ({ _tag: 'NowRoot' }), navigate: async () => { calls.push('navigate') }, signVisibleState: async () => { calls.push('sign') }, }) expect(result).toBe(expected) @@ -65,7 +83,7 @@ describe('client-control grant-check handler boundary', () => { let signatures = 0 const result = await handleGrantedClientControlIntent({ intent: { verb: 'phone.navigate', screenName: 'NowRoot' }, permission: () => active, foreground: () => true, - currentScreen: () => 'NowRoot', + currentTarget: () => ({ _tag: 'NowRoot' }), navigate: async () => { active = undefined }, signVisibleState: async () => { signatures += 1 }, }) expect(result).toBe('Refused') From a33f60574bc192103f28d6b2ab2a6700253e3b3d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:49:44 +0100 Subject: [PATCH 16/32] Assert control command sequences leave shipped answers untouched agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 4 + .../alerts/AnswerAlert.integration.test.tsx | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index c1461a2fd22..74537ccd752 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -17,6 +17,10 @@ Wire-independent source preparation only. **Application test mode remains disabl Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Internal module inputs are not daemon wire schemas. +D-5 integration assertions in that shipped-renderer test run `DataSourceProvider -> LiveAgentAlerts -> AnswerAlert` with recording `alertActions.act` and `prompts.respond` source ports, not mocked data hooks. Both structured choices and feedback/decision-words Send controls remain in the DOM, with no activation/input/submit events or confirmation state, across inspect/navigation, wrong-item and refused answer/close/read commands. Both port request logs must remain empty after EVERY command. This is a deterministic source-port test, not a fixture journey; its unit-only fixture source never supplies the real journey UI. + +The real-browser transport guard independently records/rejects attempted `/v1/client/actions` writes at `fixtureNoAnswerGuard.ts:25-36`, including successfully blocked attempts. It does not by itself prove that answer controls were never activated, so the shipped-control assertions above are separate. `useAlertAnswers.ts` is absent on this main base: equivalent coverage through that hook is tied to `37083821b6` and is added when that native-detail group lands; current tests do not claim to execute the absent hook. + ## Integration dependencies Dependency decisions below refer to the read-only integration snapshot `ff956ae72b26d92be285b3d7ca245f2afb9e4f0e`; file paths are relative to `apps/fractal-web/src`. KEEP means required behavior, not permission to cherry-pick. No dependency was cherry-picked here. diff --git a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx index ff433b7a6c5..9ebcb565a20 100644 --- a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx +++ b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx @@ -4,6 +4,7 @@ import { createRoot } from 'react-dom/client' import { afterEach, expect, it, vi } from 'vitest' import type * as Native from '@fractal/client/schema' import { DateTime } from 'effect' +import * as AtomRegistry from 'effect/reactivity/AtomRegistry' vi.mock('@stylexjs/stylex', () => ({ create: (styles: unknown) => styles, @@ -16,6 +17,13 @@ vi.mock('@stylexjs/stylex', () => ({ import { AnswerAlert } from './AnswerAlert.tsx' import type { AlertAction } from './alert-action-port.ts' import { isFixturePlainText } from '../../client-control/fixtureText.ts' +import { LiveAgentAlerts } from './AgentAlerts.tsx' +import type { AlertActionPort } from './alert-action-port.ts' +import type { PromptAnswerPort } from './prompt-answer-port.ts' +import { fixtureSource } from '../../data/fixtureSource.ts' +import { DataSourceProvider } from '../../data/react.tsx' +import { handleGrantedClientControlIntent, type ClientControlIntent } from '../../client-control/grantBoundary.ts' +import type { ClientControlRouteTarget } from '../../client-control/navigation.ts' const alert = { id: 'attention/ask', @@ -147,3 +155,100 @@ it.each([ // Observe only: answer buttons are deliberately never pressed. expect(answers).toBe(0) }) + +it.each(['decision', 'feedback'] as const)( + 'D-5 keeps shipped %s answer controls visible and untouched across the complete command sequence', + async requestType => { + const shown = { + ...alert, alert: true, conversation_id: alert.requester_id, conversation_ids: [alert.requester_id], + request: { ...alert.request!, type: requestType }, + } as Native.Attention + // Record at the real source answer-port seam used by LiveAgentAlerts (and later useAlertAnswers). + // No data-hook/module mock: the provider, live controller and answer renderer are real. + const actionRequests: Parameters[0][] = [] + const promptRequests: Parameters[0][] = [] + const source = { + ...fixtureSource({ world: { + now: Date.parse('2026-11-01T12:00:00Z'), agents: [], missions: [], attention: [shown], + events: [], conversations: {}, terminals: {}, envelopes: {}, usage: { _tag: 'undeclared' }, + } }), + alertActions: { act: async (request: Parameters[0]) => { + actionRequests.push(request) + return { _tag: 'Sent' as const } + } }, + prompts: { respond: async (request: Parameters[0]) => { + promptRequests.push(request) + return { _tag: 'Sent' as const } + } }, + } + const registry = AtomRegistry.make() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + cleanup = () => { act(() => root.unmount()); registry.dispose(); host.remove(); vi.unstubAllGlobals() } + await act(async () => root.render( + + + , + )) + const merge = button(host, 'Merge')! + const changes = button(host, 'Change it')! + const words = host.querySelector('textarea')! + expect(merge).toBeDefined() + expect(merge.disabled).toBe(false) + expect(changes).toBeDefined() + expect(words).not.toBeNull() + if (requestType === 'feedback') expect(button(host, 'Send')).toBeDefined() + const activations: string[] = [] + for (const name of ['click', 'submit', 'input', 'change', 'keydown']) { + host.addEventListener(name, event => { + if ((event.target as Element).closest('button, textarea, form') !== null) activations.push(name) + }, { capture: true }) + } + let target: ClientControlRouteTarget = { _tag: 'NowRoot' } + let signatures = 0 + const permission = { + verbs: new Set(['phone.inspect', 'phone.navigate']), + screens: new Set(['NowRoot', 'Attention']), + boundAskId: shown.id, + } + const commands: readonly ClientControlIntent[] = [ + { verb: 'phone.inspect' }, + { verb: 'phone.navigate', screenName: 'Attention', targetAskId: shown.id }, + { verb: 'phone.inspect' }, + { verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/not-bound' }, + ...['work.done', 'prompt.respond', 'attention.answer', 'attention.close', 'phone.message.read', 'read-position.advance'] + .map(verb => ({ verb })), + { verb: 'phone.navigate', screenName: 'NowRoot' }, + { verb: 'phone.inspect' }, + ] + for (const command of commands) { + const before = signatures + const previous = target + const refused = command.targetAskId === 'attention/not-bound' || !permission.verbs.has(command.verb) + await act(async () => { + expect(await handleGrantedClientControlIntent({ + intent: command, permission: () => permission, currentTarget: () => target, + foreground: () => true, navigate: async next => { target = next }, + signVisibleState: async () => { signatures += 1 }, + })).toBe(refused ? 'Refused' : 'Applied') + }) + if (refused) { expect(target).toBe(previous); expect(signatures).toBe(before) } + expect(actionRequests).toEqual([]) + expect(promptRequests).toEqual([]) + expect(activations).toEqual([]) + expect(button(host, 'Merge')).toBe(merge) + expect(button(host, 'Change it')).toBe(changes) + expect(host.querySelector('textarea')).toBe(words) + for (const control of host.querySelectorAll('button, textarea')) { + expect(control.isConnected).toBe(true) + expect(control.hidden).toBe(false) + expect(control.closest('[hidden], [aria-hidden="true"]')).toBeNull() + } + expect(words.value).toBe('') + expect(host.querySelector('[aria-label="Confirm your answer"]')).toBeNull() + if (requestType === 'feedback') expect(button(host, 'Send')).toBeDefined() + } + }, +) From 36592f7090093ae6fe94a87706e319fca78fca17 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:50:02 +0100 Subject: [PATCH 17/32] Generate shared client control contract and signed golden vectors Generated only by cargo run -j 4 -p fractal-client-codegen from exact signed source 9ac7024e8e34900795c07686d503aa7daa9aed83. Authorized short-slot receipt exit 0; admitted 17:49:05Z, released 17:49:26Z, GNU time peak RSS 1163212 KB. No tests run in this generation receipt. agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../Sources/FractalClient/Client.swift | 1 + .../ClientControl.generated.swift | 120 +++++ .../Sources/FractalClient/Models.swift | 28 +- .../fractal-client/Client.generated.ts | 4 + .../fractal-client/ClientControl.generated.ts | 51 ++ .../fractal-client/Models.generated.ts | 85 +++ .../fractal-client/ReadSchema.generated.ts | 22 +- .../fractal-client/Schema.generated.ts | 98 +++- crates/fractal-client/src/generated.rs | 35 ++ crates/fractal-client/src/lib.rs | 9 + crates/fractal-schema/src/client_control.rs | 125 +++++ .../clients/client-control-v1.golden.json | 501 ++++++++++++++++++ 12 files changed, 1060 insertions(+), 19 deletions(-) create mode 100644 clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift create mode 100644 clients/typescript/fractal-client/ClientControl.generated.ts create mode 100644 crates/fractal-schema/src/client_control.rs create mode 100644 fixtures/clients/client-control-v1.golden.json diff --git a/clients/swift/FractalClient/Sources/FractalClient/Client.swift b/clients/swift/FractalClient/Sources/FractalClient/Client.swift index 893a518c303..df611aa1dbb 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Client.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Client.swift @@ -436,6 +436,7 @@ public actor FractalClient { /// Issue one expiring foreground command, without durable receipt or replay. public func deviceCommand(_ command: DeviceCommandRequest) async throws -> Envelope { try await post("v1/client/device-commands", command) } public func deviceResult(_ result: DeviceResult) async throws -> Envelope { try await post("v1/client/device-results", result) } + public func clientTestRevoke(device: String) async throws -> JSONValue { try await post("v1/client/test-clients/revoke", ["device": device]) } public func beginPairing(deviceName: String, personID: String) async throws -> Envelope { try await post("v1/client/pairings", PersonPairingBegin(apiVersion: fractalClientAPIVersion, deviceName: deviceName, personID: personID)) } public func beginPairing(_ request: PairingBegin) async throws -> Envelope { try await post("v1/client/pairings", request) } diff --git a/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift b/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift new file mode 100644 index 00000000000..4f6ed189984 --- /dev/null +++ b/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift @@ -0,0 +1,120 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.swift.in. +import Foundation +import CryptoKit + +public let clientResultPurpose = "client.device-result.v1" +public let clientResultSignedFields = ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state_sha256"] +public enum ClientControlRefusal: String, Codable, Sendable { + case deviceNotTestClient = "device-not-test-client", grantMissing = "grant-missing", grantExpired = "grant-expired" + case screenNotGranted = "screen-not-granted", verbNotGranted = "verb-not-granted", budgetExhausted = "budget-exhausted", actorNotGranted = "actor-not-granted" +} +public struct ClientTestEnrollment: Codable, Sendable { + public var device: String; public var kind: String; public var enrolledBy: String + public var consentRef: String; public var packetDigest: String; public var host: String; public var instance: String + public var pairingSubject: String; public var publicKey: String; public var session: String +} +public struct ClientTestRevocation: Codable, Sendable { + public var device: String; public var enrollmentId: String; public var revokedBy: String +} +public struct ClientCommandReceipt: Codable, Sendable { + public var commandId: String; public var terminalState: DeviceCommandOutcome + public var issuer: String; public var device: String; public var session: String? + public var grantId: String?; public var runId: String?; public var commandKind: String + public var screen: String?; public var reason: ClientControlRefusal? + public var verifiedResultSignature: DeviceSignature?; public var signedResult: ClientResultBinding? + private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, screen, reason, verifiedResultSignature, signedResult } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(commandId, forKey: .commandId); try box.encode(terminalState, forKey: .terminalState) + try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encodeIfPresent(session, forKey: .session) + try box.encodeIfPresent(grantId, forKey: .grantId); try box.encodeIfPresent(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind) + try box.encodeIfPresent(screen, forKey: .screen); try box.encodeIfPresent(reason, forKey: .reason) + if let verifiedResultSignature { try box.encode(verifiedResultSignature, forKey: .verifiedResultSignature) } + else { try box.encodeNil(forKey: .verifiedResultSignature) } + if let signedResult { try box.encode(signedResult, forKey: .signedResult) } + else { try box.encodeNil(forKey: .signedResult) } + } +} +public struct ClientVisibleItem: Codable, Sendable, Equatable { + public var id: String; public var title: String; public var body: String + public init(id: String, title: String, body: String) { self.id = id; self.title = title; self.body = body } +} +public struct ClientVisibleState: Codable, Sendable, Equatable { + public var route: String; public var screen: String; public var visible_item_ids: [String]; public var opened_item: ClientVisibleItem? + public init(route: String, screen: String, visible_item_ids: [String], opened_item: ClientVisibleItem?) { + self.route = route; self.screen = screen; self.visible_item_ids = visible_item_ids; self.opened_item = opened_item + } + private enum CodingKeys: String, CodingKey { case route, screen, visible_item_ids, opened_item } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(route, forKey: .route); try box.encode(screen, forKey: .screen) + try box.encode(visible_item_ids, forKey: .visible_item_ids) + if let opened_item { try box.encode(opened_item, forKey: .opened_item) } + else { try box.encodeNil(forKey: .opened_item) } + } +} +public struct ClientResultBinding: Codable, Sendable, Equatable { + public var purpose: String; public var command_kind: String; public var device: String; public var session: String + public var grant_id: String; public var run_id: String; public var command_id: String; public var outcome: String + public var error_code: String?; public var visible_state: ClientVisibleState + public init(purpose: String = clientResultPurpose, command_kind: String, device: String, session: String, grant_id: String, run_id: String, command_id: String, outcome: String, error_code: String?, visible_state: ClientVisibleState) { + self.purpose = purpose; self.command_kind = command_kind; self.device = device; self.session = session + self.grant_id = grant_id; self.run_id = run_id; self.command_id = command_id; self.outcome = outcome + self.error_code = error_code; self.visible_state = visible_state + } + private enum CodingKeys: String, CodingKey { + case purpose, command_kind, device, session, grant_id, run_id, command_id, outcome, error_code, visible_state + } + public func encode(to encoder: Encoder) throws { + var box = encoder.container(keyedBy: CodingKeys.self) + try box.encode(purpose, forKey: .purpose); try box.encode(command_kind, forKey: .command_kind) + try box.encode(device, forKey: .device); try box.encode(session, forKey: .session) + try box.encode(grant_id, forKey: .grant_id); try box.encode(run_id, forKey: .run_id) + try box.encode(command_id, forKey: .command_id); try box.encode(outcome, forKey: .outcome) + if let error_code { try box.encode(error_code, forKey: .error_code) } + else { try box.encodeNil(forKey: .error_code) } + try box.encode(visible_state, forKey: .visible_state) + } +} +public struct ClientControlGrant: Codable, Sendable, Equatable { + public var grant_id: String; public var run_id: String; public var device: String; public var session: String; public var actor_agent: String + public var verbs: [String]; public var screens: [String]; public var max_commands: UInt64; public var expires_at: String; public var state: String + public var item_id: String? +} +private func clientResultQuote(_ text: String) -> String { + var result = "\"" + for scalar in text.unicodeScalars { + switch scalar.value { + case 0x22: result += "\\\"" + case 0x5c: result += "\\\\" + case 8: result += "\\b" + case 9: result += "\\t" + case 10: result += "\\n" + case 12: result += "\\f" + case 13: result += "\\r" + case 0...31: result += String(format: "\\u%04x", scalar.value) + default: result.unicodeScalars.append(scalar) + } + } + return result + "\"" +} +/// RFC 8785 JCS for the generated closed string-only visible-state schema. +public func visibleStateCanonicalJson(_ state: ClientVisibleState) -> String { + let item = state.opened_item.map { "{\"body\":" + clientResultQuote($0.body) + ",\"id\":" + clientResultQuote($0.id) + ",\"title\":" + clientResultQuote($0.title) + "}" } ?? "null" + return "{\"opened_item\":" + item + ",\"route\":" + clientResultQuote(state.route) + ",\"screen\":" + clientResultQuote(state.screen) + ",\"visible_item_ids\":[" + state.visible_item_ids.map(clientResultQuote).joined(separator: ",") + "]}" +} +public func visibleStateDigest(_ state: ClientVisibleState) -> String { + SHA256.hash(data: Data(visibleStateCanonicalJson(state).utf8)).map { String(format: "%02x", $0) }.joined() +} +/// Sign with P256.Signing.PrivateKey.signature(for:).rawRepresentation (r||s). +/// This domain is never message.send authorization. Enforce active grant first. +public func clientResultCanonicalBytes(_ binding: ClientResultBinding) -> Data { + let fields: [(String, String?)] = [ + ("purpose", binding.purpose), ("command_kind", binding.command_kind), ("device", binding.device), + ("session", binding.session), ("grant_id", binding.grant_id), ("run_id", binding.run_id), + ("command_id", binding.command_id), ("outcome", binding.outcome), ("error_code", binding.error_code), + ("visible_state_sha256", visibleStateDigest(binding.visible_state)) + ] + return Data((["smallclaims-claim-fields-v1"] + fields.map { $0.0 + "=" + ($0.1.map(clientResultQuote) ?? "null") }).joined(separator: "\n").utf8) +} diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index 4b82447846e..e99a11e6fd1 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -791,20 +791,23 @@ public struct DeviceCommandRequest: Codable, Sendable { public let apiVersion, device: String public let command: PhoneCommand public let ttlMS: UInt64? - public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, apiVersion: String = "st3.client.v0") { - self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS + public let grantID: String? + public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, grantID: String? = nil, apiVersion: String = "st3.client.v0") { + self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS; self.grantID = grantID } - private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms" } + private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms", grantID = "grant_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) apiVersion = try box.decode(String.self, forKey: .apiVersion) device = try box.decode(String.self, forKey: .device) ttlMS = try box.decodeIfPresent(UInt64.self, forKey: .ttlMS) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID) command = try PhoneCommand(from: decoder) } public func encode(to encoder: Encoder) throws { var box = encoder.container(keyedBy: Keys.self) try box.encode(apiVersion, forKey: .apiVersion); try box.encode(device, forKey: .device) + try box.encodeIfPresent(grantID, forKey: .grantID) try box.encodeIfPresent(ttlMS, forKey: .ttlMS); try command.encode(to: encoder) } } @@ -813,10 +816,12 @@ public struct DeviceResult: Codable, Sendable { public let outcome: DeviceCommandOutcome public let result: JSONValue? public let errorCode: String? - public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil) { - self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode + public let binding: ClientResultBinding? + public let signature: DeviceSignature? + public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil, binding: ClientResultBinding? = nil, signature: DeviceSignature? = nil) { + self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode; self.binding = binding; self.signature = signature } - enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code" } + enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code", binding, signature } } public struct DeviceCommand: Codable, Sendable, Identifiable { public let id, kind, revision, updatedAt, deviceID, sessionActor, issuedBy, issuedAt, expiresAt: String @@ -824,9 +829,14 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { public let state: DeviceCommandState public let result: JSONValue? public let errorCode: String? + public let grantID, runID: String? + public let controlGrant: ClientControlGrant? + public let receiptClaimID: String? private enum Keys: String, CodingKey { case id, kind, revision, updatedAt = "updated_at", deviceID = "device_id", sessionActor = "session_actor" case issuedBy = "issued_by", issuedAt = "issued_at", expiresAt = "expires_at", state, result, errorCode = "error_code" + case grantID = "grant_id", runID = "run_id", controlGrant = "control_grant" + case receiptClaimID = "receipt_claim_id" } public init(from decoder: Decoder) throws { let box = try decoder.container(keyedBy: Keys.self) @@ -836,6 +846,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { issuedBy = try box.decode(String.self, forKey: .issuedBy); issuedAt = try box.decode(String.self, forKey: .issuedAt) expiresAt = try box.decode(String.self, forKey: .expiresAt); state = try box.decode(DeviceCommandState.self, forKey: .state) result = try box.decodeIfPresent(JSONValue.self, forKey: .result); errorCode = try box.decodeIfPresent(String.self, forKey: .errorCode) + grantID = try box.decodeIfPresent(String.self, forKey: .grantID); runID = try box.decodeIfPresent(String.self, forKey: .runID) + controlGrant = try box.decodeIfPresent(ClientControlGrant.self, forKey: .controlGrant) + receiptClaimID = try box.decodeIfPresent(String.self, forKey: .receiptClaimID) command = try PhoneCommand(from: decoder, resource: true) } public func encode(to encoder: Encoder) throws { @@ -846,6 +859,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable { try box.encode(issuedBy, forKey: .issuedBy); try box.encode(issuedAt, forKey: .issuedAt) try box.encode(expiresAt, forKey: .expiresAt); try box.encode(state, forKey: .state) try box.encodeIfPresent(result, forKey: .result); try box.encodeIfPresent(errorCode, forKey: .errorCode) + try box.encodeIfPresent(grantID, forKey: .grantID); try box.encodeIfPresent(runID, forKey: .runID) + try box.encodeIfPresent(controlGrant, forKey: .controlGrant) + try box.encodeIfPresent(receiptClaimID, forKey: .receiptClaimID) try command.encode(to: encoder, resource: true) } } diff --git a/clients/typescript/fractal-client/Client.generated.ts b/clients/typescript/fractal-client/Client.generated.ts index c6d8d44ebd4..94e943b3657 100644 --- a/clients/typescript/fractal-client/Client.generated.ts +++ b/clients/typescript/fractal-client/Client.generated.ts @@ -486,6 +486,10 @@ export class FractalClient { return this.request('POST', '/v1/client/device-results', result, undefined, undefined, requestOptions); } + async clientTestRevoke(device: string, requestOptions: RequestOptions = {}): Promise { + return this.request('POST', '/v1/client/test-clients/revoke', { device }, undefined, undefined, requestOptions); + } + async beginPairing(request: PairingBegin, requestOptions: RequestOptions = {}): Promise> { return this.request('POST', '/v1/client/pairings', request, undefined, undefined, requestOptions); } diff --git a/clients/typescript/fractal-client/ClientControl.generated.ts b/clients/typescript/fractal-client/ClientControl.generated.ts new file mode 100644 index 00000000000..bf8d4fd730d --- /dev/null +++ b/clients/typescript/fractal-client/ClientControl.generated.ts @@ -0,0 +1,51 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/ClientControl.ts.in. +export const CLIENT_RESULT_PURPOSE = 'client.device-result.v1'; +export const CLIENT_RESULT_SIGNED_FIELDS = ['purpose', 'command_kind', 'device', 'session', 'grant_id', 'run_id', 'command_id', 'outcome', 'error_code', 'visible_state_sha256'] as const; +export interface ClientVisibleItem { id: string; title: string; body: string } +export interface ClientVisibleState { route: string; screen: string; visible_item_ids: string[]; opened_item: ClientVisibleItem | null } +export interface ClientResultBinding { + purpose: string; command_kind: string; device: string; session: string; + grant_id: string; run_id: string; command_id: string; outcome: string; + error_code: string | null; visible_state: ClientVisibleState; +} +export interface ClientControlGrant { + grant_id: string; run_id: string; device: string; session: string; actor_agent: string; + verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string; + item_id?: string; +} +function quote(value: string): string { + // RFC 8785 requires rejection of lone surrogates rather than replacement. + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff) { + const next = value.charCodeAt(++i); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('Invalid Unicode in client result'); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new Error('Invalid Unicode in client result'); + } + return JSON.stringify(value); +} +/** RFC 8785 JCS for the generated closed string-only visible-state schema. */ +export function visibleStateCanonicalJson(state: ClientVisibleState): string { + const item = state.opened_item; + return '{"opened_item":' + (item === null ? 'null' : '{"body":' + quote(item.body) + ',"id":' + quote(item.id) + ',"title":' + quote(item.title) + '}') + + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen) + + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}'; +} +export async function visibleStateDigest(state: ClientVisibleState): Promise { + const bytes = new TextEncoder().encode(visibleStateCanonicalJson(state)); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); +} +/** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding. + * Never use this helper as message.send authorization. Enforce active grant first. */ +export async function clientResultCanonicalBytes(binding: ClientResultBinding): Promise { + const digest = await visibleStateDigest(binding.visible_state); + const fields: [string, string | null][] = [ + ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device], + ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id], + ['command_id', binding.command_id], ['outcome', binding.outcome], ['error_code', binding.error_code], + ['visible_state_sha256', digest], + ]; + return new TextEncoder().encode(['smallclaims-claim-fields-v1', ...fields.map(([k, v]) => k + '=' + (v === null ? 'null' : quote(v)))].join('\n')); +} diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts index 19a491ae870..ba5d42c297d 100644 --- a/clients/typescript/fractal-client/Models.generated.ts +++ b/clients/typescript/fractal-client/Models.generated.ts @@ -1265,6 +1265,21 @@ export type Capability = { version: number; }; +export type ClientCommandReceipt = { + commandId: string; + commandKind: string; + device: string; + grantId?: string; + issuer: string; + reason?: ClientControlRefusal; + runId?: string; + screen?: string; + session?: string; + signedResult: (ClientResultBinding | null); + terminalState: DeviceCommandOutcome; + verifiedResultSignature: (DeviceSignature | null); +}; + export type ClientConnection = { actor: string; client?: string | null; @@ -1286,6 +1301,22 @@ export type ClientConnections = { member: string; }; +export type ClientControlGrant = { + actor_agent: string; + device: string; + expires_at: Timestamp; + grant_id: string; + item_id?: string; + max_commands: number; + run_id: string; + screens: Array; + session: string; + state: string; + verbs: Array; +}; + +export type ClientControlRefusal = "device-not-test-client" | "grant-missing" | "grant-expired" | "screen-not-granted" | "verb-not-granted" | "budget-exhausted" | "actor-not-granted"; + export type ClientDiagnosticBreadcrumb = "native-start" | "js-start" | "root-mounted" | "foreground" | "background" | "previous-launch-unclean"; export type ClientDiagnosticCaptureSource = "js" | "native-marker" | "metrickit"; @@ -1367,6 +1398,51 @@ export type ClientDiagnosticsBatch = { version: 1; }; +export type ClientResultBinding = { + command_id: string; + command_kind: "phone.inspect" | "phone.navigate"; + device: string; + error_code: string | null; + grant_id: string; + outcome: DeviceCommandOutcome; + purpose: "client.device-result.v1"; + run_id: string; + session: string; + visible_state: ClientVisibleState; +}; + +export type ClientTestEnrollment = { + consentRef: string; + device: string; + enrolledBy: string; + host: string; + instance: string; + kind: "ios" | "web" | "tui"; + packetDigest: string; + pairingSubject: string; + publicKey: string; + session: string; +}; + +export type ClientTestRevocation = { + device: string; + enrollmentId: string; + revokedBy: string; +}; + +export type ClientVisibleItem = { + body: string; + id: string; + title: string; +}; + +export type ClientVisibleState = { + opened_item: (ClientVisibleItem | null); + route: string; + screen: string; + visible_item_ids: Array; +}; + export type CollectionCommand = ((AgentsPublicationSubscribe & { actor?: never; agent?: never; capability?: never; conversation?: never; incarnation?: never; limit?: never; person?: never; since_ms?: never; status?: never; subject?: never; terminal?: never; until_ms?: never; }) | ({ actor?: string | null; collection: "commands"; @@ -1706,14 +1782,18 @@ export type Device = ResourceHeader & { export type DeviceCommand = { args: (PhoneInspectArgs | PhoneNavigateArgs | PhoneMessageReadArgs); command_kind: "phone.inspect" | "phone.navigate" | "phone.message.read"; + control_grant?: ClientControlGrant; device_id: string; error_code?: string; expires_at: Timestamp; + grant_id?: string; id: string; issued_at: Timestamp; issued_by: Id; kind: "device-command"; + receipt_claim_id?: string; result?: unknown; + run_id?: string; session_actor: Id; state: DeviceCommandState; } & ResourceHeader & ({ @@ -1733,18 +1813,21 @@ export type DeviceCommandRequest = ({ api_version: "st3.client.v0" | "fractal.client.v0"; args: PhoneInspectArgs; device: string; + grant_id?: string; kind: "phone.inspect"; ttl_ms?: number; } | { api_version: "st3.client.v0" | "fractal.client.v0"; args: PhoneNavigateArgs; device: string; + grant_id?: string; kind: "phone.navigate"; ttl_ms?: number; } | { api_version: "st3.client.v0" | "fractal.client.v0"; args: PhoneMessageReadArgs; device: string; + grant_id?: string; kind: "phone.message.read"; ttl_ms?: number; }); @@ -1752,10 +1835,12 @@ export type DeviceCommandRequest = ({ export type DeviceCommandState = "pending" | "delivered" | "completed" | "not-foreground" | "expired" | "refused" | "failed"; export type DeviceResult = { + binding?: ClientResultBinding; command_id: string; error_code?: string; outcome: DeviceCommandOutcome; result?: unknown; + signature?: DeviceSignature; }; export type DeviceSignature = { diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index b6b6a73dc95..e26da97b3b8 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -1668,6 +1668,22 @@ export const ClientConnections = /*#__PURE__*/ (() => Schema.Struct({ export type ClientConnections = typeof ClientConnections.Type export type ClientConnectionsEncoded = typeof ClientConnections.Encoded +export const ClientControlGrant = /*#__PURE__*/ (() => Schema.Struct({ + "actor_agent": Schema.String, + "device": Schema.String, + "expires_at": Timestamp, + "grant_id": Schema.String, + "item_id": optionalKey(Schema.String), + "max_commands": Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(9007199254740991)), + "run_id": Schema.String, + "screens": Schema.Array(Schema.String), + "session": Schema.String, + "state": Schema.String, + "verbs": Schema.Array(Schema.String) +}).annotate({ identifier: "ClientControlGrant" }))() +export type ClientControlGrant = typeof ClientControlGrant.Type +export type ClientControlGrantEncoded = typeof ClientControlGrant.Encoded + /** One conversation header field: its latest value, where it came from and when. */ export const ConversationHeaderField = /*#__PURE__*/ (() => Schema.Struct({ "as_of": Timestamp, @@ -1896,9 +1912,9 @@ export type PhoneNavigateArgs = typeof PhoneNavigateArgs.Type export type PhoneNavigateArgsEncoded = typeof PhoneNavigateArgs.Encoded export const DeviceCommand = /*#__PURE__*/ (() => Schema.Union([ - Schema.Struct({ "args": PhoneInspectArgs, "command_kind": Schema.Literal("phone.inspect"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), - Schema.Struct({ "args": PhoneNavigateArgs, "command_kind": Schema.Literal("phone.navigate"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), - Schema.Struct({ "args": PhoneMessageReadArgs, "command_kind": Schema.Literal("phone.message.read"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }) + Schema.Struct({ "args": PhoneInspectArgs, "command_kind": Schema.Literal("phone.inspect"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), + Schema.Struct({ "args": PhoneNavigateArgs, "command_kind": Schema.Literal("phone.navigate"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }), + Schema.Struct({ "args": PhoneMessageReadArgs, "command_kind": Schema.Literal("phone.message.read"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }) ], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("command_kind")).annotate({ identifier: "DeviceCommand" }))() export type DeviceCommand = typeof DeviceCommand.Type export type DeviceCommandEncoded = typeof DeviceCommand.Encoded diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index bcae26143dc..8b4f97de76e 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -10,8 +10,8 @@ import type { SubjectRef, UnknownCase } from "./SchemaSupport.generated.ts" export { API_VERSION, containsUnknownCase, decodeUnknownSync, decodeUnknownEffect } from "./SchemaSupport.generated.ts" export type { SubjectRef, UnknownCase, DecodeMode } from "./SchemaSupport.generated.ts" -import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts" -import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts" +import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, ClientControlGrant, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts" +import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, ClientControlGrantEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts" export * from "./ReadSchema.generated.ts" export const ActionCommon = /*#__PURE__*/ (() => Schema.Struct({ @@ -585,6 +585,63 @@ export const BlobUpload = /*#__PURE__*/ (() => Schema.Struct({ export type BlobUpload = typeof BlobUpload.Type export type BlobUploadEncoded = typeof BlobUpload.Encoded +export const ClientControlRefusal = /*#__PURE__*/ (() => Schema.Literals(["device-not-test-client","grant-missing","grant-expired","screen-not-granted","verb-not-granted","budget-exhausted","actor-not-granted"]).annotate({ identifier: "ClientControlRefusal" }))() +export type ClientControlRefusal = typeof ClientControlRefusal.Type +export type ClientControlRefusalEncoded = typeof ClientControlRefusal.Encoded + +export const ClientVisibleItem = /*#__PURE__*/ (() => Schema.Struct({ + "body": Schema.String, + "id": Schema.String, + "title": Schema.String +}).annotate({ identifier: "ClientVisibleItem" }))() +export type ClientVisibleItem = typeof ClientVisibleItem.Type +export type ClientVisibleItemEncoded = typeof ClientVisibleItem.Encoded + +export const ClientVisibleState = /*#__PURE__*/ (() => Schema.Struct({ + "opened_item": Schema.OptionFromNullOr(ClientVisibleItem), + "route": Schema.String, + "screen": Schema.String, + "visible_item_ids": Schema.Array(Schema.String) +}).annotate({ identifier: "ClientVisibleState" }))() +export type ClientVisibleState = typeof ClientVisibleState.Type +export type ClientVisibleStateEncoded = typeof ClientVisibleState.Encoded + +export const DeviceCommandOutcome = /*#__PURE__*/ (() => Schema.Literals(["completed","not-foreground","expired","refused","failed"]).annotate({ identifier: "DeviceCommandOutcome" }))() +export type DeviceCommandOutcome = typeof DeviceCommandOutcome.Type +export type DeviceCommandOutcomeEncoded = typeof DeviceCommandOutcome.Encoded + +export const ClientResultBinding = /*#__PURE__*/ (() => Schema.Struct({ + "command_id": Schema.String, + "command_kind": Schema.Literals(["phone.inspect","phone.navigate"]), + "device": Schema.String, + "error_code": Schema.OptionFromNullOr(Schema.String), + "grant_id": Schema.String, + "outcome": DeviceCommandOutcome, + "purpose": Schema.Literal("client.device-result.v1"), + "run_id": Schema.String, + "session": Schema.String, + "visible_state": ClientVisibleState +}).annotate({ identifier: "ClientResultBinding" }))() +export type ClientResultBinding = typeof ClientResultBinding.Type +export type ClientResultBindingEncoded = typeof ClientResultBinding.Encoded + +export const ClientCommandReceipt = /*#__PURE__*/ (() => Schema.Struct({ + "commandId": Schema.String, + "commandKind": Schema.String, + "device": Schema.String, + "grantId": optionalKey(Schema.String), + "issuer": Schema.String, + "reason": optionalKey(ClientControlRefusal), + "runId": optionalKey(Schema.String), + "screen": optionalKey(Schema.String), + "session": optionalKey(Schema.String), + "signedResult": Schema.OptionFromNullOr(ClientResultBinding), + "terminalState": DeviceCommandOutcome, + "verifiedResultSignature": Schema.OptionFromNullOr(DeviceSignature) +}).annotate({ identifier: "ClientCommandReceipt" }))() +export type ClientCommandReceipt = typeof ClientCommandReceipt.Type +export type ClientCommandReceiptEncoded = typeof ClientCommandReceipt.Encoded + export const ClientDiagnosticBreadcrumb = /*#__PURE__*/ (() => Schema.Literals(["native-start","js-start","root-mounted","foreground","background","previous-launch-unclean"]).annotate({ identifier: "ClientDiagnosticBreadcrumb" }))() export type ClientDiagnosticBreadcrumb = typeof ClientDiagnosticBreadcrumb.Type export type ClientDiagnosticBreadcrumbEncoded = typeof ClientDiagnosticBreadcrumb.Encoded @@ -693,6 +750,29 @@ export const ClientDiagnosticsBatch = /*#__PURE__*/ (() => Schema.Struct({ export type ClientDiagnosticsBatch = typeof ClientDiagnosticsBatch.Type export type ClientDiagnosticsBatchEncoded = typeof ClientDiagnosticsBatch.Encoded +export const ClientTestEnrollment = /*#__PURE__*/ (() => Schema.Struct({ + "consentRef": Schema.String, + "device": Schema.String, + "enrolledBy": Schema.String, + "host": Schema.String, + "instance": Schema.String, + "kind": Schema.Literals(["ios","web","tui"]), + "packetDigest": Schema.String, + "pairingSubject": Schema.String, + "publicKey": Schema.String, + "session": Schema.String +}).annotate({ identifier: "ClientTestEnrollment" }))() +export type ClientTestEnrollment = typeof ClientTestEnrollment.Type +export type ClientTestEnrollmentEncoded = typeof ClientTestEnrollment.Encoded + +export const ClientTestRevocation = /*#__PURE__*/ (() => Schema.Struct({ + "device": Schema.String, + "enrollmentId": Schema.String, + "revokedBy": Schema.String +}).annotate({ identifier: "ClientTestRevocation" }))() +export type ClientTestRevocation = typeof ClientTestRevocation.Type +export type ClientTestRevocationEncoded = typeof ClientTestRevocation.Encoded + export const CollectionCommand = /*#__PURE__*/ (() => Schema.Union([ Schema.Struct({ "agents_publication_version": Schema.Literal(1), "collection": Schema.Literal("agents"), "id": Schema.String.check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128)), "kind": Schema.Literal("subscribe"), "trace": optionalKey(SubscribeTraceContext), "actor": Schema.optionalKey(Schema.Never), "agent": Schema.optionalKey(Schema.Never), "capability": Schema.optionalKey(Schema.Never), "conversation": Schema.optionalKey(Schema.Never), "incarnation": Schema.optionalKey(Schema.Never), "limit": Schema.optionalKey(Schema.Never), "person": Schema.optionalKey(Schema.Never), "since_ms": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Never), "terminal": Schema.optionalKey(Schema.Never), "until_ms": Schema.optionalKey(Schema.Never) }), Schema.Struct({ "actor": Schema.OptionFromOptionalNullOr(Schema.String, NULL_NONE), "collection": Schema.Literal("commands"), "id": Schema.String.check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128)), "kind": Schema.Literal("subscribe"), "limit": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(200))), "status": Schema.OptionFromOptionalNullOr(Schema.String, NULL_NONE), "trace": optionalKey(SubscribeTraceContext), "agent": Schema.optionalKey(Schema.Never), "agents_publication_version": Schema.optionalKey(Schema.Never), "capability": Schema.optionalKey(Schema.Never), "conversation": Schema.optionalKey(Schema.Never), "incarnation": Schema.optionalKey(Schema.Never), "person": Schema.optionalKey(Schema.Never), "since_ms": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Never), "terminal": Schema.optionalKey(Schema.Never), "until_ms": Schema.optionalKey(Schema.Never) }), @@ -834,25 +914,23 @@ export const CollectionFrame = /*#__PURE__*/ (() => Schema.Union([ export type CollectionFrame = typeof CollectionFrame.Type export type CollectionFrameEncoded = typeof CollectionFrame.Encoded -export const DeviceCommandOutcome = /*#__PURE__*/ (() => Schema.Literals(["completed","not-foreground","expired","refused","failed"]).annotate({ identifier: "DeviceCommandOutcome" }))() -export type DeviceCommandOutcome = typeof DeviceCommandOutcome.Type -export type DeviceCommandOutcomeEncoded = typeof DeviceCommandOutcome.Encoded - /** One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device. */ export const DeviceCommandRequest = /*#__PURE__*/ (() => Schema.Union([ - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneInspectArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.inspect"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneNavigateArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.navigate"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), - Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneMessageReadArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.message.read"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }) + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneInspectArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.inspect"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneNavigateArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.navigate"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }), + Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneMessageReadArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.message.read"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }) ], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("kind")).annotate({ identifier: "DeviceCommandRequest", description: "One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device." }))() export type DeviceCommandRequest = typeof DeviceCommandRequest.Type export type DeviceCommandRequestEncoded = typeof DeviceCommandRequest.Encoded /** Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry. */ export const DeviceResult = /*#__PURE__*/ (() => Schema.Struct({ + "binding": optionalKey(ClientResultBinding), "command_id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "outcome": DeviceCommandOutcome, - "result": optionalKey(Schema.Unknown) + "result": optionalKey(Schema.Unknown), + "signature": optionalKey(DeviceSignature) }).annotate({ identifier: "DeviceResult", description: "Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry." }))() export type DeviceResult = typeof DeviceResult.Type export type DeviceResultEncoded = typeof DeviceResult.Encoded diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index c26061f4283..37c9b1d1a64 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -6175,6 +6175,8 @@ pub enum PhoneCommand { pub struct DeviceCommandRequest { pub api_version: String, pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_id: Option, #[serde(flatten)] pub command: PhoneCommand, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -6219,6 +6221,31 @@ pub struct DeviceResult { pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientCommandReceipt { + pub command_id: String, + pub terminal_state: DeviceCommandOutcome, + pub issuer: String, + pub device: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + pub command_kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub screen: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub verified_result_signature: Option, + pub signed_result: Option, } #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] pub struct DeviceCommand { @@ -6231,6 +6258,14 @@ pub struct DeviceCommand { pub issued_by: String, pub issued_at: String, pub expires_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grant_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_grant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub receipt_claim_id: Option, pub state: DeviceCommandState, #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs index acacfab4677..3c5841bea44 100644 --- a/crates/fractal-client/src/lib.rs +++ b/crates/fractal-client/src/lib.rs @@ -16,6 +16,7 @@ pub mod propagation; pub mod quota; pub mod scenarios; pub use contract::*; +pub use fractal_schema::client_control::*; pub use generated::*; mod identities; pub use identities::*; @@ -3689,6 +3690,14 @@ impl Client { self.post("/v1/client/device-results", result).await } + pub async fn client_test_revoke(&self, device: &str) -> Result { + self.post( + "/v1/client/test-clients/revoke", + &serde_json::json!({"device": device}), + ) + .await + } + pub async fn pairing_begin( &self, request: &PairingBegin, diff --git a/crates/fractal-schema/src/client_control.rs b/crates/fractal-schema/src/client_control.rs new file mode 100644 index 00000000000..cd961871806 --- /dev/null +++ b/crates/fractal-schema/src/client_control.rs @@ -0,0 +1,125 @@ +// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT. +// Source: crates/fractal-client-codegen/templates/client_control.rs.in. +//! Purpose-specific client results. This is not a message signing authorization. +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +pub const CLIENT_RESULT_PURPOSE: &str = "client.device-result.v1"; +pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[ + "purpose", "command_kind", "device", "session", "grant_id", "run_id", + "command_id", "outcome", "error_code", "visible_state_sha256", +]; +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ClientControlRefusal { + DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted, + VerbNotGranted, BudgetExhausted, ActorNotGranted, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestEnrollment { + pub device: String, pub kind: String, pub enrolled_by: String, + pub consent_ref: String, pub packet_digest: String, pub host: String, + pub instance: String, pub pairing_subject: String, pub public_key: String, pub session: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ClientTestRevocation { + pub device: String, pub enrollment_id: String, pub revoked_by: String, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleItem { pub id: String, pub title: String, pub body: String } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientVisibleState { + pub route: String, + pub screen: String, + pub visible_item_ids: Vec, + pub opened_item: Option, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientResultBinding { + pub purpose: String, + pub command_kind: String, + pub device: String, + pub session: String, + pub grant_id: String, + pub run_id: String, + pub command_id: String, + pub outcome: String, + pub error_code: Option, + pub visible_state: ClientVisibleState, +} +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ClientControlGrant { + pub grant_id: String, + pub run_id: String, + pub device: String, + pub session: String, + pub actor_agent: String, + pub verbs: Vec, + pub screens: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, + pub max_commands: u64, + pub expires_at: String, + pub state: String, +} +/// RFC 8785 JCS for this closed, string-only schema. Keys are emitted in ASCII +/// order explicitly, independent of serde_json's preserve_order feature. +/// There are no numbers or arbitrary object keys in this type. +pub fn visible_state_canonical_json(state: &ClientVisibleState) -> Result { + #[derive(Serialize)] + struct Item<'a> { body: &'a str, id: &'a str, title: &'a str } + #[derive(Serialize)] + struct State<'a> { opened_item: Option>, route: &'a str, screen: &'a str, visible_item_ids: &'a [String] } + serde_json::to_string(&State { + opened_item: state.opened_item.as_ref().map(|item| Item { body: &item.body, id: &item.id, title: &item.title }), + route: &state.route, screen: &state.screen, visible_item_ids: &state.visible_item_ids, + }) +} +pub fn visible_state_digest(state: &ClientVisibleState) -> Result { + Ok(hex::encode(Sha256::digest(visible_state_canonical_json(state)?.as_bytes()))) +} +/// Purpose is the first fields-v1 field. Values are JSON, not raw lines, so +/// embedded newlines cannot inject fields. No trailing newline is signed. +/// Callers MUST enforce the purpose constant, enrolled key, grant and windows. +pub fn client_result_canonical_bytes(binding: &ClientResultBinding) -> Result, serde_json::Error> { + let digest = visible_state_digest(&binding.visible_state)?; + let mut lines = vec!["smallclaims-claim-fields-v1".to_owned()]; + for (name, value) in [ + ("purpose", Some(binding.purpose.as_str())), + ("command_kind", Some(binding.command_kind.as_str())), + ("device", Some(binding.device.as_str())), + ("session", Some(binding.session.as_str())), + ("grant_id", Some(binding.grant_id.as_str())), + ("run_id", Some(binding.run_id.as_str())), + ("command_id", Some(binding.command_id.as_str())), + ("outcome", Some(binding.outcome.as_str())), + ("error_code", binding.error_code.as_deref()), + ("visible_state_sha256", Some(digest.as_str())), + ] { lines.push(format!("{name}={}", serde_json::to_string(&value)?)); } + Ok(lines.join("\n").into_bytes()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn shared_golden_vectors() { + let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap(); + for vector in fixture["vectors"].as_array().unwrap() { + let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap(); + assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap()); + assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap()); + let bytes = client_result_canonical_bytes(&binding).unwrap(); + assert_eq!(bytes, vector["canonical_bytes"].as_str().unwrap().as_bytes()); + for mutation in vector["negative_bindings"].as_array().unwrap() { + let changed: ClientResultBinding = serde_json::from_value(mutation.clone()).unwrap(); + assert_ne!(bytes, client_result_canonical_bytes(&changed).unwrap()); + } + } + } +} diff --git a/fixtures/clients/client-control-v1.golden.json b/fixtures/clients/client-control-v1.golden.json new file mode 100644 index 00000000000..97bd57cb9ff --- /dev/null +++ b/fixtures/clients/client-control-v1.golden.json @@ -0,0 +1,501 @@ +{ + "_generated": "cargo run -p fractal-client-codegen; source templates/client-control-v1.golden.json.in; fixed PUBLIC TEST key scalar 1, nonce scalar 2, never use for enrollment", + "format": "client-control-v1", + "public_key_sec1_base64url": "BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU", + "private_scalar_hex_for_tests": "0000000000000000000000000000000000000000000000000000000000000001", + "message_domain_bytes": "smallclaims-claim-fields-v1\nmessage/test\nmessage.sent\nperson/client-control-fixture\ncontent=\"hello\"", + "message_domain_signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXgRe11q295ks9iyv8QCIwcWwVU7WhRTUdQe_JG0MhGy1w", + "vectors": [ + { + "name": "inbox", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"step-run/ask-golden/ask\"]}", + "visible_state_sha256": "4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXiuu76h6230PGyTZfFujp1lxwC70zSTNzoxlic27FPzmg", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/inbox", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.inspect", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-0", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "NowRoot", + "visible_item_ids": [ + "step-run/ask-golden/ask" + ], + "opened_item": null + } + } + ] + }, + { + "name": "unicode-opened-item", + "input": { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/

\",\"id\":\"step-run/ask-golden/ask\",\"title\":\"Seed é 😀\"},\"route\":\"/attention/step-run/ask-golden/ask\",\"screen\":\"Attention\",\"visible_item_ids\":[\"step-run/ask-golden/ask\",\"message/é😀\"]}", + "visible_state_sha256": "2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3", + "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3\"", + "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXi45Ckt8qvn0tLGFBvQ7AEZ-86R5CQtnzYJQ5kea6paXQ", + "negative_bindings": [ + { + "purpose": "message.send", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/other", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/other", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/other", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.message.read", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/other", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/other", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "failed", + "error_code": null, + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": "tampered", + "visible_state": { + "route": "/attention/step-run/ask-golden/ask", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + }, + { + "purpose": "client.device-result.v1", + "command_kind": "phone.navigate", + "device": "device/test-tui", + "session": "client-session/test-tui", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "command_id": "device-command/golden-1", + "outcome": "completed", + "error_code": null, + "visible_state": { + "route": "/tampered", + "screen": "Attention", + "visible_item_ids": [ + "step-run/ask-golden/ask", + "message/é😀" + ], + "opened_item": { + "id": "step-run/ask-golden/ask", + "title": "Seed é 😀", + "body": "Line 1\n\t\"\\\u0000\u001f<>/

" + } + } + } + ] + } + ] +} From 61ea410fe386ef5e88b29936e2012ef0a079300c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:02:52 +0100 Subject: [PATCH 18/32] Consume generated client-control schemas and shared web vectors agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 33 ++++--- .../client-control/command.test-support.ts | 18 ++++ .../src/client-control/grantBoundary.ts | 57 +++++------ .../client-control/grantBoundary.unit.test.ts | 87 +++++++++------- .../src/client-control/identity.ts | 4 +- apps/fractal-web/src/client-control/wire.ts | 50 ++++++++++ .../src/client-control/wire.unit.test.ts | 99 +++++++++++++++++++ .../alerts/AnswerAlert.integration.test.tsx | 32 +++--- 8 files changed, 276 insertions(+), 104 deletions(-) create mode 100644 apps/fractal-web/src/client-control/command.test-support.ts create mode 100644 apps/fractal-web/src/client-control/wire.ts create mode 100644 apps/fractal-web/src/client-control/wire.unit.test.ts diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 74537ccd752..0556e9b39a3 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -1,21 +1,22 @@ # Client-control web modules -Wire-independent source preparation only. **Application test mode remains disabled.** These modules do not change existing entrypoints, pairings or read stores, and do not create enrollment or grant authority. +Source preparation stacked on the canonical generated contract. **Application test mode remains disabled.** These modules do not change existing entrypoints, pairings or read stores, and do not create enrollment or grant authority. ## APIs - `identity.ts`: `getOrCreateClientControlIdentity()` atomically acquires an origin-local IndexedDB P-256 identity. The private key is non-extractable; public SPKI export and supplied-byte ECDSA/SHA-256 signing remain internal capabilities. Storage failures never replace a key or fall back to ephemeral identity. Key deletion is not enrollment revocation. - `foreground.ts`: visible AND focused. Re-evaluate before navigation and signing. - `navigation.ts`: only NowRoot and Attention(ask id) destinations; the host supplies its authoritative subject-to-href resolver. -- `grantBoundary.ts`: wire-independent handler allowing only granted inspect or NowRoot/Attention navigation, with Attention restricted to the grant-bound ask. There is no answer/close/read-state or DOM activation callback. It rechecks current target, permission and foreground before signing after navigation. Unit assertions cover nongranted screens, wrong-item and mutation refusals with no navigation/signing; generated decoding, identity/session/run/window validation and actual canonical signatures remain pending. +- `grantBoundary.ts`: decodes actual delivered commands with the generated DeviceCommand schema; uses generated ClientControlGrant permissions directly, not a local wire projection. Only granted inspect or NowRoot/Attention navigation exists, with Attention restricted to the optional grant item_id (unbound grants cannot open/inspect an ask). Checks device/grant/run binding and rechecks target, permission and foreground before signing. There is no answer/close/read-state or DOM activation callback. The later authority adapter owns enrollment, session and window admission. - `readAdvancement.ts`: future sticky page-lifetime prohibition checked by `gateReadAdvancement` at execution time. The read-position store is absent on this branch's main base, and the Attention route does not use it. This is not a present journey blocker: wrap the entire store advance (persistence and outbound synchronization) when that writer group lands, not individual UI callers. Enrollment ending must not re-enable writes. - `ClientControlIndicator.tsx`: controller-owned run indicator and enrollment-revoke action with pending/unconfirmed states. The component does not create authority or optimistically hide an active grant. - `fixtureGuard.ts`: requires the exact approved dedicated loopback origin and realpath-resolved fixture instance client socket, with normal-origin/socket deny lists. This is configuration validation, not daemon attestation or enrollment authority. - `fixtureDomEvidence.ts`: independent real Playwright-compatible DOM observation of the open native ask's title and request.question text. The digest is lowercase SHA-256 of exact UTF-8 question text. Each must render as one unsplit leaf element; altered text/digest or IDs outside the verified actual catalog fail observation. Persists unsigned observations with exact candidate commit and origin. - `fixtureText.ts`: both title and question must be printable ASCII, at most 60 characters, no Markdown syntax characters (asterisk, underscore, hash, square brackets, backtick, greater-than or vertical bar), JSON, or leading/trailing/repeated whitespace. The title comes from the verified fixture manifest, never a hard-coded value. - `fixtureNoAnswerGuard.ts`: install on the dedicated Playwright context before opening the app; it blocks all subject-action mutation requests and fails assertions if any were attempted, even when successfully blocked. The observer asserts the guard before and after capture; the journey must assert it again at completion and must never press answer controls. +- `wire.ts`: generated command/grant/binding/result/receipt decode/encode boundaries, generated canonical signing bytes, native identity signing and generated DeviceResult envelope encoding. Canonicalization/digest/signed-field order come only from `clients/typescript/fractal-client/ClientControl.generated.ts`; no second canonicalizer. Signature metadata is caller-supplied generated DeviceSignature data, not enrollment authority. The DOM evidence is an independent observation, distinct from generated durable receipt evidence. -Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Internal module inputs are not daemon wire schemas. +Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Protocol shapes come from generated schemas; route and independent DOM observer inputs remain local app concepts. D-5 integration assertions in that shipped-renderer test run `DataSourceProvider -> LiveAgentAlerts -> AnswerAlert` with recording `alertActions.act` and `prompts.respond` source ports, not mocked data hooks. Both structured choices and feedback/decision-words Send controls remain in the DOM, with no activation/input/submit events or confirmation state, across inspect/navigation, wrong-item and refused answer/close/read commands. Both port request logs must remain empty after EVERY command. This is a deterministic source-port test, not a fixture journey; its unit-only fixture source never supplies the real journey UI. @@ -36,24 +37,28 @@ Dependency decisions below refer to the read-only integration snapshot `ff956ae7 | `0d21c1286b` read-position clock-skew/remount fix | DROP from current journey blockers | `web/readPosition.ts:45,125` exposes stream seen/mark-read hooks; native ask detail does not invoke them (`web/AlertDetailsPane.tsx:37-60`). | | `45710bb1d5` last_prose_at unread fix | DROP from current journey blockers | The relevant write boundary remains `state/cells/readPositions.ts:36-39`; neither native ask surface requires it (`workspace/registry.ts:89-90`). The suppression integration must land with any introduced writer, before test-mode rendering can reach it. | | [#6108 read-position sync](https://github.com/compoundingtech/fractal/pull/6108) | DROP from current journey blockers | Inspected head `01fd722d1cdfc4beb916817ab9dc68f0307b437b` adds lastProseAt projection/types, not a web sync caller. A future writer must share the gated store boundary above. | -| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | KEEP | Inspected source head `6ad7f674d4503ce4dddd2186c66943c9a1b08751`; `crates/fractal-client-codegen/templates/ClientControl.ts.in` is source, not consumer-ready output. Consume generated `ClientControl.generated.ts` and repository `fixtures/clients/client-control-v1.golden.json` after they land, never copy vector fields/bytes. | -| Later enrollment readback/revoke PR | KEEP | Authoritative pairing/key enrollment and enrollment-ending revoke are required before activation; browser flags cannot supply authority. Handle/readback module not yet published. | -| Later grant-subscription PR | KEEP | Commands and grant lifecycle must be session/run/key/item bound; the internal grant permission is not authoritative subscription input. Handle/module pending. | +| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | MERGED prerequisite | Signed no-ff merge of generated head `36592f7090093ae6fe94a87706e319fca78fca17` (source parent `9ac7024e`). Imported `ClientControl.generated.ts`, generated Effect schemas and repository `fixtures/clients/client-control-v1.golden.json`; no generated file hand-edited or copied. | +| Later enrollment readback/revoke PR | KEEP | Generated enrollment/revocation schemas and `FractalClient.clientTestRevoke` now exist, but authoritative browser enrollment readback/controller and live revoke confirmation remain integration prerequisites. | +| Later grant-subscription PR | KEEP | Commands and grants now have generated schemas; authoritative enrollment/session/run/key/window admission and grant lifecycle subscription remain unconnected. | | Later verification/receipt PR | KEEP for journey acceptance | Actual canonical result verification and durable terminal receipts cannot be established by DOM observations. Handle/module pending. | No file:line can be cited for unpublished later PR APIs; their absence remains an explicit integration blocker, not an assumed implementation. -#6278 stays based on origin/main and consumes #6274's generated TS module and shared vector file after they land on main. +#6278 is STACKED on #6274's branch `schickling-assistant/2026-11-10-client-control-daemon`, merged at generated head `36592f7090093ae6fe94a87706e319fca78fca17`. This supersedes the earlier main-first decision. -## Resolved source contract; consumption still blocked +## Generated-contract consumption and vector coverage -#6274 now specifies the existing phone.inspect/phone.navigate vocabulary, generated visible-state and result-binding/grant types, purpose-first claim-fields canonicalization over the JCS visible-state digest, and P-256/SHA-256 IEEE P1363 signatures with unpadded base64url envelope serialization. The signing interface deliberately remains supplied-byte-only: the future adapter must consume `ClientControl.generated.ts` helpers/types rather than reproduce template fields locally. Native WebCrypto signature bytes are compatible with the specified P1363 format; envelope construction belongs to that adapter, not the identity store. These are source-contract resolutions, not conformance or consumer-readiness claims. +`wire.unit.test.ts` imports the shared JSON directly. It covers BOTH published vectors (`inbox`, native Attention `unicode-opened-item`), their generated decode/encode roundtrips, exact JCS/digest/signing bytes, WebCrypto verification of fixed signatures, all TWENTY negative bindings, and both message/result purpose-confusion directions. It also signs both vector bindings with a real independently generated non-extractable browser identity and verifies the generated result envelope. These are conformance assertions in source, NOT executed evidence. + +The shared JSON contains no grant/command/envelope/receipt or fingerprint golden vectors. Optional item_id present/absent and delivered native Attention command coverage therefore uses generated-schema roundtrip examples, not invented golden bytes; receipt examples likewise use the generated evidence schema. No published web-relevant vector is skipped. The fixed test scalar is public test material only, never an enrollment key. Unicode cryptographic compatibility does not relax the fixture's ASCII-only rendered-text rule. ## Open contracts -No guessed wire fields or signing bytes are implemented. Still pending: consumer-ready generated outputs and approved computed golden vectors; authoritative enrollment readback and pairing/public-key binding adapter; grant start/end/revoke delivery and its executable authority semantics; enrollment-revoke operation; fixture local-only attestation, manifest/readback and authorized bootstrap. The published enrollment documentation describes local authority constraints, but does not yet supply the browser readback/revoke integration. +No guessed wire fields or signing bytes are implemented. Generated command/grant/visible-state/result/receipt schemas, canonical helpers, signed vectors and revoke SDK are now consumed or available. Still pending: authoritative enrollment readback and pairing/public-key binding controller; grant lifecycle delivery with session/window/budget admission; live revoke confirmation; fixture local-only attestation, manifest/readback and authorized bootstrap. + +The generated schema currently has delivered command rows with optional control_grant, but no generated start/end/revoke lifecycle event type. That lifecycle remains owned by the daemon's existing commands collection work. No custom event envelope, second socket, handwritten lifecycle shape or permissive fallback is introduced: unsupported command/lifecycle input is refused by generated decoding; application activation stays disabled. -Activation, command subscription, generated capture/signing and the complete journey await the KEEP dependencies. Store wrapping is conditional on a read-position writer landing; that guard must land with the writer, not delay the current ask-only journey on a nonexistent store. Browser flags or preferences must never enable test mode on a normal pairing. +Application activation, command subscription, route-state capture and the complete journey await the remaining KEEP dependencies. Store wrapping is conditional on a read-position writer landing; that guard must land with the writer, not delay the current ask-only journey on a nonexistent store. Browser flags or preferences must never enable test mode on a normal pairing. ## Final seed and route ruling @@ -71,10 +76,8 @@ The E3 exclusion keys are exactly: IndexedDB `fractal-client-control-identity-v1 No builds, typechecks, tests, linters, formatters, Storybook, Playwright, Rust, servers or browser captures were run during source preparation. No live setup, pairing, enrollment, grant or daemon operation was performed. -Next source-regression command on the approved hosted runner, from `apps/fractal-web`: +#6278 stays DRAFT. Source readiness requires focused LOCAL tests and the package's real typecheck at the exact candidate head, through an approved gate-slot ticket, plus independent exact-head review. Hosted CI is regression evidence only, never the reason to mark the source ready. Official generated TS tests reported by the owner at the generated head are not web integration evidence. -```sh -pnpm test -- src/client-control src/conversation/alerts/AnswerAlert.integration.test.tsx -``` +The prepared local runner uses the same `nix develop .#web` shell as web CI (`.github/workflows/workspace-ci.ts:998-999`). It records each exit status and retains outputs: frozen install; `pnpm --dir apps/fractal-web run typecheck` (the package script runs changelog-registry generation and `tsc --noEmit`); focused Vitest including `src/client-control`, shipped AnswerAlert integration and `wire.unit.test.ts`; official generated TS golden tests. The script is an external task artifact, not run during this source pass. Hosted CI provides source regression evidence only, never journey acceptance. Journey acceptance requires a LOCAL exact-candidate real-fixture Playwright/DOM run after authorized setup/bootstrap/enrollment. Its runner must guard before opening connections; independently verify fixture/local-only daemon identity; supply the exact clean commit, approved loopback origin, fixture-only `WF_ST_GATEWAY` client socket and approved bootstrap; use the dedicated isolated browser profile/context without resetting it per journey; capture every pre-render HTTP/collections payload; use the actual manifest and real DOM selectors; persist evidence for authorized signing; and execute grant/refusal/not-foreground/revoke/read-state/receipt checks. Neither fixtureSource/embed rendering nor this independent observer alone is journey acceptance or a signed receipt. diff --git a/apps/fractal-web/src/client-control/command.test-support.ts b/apps/fractal-web/src/client-control/command.test-support.ts new file mode 100644 index 00000000000..dfc049153dd --- /dev/null +++ b/apps/fractal-web/src/client-control/command.test-support.ts @@ -0,0 +1,18 @@ +import { decodeGrant } from './wire.ts' + +/** Synthetic protocol examples, not golden signing vectors or enrollment authority. */ +export const testGrant = (itemId: string = 'attention/unit-ask') => decodeGrant({ + grant_id: 'client-control-grant/unit', run_id: 'mission-run/unit', device: 'device/unit', + session: 'client-session/unit', actor_agent: 'agent/unit', + verbs: ['phone.inspect', 'phone.navigate'], screens: ['NowRoot', 'Attention'], + max_commands: 100, expires_at: '2030-01-01T00:00:00.000Z', state: 'active', item_id: itemId, +}) + +/** Unknown permits deliberately invalid/refused verbs to exercise the generated decoder. */ +export const testCommand = (verb: string, args: unknown = {}) => ({ + kind: 'device-command', id: 'device-command/unit', revision: 'r1', + command_kind: verb, args, device_id: 'device/unit', session_actor: 'client-session/unit', + issued_by: 'agent/unit', issued_at: '2026-11-01T00:00:00.000Z', updated_at: '2026-11-01T00:00:00.000Z', + expires_at: '2030-01-01T00:00:00.000Z', state: 'delivered', + grant_id: 'client-control-grant/unit', run_id: 'mission-run/unit', +}) diff --git a/apps/fractal-web/src/client-control/grantBoundary.ts b/apps/fractal-web/src/client-control/grantBoundary.ts index e7cf75bd061..05dc41c61c8 100644 --- a/apps/fractal-web/src/client-control/grantBoundary.ts +++ b/apps/fractal-web/src/client-control/grantBoundary.ts @@ -1,50 +1,41 @@ +import type * as Wire from '@fractal/client/schema' import type { ClientControlRouteTarget } from './navigation.ts' +import { decodeCommand } from './wire.ts' -/** Internal adapter inputs, NOT copied wire types. Decode generated commands before this boundary. */ -export interface ClientControlIntent { - readonly verb: string - readonly screenName?: string - readonly targetAskId?: string -} -export interface ClientControlPermission { - /** Authoritative adapter returns nothing unless identity/session/run/grant binding is valid. */ - readonly verbs: ReadonlySet - readonly screens: ReadonlySet - /** Internal authority projection of the grant's bound item, not a wire field name. */ - readonly boundAskId: string -} - -const destinationFor = (intent: ClientControlIntent): ClientControlRouteTarget | undefined => { - if (intent.screenName === 'NowRoot' && intent.targetAskId === undefined) return { _tag: 'NowRoot' } - if (intent.screenName === 'Attention' && intent.targetAskId !== undefined && intent.targetAskId !== '') - return { _tag: 'Attention', askId: intent.targetAskId } +const destinationFor = (command: Wire.DeviceCommand): ClientControlRouteTarget | undefined => { + if (command.command_kind !== 'phone.navigate') return undefined + if (command.args.screen === 'NowRoot') return { _tag: 'NowRoot' } + if (command.args.screen === 'Attention') return { _tag: 'Attention', askId: command.args.params.id } return undefined } /** - * Narrow handler boundary: there is deliberately no answer/close/read-state callback or arbitrary - * DOM activation capability. Only granted inspect and the final NowRoot/Attention route set exist. - * The later generated-wire adapter owns identity/session/run/window validation and permission revocation. + * Decodes the actual generated delivered command. No answer/close/read-state or DOM activation + * callback exists. The authority adapter must return an active enrollment-validated grant only; + * decoding a grant does not establish enrollment, foreground, expiry or session authority. */ -export const handleGrantedClientControlIntent = async (input: { +export const handleGrantedDeviceCommand = async (input: { readonly currentTarget: () => ClientControlRouteTarget | undefined - readonly intent: ClientControlIntent - readonly permission: () => ClientControlPermission | undefined + readonly command: unknown + readonly permission: () => Wire.ClientControlGrant | undefined readonly foreground: () => boolean readonly navigate: (target: ClientControlRouteTarget) => Promise readonly signVisibleState: () => Promise }): Promise<'Refused' | 'NotForeground' | 'Applied'> => { - const intent = input.intent - if (intent.verb !== 'phone.inspect' && intent.verb !== 'phone.navigate') return 'Refused' - const destination = intent.verb === 'phone.navigate' ? destinationFor(intent) : undefined - if (intent.verb === 'phone.navigate' && destination === undefined) return 'Refused' - if (intent.verb === 'phone.inspect' && (intent.screenName !== undefined || intent.targetAskId !== undefined)) return 'Refused' - const targetAllowed = (permission: ClientControlPermission, target: ClientControlRouteTarget | undefined) => - target !== undefined && permission.screens.has(target._tag) - && (target._tag !== 'Attention' || target.askId === permission.boundAskId) + let command: Wire.DeviceCommand + try { command = decodeCommand(input.command) } catch { return 'Refused' } + if (command.state !== 'pending' && command.state !== 'delivered') return 'Refused' + if (command.command_kind !== 'phone.inspect' && command.command_kind !== 'phone.navigate') return 'Refused' + const destination = destinationFor(command) + if (command.command_kind === 'phone.navigate' && destination === undefined) return 'Refused' + const targetAllowed = (permission: Wire.ClientControlGrant, target: ClientControlRouteTarget | undefined) => + target !== undefined && permission.screens.includes(target._tag) + && (target._tag !== 'Attention' || (permission.item_id !== undefined && target.askId === permission.item_id)) const allowed = () => { const permission = input.permission() - return permission !== undefined && permission.verbs.has(intent.verb) + return permission !== undefined && permission.verbs.includes(command.command_kind) + && command.device_id === permission.device && command.grant_id === permission.grant_id + && command.run_id === permission.run_id && targetAllowed(permission, destination ?? input.currentTarget()) } if (!allowed()) return 'Refused' diff --git a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts index 412f19811dd..548be207712 100644 --- a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts +++ b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts @@ -1,22 +1,19 @@ import { describe, expect, it } from 'vitest' -import { handleGrantedClientControlIntent, type ClientControlIntent, type ClientControlPermission } from './grantBoundary.ts' +import type * as Wire from '@fractal/client/schema' +import { handleGrantedDeviceCommand } from './grantBoundary.ts' import type { ClientControlRouteTarget } from './navigation.ts' - -const permission: ClientControlPermission = { - verbs: new Set(['phone.inspect', 'phone.navigate']), screens: new Set(['NowRoot', 'Attention']), - boundAskId: 'attention/unit-ask', -} +import { testCommand, testGrant } from './command.test-support.ts' const exercise = async ( - intent: ClientControlIntent, - grant: ClientControlPermission | undefined = permission, + command: unknown, + grant: Wire.ClientControlGrant | undefined = testGrant(), initialTarget: ClientControlRouteTarget = { _tag: 'NowRoot' }, ) => { const navigations: ClientControlRouteTarget[] = [] let signatures = 0 let currentTarget = initialTarget - const result = await handleGrantedClientControlIntent({ - intent, permission: () => grant, foreground: () => true, + const result = await handleGrantedDeviceCommand({ + command, permission: () => grant, foreground: () => true, currentTarget: () => currentTarget, navigate: async target => { navigations.push(target); currentTarget = target }, signVisibleState: async () => { signatures += 1 }, @@ -24,53 +21,72 @@ const exercise = async ( return { result, navigations, signatures } } -describe('client-control grant-check handler boundary', () => { +const refused = { result: 'Refused', navigations: [], signatures: 0 } +describe('generated client-control grant-check handler boundary', () => { it('refuses Conversation under a NowRoot/Attention grant without navigating or signing', async () => { - expect(await exercise({ verb: 'phone.navigate', screenName: 'Conversation' })).toEqual({ - result: 'Refused', navigations: [], signatures: 0, - }) + expect(await exercise(testCommand('phone.navigate', { screen: 'Conversation', params: { target: 'agent/unit' } }))).toEqual(refused) + }) + + it.each([null, {}, { kind: 'unsupported-event' }])('refuses unsupported event input without fallback', async input => { + expect(await exercise(input)).toEqual(refused) + }) + + it.each(['completed', 'not-foreground', 'expired', 'refused', 'failed'])('does not replay terminal %s commands', async state => { + expect(await exercise({ ...testCommand('phone.inspect'), state })).toEqual(refused) }) + it.each(['Agents', 'Missions', 'Usage', 'History'])( + 'refuses nongranted screen %s without navigation or signing', async screen => { + expect(await exercise(testCommand('phone.navigate', { screen }))).toEqual(refused) + }, + ) + it.each(['work.done', 'prompt.respond', 'attention.answer', 'attention.close', 'phone.message.read', 'read-position.advance'])( 'refuses %s without navigation or a signed result', async verb => { - // Even a malformed over-broad grant cannot turn the narrow handler into a write executor. - const grant = { ...permission, verbs: new Set([...permission.verbs, verb]) } - expect(await exercise({ verb }, grant)).toEqual({ result: 'Refused', navigations: [], signatures: 0 }) + const grant = testGrant() + const args = verb === 'phone.message.read' ? { message_id: 'message/unit' } : {} + expect(await exercise(testCommand(verb, args), { ...grant, verbs: [...grant.verbs, verb] })).toEqual(refused) }, ) it('only exposes local routing and inspect/sign, never an answer-control activation capability', async () => { - expect(await exercise({ verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/unit-ask' })).toEqual({ + expect(await exercise(testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/unit-ask' } }))).toEqual({ result: 'Applied', navigations: [{ _tag: 'Attention', askId: 'attention/unit-ask' }], signatures: 1, }) - expect(await exercise({ verb: 'phone.inspect' })).toEqual({ result: 'Applied', navigations: [], signatures: 1 }) + expect(await exercise(testCommand('phone.inspect'))).toEqual({ result: 'Applied', navigations: [], signatures: 1 }) }) it('refuses a grant that omits the destination', async () => { - expect(await exercise({ verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/unit-ask' }, - { ...permission, screens: new Set(['NowRoot']) })).toEqual({ result: 'Refused', navigations: [], signatures: 0 }) + expect(await exercise(testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/unit-ask' } }), + { ...testGrant(), screens: ['NowRoot'] })).toEqual(refused) }) it('refuses navigation to an ask other than the grant-bound item', async () => { - expect(await exercise({ verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/other-ask' })).toEqual({ - result: 'Refused', navigations: [], signatures: 0, - }) + expect(await exercise(testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/other-ask' } }))).toEqual(refused) }) it('refuses inspecting an ask other than the grant-bound item', async () => { - expect(await exercise({ verb: 'phone.inspect' }, permission, { _tag: 'Attention', askId: 'attention/other-ask' })).toEqual({ - result: 'Refused', navigations: [], signatures: 0, - }) + expect(await exercise(testCommand('phone.inspect'), testGrant(), { _tag: 'Attention', askId: 'attention/other-ask' })).toEqual(refused) + }) + + it('allows optional item_id absence for NowRoot but refuses unbound Attention', async () => { + const { item_id: _item, ...unbound } = testGrant() + expect(await exercise(testCommand('phone.inspect'), unbound)).toEqual({ result: 'Applied', navigations: [], signatures: 1 }) + expect(await exercise(testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/unit-ask' } }), unbound)).toEqual(refused) + }) + + it.each(['device_id', 'grant_id', 'run_id'] as const)('refuses mismatched %s binding', async field => { + expect(await exercise({ ...testCommand('phone.inspect'), [field]: 'other/unit' })).toEqual(refused) }) it.each([ [undefined, true, 'Refused'], - [permission, false, 'NotForeground'], - [{ ...permission, verbs: new Set() }, true, 'Refused'], + [testGrant(), false, 'NotForeground'], + [{ ...testGrant(), verbs: [] }, true, 'Refused'], ] as const)('does not act without permission or foreground', async (grant, foreground, expected) => { const calls: string[] = [] - const result = await handleGrantedClientControlIntent({ - intent: { verb: 'phone.navigate', screenName: 'NowRoot' }, permission: () => grant, + const result = await handleGrantedDeviceCommand({ + command: testCommand('phone.navigate', { screen: 'NowRoot' }), permission: () => grant, foreground: () => foreground, currentTarget: () => ({ _tag: 'NowRoot' }), navigate: async () => { calls.push('navigate') }, signVisibleState: async () => { calls.push('sign') }, }) @@ -79,10 +95,10 @@ describe('client-control grant-check handler boundary', () => { }) it('does not sign after grant loss during navigation', async () => { - let active: ClientControlPermission | undefined = permission + let active: Wire.ClientControlGrant | undefined = testGrant() let signatures = 0 - const result = await handleGrantedClientControlIntent({ - intent: { verb: 'phone.navigate', screenName: 'NowRoot' }, permission: () => active, foreground: () => true, + const result = await handleGrantedDeviceCommand({ + command: testCommand('phone.navigate', { screen: 'NowRoot' }), permission: () => active, foreground: () => true, currentTarget: () => ({ _tag: 'NowRoot' }), navigate: async () => { active = undefined }, signVisibleState: async () => { signatures += 1 }, }) @@ -91,5 +107,4 @@ describe('client-control grant-check handler boundary', () => { }) }) -// These assertions run against the wire-independent handler. Generated command decoding, -// identity/session/run/window authority and canonical signing conformance remain pending #6274. +// Authoritative enrollment/session/window admission remains the later daemon adapter's boundary. diff --git a/apps/fractal-web/src/client-control/identity.ts b/apps/fractal-web/src/client-control/identity.ts index 584205954ed..bdbcf75518a 100644 --- a/apps/fractal-web/src/client-control/identity.ts +++ b/apps/fractal-web/src/client-control/identity.ts @@ -28,8 +28,8 @@ export interface ClientControlIdentity { /** * Signs bytes supplied by the generated client-result canonical helper; never reconstruct fields here. * The caller must enforce enrollment, foreground, command and active-grant checks first. - * Returns native P1363 signature bytes. The generated-contract adapter owns base64url/envelope - * serialization; #6274's source contract is known but its generated outputs are not consumer-ready. + * Returns native P1363 signature bytes. wire.ts uses the generated canonical helper and owns + * base64url/envelope serialization; this store does not reconstruct any protocol fields. */ signCanonicalBytes(bytes: Uint8Array): Promise } diff --git a/apps/fractal-web/src/client-control/wire.ts b/apps/fractal-web/src/client-control/wire.ts new file mode 100644 index 00000000000..20b43de625f --- /dev/null +++ b/apps/fractal-web/src/client-control/wire.ts @@ -0,0 +1,50 @@ +import { Schema } from 'effect' +import * as Wire from '@fractal/client/schema' +import { + CLIENT_RESULT_SIGNED_FIELDS, + clientResultCanonicalBytes, + type ClientResultBinding as CanonicalBinding, +} from '../../../../clients/typescript/fractal-client/ClientControl.generated.ts' +import type { ClientControlIdentity } from './identity.ts' + +export const decodeCommand = Schema.decodeUnknownSync(Wire.DeviceCommand) +export const encodeCommand = Schema.encodeSync(Wire.DeviceCommand) +export const decodeGrant = Schema.decodeUnknownSync(Wire.ClientControlGrant) +export const encodeGrant = Schema.encodeSync(Wire.ClientControlGrant) +export const decodeBinding = Schema.decodeUnknownSync(Wire.ClientResultBinding) +export const encodeBinding = Schema.encodeSync(Wire.ClientResultBinding) +export const decodeResult = Schema.decodeUnknownSync(Wire.DeviceResult) +export const encodeResult = Schema.encodeSync(Wire.DeviceResult) +export const decodeReceipt = Schema.decodeUnknownSync(Wire.ClientCommandReceipt) +export const encodeReceipt = Schema.encodeSync(Wire.ClientCommandReceipt) + +const base64url = (bytes: Uint8Array): string => + btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join('')).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '') +const fromBase64url = (value: string): Uint8Array => + Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/')), byte => byte.charCodeAt(0)) + +/** Authority/foreground checks belong to the command handler; this codec creates no authority. */ +export const signDeviceResult = async (input: { + readonly binding: Wire.ClientResultBinding + readonly signatureMetadata: Omit + readonly identity: ClientControlIdentity +}): Promise => { + const binding = encodeBinding(input.binding) + // The generated helper constructs these bytes with TextEncoder (ArrayBuffer-backed). + const bytes = await clientResultCanonicalBytes(binding as CanonicalBinding) as Uint8Array + const signature = await input.identity.signCanonicalBytes(bytes) + return encodeResult(decodeResult({ + command_id: binding.command_id, outcome: binding.outcome, + ...(binding.error_code === null ? {} : { error_code: binding.error_code }), + binding, result: binding.visible_state, + signature: { ...input.signatureMetadata, signature: base64url(new Uint8Array(signature)), signed_fields: [...CLIENT_RESULT_SIGNED_FIELDS] }, + })) +} + +/** Invalid binding/schema or signature is a verification refusal, not an alternate encoding. */ +export const verifyClientResultBinding = async (binding: unknown, signature: string, key: CryptoKey): Promise => { + let decoded: Wire.ClientResultBinding + try { decoded = decodeBinding(binding) } catch { return false } + const bytes = await clientResultCanonicalBytes(encodeBinding(decoded) as CanonicalBinding) as Uint8Array + return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, fromBase64url(signature), bytes) +} diff --git a/apps/fractal-web/src/client-control/wire.unit.test.ts b/apps/fractal-web/src/client-control/wire.unit.test.ts new file mode 100644 index 00000000000..77dc40c5ed9 --- /dev/null +++ b/apps/fractal-web/src/client-control/wire.unit.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment node +import { IDBFactory } from 'fake-indexeddb' +import { afterEach, describe, expect, it, vi } from 'vitest' +import golden from '../../../../fixtures/clients/client-control-v1.golden.json' with { type: 'json' } +import { + CLIENT_RESULT_SIGNED_FIELDS, + clientResultCanonicalBytes, + visibleStateCanonicalJson, + visibleStateDigest, +} from '../../../../clients/typescript/fractal-client/ClientControl.generated.ts' +import { + decodeBinding, encodeBinding, decodeCommand, encodeCommand, decodeGrant, encodeGrant, + decodeResult, encodeResult, decodeReceipt, encodeReceipt, signDeviceResult, verifyClientResultBinding, +} from './wire.ts' +import { getOrCreateClientControlIdentity } from './identity.ts' +import { testCommand, testGrant } from './command.test-support.ts' + +const bytes = (value: string): Uint8Array => Uint8Array.from(Buffer.from(value, 'base64url')) +const verificationKey = () => crypto.subtle.importKey( + 'raw', bytes(golden.public_key_sec1_base64url), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify'], +) +afterEach(() => vi.unstubAllGlobals()) + +describe('web consumption of the repository client-control golden file', () => { + it.each(golden.vectors)('decodes/encodes and verifies every shared vector: $name', async vector => { + const binding = encodeBinding(decodeBinding(vector.input)) + expect(binding).toEqual(vector.input) + expect(visibleStateCanonicalJson(vector.input.visible_state)).toBe(vector.canonical_visible_state) + expect(await visibleStateDigest(vector.input.visible_state)).toBe(vector.visible_state_sha256) + expect(new TextDecoder().decode(await clientResultCanonicalBytes(vector.input))).toBe(vector.canonical_bytes) + const key = await verificationKey() + expect(await verifyClientResultBinding(binding, vector.signature_p1363_base64url, key)).toBe(true) + for (const changed of vector.negative_bindings) { + expect(await verifyClientResultBinding(changed, vector.signature_p1363_base64url, key)).toBe(false) + // Even negatives excluded by the strict wire schema must fail cryptographic verification. + expect(await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, key, bytes(vector.signature_p1363_base64url), + await clientResultCanonicalBytes(changed) as Uint8Array, + )).toBe(false) + } + expect(await verifyClientResultBinding(binding, golden.message_domain_signature_p1363_base64url, key)).toBe(false) + expect(await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, key, bytes(vector.signature_p1363_base64url), + new TextEncoder().encode(golden.message_domain_bytes), + )).toBe(false) + }) + + it('verifies the shared message signature only in its own domain', async () => { + expect(await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, await verificationKey(), + bytes(golden.message_domain_signature_p1363_base64url), new TextEncoder().encode(golden.message_domain_bytes), + )).toBe(true) + }) + + it.each(golden.vectors)('signs and encodes a real web identity result using shared binding $name', async vector => { + vi.stubGlobal('indexedDB', new IDBFactory()) + const identity = await getOrCreateClientControlIdentity() + const key = await crypto.subtle.importKey('spki', await identity.exportPublicKey(), { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']) + const result = await signDeviceResult({ + binding: decodeBinding(vector.input), identity, + signatureMetadata: { chain: [], format: 'fields-v1', key: 'public-test-key', nonce: 'unit-result-nonce-0001', signed_at_unix_ms: 0, signer: 'person/unit' }, + }) + expect(encodeResult(decodeResult(result))).toEqual(result) + expect(result.binding).toEqual(vector.input) + expect(result.result).toEqual(vector.input.visible_state) + expect(result.command_id).toBe(vector.input.command_id) + expect(result.outcome).toBe(vector.input.outcome) + expect(result.signature!.signed_fields).toEqual(CLIENT_RESULT_SIGNED_FIELDS) + expect(bytes(result.signature!.signature)).toHaveLength(64) + expect(result.signature!.signature).toMatch(/^[A-Za-z0-9_-]+$/u) + expect(await verifyClientResultBinding(result.binding, result.signature!.signature, key)).toBe(true) + }) + + it('roundtrips generated grants with optional item_id and delivered native Attention commands', () => { + const bound = encodeGrant(testGrant()) + expect(encodeGrant(decodeGrant(bound))).toEqual(bound) + const { item_id: _item, ...unbound } = bound + expect(encodeGrant(decodeGrant(unbound))).toEqual(unbound) + for (const control_grant of [bound, unbound]) { + const command = { ...testCommand('phone.navigate', { screen: 'Attention', params: { id: bound.item_id! } }), control_grant } + expect(encodeCommand(decodeCommand(command))).toEqual(command) + } + }) + + it('uses the generated durable-receipt evidence schema without defining a local wire shape', () => { + const vector = golden.vectors[0]! + const receipt = { + commandId: vector.input.command_id, commandKind: vector.input.command_kind, + device: vector.input.device, session: vector.input.session, grantId: vector.input.grant_id, + runId: vector.input.run_id, issuer: 'agent/unit', verifiedResultSignature: null, + signedResult: vector.input, terminalState: vector.input.outcome, + } + expect(encodeReceipt(decodeReceipt(receipt))).toEqual(receipt) + }) +}) + +// The shared file has two signing vectors and twenty negative bindings. It has no grant, +// command, envelope/receipt or fingerprint vectors: those roundtrips are schema examples, +// not golden-byte conformance. No web-relevant vector in the published file is skipped. diff --git a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx index 9ebcb565a20..a37b97e753c 100644 --- a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx +++ b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx @@ -22,7 +22,8 @@ import type { AlertActionPort } from './alert-action-port.ts' import type { PromptAnswerPort } from './prompt-answer-port.ts' import { fixtureSource } from '../../data/fixtureSource.ts' import { DataSourceProvider } from '../../data/react.tsx' -import { handleGrantedClientControlIntent, type ClientControlIntent } from '../../client-control/grantBoundary.ts' +import { handleGrantedDeviceCommand } from '../../client-control/grantBoundary.ts' +import { testCommand, testGrant } from '../../client-control/command.test-support.ts' import type { ClientControlRouteTarget } from '../../client-control/navigation.ts' const alert = { @@ -208,28 +209,23 @@ it.each(['decision', 'feedback'] as const)( } let target: ClientControlRouteTarget = { _tag: 'NowRoot' } let signatures = 0 - const permission = { - verbs: new Set(['phone.inspect', 'phone.navigate']), - screens: new Set(['NowRoot', 'Attention']), - boundAskId: shown.id, - } - const commands: readonly ClientControlIntent[] = [ - { verb: 'phone.inspect' }, - { verb: 'phone.navigate', screenName: 'Attention', targetAskId: shown.id }, - { verb: 'phone.inspect' }, - { verb: 'phone.navigate', screenName: 'Attention', targetAskId: 'attention/not-bound' }, + const permission = testGrant(shown.id) + const commands = [ + { command: testCommand('phone.inspect'), refused: false }, + { command: testCommand('phone.navigate', { screen: 'Attention', params: { id: shown.id } }), refused: false }, + { command: testCommand('phone.inspect'), refused: false }, + { command: testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/not-bound' } }), refused: true }, ...['work.done', 'prompt.respond', 'attention.answer', 'attention.close', 'phone.message.read', 'read-position.advance'] - .map(verb => ({ verb })), - { verb: 'phone.navigate', screenName: 'NowRoot' }, - { verb: 'phone.inspect' }, + .map(verb => ({ command: testCommand(verb, verb === 'phone.message.read' ? { message_id: 'message/unit' } : {}), refused: true })), + { command: testCommand('phone.navigate', { screen: 'NowRoot' }), refused: false }, + { command: testCommand('phone.inspect'), refused: false }, ] - for (const command of commands) { + for (const { command, refused } of commands) { const before = signatures const previous = target - const refused = command.targetAskId === 'attention/not-bound' || !permission.verbs.has(command.verb) await act(async () => { - expect(await handleGrantedClientControlIntent({ - intent: command, permission: () => permission, currentTarget: () => target, + expect(await handleGrantedDeviceCommand({ + command, permission: () => permission, currentTarget: () => target, foreground: () => true, navigate: async next => { target = next }, signVisibleState: async () => { signatures += 1 }, })).toBe(refused ? 'Refused' : 'Applied') From 04028d7c6325200beb24f5a5353c212ba2ef1aad Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:05:55 +0100 Subject: [PATCH 19/32] Point identity tests at generated signing conformance agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/identity.unit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/fractal-web/src/client-control/identity.unit.test.ts b/apps/fractal-web/src/client-control/identity.unit.test.ts index 0c56c2b21b6..9ecbe57b9f4 100644 --- a/apps/fractal-web/src/client-control/identity.unit.test.ts +++ b/apps/fractal-web/src/client-control/identity.unit.test.ts @@ -41,7 +41,7 @@ describe('client-control P-256 identity', () => { expect(await subtle.verify(algorithm, key, signature, new TextEncoder().encode('different bytes'))).toBe(false) const other = await generateClientControlKeyPair() expect(await subtle.verify(algorithm, other.publicKey, signature, bytes)).toBe(false) - // No signature length/serialization assertion: daemon canonical encoding is unresolved. + // Generated canonical signing/envelope conformance belongs to wire.unit.test.ts. }) it('retains the committed identity across independent acquisitions without a key cache', async () => { From e4b298678573930fe6748a05a89204304b58563d Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:15:44 +0100 Subject: [PATCH 20/32] Add generated client-control lifecycle schema sources agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../ClientControlLifecycleTests.swift | 29 ++ .../fractal-client/client-control.test.cjs | 12 +- clients/typescript/fractal-client/index.ts | 3 +- .../typescript/fractal-client/schema.test.cjs | 21 ++ crates/fractal-client-codegen/src/main.rs | 5 +- .../templates/ClientControl.ts.in | 14 +- .../templates/Models.swift.in | 25 +- .../templates/client_control.rs.in | 15 + .../templates/generated.rs.in | 13 + .../tests/client_control_lifecycle.rs | 27 ++ docs/st3/client-v0/device-commands.md | 31 ++ .../client-v0/schemas/client-v0.schema.json | 12 + .../clients/client-control-lifecycle-v1.json | 313 ++++++++++++++++++ .../client-control-lifecycle-schema.json | 11 + 14 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift create mode 100644 crates/fractal-client/tests/client_control_lifecycle.rs create mode 100644 fixtures/clients/client-control-lifecycle-v1.json create mode 100644 release-notes/client-control-lifecycle-schema.json diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift new file mode 100644 index 00000000000..0c106c4a747 --- /dev/null +++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift @@ -0,0 +1,29 @@ +import Foundation +import XCTest +@testable import FractalClient + +final class ClientControlLifecycleTests: XCTestCase { + func testSharedCommandsLifecycleDecoders() throws { + var root = URL(fileURLWithPath: #filePath) + for _ in 0..<6 { root.deleteLastPathComponent() } + let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: root.appendingPathComponent("fixtures/clients/client-control-lifecycle-v1.json"))) as? [String: Any]) + for row in try XCTUnwrap(fixture["resources"] as? [[String: Any]]) { + let bytes = try JSONSerialization.data(withJSONObject: row) + let resource = try JSONDecoder().decode(Resource.self, from: bytes) + guard case .clientControlGrantLifecycle(let lifecycle) = resource else { return XCTFail("Lifecycle decoded as unknown") } + XCTAssertEqual(lifecycle.grantID, row["grant_id"] as? String) + XCTAssertEqual(lifecycle.itemID, row["item_id"] as? String) + XCTAssertEqual(lifecycle.state.rawValue, row["state"] as? String) + XCTAssertEqual(lifecycle.sequence, (row["sequence"] as? NSNumber)?.uint64Value) + } + for row in try XCTUnwrap(fixture["frames"] as? [[String: Any]]) { + let frame = try JSONDecoder().decode(ClientCommandsCollectionFrame.self, from: JSONSerialization.data(withJSONObject: row)) + XCTAssertEqual(frame.collection, "commands") + let lifecycle = try XCTUnwrap((frame.items ?? frame.upserts)?.first) + guard case .clientControlGrantLifecycle = lifecycle else { return XCTFail("Frame lost typed lifecycle row") } + } + var invalid = try XCTUnwrap((fixture["resources"] as? [[String: Any]])?.first) + invalid["state"] = "future-state" + XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: invalid))) + } +} diff --git a/clients/typescript/fractal-client/client-control.test.cjs b/clients/typescript/fractal-client/client-control.test.cjs index b6bfc83df55..4d08678ceb2 100644 --- a/clients/typescript/fractal-client/client-control.test.cjs +++ b/clients/typescript/fractal-client/client-control.test.cjs @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const ts = require('typescript'); -const { webcrypto, createPublicKey, verify } = require('node:crypto'); +const { webcrypto, createPublicKey, createHash, verify } = require('node:crypto'); const Module = require('node:module'); const filename = path.join(__dirname, 'ClientControl.generated.ts'); const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { @@ -41,3 +41,13 @@ test('JCS refuses lone surrogate strings', () => { const state = { ...fixture.vectors[0].input.visible_state, route: '\ud800' }; assert.throws(() => visibleStateCanonicalJson(state), /Invalid Unicode/); }); + +test('injected SHA256 reproduces golden bytes without a WebCrypto dependency', async () => { + let calls = 0; + const sha256 = async bytes => { calls++; return new Uint8Array(createHash('sha256').update(bytes).digest()); }; + for (const vector of fixture.vectors) { + assert.equal(await visibleStateDigest(vector.input.visible_state, sha256), vector.visible_state_sha256); + assert.equal(Buffer.from(await clientResultCanonicalBytes(vector.input, sha256)).toString('utf8'), vector.canonical_bytes); + } + assert.equal(calls, fixture.vectors.length * 2); +}); diff --git a/clients/typescript/fractal-client/index.ts b/clients/typescript/fractal-client/index.ts index f04e3c9d52a..a30424a2063 100644 --- a/clients/typescript/fractal-client/index.ts +++ b/clients/typescript/fractal-client/index.ts @@ -2,4 +2,5 @@ export * from './Models.generated.ts'; export * from './Client.generated.ts'; export * from './errors.ts'; export * from './subagent-drivers.ts'; -export { CLIENT_RESULT_PURPOSE, CLIENT_RESULT_SIGNED_FIELDS, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts'; +export { CLIENT_RESULT_PURPOSE, CLIENT_RESULT_SIGNED_FIELDS, defaultWebCryptoSha256, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts'; +export type { ClientControlSha256 } from './ClientControl.generated.ts'; diff --git a/clients/typescript/fractal-client/schema.test.cjs b/clients/typescript/fractal-client/schema.test.cjs index b87ca5bc567..60ece03a926 100644 --- a/clients/typescript/fractal-client/schema.test.cjs +++ b/clients/typescript/fractal-client/schema.test.cjs @@ -1335,3 +1335,24 @@ test('notice fixtures preserve rich Options, identifiers and lossless timestamps // Existing timestamp annotations retain the established DateTime.Utc contract. assert.equal(DateTime.toEpochMillis(Rich.decodeUnknownSync(Rich.Timestamp)(wire)), Date.parse(wire)); }); + +test('commands Resource and CollectionFrame decode shared lifecycle vectors', async () => { + const [, Rich] = await modules; + const fs = require('node:fs'); + const path = require('node:path'); + const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../fixtures/clients/client-control-lifecycle-v1.json'), 'utf8')); + for (const row of fixture.resources) { + const decoded = Rich.decodeUnknownSync(Rich.Resource, 'strict')(row); + assert.equal(decoded.kind, 'client-control-grant-lifecycle'); + assert.equal(decoded.grant_id, row.grant_id); + assert.equal(decoded.state, row.state); + assert.equal(decoded.sequence, row.sequence); + } + for (const frame of fixture.frames) { + const decoded = Rich.decodeUnknownSync(Rich.CollectionFrame, 'strict')(frame); + assert.equal(decoded.collection, 'commands'); + const rows = decoded.items ?? decoded.upserts; + assert.equal(rows[0].kind, 'client-control-grant-lifecycle'); + } + assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')({ ...fixture.resources[0], state: 'future-state' })); +}); diff --git a/crates/fractal-client-codegen/src/main.rs b/crates/fractal-client-codegen/src/main.rs index f1bb4a0c762..af0ef57dc8f 100644 --- a/crates/fractal-client-codegen/src/main.rs +++ b/crates/fractal-client-codegen/src/main.rs @@ -760,7 +760,10 @@ fn validate_model( let command_body = definition == "DeviceCommand" && matches!(property.as_str(), "command_kind" | "args") && rust_block.contains("pub command: DeviceCommandBody") && rust.contains("#[serde(tag = \"command_kind\", content = \"args\")]"); - if !rust_block.contains(&format!("pub {rust_field}:")) && !rust_discriminated_timeline && !command_body { + let lifecycle_field = definition == "ClientControlGrantLifecycle" + && rust_block.contains("pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent") + && include_str!("../templates/client_control.rs.in").contains(&format!("pub {rust_field}:")); + if !rust_block.contains(&format!("pub {rust_field}:")) && !rust_discriminated_timeline && !command_body && !lifecycle_field { bail!("Rust `{rust_name}` does not model schema field `{property}`"); } let swift_field = match property.as_str() { diff --git a/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in index bf8d4fd730d..177aeb33c04 100644 --- a/crates/fractal-client-codegen/templates/ClientControl.ts.in +++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in @@ -32,15 +32,17 @@ export function visibleStateCanonicalJson(state: ClientVisibleState): string { + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen) + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}'; } -export async function visibleStateDigest(state: ClientVisibleState): Promise { - const bytes = new TextEncoder().encode(visibleStateCanonicalJson(state)); - const digest = await crypto.subtle.digest('SHA-256', bytes); - return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); +export type ClientControlSha256 = (bytes: Uint8Array) => Promise; +export const defaultWebCryptoSha256: ClientControlSha256 = async bytes => + new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); +export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state))); + return Array.from(digest, b => b.toString(16).padStart(2, '0')).join(''); } /** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding. * Never use this helper as message.send authorization. Enforce active grant first. */ -export async function clientResultCanonicalBytes(binding: ClientResultBinding): Promise { - const digest = await visibleStateDigest(binding.visible_state); +export async function clientResultCanonicalBytes(binding: ClientResultBinding, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await visibleStateDigest(binding.visible_state, sha256); const fields: [string, string | null][] = [ ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device], ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id], diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index c6d43fa6a5e..0817fabf4c8 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -1010,11 +1010,32 @@ public enum HarnessInvalidAnswerReason: String, Codable, Sendable { case unknown public struct HarnessInvalidAnswerDetails: Codable, Sendable { public let reason: HarnessInvalidAnswerReason } public enum HarnessNotAnswerableReason: String, Codable, Sendable { case adapterMissing = "adapter_missing", adapterUnavailable = "adapter_unavailable" } public struct HarnessNotAnswerableDetails: Codable, Sendable { public let reason: HarnessNotAnswerableReason } +public enum ClientControlGrantLifecycleState: String, Codable, Sendable { case started, ended, revoked } +public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiable { + public let id, kind, revision, updatedAt, grantID, runID, device, session: String + public let itemID: String? + public let state: ClientControlGrantLifecycleState + public let reason: String + public let sequence: UInt64 + enum CodingKeys: String, CodingKey { + case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence + } +} +/// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding. +public struct ClientCommandsCollectionFrame: Codable, Sendable { + public let kind, id, collection: String + public let snapshot: Snapshot + public let items, upserts: [Resource]? + public let removes, order: [String]? + public let hasMore: Bool + enum CodingKeys: String, CodingKey { case kind, id, collection, snapshot, items, upserts, removes, order, hasMore = "has_more" } +} public enum Resource: Codable, Sendable, Identifiable { case harnessQuestion(HarnessQuestionResource) case connectedClient(ConnectedClientResource), usage(UsageResource) case summary(SummaryResource) case deviceCommand(DeviceCommand) + case clientControlGrantLifecycle(ClientControlGrantLifecycleResource) case attention(AttentionResource), message(MessageResource), launch(LaunchResource), launchVariant(LaunchVariantResource), launchDecision(LaunchDecisionResource), launchApproval(LaunchApprovalResource), mission(MissionResource), work(WorkResource), agent(AgentResource), runtime(RuntimeResource), observer(ObserverResource), subscription(SubscriptionResource), lane(LaneResource), machine(MachineResource), device(DeviceResource), operation(OperationResource), history(HistoryResource), session(SessionResource), glass(GlassResource), ownedSet(OwnedSetResource) /// A kind this client does not know, from a member newer than it: kept whole so an older /// client skips it or shows it plainly instead of failing the page it came in. @@ -1043,6 +1064,7 @@ public enum Resource: Codable, Sendable, Identifiable { case "machine": self = .machine(try MachineResource(from: decoder)) case "device": self = .device(try DeviceResource(from: decoder)) case "device-command": self = .deviceCommand(try DeviceCommand(from: decoder)) + case "client-control-grant-lifecycle": self = .clientControlGrantLifecycle(try ClientControlGrantLifecycleResource(from: decoder)) case "operation": self = .operation(try OperationResource(from: decoder)) case "history": self = .history(try HistoryResource(from: decoder)) case "session": self = .session(try SessionResource(from: decoder)) @@ -1074,6 +1096,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .machine(let v): try v.encode(to: encoder) case .device(let v): try v.encode(to: encoder) case .deviceCommand(let v): try v.encode(to: encoder) + case .clientControlGrantLifecycle(let v): try v.encode(to: encoder) case .operation(let v): try v.encode(to: encoder) case .history(let v): try v.encode(to: encoder) case .session(let v): try v.encode(to: encoder) @@ -1083,7 +1106,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .unknown(let v): try v.encode(to: encoder) } } - public var id: String { switch self { case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } + public var id: String { switch self { case .clientControlGrantLifecycle(let v): v.id; case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } } /// A resource of a kind this client does not model: its header, and every field as sent. public struct UnknownResource: Codable, Sendable, Identifiable { diff --git a/crates/fractal-client-codegen/templates/client_control.rs.in b/crates/fractal-client-codegen/templates/client_control.rs.in index cd961871806..7bf0a78c84a 100644 --- a/crates/fractal-client-codegen/templates/client_control.rs.in +++ b/crates/fractal-client-codegen/templates/client_control.rs.in @@ -11,6 +11,21 @@ pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[ ]; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] +pub enum ClientControlGrantLifecycleState { Started, Ended, Revoked } +/// Session-scoped payload carried only by the existing commands collection. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ClientControlGrantLifecycleEvent { + pub grant_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, + pub run_id: String, + pub device: String, + pub session: String, + pub state: ClientControlGrantLifecycleState, + pub reason: String, + pub sequence: u64, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] pub enum ClientControlRefusal { DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted, VerbNotGranted, BudgetExhausted, ActorNotGranted, diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index 461e5626ed4..8e29251ab9b 100644 --- a/crates/fractal-client-codegen/templates/generated.rs.in +++ b/crates/fractal-client-codegen/templates/generated.rs.in @@ -2856,6 +2856,7 @@ pub enum Resource { Machine(Machine), Device(Device), DeviceCommand(DeviceCommand), + ClientControlGrantLifecycle(ClientControlGrantLifecycle), Operation(Operation), History(History), Session(Session), @@ -2891,6 +2892,7 @@ pub const KNOWN_RESOURCE_KINDS: &[&str] = &[ "machine", "device", "device-command", + "client-control-grant-lifecycle", "operation", "history", "session", @@ -2960,6 +2962,7 @@ impl Resource { Self::Machine(v) => &v.header, Self::Device(v) => &v.header, Self::DeviceCommand(v) => &v.header, + Self::ClientControlGrantLifecycle(v) => &v.header, Self::Operation(v) => &v.header, Self::History(v) => &v.header, Self::Session(v) => &v.header, @@ -5323,3 +5326,13 @@ pub struct DeviceCommand { #[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, } + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct ClientControlGrantLifecycle { + #[serde(flatten)] pub header: ResourceHeader, + #[serde(flatten)] pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent, +} +impl std::ops::Deref for ClientControlGrantLifecycle { + type Target = fractal_schema::client_control::ClientControlGrantLifecycleEvent; + fn deref(&self) -> &Self::Target { &self.lifecycle } +} diff --git a/crates/fractal-client/tests/client_control_lifecycle.rs b/crates/fractal-client/tests/client_control_lifecycle.rs new file mode 100644 index 00000000000..ebe9c48a06d --- /dev/null +++ b/crates/fractal-client/tests/client_control_lifecycle.rs @@ -0,0 +1,27 @@ +use fractal_client::{CollectionEvent, Resource}; + +#[test] +fn commands_collection_decodes_typed_grant_lifecycle_vectors() { + let vectors: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-lifecycle-v1.json")).unwrap(); + for row in vectors["resources"].as_array().unwrap() { + let resource: Resource = serde_json::from_value(row.clone()).unwrap(); + let Resource::ClientControlGrantLifecycle(lifecycle) = resource else { panic!("lifecycle must never decode as Unknown") }; + assert_eq!(lifecycle.grant_id, row["grant_id"].as_str().unwrap()); + assert_eq!(lifecycle.item_id.as_deref(), row["item_id"].as_str()); + assert_eq!(lifecycle.sequence, row["sequence"].as_u64().unwrap()); + assert_eq!(serde_json::to_value(lifecycle.lifecycle.state).unwrap(), row["state"]); + } + for frame in vectors["frames"].as_array().unwrap() { + let event: CollectionEvent = serde_json::from_value(frame.clone()).unwrap(); + let (collection, rows) = match event { + CollectionEvent::Snapshot { collection, items, .. } => (collection, items), + CollectionEvent::Changes { collection, upserts, .. } => (collection, upserts), + _ => panic!("lifecycle uses existing snapshot/changes frames only"), + }; + assert_eq!(collection, "commands"); + assert!(matches!(&rows[0], Resource::ClientControlGrantLifecycle(_))); + } + let mut invalid = vectors["resources"][0].clone(); + invalid["state"] = "future-state".into(); + assert!(serde_json::from_value::(invalid).is_err()); +} diff --git a/docs/st3/client-v0/device-commands.md b/docs/st3/client-v0/device-commands.md index 655f8276959..7a255f9f0a6 100644 --- a/docs/st3/client-v0/device-commands.md +++ b/docs/st3/client-v0/device-commands.md @@ -223,3 +223,34 @@ This persisted withdrawal ends every grant bound to that enrollment, regardless of timestamps; new enrollment cannot resurrect old grants. Grant issue, command admission and result acceptance reload enrollment and pairing authority. Operators can revoke even after the pairing expires. + +## Grant lifecycle on the commands collection + +The existing session-scoped `commands` subscription carries generated +`client-control-grant-lifecycle` resources alongside device commands, using the +same snapshot/changes frames and typed `Resource` decoder. Each lifecycle row +has the ordinary resource header plus `grant_id`, optional `item_id`, `run_id`, +`device`, `session`, `state` (`started`, `ended`, `revoked`), `reason` and a +monotonic, positive JSON-safe integer `sequence`. + +These rows drive the visible indicator and termination only. They do not carry +verbs, screens or budget policy; policy remains in each +`DeviceCommand.control_grant`. A command without an active, nonterminal +lifecycle grant for its `grant_id` must produce no navigation or signature. +When a grant binds `item_id`, every lifecycle row must carry that exact item. +Clients ignore started rows whose sequence is not greater than the last +observed sequence for the grant, and treat ended/revoked as permanently final +for that grant ID: a later started row cannot reactivate it. + +`fixtures/clients/client-control-lifecycle-v1.json` is shared decoder and +acceptance-trace data for all clients, covering each state, stale/reordered +started rows, missing/wrong item binding, command-before-start and +command-after-terminal. Context-dependent lifecycle checks belong to the +client handler; decoding a resource never grants authority. + +TypeScript canonical hashing supports Hermes without WebCrypto through an +injected `ClientControlSha256 = (bytes: Uint8Array) => Promise`. +Pass it as the second argument to `visibleStateDigest` or +`clientResultCanonicalBytes`; omitting it uses generated +`defaultWebCryptoSha256`. Canonical encoding remains generated and unchanged; +the injected implementation returns the raw 32 SHA-256 bytes, not hex or text. diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index e4429b1ea3a..510f985eefd 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -518,6 +518,16 @@ "properties": {"command_id": {"type": "string", "pattern": "^device-command/.+"}, "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}, "binding": {"$ref": "#/$defs/ClientResultBinding"}, "signature": {"$ref": "#/$defs/DeviceSignature"}}, "description": "Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry." }, + "ClientControlGrantLifecycle": { + "allOf": [{"$ref": "#/$defs/ResourceHeader"}], "type": "object", + "required": ["id", "kind", "revision", "updated_at", "grant_id", "run_id", "device", "session", "state", "reason", "sequence"], + "properties": { + "kind": {"const": "client-control-grant-lifecycle"}, "grant_id": {"type": "string"}, "item_id": {"type": "string"}, + "run_id": {"type": "string"}, "device": {"type": "string"}, "session": {"type": "string"}, + "state": {"enum": ["started", "ended", "revoked"]}, "reason": {"type": "string"}, + "sequence": {"type": "integer", "minimum": 1, "maximum": 9007199254740991} + } + }, "DeviceCommand": { "allOf": [{"$ref": "#/$defs/ResourceHeader"}], "type": "object", @@ -742,6 +752,7 @@ "machine", "device", "device-command", + "client-control-grant-lifecycle", "operation", "history", "session", @@ -3172,6 +3183,7 @@ { "$ref": "#/$defs/DeviceCommand" }, + {"$ref": "#/$defs/ClientControlGrantLifecycle"}, { "$ref": "#/$defs/Operation" }, diff --git a/fixtures/clients/client-control-lifecycle-v1.json b/fixtures/clients/client-control-lifecycle-v1.json new file mode 100644 index 00000000000..d8cced0481e --- /dev/null +++ b/fixtures/clients/client-control-lifecycle-v1.json @@ -0,0 +1,313 @@ +{ + "version": 1, + "rules": [ + "A bound grant item_id must be present and equal on every lifecycle row.", + "Started sequence must be strictly greater than the last observed sequence for that grant_id.", + "Ended and revoked are final; a subsequent started row never reactivates that grant_id.", + "Lifecycle only controls indicator/termination; each command still requires its own DeviceCommand.control_grant policy.", + "No active nonterminal lifecycle for command.grant_id means refuse without navigation or signing." + ], + "resources": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + }, + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "ended", + "reason": "run-ended", + "sequence": 2 + }, + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "3", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "revoked", + "reason": "revoked", + "sequence": 3 + } + ], + "frames": [ + { + "kind": "snapshot", + "id": "commands", + "collection": "commands", + "snapshot": { + "id": "snapshot/golden", + "host_id": "host/fixture", + "store_index": 1, + "projection_version": "client-projection.v0", + "created_at": "2026-11-10T12:00:00Z" + }, + "order": [ + "client-control-grant-lifecycle/golden" + ], + "has_more": false, + "items": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + } + ] + }, + { + "kind": "changes", + "id": "commands", + "collection": "commands", + "snapshot": { + "id": "snapshot/golden", + "host_id": "host/fixture", + "store_index": 2, + "projection_version": "client-projection.v0", + "created_at": "2026-11-10T12:00:00Z" + }, + "order": [ + "client-control-grant-lifecycle/golden" + ], + "has_more": false, + "upserts": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "ended", + "reason": "run-ended", + "sequence": 2 + } + ], + "removes": [] + }, + { + "kind": "changes", + "id": "commands", + "collection": "commands", + "snapshot": { + "id": "snapshot/golden", + "host_id": "host/fixture", + "store_index": 3, + "projection_version": "client-projection.v0", + "created_at": "2026-11-10T12:00:00Z" + }, + "order": [ + "client-control-grant-lifecycle/golden" + ], + "has_more": false, + "upserts": [ + { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "3", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "revoked", + "reason": "revoked", + "sequence": 3 + } + ], + "removes": [] + } + ], + "traces": [ + { + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "steps": [ + { + "command_grant_id": "client-control-grant/golden", + "expected": "refuse-no-active-lifecycle" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + }, + "expected": "activate" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "1", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 1 + }, + "expected": "ignore-stale-started" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "0", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 0 + }, + "expected": "ignore-stale-started" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/other/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 2 + }, + "expected": "ignore-item-mismatch" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 2 + }, + "expected": "ignore-item-mismatch" + }, + { + "command_grant_id": "client-control-grant/golden", + "expected": "allow-lifecycle-gate-only" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "2", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "ended", + "reason": "run-ended", + "sequence": 2 + }, + "expected": "terminate" + }, + { + "command_grant_id": "client-control-grant/golden", + "expected": "refuse-terminal-lifecycle" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "4", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "started", + "reason": "granted", + "sequence": 4 + }, + "expected": "ignore-final-grant" + }, + { + "resource": { + "id": "client-control-grant-lifecycle/golden", + "kind": "client-control-grant-lifecycle", + "revision": "3", + "updated_at": "2026-11-10T12:00:00Z", + "grant_id": "client-control-grant/golden", + "item_id": "step-run/ask-golden/ask", + "run_id": "mission-run/golden", + "device": "device/test-tui", + "session": "client-session/test-tui", + "state": "revoked", + "reason": "revoked", + "sequence": 3 + }, + "expected": "remain-terminal" + }, + { + "command_grant_id": "client-control-grant/golden", + "expected": "refuse-terminal-lifecycle" + } + ] + } + ] +} diff --git a/release-notes/client-control-lifecycle-schema.json b/release-notes/client-control-lifecycle-schema.json new file mode 100644 index 00000000000..139caafc6c1 --- /dev/null +++ b/release-notes/client-control-lifecycle-schema.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Generate typed session-scoped client-control grant lifecycle resources and injectable TypeScript SHA-256", + "replay": {"status":"none","detail":"Wire schema and client decoder sources only; this fragment does not change claim replay."}, + "schema": {"status":"changed","detail":"Adds client-control-grant-lifecycle Resource with grant/run/device/session/item binding, started/ended/revoked state, reason and monotonic sequence on existing commands snapshot/changes frames."}, + "rules": {"status":"changed","detail":"Lifecycle rows drive indicators and termination, not command policy. Bound item IDs must match; stale started rows are ignored; terminal grants cannot reactivate; commands without active lifecycle state fail closed."}, + "compatibility": {"status":"changed","detail":"Rust, Swift and TypeScript decode the lifecycle resource through generated Resource arms. Older clients preserve the unknown resource. TypeScript canonical helpers accept an optional injected raw-byte SHA-256 implementation for Hermes; their canonical bytes remain unchanged."}, + "downtime": {"status":"none","detail":"No live setup, enrollment or daemon operation is performed by these source changes."}, + "manual": {"status":"none","detail":"Integrate source layers before one cargo run -p fractal-client-codegen. Shared lifecycle resources, frames and fail-closed traces are checked by client tests."}, + "recovery": {"status":"none","detail":"No stored data migration is introduced by generated lifecycle decoder source."} +} From ad814f6b162a4341dac64a576c33b11d7ad3e382 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:16:02 +0100 Subject: [PATCH 21/32] Refuse nonstrict control commands and malformed signatures agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 2 + .../client-control/grantBoundary.unit.test.ts | 20 +++++++++ apps/fractal-web/src/client-control/wire.ts | 34 +++++++++++---- .../src/client-control/wire.unit.test.ts | 41 ++++++++++++++++++- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 0556e9b39a3..5ee00856a13 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -16,6 +16,8 @@ Source preparation stacked on the canonical generated contract. **Application te - `fixtureNoAnswerGuard.ts`: install on the dedicated Playwright context before opening the app; it blocks all subject-action mutation requests and fails assertions if any were attempted, even when successfully blocked. The observer asserts the guard before and after capture; the journey must assert it again at completion and must never press answer controls. - `wire.ts`: generated command/grant/binding/result/receipt decode/encode boundaries, generated canonical signing bytes, native identity signing and generated DeviceResult envelope encoding. Canonicalization/digest/signed-field order come only from `clients/typescript/fractal-client/ClientControl.generated.ts`; no second canonicalizer. Signature metadata is caller-supplied generated DeviceSignature data, not enrollment authority. The DOM evidence is an independent observation, distinct from generated durable receipt evidence. +All untrusted wire inputs use the generated `decodeUnknownSync(..., 'strict')` entry point: excess/unknown keys and forbidden null parameters are refused, never stripped into an executable command. Signature verification accepts only canonical unpadded 64-byte P1363 base64url; malformed inputs resolve false. Only signature decode/data/operation failures are treated as refusals; wrong-key/algorithm and other programmer errors propagate. Regression assertions cover both boundaries and remain NOT RUN. + Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Protocol shapes come from generated schemas; route and independent DOM observer inputs remain local app concepts. D-5 integration assertions in that shipped-renderer test run `DataSourceProvider -> LiveAgentAlerts -> AnswerAlert` with recording `alertActions.act` and `prompts.respond` source ports, not mocked data hooks. Both structured choices and feedback/decision-words Send controls remain in the DOM, with no activation/input/submit events or confirmation state, across inspect/navigation, wrong-item and refused answer/close/read commands. Both port request logs must remain empty after EVERY command. This is a deterministic source-port test, not a fixture journey; its unit-only fixture source never supplies the real journey UI. diff --git a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts index 548be207712..96a508f1a8b 100644 --- a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts +++ b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts @@ -35,6 +35,26 @@ describe('generated client-control grant-check handler boundary', () => { expect(await exercise({ ...testCommand('phone.inspect'), state })).toEqual(refused) }) + it.each([ + { screen: 'NowRoot', params: null }, + { screen: 'NowRoot', unexpected: 'value' }, + { screen: 'Attention', params: null }, + { screen: 'Attention' }, + { screen: 'Attention', params: { id: 'attention/unit-ask', unexpected: 'value' } }, + ])('refuses malformed navigation args without normalization: %j', async args => { + expect(await exercise(testCommand('phone.navigate', args))).toEqual(refused) + }) + + it('refuses an unknown delivered-command key without navigating or signing', async () => { + expect(await exercise({ ...testCommand('phone.navigate', { screen: 'NowRoot' }), unexpected: 'value' })).toEqual(refused) + }) + + it('accepts absent params only for the generated no-param route', async () => { + expect(await exercise(testCommand('phone.navigate', { screen: 'NowRoot' }))).toEqual({ + result: 'Applied', navigations: [{ _tag: 'NowRoot' }], signatures: 1, + }) + }) + it.each(['Agents', 'Missions', 'Usage', 'History'])( 'refuses nongranted screen %s without navigation or signing', async screen => { expect(await exercise(testCommand('phone.navigate', { screen }))).toEqual(refused) diff --git a/apps/fractal-web/src/client-control/wire.ts b/apps/fractal-web/src/client-control/wire.ts index 20b43de625f..2ed69e1a15a 100644 --- a/apps/fractal-web/src/client-control/wire.ts +++ b/apps/fractal-web/src/client-control/wire.ts @@ -7,21 +7,31 @@ import { } from '../../../../clients/typescript/fractal-client/ClientControl.generated.ts' import type { ClientControlIdentity } from './identity.ts' -export const decodeCommand = Schema.decodeUnknownSync(Wire.DeviceCommand) +export const decodeCommand = Wire.decodeUnknownSync(Wire.DeviceCommand, 'strict') export const encodeCommand = Schema.encodeSync(Wire.DeviceCommand) -export const decodeGrant = Schema.decodeUnknownSync(Wire.ClientControlGrant) +export const decodeGrant = Wire.decodeUnknownSync(Wire.ClientControlGrant, 'strict') export const encodeGrant = Schema.encodeSync(Wire.ClientControlGrant) -export const decodeBinding = Schema.decodeUnknownSync(Wire.ClientResultBinding) +export const decodeBinding = Wire.decodeUnknownSync(Wire.ClientResultBinding, 'strict') export const encodeBinding = Schema.encodeSync(Wire.ClientResultBinding) -export const decodeResult = Schema.decodeUnknownSync(Wire.DeviceResult) +export const decodeResult = Wire.decodeUnknownSync(Wire.DeviceResult, 'strict') export const encodeResult = Schema.encodeSync(Wire.DeviceResult) -export const decodeReceipt = Schema.decodeUnknownSync(Wire.ClientCommandReceipt) +export const decodeReceipt = Wire.decodeUnknownSync(Wire.ClientCommandReceipt, 'strict') export const encodeReceipt = Schema.encodeSync(Wire.ClientCommandReceipt) const base64url = (bytes: Uint8Array): string => btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join('')).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, '') -const fromBase64url = (value: string): Uint8Array => - Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/')), byte => byte.charCodeAt(0)) +const fromBase64url = (value: string): Uint8Array | undefined => { + // A 64-byte P1363 signature has exactly 86 unpadded base64url characters. + if (!/^[A-Za-z0-9_-]{86}$/u.test(value)) return undefined + let bytes: Uint8Array + try { + bytes = Uint8Array.from(atob(value.replaceAll('-', '+').replaceAll('_', '/')), byte => byte.charCodeAt(0)) + } catch (error) { + if (error instanceof DOMException && error.name === 'InvalidCharacterError') return undefined + throw error + } + return bytes.length === 64 && base64url(bytes) === value ? bytes : undefined +} /** Authority/foreground checks belong to the command handler; this codec creates no authority. */ export const signDeviceResult = async (input: { @@ -45,6 +55,14 @@ export const signDeviceResult = async (input: { export const verifyClientResultBinding = async (binding: unknown, signature: string, key: CryptoKey): Promise => { let decoded: Wire.ClientResultBinding try { decoded = decodeBinding(binding) } catch { return false } + const signatureBytes = fromBase64url(signature) + if (signatureBytes === undefined) return false const bytes = await clientResultCanonicalBytes(encodeBinding(decoded) as CanonicalBinding) as Uint8Array - return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, fromBase64url(signature), bytes) + try { + return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signatureBytes, bytes) + } catch (error) { + // InvalidAccessError (wrong key/algorithm), TypeError and other programmer errors propagate. + if (error instanceof DOMException && (error.name === 'DataError' || error.name === 'OperationError')) return false + throw error + } } diff --git a/apps/fractal-web/src/client-control/wire.unit.test.ts b/apps/fractal-web/src/client-control/wire.unit.test.ts index 77dc40c5ed9..9fab2c8f628 100644 --- a/apps/fractal-web/src/client-control/wire.unit.test.ts +++ b/apps/fractal-web/src/client-control/wire.unit.test.ts @@ -27,7 +27,7 @@ describe('web consumption of the repository client-control golden file', () => { expect(binding).toEqual(vector.input) expect(visibleStateCanonicalJson(vector.input.visible_state)).toBe(vector.canonical_visible_state) expect(await visibleStateDigest(vector.input.visible_state)).toBe(vector.visible_state_sha256) - expect(new TextDecoder().decode(await clientResultCanonicalBytes(vector.input))).toBe(vector.canonical_bytes) + expect(await clientResultCanonicalBytes(vector.input)).toEqual(new TextEncoder().encode(vector.canonical_bytes)) const key = await verificationKey() expect(await verifyClientResultBinding(binding, vector.signature_p1363_base64url, key)).toBe(true) for (const changed of vector.negative_bindings) { @@ -52,6 +52,45 @@ describe('web consumption of the repository client-control golden file', () => { )).toBe(true) }) + it.each([ + '', '!', 'AA', 'A'.repeat(85), 'A'.repeat(87), 'A'.repeat(85) + 'B', + golden.vectors[0]!.signature_p1363_base64url + '=', + ' ' + golden.vectors[0]!.signature_p1363_base64url, + 'A'.repeat(86), + ])('refuses malformed or invalid signatures without rejecting: %j', async signature => { + await expect(verifyClientResultBinding(golden.vectors[0]!.input, signature, await verificationKey())).resolves.toBe(false) + }) + + it('does not swallow a programmer error using a non-ECDSA verification key', async () => { + const key = await crypto.subtle.importKey('raw', new Uint8Array(16), 'AES-GCM', false, ['encrypt']) + await expect(verifyClientResultBinding( + golden.vectors[0]!.input, golden.vectors[0]!.signature_p1363_base64url, key, + )).rejects.toMatchObject({ name: 'InvalidAccessError' }) + }) + + it.each([ + { screen: 'NowRoot', params: null }, + { screen: 'NowRoot', unexpected: 'value' }, + { screen: 'Attention', params: null }, + { screen: 'Attention' }, + { screen: 'Attention', params: { id: 'attention/unit-ask', unexpected: 'value' } }, + ])('strictly rejects unsupported navigation wire properties: %j', args => { + expect(() => decodeCommand(testCommand('phone.navigate', args))).toThrow() + }) + + it('strictly rejects unknown boundary keys and null optional values', () => { + expect(() => decodeCommand({ ...testCommand('phone.inspect'), unexpected: 'value' })).toThrow() + expect(() => decodeGrant({ ...encodeGrant(testGrant()), unexpected: 'value' })).toThrow() + expect(() => decodeGrant({ ...encodeGrant(testGrant()), item_id: null })).toThrow() + expect(() => decodeBinding({ ...golden.vectors[0]!.input, unexpected: 'value' })).toThrow() + expect(() => decodeResult({ command_id: golden.vectors[0]!.input.command_id, outcome: 'refused', unexpected: 'value' })).toThrow() + expect(() => decodeReceipt({ + commandId: golden.vectors[0]!.input.command_id, commandKind: 'phone.inspect', + device: golden.vectors[0]!.input.device, issuer: 'agent/unit', + signedResult: null, terminalState: 'refused', verifiedResultSignature: null, unexpected: 'value', + })).toThrow() + }) + it.each(golden.vectors)('signs and encodes a real web identity result using shared binding $name', async vector => { vi.stubGlobal('indexedDB', new IDBFactory()) const identity = await getOrCreateClientControlIdentity() From d86b71759570a9aa56f2ee98e177ff290c18ec45 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:21:46 +0100 Subject: [PATCH 22/32] Refuse generated malformed-Unicode result bindings agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 4 ++- apps/fractal-web/src/client-control/wire.ts | 11 ++++++- .../src/client-control/wire.unit.test.ts | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 5ee00856a13..9ea4dd1eb01 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -16,7 +16,9 @@ Source preparation stacked on the canonical generated contract. **Application te - `fixtureNoAnswerGuard.ts`: install on the dedicated Playwright context before opening the app; it blocks all subject-action mutation requests and fails assertions if any were attempted, even when successfully blocked. The observer asserts the guard before and after capture; the journey must assert it again at completion and must never press answer controls. - `wire.ts`: generated command/grant/binding/result/receipt decode/encode boundaries, generated canonical signing bytes, native identity signing and generated DeviceResult envelope encoding. Canonicalization/digest/signed-field order come only from `clients/typescript/fractal-client/ClientControl.generated.ts`; no second canonicalizer. Signature metadata is caller-supplied generated DeviceSignature data, not enrollment authority. The DOM evidence is an independent observation, distinct from generated durable receipt evidence. -All untrusted wire inputs use the generated `decodeUnknownSync(..., 'strict')` entry point: excess/unknown keys and forbidden null parameters are refused, never stripped into an executable command. Signature verification accepts only canonical unpadded 64-byte P1363 base64url; malformed inputs resolve false. Only signature decode/data/operation failures are treated as refusals; wrong-key/algorithm and other programmer errors propagate. Regression assertions cover both boundaries and remain NOT RUN. +All untrusted wire inputs use the generated `decodeUnknownSync(..., 'strict')` entry point: excess/unknown keys and forbidden null parameters are refused, never stripped into an executable command. Signature verification accepts only canonical unpadded 64-byte P1363 base64url; malformed inputs resolve false. Expected signature decode/data/operation failures and the generated malformed-Unicode refusal return false; wrong-key/algorithm and unrelated programmer errors propagate. Regression assertions remain NOT RUN. + +The generated canonicalizer currently exports no refusal class, error code or predicate: its private quote helper throws a plain `Error('Invalid Unicode in client result')`. Verification therefore matches exactly that plain Error and message only around the generated canonicalizer call. No second Unicode validator or canonicalizer is added. Regression cases put lone high and low surrogates in route, other binding strings, visible IDs and opened-item fields; the published native Attention vector's valid astral pair must still verify. Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Protocol shapes come from generated schemas; route and independent DOM observer inputs remain local app concepts. diff --git a/apps/fractal-web/src/client-control/wire.ts b/apps/fractal-web/src/client-control/wire.ts index 2ed69e1a15a..cf40a8547fb 100644 --- a/apps/fractal-web/src/client-control/wire.ts +++ b/apps/fractal-web/src/client-control/wire.ts @@ -57,7 +57,16 @@ export const verifyClientResultBinding = async (binding: unknown, signature: str try { decoded = decodeBinding(binding) } catch { return false } const signatureBytes = fromBase64url(signature) if (signatureBytes === undefined) return false - const bytes = await clientResultCanonicalBytes(encodeBinding(decoded) as CanonicalBinding) as Uint8Array + const encoded = encodeBinding(decoded) + let bytes: Uint8Array + try { + bytes = await clientResultCanonicalBytes(encoded as CanonicalBinding) as Uint8Array + } catch (error) { + // The generated module exports no refusal class/predicate: its quote helper throws this + // exact plain Error. Translate only that refusal here, never duplicate Unicode validation. + if (error instanceof Error && error.constructor === Error && error.message === 'Invalid Unicode in client result') return false + throw error + } try { return await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, signatureBytes, bytes) } catch (error) { diff --git a/apps/fractal-web/src/client-control/wire.unit.test.ts b/apps/fractal-web/src/client-control/wire.unit.test.ts index 9fab2c8f628..fd0957027a0 100644 --- a/apps/fractal-web/src/client-control/wire.unit.test.ts +++ b/apps/fractal-web/src/client-control/wire.unit.test.ts @@ -68,6 +68,35 @@ describe('web consumption of the repository client-control golden file', () => { )).rejects.toMatchObject({ name: 'InvalidAccessError' }) }) + it.each(['\uD800', '\uDC00'])('refuses generated canonicalizer lone-surrogate failures: %j', async malformed => { + const vector = golden.vectors[1]! + const binding = vector.input + const state = binding.visible_state + const opened = state.opened_item + if (opened === null) throw new Error('Native Attention golden vector must have an opened item') + const bindings: unknown[] = [ + ...(['device', 'session', 'grant_id', 'run_id', 'command_id', 'error_code'] as const) + .map(field => ({ ...binding, [field]: malformed })), + ...(['route', 'screen'] as const) + .map(field => ({ ...binding, visible_state: { ...state, [field]: malformed } })), + { ...binding, visible_state: { ...state, visible_item_ids: [malformed] } }, + ...(['id', 'title', 'body'] as const) + .map(field => ({ ...binding, visible_state: { ...state, opened_item: { ...opened, [field]: malformed } } })), + ] + const key = await verificationKey() + for (const changed of bindings) { + // These pass the generated String schemas: refusal must come from the canonicalizer. + expect(() => decodeBinding(changed)).not.toThrow() + await expect(verifyClientResultBinding(changed, vector.signature_p1363_base64url, key)).resolves.toBe(false) + } + }) + + it('still verifies the shared native Attention signature containing a valid astral pair', async () => { + const vector = golden.vectors[1]! + expect(vector.input.visible_state.opened_item?.title).toContain('\uD83D\uDE00') + await expect(verifyClientResultBinding(vector.input, vector.signature_p1363_base64url, await verificationKey())).resolves.toBe(true) + }) + it.each([ { screen: 'NowRoot', params: null }, { screen: 'NowRoot', unexpected: 'value' }, From 7cafdf138efbea937cbefa83323d5d2fc8dda15c Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 19:34:46 +0100 Subject: [PATCH 23/32] Regenerate client control artifacts from d8872e300e77e45987941b09325d037b3a672275 --- .../Sources/FractalClient/Models.swift | 25 ++++++++++++++++++- .../fractal-client/ClientControl.generated.ts | 14 ++++++----- .../fractal-client/Models.generated.ts | 14 ++++++++++- .../fractal-client/ReadSchema.generated.ts | 21 +++++++++++++++- .../fractal-client/Schema.generated.ts | 4 +-- crates/fractal-client/src/generated.rs | 17 +++++++++++++ crates/fractal-schema/src/client_control.rs | 15 +++++++++++ 7 files changed, 99 insertions(+), 11 deletions(-) diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index e99a11e6fd1..de5002670fe 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -1010,11 +1010,32 @@ public enum HarnessInvalidAnswerReason: String, Codable, Sendable { case unknown public struct HarnessInvalidAnswerDetails: Codable, Sendable { public let reason: HarnessInvalidAnswerReason } public enum HarnessNotAnswerableReason: String, Codable, Sendable { case adapterMissing = "adapter_missing", adapterUnavailable = "adapter_unavailable" } public struct HarnessNotAnswerableDetails: Codable, Sendable { public let reason: HarnessNotAnswerableReason } +public enum ClientControlGrantLifecycleState: String, Codable, Sendable { case started, ended, revoked } +public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiable { + public let id, kind, revision, updatedAt, grantID, runID, device, session: String + public let itemID: String? + public let state: ClientControlGrantLifecycleState + public let reason: String + public let sequence: UInt64 + enum CodingKeys: String, CodingKey { + case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence + } +} +/// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding. +public struct ClientCommandsCollectionFrame: Codable, Sendable { + public let kind, id, collection: String + public let snapshot: Snapshot + public let items, upserts: [Resource]? + public let removes, order: [String]? + public let hasMore: Bool + enum CodingKeys: String, CodingKey { case kind, id, collection, snapshot, items, upserts, removes, order, hasMore = "has_more" } +} public enum Resource: Codable, Sendable, Identifiable { case harnessQuestion(HarnessQuestionResource) case connectedClient(ConnectedClientResource), usage(UsageResource) case summary(SummaryResource) case deviceCommand(DeviceCommand) + case clientControlGrantLifecycle(ClientControlGrantLifecycleResource) case attention(AttentionResource), message(MessageResource), launch(LaunchResource), launchVariant(LaunchVariantResource), launchDecision(LaunchDecisionResource), launchApproval(LaunchApprovalResource), mission(MissionResource), work(WorkResource), agent(AgentResource), runtime(RuntimeResource), observer(ObserverResource), subscription(SubscriptionResource), lane(LaneResource), machine(MachineResource), device(DeviceResource), operation(OperationResource), history(HistoryResource), session(SessionResource), glass(GlassResource), ownedSet(OwnedSetResource) /// A kind this client does not know, from a member newer than it: kept whole so an older /// client skips it or shows it plainly instead of failing the page it came in. @@ -1043,6 +1064,7 @@ public enum Resource: Codable, Sendable, Identifiable { case "machine": self = .machine(try MachineResource(from: decoder)) case "device": self = .device(try DeviceResource(from: decoder)) case "device-command": self = .deviceCommand(try DeviceCommand(from: decoder)) + case "client-control-grant-lifecycle": self = .clientControlGrantLifecycle(try ClientControlGrantLifecycleResource(from: decoder)) case "operation": self = .operation(try OperationResource(from: decoder)) case "history": self = .history(try HistoryResource(from: decoder)) case "session": self = .session(try SessionResource(from: decoder)) @@ -1074,6 +1096,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .machine(let v): try v.encode(to: encoder) case .device(let v): try v.encode(to: encoder) case .deviceCommand(let v): try v.encode(to: encoder) + case .clientControlGrantLifecycle(let v): try v.encode(to: encoder) case .operation(let v): try v.encode(to: encoder) case .history(let v): try v.encode(to: encoder) case .session(let v): try v.encode(to: encoder) @@ -1083,7 +1106,7 @@ public enum Resource: Codable, Sendable, Identifiable { case .unknown(let v): try v.encode(to: encoder) } } - public var id: String { switch self { case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } + public var id: String { switch self { case .clientControlGrantLifecycle(let v): v.id; case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } } } /// A resource of a kind this client does not model: its header, and every field as sent. public struct UnknownResource: Codable, Sendable, Identifiable { diff --git a/clients/typescript/fractal-client/ClientControl.generated.ts b/clients/typescript/fractal-client/ClientControl.generated.ts index bf8d4fd730d..177aeb33c04 100644 --- a/clients/typescript/fractal-client/ClientControl.generated.ts +++ b/clients/typescript/fractal-client/ClientControl.generated.ts @@ -32,15 +32,17 @@ export function visibleStateCanonicalJson(state: ClientVisibleState): string { + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen) + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}'; } -export async function visibleStateDigest(state: ClientVisibleState): Promise { - const bytes = new TextEncoder().encode(visibleStateCanonicalJson(state)); - const digest = await crypto.subtle.digest('SHA-256', bytes); - return Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); +export type ClientControlSha256 = (bytes: Uint8Array) => Promise; +export const defaultWebCryptoSha256: ClientControlSha256 = async bytes => + new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); +export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state))); + return Array.from(digest, b => b.toString(16).padStart(2, '0')).join(''); } /** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding. * Never use this helper as message.send authorization. Enforce active grant first. */ -export async function clientResultCanonicalBytes(binding: ClientResultBinding): Promise { - const digest = await visibleStateDigest(binding.visible_state); +export async function clientResultCanonicalBytes(binding: ClientResultBinding, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { + const digest = await visibleStateDigest(binding.visible_state, sha256); const fields: [string, string | null][] = [ ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device], ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id], diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts index ba5d42c297d..7c13754ecbc 100644 --- a/clients/typescript/fractal-client/Models.generated.ts +++ b/clients/typescript/fractal-client/Models.generated.ts @@ -1315,6 +1315,18 @@ export type ClientControlGrant = { verbs: Array; }; +export type ClientControlGrantLifecycle = { + device: string; + grant_id: string; + item_id?: string; + kind: "client-control-grant-lifecycle"; + reason: string; + run_id: string; + sequence: number; + session: string; + state: "started" | "ended" | "revoked"; +} & ResourceHeader; + export type ClientControlRefusal = "device-not-test-client" | "grant-missing" | "grant-expired" | "screen-not-granted" | "verb-not-granted" | "budget-exhausted" | "actor-not-granted"; export type ClientDiagnosticBreadcrumb = "native-start" | "js-start" | "root-mounted" | "foreground" | "background" | "previous-launch-unclean"; @@ -3404,7 +3416,7 @@ export type RequestSubject = { url?: string; }; -export type Resource = (ConnectedClient | Usage | Summary | Attention | Message | Launch | LaunchVariant | LaunchDecision | LaunchApproval | Mission | Work | Agent | Runtime | Observer | Subscription | Lane | Machine | Device | DeviceCommand | Operation | History | Session | Glass | Arrangement | OwnedSet | HarnessQuestion); +export type Resource = (ConnectedClient | Usage | Summary | Attention | Message | Launch | LaunchVariant | LaunchDecision | LaunchApproval | Mission | Work | Agent | Runtime | Observer | Subscription | Lane | Machine | Device | DeviceCommand | ClientControlGrantLifecycle | Operation | History | Session | Glass | Arrangement | OwnedSet | HarnessQuestion); export type ResourceCollectionName = "missions" diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts index e26da97b3b8..f753e78ff01 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -1684,6 +1684,24 @@ export const ClientControlGrant = /*#__PURE__*/ (() => Schema.Struct({ export type ClientControlGrant = typeof ClientControlGrant.Type export type ClientControlGrantEncoded = typeof ClientControlGrant.Encoded +export const ClientControlGrantLifecycle = /*#__PURE__*/ (() => Schema.Struct({ + "device": Schema.String, + "grant_id": Schema.String, + "id": Id, + "item_id": optionalKey(Schema.String), + "kind": Schema.Literal("client-control-grant-lifecycle"), + "operational": optionalKey(Operational), + "reason": Schema.String, + "revision": Revision, + "run_id": Schema.String, + "sequence": Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(9007199254740991)), + "session": Schema.String, + "state": Schema.Literals(["started","ended","revoked"]), + "updated_at": Timestamp +}).annotate({ identifier: "ClientControlGrantLifecycle" }))() +export type ClientControlGrantLifecycle = typeof ClientControlGrantLifecycle.Type +export type ClientControlGrantLifecycleEncoded = typeof ClientControlGrantLifecycle.Encoded + /** One conversation header field: its latest value, where it came from and when. */ export const ConversationHeaderField = /*#__PURE__*/ (() => Schema.Struct({ "as_of": Timestamp, @@ -2820,7 +2838,7 @@ export type SummaryEncoded = typeof Summary.Encoded export const UnknownResource = /*#__PURE__*/ (() => Schema.Struct({ "id": Id, - "kind": unknownCase(["attention","message","launch","launch-variant","launch-decision","launch-approval","mission","work","agent","runtime","observer","subscription","lane","machine","device","device-command","operation","history","session","glass","arrangement","owned-set","summary","connected-client","usage","harness-question"]), + "kind": unknownCase(["attention","message","launch","launch-variant","launch-decision","launch-approval","mission","work","agent","runtime","observer","subscription","lane","machine","device","device-command","client-control-grant-lifecycle","operation","history","session","glass","arrangement","owned-set","summary","connected-client","usage","harness-question"]), "operational": optionalKey(Operational), "revision": Revision, "updated_at": Timestamp @@ -3064,6 +3082,7 @@ export const Resource = /*#__PURE__*/ (() => Schema.Union([ Machine, Device, DeviceCommand, + ClientControlGrantLifecycle, Operation, History, Session, diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts index 8b4f97de76e..4d79e62706c 100644 --- a/clients/typescript/fractal-client/Schema.generated.ts +++ b/clients/typescript/fractal-client/Schema.generated.ts @@ -10,8 +10,8 @@ import type { SubjectRef, UnknownCase } from "./SchemaSupport.generated.ts" export { API_VERSION, containsUnknownCase, decodeUnknownSync, decodeUnknownEffect } from "./SchemaSupport.generated.ts" export type { SubjectRef, UnknownCase, DecodeMode } from "./SchemaSupport.generated.ts" -import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, ClientControlGrant, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts" -import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, ClientControlGrantEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts" +import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, ClientControlGrant, ClientControlGrantLifecycle, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts" +import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, ClientControlGrantEncoded, ClientControlGrantLifecycleEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts" export * from "./ReadSchema.generated.ts" export const ActionCommon = /*#__PURE__*/ (() => Schema.Struct({ diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs index 37c9b1d1a64..e61201fecdc 100644 --- a/crates/fractal-client/src/generated.rs +++ b/crates/fractal-client/src/generated.rs @@ -2857,6 +2857,7 @@ pub enum Resource { Machine(Machine), Device(Device), DeviceCommand(DeviceCommand), + ClientControlGrantLifecycle(ClientControlGrantLifecycle), Operation(Operation), History(History), Session(Session), @@ -2892,6 +2893,7 @@ pub const KNOWN_RESOURCE_KINDS: &[&str] = &[ "machine", "device", "device-command", + "client-control-grant-lifecycle", "operation", "history", "session", @@ -2961,6 +2963,7 @@ impl Resource { Self::Machine(v) => &v.header, Self::Device(v) => &v.header, Self::DeviceCommand(v) => &v.header, + Self::ClientControlGrantLifecycle(v) => &v.header, Self::Operation(v) => &v.header, Self::History(v) => &v.header, Self::Session(v) => &v.header, @@ -6272,3 +6275,17 @@ pub struct DeviceCommand { #[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option, } + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +pub struct ClientControlGrantLifecycle { + #[serde(flatten)] + pub header: ResourceHeader, + #[serde(flatten)] + pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent, +} +impl std::ops::Deref for ClientControlGrantLifecycle { + type Target = fractal_schema::client_control::ClientControlGrantLifecycleEvent; + fn deref(&self) -> &Self::Target { + &self.lifecycle + } +} diff --git a/crates/fractal-schema/src/client_control.rs b/crates/fractal-schema/src/client_control.rs index cd961871806..7bf0a78c84a 100644 --- a/crates/fractal-schema/src/client_control.rs +++ b/crates/fractal-schema/src/client_control.rs @@ -11,6 +11,21 @@ pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[ ]; #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] +pub enum ClientControlGrantLifecycleState { Started, Ended, Revoked } +/// Session-scoped payload carried only by the existing commands collection. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub struct ClientControlGrantLifecycleEvent { + pub grant_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option, + pub run_id: String, + pub device: String, + pub session: String, + pub state: ClientControlGrantLifecycleState, + pub reason: String, + pub sequence: u64, +} +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] pub enum ClientControlRefusal { DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted, VerbNotGranted, BudgetExhausted, ActorNotGranted, From ed2030149da0477007b76c1ae41b8994d91770d8 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 21:52:05 +0100 Subject: [PATCH 24/32] Consume generated lifecycle frames and canonical injectable hashing agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 21 +- .../client-control/command.test-support.ts | 37 +- .../src/client-control/fixtureDomEvidence.ts | 5 +- .../fixtureDomEvidence.unit.test.ts | 50 ++- .../src/client-control/grantBoundary.ts | 10 +- .../client-control/grantBoundary.unit.test.ts | 54 ++- .../src/client-control/lifecycle.ts | 102 ++++++ .../src/client-control/lifecycle.unit.test.ts | 316 ++++++++++++++++++ .../src/client-control/wire.unit.test.ts | 14 + .../alerts/AnswerAlert.integration.test.tsx | 20 +- 10 files changed, 599 insertions(+), 30 deletions(-) create mode 100644 apps/fractal-web/src/client-control/lifecycle.ts create mode 100644 apps/fractal-web/src/client-control/lifecycle.unit.test.ts diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 9ea4dd1eb01..60140516fd0 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -1,13 +1,13 @@ # Client-control web modules -Source preparation stacked on the canonical generated contract. **Application test mode remains disabled.** These modules do not change existing entrypoints, pairings or read stores, and do not create enrollment or grant authority. +Source preparation consuming the merged canonical generated contract. **Application test mode remains disabled.** These modules do not change existing entrypoints, pairings or read stores, and do not create enrollment or grant authority. ## APIs - `identity.ts`: `getOrCreateClientControlIdentity()` atomically acquires an origin-local IndexedDB P-256 identity. The private key is non-extractable; public SPKI export and supplied-byte ECDSA/SHA-256 signing remain internal capabilities. Storage failures never replace a key or fall back to ephemeral identity. Key deletion is not enrollment revocation. - `foreground.ts`: visible AND focused. Re-evaluate before navigation and signing. - `navigation.ts`: only NowRoot and Attention(ask id) destinations; the host supplies its authoritative subject-to-href resolver. -- `grantBoundary.ts`: decodes actual delivered commands with the generated DeviceCommand schema; uses generated ClientControlGrant permissions directly, not a local wire projection. Only granted inspect or NowRoot/Attention navigation exists, with Attention restricted to the optional grant item_id (unbound grants cannot open/inspect an ask). Checks device/grant/run binding and rechecks target, permission and foreground before signing. There is no answer/close/read-state or DOM activation callback. The later authority adapter owns enrollment, session and window admission. +- `grantBoundary.ts`: strictly decodes actual delivered commands and requires active nonterminal lifecycle plus the command's own generated control_grant policy, intersected with current verified authority. Only inspect or NowRoot/Attention navigation exists; Attention must match the bound item_id. Rechecks lifecycle, target, policy and foreground after navigation before signing. No answer/close/read-state or DOM activation callback exists. The host still owns authenticated enrollment/session/window/budget admission. - `readAdvancement.ts`: future sticky page-lifetime prohibition checked by `gateReadAdvancement` at execution time. The read-position store is absent on this branch's main base, and the Attention route does not use it. This is not a present journey blocker: wrap the entire store advance (persistence and outbound synchronization) when that writer group lands, not individual UI callers. Enrollment ending must not re-enable writes. - `ClientControlIndicator.tsx`: controller-owned run indicator and enrollment-revoke action with pending/unconfirmed states. The component does not create authority or optimistically hide an active grant. - `fixtureGuard.ts`: requires the exact approved dedicated loopback origin and realpath-resolved fixture instance client socket, with normal-origin/socket deny lists. This is configuration validation, not daemon attestation or enrollment authority. @@ -15,6 +15,7 @@ Source preparation stacked on the canonical generated contract. **Application te - `fixtureText.ts`: both title and question must be printable ASCII, at most 60 characters, no Markdown syntax characters (asterisk, underscore, hash, square brackets, backtick, greater-than or vertical bar), JSON, or leading/trailing/repeated whitespace. The title comes from the verified fixture manifest, never a hard-coded value. - `fixtureNoAnswerGuard.ts`: install on the dedicated Playwright context before opening the app; it blocks all subject-action mutation requests and fails assertions if any were attempted, even when successfully blocked. The observer asserts the guard before and after capture; the journey must assert it again at completion and must never press answer controls. - `wire.ts`: generated command/grant/binding/result/receipt decode/encode boundaries, generated canonical signing bytes, native identity signing and generated DeviceResult envelope encoding. Canonicalization/digest/signed-field order come only from `clients/typescript/fractal-client/ClientControl.generated.ts`; no second canonicalizer. Signature metadata is caller-supplied generated DeviceSignature data, not enrollment authority. The DOM evidence is an independent observation, distinct from generated durable receipt evidence. +- `lifecycle.ts`: strictly decodes generated lifecycle resources and commands snapshot/changes frames. Verifies device/session/run/grant/item binding against the host's current enrollment-validated authority before applying state. Tracks increasing starts and permanent terminal tombstones; only verified terminal rows end a run, and only a verified revoke marks enrollment revoked. Missing policy, before-start, after-terminal and after-revoke commands cannot navigate or sign. Lifecycle supplies no verb/screen/budget authority. All untrusted wire inputs use the generated `decodeUnknownSync(..., 'strict')` entry point: excess/unknown keys and forbidden null parameters are refused, never stripped into an executable command. Signature verification accepts only canonical unpadded 64-byte P1363 base64url; malformed inputs resolve false. Expected signature decode/data/operation failures and the generated malformed-Unicode refusal return false; wrong-key/algorithm and unrelated programmer errors propagate. Regression assertions remain NOT RUN. @@ -41,14 +42,14 @@ Dependency decisions below refer to the read-only integration snapshot `ff956ae7 | `0d21c1286b` read-position clock-skew/remount fix | DROP from current journey blockers | `web/readPosition.ts:45,125` exposes stream seen/mark-read hooks; native ask detail does not invoke them (`web/AlertDetailsPane.tsx:37-60`). | | `45710bb1d5` last_prose_at unread fix | DROP from current journey blockers | The relevant write boundary remains `state/cells/readPositions.ts:36-39`; neither native ask surface requires it (`workspace/registry.ts:89-90`). The suppression integration must land with any introduced writer, before test-mode rendering can reach it. | | [#6108 read-position sync](https://github.com/compoundingtech/fractal/pull/6108) | DROP from current journey blockers | Inspected head `01fd722d1cdfc4beb916817ab9dc68f0307b437b` adds lastProseAt projection/types, not a web sync caller. A future writer must share the gated store boundary above. | -| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | MERGED prerequisite | Signed no-ff merge of generated head `36592f7090093ae6fe94a87706e319fca78fca17` (source parent `9ac7024e`). Imported `ClientControl.generated.ts`, generated Effect schemas and repository `fixtures/clients/client-control-v1.golden.json`; no generated file hand-edited or copied. | -| Later enrollment readback/revoke PR | KEEP | Generated enrollment/revocation schemas and `FractalClient.clientTestRevoke` now exist, but authoritative browser enrollment readback/controller and live revoke confirmation remain integration prerequisites. | -| Later grant-subscription PR | KEEP | Commands and grants now have generated schemas; authoritative enrollment/session/run/key/window admission and grant lifecycle subscription remain unconnected. | +| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | MERGED prerequisite | Signed no-ff merges of generated result head `36592f7090093ae6fe94a87706e319fca78fca17` and lifecycle/hash head `7cafdf138e` (source `d8872e300e`). Imported generated schemas/helpers and shared result/lifecycle files; no generated file hand-edited or copied. | +| Later enrollment readback/revoke PR | KEEP | Generated enrollment/revocation schemas and `FractalClient.clientTestRevoke` exist, but authoritative browser enrollment readback/controller and live revoke confirmation remain integration prerequisites. | +| Authenticated grant-subscription integration | KEEP | Generated lifecycle resources/frame decoder and state gate now exist. Wiring the real session-scoped subscription to verified enrollment/key/window/budget authority and the visible indicator remains unconnected; decoding alone never enables a client. | | Later verification/receipt PR | KEEP for journey acceptance | Actual canonical result verification and durable terminal receipts cannot be established by DOM observations. Handle/module pending. | No file:line can be cited for unpublished later PR APIs; their absence remains an explicit integration blocker, not an assumed implementation. -#6278 is STACKED on #6274's branch `schickling-assistant/2026-11-10-client-control-daemon`, merged at generated head `36592f7090093ae6fe94a87706e319fca78fca17`. This supersedes the earlier main-first decision. +#6278 contains signed no-ff merges of #6274's result and lifecycle generated contracts. The lifecycle delta is one merge commit followed by one web consumption commit; generated artifacts remain owned by the daemon generator. ## Generated-contract consumption and vector coverage @@ -56,11 +57,17 @@ No file:line can be cited for unpublished later PR APIs; their absence remains a The shared JSON contains no grant/command/envelope/receipt or fingerprint golden vectors. Optional item_id present/absent and delivered native Attention command coverage therefore uses generated-schema roundtrip examples, not invented golden bytes; receipt examples likewise use the generated evidence schema. No published web-relevant vector is skipped. The fixed test scalar is public test material only, never an enrollment key. Unicode cryptographic compatibility does not relax the fixture's ASCII-only rendered-text rule. +`lifecycle.unit.test.ts` reads every resource, frame and acceptance-trace step in `fixtures/clients/client-control-lifecycle-v1.json`: started/ended/revoked, stale/reordered starts, missing/wrong bound item, command-before-start and command-after-terminal. Additional source assertions cover strict malformed/unknown frame rejection, binding verification and permanent termination. These are source assertions, NOT local behavioural/typecheck receipts. + +One published fixture/schema mismatch is deliberately NOT normalized: `client-control-lifecycle-v1.json:198-212` expects `ignore-stale-started` for sequence 0, while generated `ReadSchema.generated.ts:1697` requires sequence >= 1. The trace test identifies this exception by resource identity plus its malformed sequence/expectation, never array position; asserts strict refusal and exactly one mismatch, so a fixture/schema correction fails loudly instead of silently passing. + +Observer hashing now uses generated `ClientControlSha256`/`defaultWebCryptoSha256`, with optional injection over the exact UTF-8 rendered question bytes (not a visible-state wrapper). Result conformance asserts default and injected hashing preserve generated JCS/digest/signing bytes. The identity reader has no fingerprint hash path: it still returns the SEC1 point only, and ECDSA/SHA-256 signing remains native. No extra identity hash or second hash implementation is introduced. + ## Open contracts No guessed wire fields or signing bytes are implemented. Generated command/grant/visible-state/result/receipt schemas, canonical helpers, signed vectors and revoke SDK are now consumed or available. Still pending: authoritative enrollment readback and pairing/public-key binding controller; grant lifecycle delivery with session/window/budget admission; live revoke confirmation; fixture local-only attestation, manifest/readback and authorized bootstrap. -The generated schema currently has delivered command rows with optional control_grant, but no generated start/end/revoke lifecycle event type. That lifecycle remains owned by the daemon's existing commands collection work. No custom event envelope, second socket, handwritten lifecycle shape or permissive fallback is introduced: unsupported command/lifecycle input is refused by generated decoding; application activation stays disabled. +The generated `ClientControlGrantLifecycle` resource now resolves the previously missing start/end/revoke schema on the existing commands collection. The lifecycle gate consumes these generated rows and refuses unknown/malformed/wrong-binding input without mutating terminal state. Authenticated subscription/bootstrap and actual UI/controller wiring remain separate prerequisites; no custom event envelope, second socket or browser enable flag is introduced. Application activation, command subscription, route-state capture and the complete journey await the remaining KEEP dependencies. Store wrapping is conditional on a read-position writer landing; that guard must land with the writer, not delay the current ask-only journey on a nonexistent store. Browser flags or preferences must never enable test mode on a normal pairing. diff --git a/apps/fractal-web/src/client-control/command.test-support.ts b/apps/fractal-web/src/client-control/command.test-support.ts index dfc049153dd..0992fb25425 100644 --- a/apps/fractal-web/src/client-control/command.test-support.ts +++ b/apps/fractal-web/src/client-control/command.test-support.ts @@ -1,4 +1,6 @@ -import { decodeGrant } from './wire.ts' +import type * as Wire from '@fractal/client/schema' +import { decodeGrant, encodeGrant } from './wire.ts' +import { createClientControlLifecycle } from './lifecycle.ts' /** Synthetic protocol examples, not golden signing vectors or enrollment authority. */ export const testGrant = (itemId: string = 'attention/unit-ask') => decodeGrant({ @@ -9,10 +11,35 @@ export const testGrant = (itemId: string = 'attention/unit-ask') => decodeGrant( }) /** Unknown permits deliberately invalid/refused verbs to exercise the generated decoder. */ -export const testCommand = (verb: string, args: unknown = {}) => ({ +export const testCommand = (verb: string, args: unknown = {}, grant: Wire.ClientControlGrant = testGrant()) => ({ kind: 'device-command', id: 'device-command/unit', revision: 'r1', - command_kind: verb, args, device_id: 'device/unit', session_actor: 'client-session/unit', - issued_by: 'agent/unit', issued_at: '2026-11-01T00:00:00.000Z', updated_at: '2026-11-01T00:00:00.000Z', + command_kind: verb, args, device_id: grant.device, session_actor: grant.session, + issued_by: grant.actor_agent, issued_at: '2026-11-01T00:00:00.000Z', updated_at: '2026-11-01T00:00:00.000Z', expires_at: '2030-01-01T00:00:00.000Z', state: 'delivered', - grant_id: 'client-control-grant/unit', run_id: 'mission-run/unit', + grant_id: grant.grant_id, run_id: grant.run_id, + control_grant: encodeGrant(grant), }) + +export const testLifecycleResource = ( + grant: Wire.ClientControlGrant, + state: Wire.ClientControlGrantLifecycle['state'] = 'started', + sequence: number = 1, +) => ({ + kind: 'client-control-grant-lifecycle', id: 'client-control-grant-lifecycle/unit', + revision: String(sequence), updated_at: '2026-11-10T12:00:00.000Z', + grant_id: grant.grant_id, run_id: grant.run_id, device: grant.device, session: grant.session, + ...(grant.item_id === undefined ? {} : { item_id: grant.item_id }), + state, reason: state, sequence, +}) + +/** Activates only a synthetic generated lifecycle resource; never application enrollment. */ +export const testLifecycle = (grant: Wire.ClientControlGrant = testGrant()) => { + const lifecycle = createClientControlLifecycle({ + subscriptionId: 'commands', + authority: grantId => grantId === grant.grant_id ? grant : undefined, + }) + if (lifecycle.acceptResource(testLifecycleResource(grant)) !== 'activate') { + throw new Error('Synthetic generated lifecycle start was refused') + } + return lifecycle +} diff --git a/apps/fractal-web/src/client-control/fixtureDomEvidence.ts b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts index 7c89c1f907a..44947c522f2 100644 --- a/apps/fractal-web/src/client-control/fixtureDomEvidence.ts +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts @@ -1,3 +1,4 @@ +import { defaultWebCryptoSha256, type ClientControlSha256 } from '../../../../clients/typescript/fractal-client/ClientControl.generated.ts' import { guardFixtureBoundary, type FixtureGuardInput } from './fixtureGuard.ts' import type { FixtureNoAnswerGuard } from './fixtureNoAnswerGuard.ts' import { isFixturePlainText } from './fixtureText.ts' @@ -68,6 +69,8 @@ export const observeFixtureDom = async (input: { readonly titleSelector: string readonly questionSelector: string readonly noAnswerGuard: FixtureNoAnswerGuard + /** Hashes exact question UTF-8 bytes, independently of visible-state canonicalization. */ + readonly sha256?: ClientControlSha256 readonly writeEvidence: (evidence: FixtureDomEvidence) => Promise }): Promise => { const boundary = guardFixtureBoundary(input.boundary) @@ -102,7 +105,7 @@ export const observeFixtureDom = async (input: { if (!isFixturePlainText(title) || !isFixturePlainText(question)) throw new TypeError('Rendered ask title and question must preserve the approved plain fixture text') if (new URL(input.page.url()).origin !== boundary.origin) throw new TypeError('Origin changed during observation') - const renderedQuestionTextSha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(question))), byte => byte.toString(16).padStart(2, '0')).join('') + const renderedQuestionTextSha256 = Array.from(await (input.sha256 ?? defaultWebCryptoSha256)(new TextEncoder().encode(question)), byte => byte.toString(16).padStart(2, '0')).join('') const renderedItemIds = [...new Set(snapshots[0]!.itemIds)] input.noAnswerGuard.assertNoAnswers() const evidence: FixtureDomEvidence = { diff --git a/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts b/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts index a91d20e4ed7..d58145038cc 100644 --- a/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts @@ -1,6 +1,8 @@ +// @vitest-environment node import { describe, it } from '@effect/vitest' -import { expect } from 'vitest' -import { compareFixtureDom, type FixtureSeedExpectation } from './fixtureDomEvidence.ts' +import { expect, vi } from 'vitest' +import { defaultWebCryptoSha256, type ClientControlSha256 } from '../../../../clients/typescript/fractal-client/ClientControl.generated.ts' +import { compareFixtureDom, observeFixtureDom, type FixtureDomEvidence, type FixtureSeedExpectation } from './fixtureDomEvidence.ts' const seed: FixtureSeedExpectation = { askId: 'attention/unit-seed', title: 'Unit ask title', @@ -28,4 +30,48 @@ describe('independent rendered fixture comparison', () => { expect(compareFixtureDom(seed, { ...observation, itemIds: [] }).passed).toBe(false) expect(compareFixtureDom(seed, { ...observation, itemIds: ['attention/unit-other'] }).passed).toBe(false) }) + + it('hashes only exact question UTF-8 with the generated default or an injected recording implementation', async () => { + const question = 'Is the unit fixture ready?' + const questionBytes = new TextEncoder().encode(question) + const expectedDigest = Buffer.from(await defaultWebCryptoSha256(questionBytes)).toString('hex') + const observedSeed = { ...seed, questionTextSha256: expectedDigest } + const hashInputs: Uint8Array[] = [] + const recordingSha256: ClientControlSha256 = async input => { + hashInputs.push(input.slice()) + return defaultWebCryptoSha256(input) + } + for (const sha256 of [undefined, recordingSha256]) { + // Model the single browser DOM-read boundary, not the hashing implementation. + const evaluateAll = vi.fn().mockResolvedValue([{ + titles: [{ text: seed.title, leaf: true }], + questions: [{ text: question, leaf: true }], + itemIds: [seed.askId], + }]) + const written: FixtureDomEvidence[] = [] + const evidence = await observeFixtureDom({ + boundary: { + origin: 'http://127.0.0.1:43127', approvedOrigin: 'http://127.0.0.1:43127', + normalOrigins: ['http://127.0.0.1:5173'], + gateway: 'unix:/isolated/client-control-fixture/run/fractal-client.sock', + resolvedGatewayPath: '/isolated/client-control-fixture/run/fractal-client.sock', + approvedClientSocketPath: '/isolated/client-control-fixture/run/fractal-client.sock', + approvedInstanceRoot: '/isolated/client-control-fixture', + normalGatewayPaths: ['/run/fractal-client.sock'], + }, + commit: 'a'.repeat(40), + page: { url: () => 'http://127.0.0.1:43127/attention', locator: () => ({ evaluateAll }) }, + seed: observedSeed, titleSelector: '.title', questionSelector: '.question', + noAnswerGuard: { assertNoAnswers: () => {} }, + ...(sha256 === undefined ? {} : { sha256 }), + writeEvidence: async value => { written.push(value) }, + }) + expect(evidence.renderedQuestionTextSha256).toBe(expectedDigest) + expect(evidence.questionMatches).toBe(true) + expect(evidence.passed).toBe(true) + expect(written).toEqual([evidence]) + expect(evaluateAll).toHaveBeenCalledOnce() + } + expect(hashInputs).toEqual([questionBytes]) + }) }) diff --git a/apps/fractal-web/src/client-control/grantBoundary.ts b/apps/fractal-web/src/client-control/grantBoundary.ts index 05dc41c61c8..3acbba954a3 100644 --- a/apps/fractal-web/src/client-control/grantBoundary.ts +++ b/apps/fractal-web/src/client-control/grantBoundary.ts @@ -1,6 +1,7 @@ import type * as Wire from '@fractal/client/schema' import type { ClientControlRouteTarget } from './navigation.ts' import { decodeCommand } from './wire.ts' +import type { ClientControlLifecycle } from './lifecycle.ts' const destinationFor = (command: Wire.DeviceCommand): ClientControlRouteTarget | undefined => { if (command.command_kind !== 'phone.navigate') return undefined @@ -18,6 +19,7 @@ export const handleGrantedDeviceCommand = async (input: { readonly currentTarget: () => ClientControlRouteTarget | undefined readonly command: unknown readonly permission: () => Wire.ClientControlGrant | undefined + readonly lifecycle: Pick readonly foreground: () => boolean readonly navigate: (target: ClientControlRouteTarget) => Promise readonly signVisibleState: () => Promise @@ -33,17 +35,21 @@ export const handleGrantedDeviceCommand = async (input: { && (target._tag !== 'Attention' || (permission.item_id !== undefined && target.askId === permission.item_id)) const allowed = () => { const permission = input.permission() - return permission !== undefined && permission.verbs.includes(command.command_kind) + const policy = command.control_grant + return permission !== undefined && policy !== undefined && input.lifecycle.allows(command) + && permission.verbs.includes(command.command_kind) && policy.verbs.includes(command.command_kind) && command.device_id === permission.device && command.grant_id === permission.grant_id && command.run_id === permission.run_id && targetAllowed(permission, destination ?? input.currentTarget()) + && targetAllowed(policy, destination ?? input.currentTarget()) } if (!allowed()) return 'Refused' if (!input.foreground()) return 'NotForeground' if (destination !== undefined) await input.navigate(destination) // Navigation may yield; revoked authority or lost foreground must never sign afterward. const permission = input.permission() - if (permission === undefined || !targetAllowed(permission, input.currentTarget())) return 'Refused' + if (permission === undefined || command.control_grant === undefined + || !targetAllowed(permission, input.currentTarget()) || !targetAllowed(command.control_grant, input.currentTarget())) return 'Refused' if (!allowed()) return 'Refused' if (!input.foreground()) return 'NotForeground' await input.signVisibleState() diff --git a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts index 96a508f1a8b..1128069f59c 100644 --- a/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts +++ b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type * as Wire from '@fractal/client/schema' import { handleGrantedDeviceCommand } from './grantBoundary.ts' import type { ClientControlRouteTarget } from './navigation.ts' -import { testCommand, testGrant } from './command.test-support.ts' +import { testCommand, testGrant, testLifecycle, testLifecycleResource } from './command.test-support.ts' +import { createClientControlLifecycle } from './lifecycle.ts' const exercise = async ( command: unknown, @@ -13,7 +14,7 @@ const exercise = async ( let signatures = 0 let currentTarget = initialTarget const result = await handleGrantedDeviceCommand({ - command, permission: () => grant, foreground: () => true, + command, permission: () => grant, lifecycle: testLifecycle(grant), foreground: () => true, currentTarget: () => currentTarget, navigate: async target => { navigations.push(target); currentTarget = target }, signVisibleState: async () => { signatures += 1 }, @@ -23,6 +24,49 @@ const exercise = async ( const refused = { result: 'Refused', navigations: [], signatures: 0 } describe('generated client-control grant-check handler boundary', () => { + it.each(['ended', 'revoked'] as const)('requires verified lifecycle activation and refuses after %s', async terminal => { + const grant = testGrant() + const lifecycle = createClientControlLifecycle({ + subscriptionId: 'commands', authority: grantId => grantId === grant.grant_id ? grant : undefined, + }) + const calls: string[] = [] + const attempt = () => handleGrantedDeviceCommand({ + command: testCommand('phone.navigate', { screen: 'NowRoot' }), permission: () => grant, lifecycle, + currentTarget: () => ({ _tag: 'NowRoot' }), foreground: () => true, + navigate: async () => { calls.push('navigate') }, signVisibleState: async () => { calls.push('sign') }, + }) + expect(await attempt()).toBe('Refused') + expect(calls).toEqual([]) + expect(lifecycle.acceptResource(testLifecycleResource(grant))).toBe('activate') + expect(await attempt()).toBe('Applied') + expect(calls).toEqual(['navigate', 'sign']) + calls.length = 0 + expect(lifecycle.acceptResource(testLifecycleResource(grant, terminal, 2))).toBe('terminate') + expect(await attempt()).toBe('Refused') + expect(calls).toEqual([]) + }) + + it('requires each delivered command to carry its own policy as well as active lifecycle', async () => { + const { control_grant: _policy, ...command } = testCommand('phone.inspect') + expect(await exercise(command)).toEqual(refused) + const limited = { ...testGrant(), screens: ['NowRoot'] } + expect(await exercise(testCommand('phone.navigate', { screen: 'Attention', params: { id: limited.item_id } }, limited))).toEqual(refused) + }) + + it('does not sign if a verified lifecycle end arrives while navigation yields', async () => { + const grant = testGrant() + const lifecycle = testLifecycle(grant) + let signatures = 0 + const result = await handleGrantedDeviceCommand({ + command: testCommand('phone.navigate', { screen: 'NowRoot' }), permission: () => grant, lifecycle, + currentTarget: () => ({ _tag: 'NowRoot' }), foreground: () => true, + navigate: async () => { lifecycle.acceptResource(testLifecycleResource(grant, 'ended', 2)) }, + signVisibleState: async () => { signatures += 1 }, + }) + expect(result).toBe('Refused') + expect(signatures).toBe(0) + }) + it('refuses Conversation under a NowRoot/Attention grant without navigating or signing', async () => { expect(await exercise(testCommand('phone.navigate', { screen: 'Conversation', params: { target: 'agent/unit' } }))).toEqual(refused) }) @@ -91,8 +135,8 @@ describe('generated client-control grant-check handler boundary', () => { it('allows optional item_id absence for NowRoot but refuses unbound Attention', async () => { const { item_id: _item, ...unbound } = testGrant() - expect(await exercise(testCommand('phone.inspect'), unbound)).toEqual({ result: 'Applied', navigations: [], signatures: 1 }) - expect(await exercise(testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/unit-ask' } }), unbound)).toEqual(refused) + expect(await exercise(testCommand('phone.inspect', {}, unbound), unbound)).toEqual({ result: 'Applied', navigations: [], signatures: 1 }) + expect(await exercise(testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/unit-ask' } }, unbound), unbound)).toEqual(refused) }) it.each(['device_id', 'grant_id', 'run_id'] as const)('refuses mismatched %s binding', async field => { @@ -107,6 +151,7 @@ describe('generated client-control grant-check handler boundary', () => { const calls: string[] = [] const result = await handleGrantedDeviceCommand({ command: testCommand('phone.navigate', { screen: 'NowRoot' }), permission: () => grant, + lifecycle: testLifecycle(grant), foreground: () => foreground, currentTarget: () => ({ _tag: 'NowRoot' }), navigate: async () => { calls.push('navigate') }, signVisibleState: async () => { calls.push('sign') }, }) @@ -119,6 +164,7 @@ describe('generated client-control grant-check handler boundary', () => { let signatures = 0 const result = await handleGrantedDeviceCommand({ command: testCommand('phone.navigate', { screen: 'NowRoot' }), permission: () => active, foreground: () => true, + lifecycle: testLifecycle(active), currentTarget: () => ({ _tag: 'NowRoot' }), navigate: async () => { active = undefined }, signVisibleState: async () => { signatures += 1 }, }) diff --git a/apps/fractal-web/src/client-control/lifecycle.ts b/apps/fractal-web/src/client-control/lifecycle.ts new file mode 100644 index 00000000000..5034df6ccdc --- /dev/null +++ b/apps/fractal-web/src/client-control/lifecycle.ts @@ -0,0 +1,102 @@ +import * as Wire from '@fractal/client/schema' + +const decodeLifecycle = Wire.decodeUnknownSync(Wire.ClientControlGrantLifecycle, 'strict') +const decodeFrame = Wire.decodeUnknownSync(Wire.CollectionFrame, 'strict') + +type Binding = Pick +const sameBinding = (left: Binding, right: Binding): boolean => + left.grant_id === right.grant_id && left.item_id === right.item_id && + left.run_id === right.run_id && left.device === right.device && left.session === right.session + +type RecordState = { readonly row: Wire.ClientControlGrantLifecycle; readonly terminal: boolean } +type Decision = 'activate' | 'ignore-stale-started' | 'ignore-final-grant' | 'terminate' | + 'remain-terminal' | 'ignore-item-mismatch' | 'refused-binding' | 'refused-invalid' + +export interface ClientControlLifecycle { + readonly acceptResource: (raw: unknown) => string + readonly acceptFrame: (raw: unknown) => { readonly accepted: boolean; readonly decisions: readonly string[] } + readonly allows: (command: Wire.DeviceCommand) => boolean + readonly activeRunId: (grantId: string) => string | undefined + readonly enrollmentRevoked: () => boolean +} + +/** Lifecycle is a gate, never enrollment or verb/screen/budget authority. */ +export const createClientControlLifecycle = ({ authority, subscriptionId }: { + /** The host must verify the current enrollment, session, key and binding before returning a grant. */ + readonly authority: (grantId: string) => Wire.ClientControlGrant | undefined + readonly subscriptionId: string +}): ClientControlLifecycle => { + const records = new Map() + let revoked = false + + const bindingRefusal = (row: Wire.ClientControlGrantLifecycle): Decision | undefined => { + const grant = authority(row.grant_id) + if (grant === undefined) return 'refused-binding' + if (row.item_id !== grant.item_id) return 'ignore-item-mismatch' + if (!sameBinding(row, grant)) return 'refused-binding' + const previous = records.get(row.grant_id) + if (previous !== undefined) { + if (row.item_id !== previous.row.item_id) return 'ignore-item-mismatch' + if (!sameBinding(row, previous.row)) return 'refused-binding' + } + return undefined + } + + const apply = (row: Wire.ClientControlGrantLifecycle): Decision => { + const previous = records.get(row.grant_id) + if (row.state === 'revoked') revoked = true + if (previous?.terminal === true) return row.state === 'started' ? 'ignore-final-grant' : 'remain-terminal' + if (row.state === 'started') { + if (previous !== undefined && row.sequence <= previous.row.sequence) return 'ignore-stale-started' + records.set(row.grant_id, { row, terminal: false }) + return 'activate' + } + records.set(row.grant_id, { row, terminal: true }) + return 'terminate' + } + + return { + acceptResource: raw => { + let row: Wire.ClientControlGrantLifecycle + try { row = decodeLifecycle(raw) } catch { return 'refused-invalid' } + return bindingRefusal(row) ?? apply(row) + }, + acceptFrame: raw => { + let frame: Wire.CollectionFrame + try { frame = decodeFrame(raw) } catch { return { accepted: false, decisions: [] } } + if ((frame.kind !== 'snapshot' && frame.kind !== 'changes') || + frame.collection !== 'commands' || frame.id !== subscriptionId) { + return { accepted: false, decisions: [] } + } + const resources = frame.kind === 'snapshot' ? frame.items : frame.upserts + if (resources.some(row => row.kind !== 'client-control-grant-lifecycle' && row.kind !== 'device-command')) { + return { accepted: false, decisions: [] } + } + const rows = resources.filter((row): row is Wire.ClientControlGrantLifecycle => row.kind === 'client-control-grant-lifecycle') + // Validate the whole batch before applying even a terminal event. Include bindings + // introduced within this frame, not only records from previous frames. + const bindings = new Map() + for (const row of rows) { + if (bindingRefusal(row) !== undefined) return { accepted: false, decisions: [] } + const prior = bindings.get(row.grant_id) + if (prior !== undefined && !sameBinding(row, prior)) return { accepted: false, decisions: [] } + bindings.set(row.grant_id, row) + } + return { accepted: true, decisions: rows.map(apply) } + }, + allows: command => { + if (revoked || command.grant_id === undefined || command.control_grant === undefined) return false + const record = records.get(command.grant_id) + if (record === undefined || record.terminal) return false + const grant = authority(command.grant_id) + return grant !== undefined && grant.state === 'active' && command.control_grant.state === 'active' && + sameBinding(record.row, grant) && sameBinding(record.row, command.control_grant) && + command.run_id === record.row.run_id && command.device_id === record.row.device + }, + activeRunId: grantId => { + const record = records.get(grantId) + return revoked || record === undefined || record.terminal ? undefined : record.row.run_id + }, + enrollmentRevoked: () => revoked, + } +} diff --git a/apps/fractal-web/src/client-control/lifecycle.unit.test.ts b/apps/fractal-web/src/client-control/lifecycle.unit.test.ts new file mode 100644 index 00000000000..c870a7bd27a --- /dev/null +++ b/apps/fractal-web/src/client-control/lifecycle.unit.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it } from 'vitest' +import { Schema } from 'effect' +import * as Wire from '@fractal/client/schema' +import vectors from '../../../../fixtures/clients/client-control-lifecycle-v1.json' +import { testCommand, testGrant } from './command.test-support.ts' +import { createClientControlLifecycle } from './lifecycle.ts' + +const decodeLifecycle = Wire.decodeUnknownSync(Wire.ClientControlGrantLifecycle, 'strict') +const decodeCommand = Wire.decodeUnknownSync(Wire.DeviceCommand, 'strict') +const encodeGrant = Schema.encodeSync(Wire.ClientControlGrant) +const [started, ended, revoked] = vectors.resources +if (started === undefined || ended === undefined || revoked === undefined) throw new Error('Missing canonical lifecycle resources') +const snapshot = vectors.frames.find(frame => frame.kind === 'snapshot') +const changes = vectors.frames.find(frame => frame.kind === 'changes') +if (snapshot === undefined || changes === undefined) throw new Error('Missing canonical lifecycle frames') + +const grant: Wire.ClientControlGrant = { + ...testGrant(), grant_id: started.grant_id, item_id: started.item_id, + run_id: started.run_id, device: started.device, session: started.session, +} +const command = (binding: Wire.ClientControlGrant = grant): Wire.DeviceCommand => decodeCommand({ + ...testCommand('phone.inspect'), grant_id: binding.grant_id, run_id: binding.run_id, + device_id: binding.device, session_actor: binding.session, control_grant: encodeGrant(binding), +}) +const makeGate = (authority: (grantId: string) => Wire.ClientControlGrant | undefined = id => id === grant.grant_id ? grant : undefined) => + createClientControlLifecycle({ authority, subscriptionId: snapshot.id }) +const refusedFrame = { accepted: false, decisions: [] } + +// These fixtures are decoder/acceptance vectors, not enrollment authority. Only this +// explicit test callback supplies verified authority; the application remains disabled. +describe('client-control lifecycle gate', () => { + it('loads every canonical resource and frame through the generated strict schemas', () => { + const resourcesGate = makeGate() + const framesGate = makeGate() + const resourceDecisions = ['activate', 'terminate', 'remain-terminal'] + for (const [index, resource] of vectors.resources.entries()) { + expect(() => decodeLifecycle(resource)).not.toThrow() + expect(resourcesGate.acceptResource(resource)).toBe(resourceDecisions[index]) + } + for (const [index, frame] of vectors.frames.entries()) { + expect(() => Wire.decodeUnknownSync(Wire.CollectionFrame, 'strict')(frame)).not.toThrow() + expect(framesGate.acceptFrame(frame)).toEqual({ accepted: true, decisions: [resourceDecisions[index]] }) + } + expect(resourcesGate.enrollmentRevoked()).toBe(true) + expect(framesGate.enrollmentRevoked()).toBe(true) + }) + + it('replays every shared trace, exposing exactly the known zero-sequence schema contradiction', () => { + let contradictions = 0 + for (const trace of vectors.traces) { + const gate = makeGate() + expect(trace.grant_id).toBe(grant.grant_id) + expect(trace.item_id).toBe(grant.item_id) + for (const step of trace.steps) { + if ('resource' in step && step.resource !== undefined) { + const resource = step.resource + if (resource.grant_id === trace.grant_id && resource.id === started.id && resource.sequence === 0 && + step.expected === 'ignore-stale-started') { + // Positive sequence is canonical. Do not normalize this contradictory vector. + contradictions += 1 + expect(() => decodeLifecycle(resource)).toThrow() + expect(gate.acceptResource(resource)).toBe('refused-invalid') + expect(gate.activeRunId(trace.grant_id)).toBe(grant.run_id) + } else { + expect(gate.acceptResource(resource)).toBe(step.expected) + } + } else if ('command_grant_id' in step) { + expect(step.command_grant_id).toBe(grant.grant_id) + expect(gate.allows(command())).toBe(step.expected === 'allow-lifecycle-gate-only') + } else { + throw new Error('Unknown lifecycle trace step') + } + } + } + // Fail loudly if either the fixture or generated schema fixes the contradiction. + expect(contradictions).toBe(1) + }) + + it('requires separately verified authority and an embedded command grant', () => { + const gate = makeGate(() => undefined) + expect(gate.acceptResource(started)).toBe('refused-binding') + expect(gate.acceptResource(revoked)).toBe('refused-binding') + expect(gate.activeRunId(grant.grant_id)).toBeUndefined() + expect(gate.enrollmentRevoked()).toBe(false) + expect(gate.allows(command())).toBe(false) + const verified = makeGate() + expect(verified.allows(command())).toBe(false) + expect(verified.acceptResource(started)).toBe('activate') + const { control_grant: _control, ...withoutGrant } = command() + expect(verified.allows(withoutGrant)).toBe(false) + expect(verified.allows(command())).toBe(true) + }) + + it.each(['grant_id', 'run_id', 'device', 'session'] as const)('refuses lifecycle %s mismatch on every state', field => { + const gate = makeGate() + expect(gate.acceptResource(started)).toBe('activate') + for (const row of vectors.resources) { + expect(gate.acceptResource({ ...row, [field]: 'other/binding' })).toBe('refused-binding') + expect(gate.activeRunId(grant.grant_id)).toBe(grant.run_id) + expect(gate.enrollmentRevoked()).toBe(false) + } + }) + + it('requires exact optional item binding for every state, including unbound grants', () => { + const gate = makeGate() + gate.acceptResource(started) + for (const row of vectors.resources) { + const { item_id: _item, ...missing } = row + expect(gate.acceptResource(missing)).toBe('ignore-item-mismatch') + expect(gate.acceptResource({ ...row, item_id: 'other/item' })).toBe('ignore-item-mismatch') + } + expect(gate.allows(command())).toBe(true) + expect(gate.enrollmentRevoked()).toBe(false) + const { item_id: _item, ...unbound } = grant + const unboundGate = makeGate(() => unbound) + expect(unboundGate.acceptResource(started)).toBe('ignore-item-mismatch') + const { item_id: _rowItem, ...unboundRow } = started + expect(unboundGate.acceptResource(unboundRow)).toBe('activate') + expect(unboundGate.allows(command(unbound))).toBe(true) + expect(unboundGate.allows(command())).toBe(false) + expect(unboundGate.acceptResource(revoked)).toBe('ignore-item-mismatch') + expect(unboundGate.enrollmentRevoked()).toBe(false) + }) + + it.each([ + null, undefined, {}, [], 'ended', { kind: 'unknown' }, + { ...started, extra: true }, { ...ended, state: 'unknown' }, + { ...revoked, sequence: 0 }, { ...revoked, sequence: -1 }, + { ...revoked, sequence: 1.5 }, { ...revoked, sequence: Number.MAX_SAFE_INTEGER + 1 }, + { ...revoked, sequence: null }, { ...revoked, item_id: null }, + { ...revoked, device: null }, { ...ended, updated_at: 'not-a-timestamp' }, + ])('refuses malformed or unknown resources without authority calls or termination: %j', raw => { + let calls = 0 + const gate = makeGate(() => { calls += 1; return grant }) + gate.acceptResource(started) + calls = 0 + expect(gate.acceptResource(raw)).toBe('refused-invalid') + expect(calls).toBe(0) + expect(gate.activeRunId(grant.grant_id)).toBe(grant.run_id) + expect(gate.enrollmentRevoked()).toBe(false) + }) + + it('accepts strictly increasing starts, ignores positive reordered starts, and never resurrects finals', () => { + const gate = makeGate() + expect(gate.acceptResource({ ...started, sequence: 5 })).toBe('activate') + expect(gate.acceptResource({ ...started, sequence: 4 })).toBe('ignore-stale-started') + expect(gate.acceptResource({ ...started, sequence: 5 })).toBe('ignore-stale-started') + expect(gate.acceptResource({ ...started, sequence: 6 })).toBe('activate') + // Termination is final even if its sequence precedes the latest start. + expect(gate.acceptResource(ended)).toBe('terminate') + expect(gate.activeRunId(grant.grant_id)).toBeUndefined() + expect(gate.acceptResource({ ...started, sequence: 100 })).toBe('ignore-final-grant') + expect(gate.allows(command())).toBe(false) + expect(gate.acceptResource(revoked)).toBe('remain-terminal') + expect(gate.enrollmentRevoked()).toBe(true) + expect(gate.activeRunId(grant.grant_id)).toBeUndefined() + }) + + it.each([ended, revoked])('makes a terminal row before any start permanently final: $state', terminal => { + const gate = makeGate() + expect(gate.acceptResource(terminal)).toBe('terminate') + expect(gate.acceptResource({ ...started, sequence: 100 })).toBe('ignore-final-grant') + expect(gate.activeRunId(grant.grant_id)).toBeUndefined() + expect(gate.allows(command())).toBe(false) + }) + + it('ends only one grant, but verified revocation disables every active grant', () => { + const other = { ...grant, grant_id: 'client-control-grant/other' } + const gate = makeGate(id => id === grant.grant_id ? grant : id === other.grant_id ? other : undefined) + gate.acceptResource(started) + gate.acceptResource({ ...started, grant_id: other.grant_id }) + gate.acceptResource(ended) + expect(gate.enrollmentRevoked()).toBe(false) + expect(gate.activeRunId(other.grant_id)).toBe(other.run_id) + expect(gate.allows(command(other))).toBe(true) + expect(gate.acceptResource(revoked)).toBe('remain-terminal') + expect(gate.enrollmentRevoked()).toBe(true) + expect(gate.activeRunId(other.grant_id)).toBeUndefined() + expect(gate.allows(command(other))).toBe(false) + gate.acceptResource({ ...started, grant_id: 'client-control-grant/unknown' }) + expect(gate.enrollmentRevoked()).toBe(true) + }) + + it.each(['grant_id', 'run_id', 'device', 'session', 'item_id'] as const)('rechecks current authority and embedded grant %s', field => { + let current: Wire.ClientControlGrant | undefined = grant + const gate = makeGate(() => current) + gate.acceptResource(started) + expect(gate.allows(command())).toBe(true) + const mismatched = { ...grant, [field]: 'other/binding' } + expect(gate.allows({ ...command(), control_grant: mismatched })).toBe(false) + current = mismatched + expect(gate.allows(command())).toBe(false) + expect(gate.acceptResource({ ...started, [field]: 'other/binding', sequence: 2 })).not.toBe('activate') + current = undefined + expect(gate.allows(command())).toBe(false) + }) + + it.each(['grant_id', 'run_id', 'device_id'] as const)('checks command %s against the active binding', field => { + const gate = makeGate() + gate.acceptResource(started) + expect(gate.allows({ ...command(), [field]: 'other/binding' })).toBe(false) + }) + + it('requires command grant/run IDs, leaving session_actor projection checks to verified authority', () => { + const gate = makeGate() + gate.acceptResource(started) + const { grant_id: _grantId, ...withoutGrantId } = command() + const { run_id: _runId, ...withoutRunId } = command() + expect(gate.allows(withoutGrantId)).toBe(false) + expect(gate.allows(withoutRunId)).toBe(false) + expect(gate.allows({ ...command(), session_actor: 'person/test/session/projected' })).toBe(true) + }) + + it.each(['grant_id', 'item_id', 'run_id', 'device', 'session', 'state', 'reason', 'sequence', 'id', 'kind', 'revision', 'updated_at'] as const)( + 'refuses null lifecycle field %s without state changes', field => { + const gate = makeGate() + gate.acceptResource(started) + expect(gate.acceptResource({ ...revoked, [field]: null })).toBe('refused-invalid') + expect(gate.activeRunId(grant.grant_id)).toBe(grant.run_id) + expect(gate.enrollmentRevoked()).toBe(false) + }, + ) + + it('refuses otherwise-valid resource kinds outside the commands collection contract', () => { + const operation = { + kind: 'operation', id: 'operation/unit', revision: started.revision, updated_at: started.updated_at, + component: 'daemon', severity: 'info', state: 'healthy', summary: 'Synthetic protocol example', + } + expect(() => Wire.decodeUnknownSync(Wire.Resource, 'strict')(operation)).not.toThrow() + let calls = 0 + const gate = makeGate(() => { calls += 1; return grant }) + gate.acceptResource(started) + calls = 0 + expect(gate.acceptFrame({ ...snapshot, items: [revoked, operation] })).toEqual(refusedFrame) + expect(calls).toBe(0) + expect(gate.activeRunId(grant.grant_id)).toBe(grant.run_id) + expect(gate.enrollmentRevoked()).toBe(false) + }) + + it('does not interpret verb, screen, or budget policy as lifecycle authority', () => { + const gate = makeGate() + gate.acceptResource(started) + expect(gate.allows(command({ ...grant, verbs: [], screens: [], max_commands: 1 }))).toBe(true) + }) + + it.each(['ended', 'revoked', 'expired', 'unknown', ''])( + 'refuses a command embedding nonactive grant state %j despite active lifecycle and authority', state => { + const gate = makeGate() + expect(gate.acceptResource(started)).toBe('activate') + expect(gate.allows(command())).toBe(true) + expect(gate.allows(command({ ...grant, state }))).toBe(false) + expect(gate.activeRunId(grant.grant_id)).toBe(grant.run_id) + }, + ) + + it.each([ + null, undefined, {}, { ...snapshot, unexpected: true }, { ...snapshot, items: null }, + { ...snapshot, snapshot: { ...snapshot.snapshot, unexpected: true } }, + { ...snapshot, items: [ended, { ...revoked, sequence: 0 }] }, + { ...snapshot, items: [ended, { ...revoked, unexpected: true }] }, + { ...snapshot, items: [ended, { kind: 'unknown' }] }, + { ...changes, upserts: [ended, { ...revoked, item_id: null }] }, + ])('atomically refuses malformed frames before any authority callback: %j', raw => { + let calls = 0 + const gate = makeGate(() => { calls += 1; return grant }) + gate.acceptResource(started) + calls = 0 + expect(gate.acceptFrame(raw)).toEqual(refusedFrame) + expect(calls).toBe(0) + expect(gate.activeRunId(grant.grant_id)).toBe(grant.run_id) + expect(gate.enrollmentRevoked()).toBe(false) + }) + + it.each([ + { ...snapshot, id: 'wrong-subscription' }, { ...snapshot, collection: 'attention' }, + { kind: 'resync', id: snapshot.id, collection: 'commands' }, + { kind: 'error', id: snapshot.id, collection: 'commands', message: 'failed' }, + ])('refuses non-command or nonmatching-subscription frames without authority calls: %j', raw => { + let calls = 0 + const gate = makeGate(() => { calls += 1; return grant }) + expect(gate.acceptFrame(raw)).toEqual(refusedFrame) + expect(calls).toBe(0) + expect(gate.enrollmentRevoked()).toBe(false) + }) + + it.each(['grant_id', 'run_id', 'device', 'session', 'item_id'] as const)('prevalidates every frame binding before applying termination: %s', field => { + const gate = makeGate() + gate.acceptResource(started) + for (const terminal of [ended, revoked]) { + expect(gate.acceptFrame({ ...changes, upserts: [terminal, { ...revoked, [field]: 'other/binding' }] })).toEqual(refusedFrame) + expect(gate.activeRunId(grant.grant_id)).toBe(grant.run_id) + expect(gate.enrollmentRevoked()).toBe(false) + expect(gate.allows(command())).toBe(true) + } + }) + + it('prevalidates bindings introduced in the same frame even if authority changes between callbacks', () => { + let calls = 0 + const gate = makeGate(() => { + calls += 1 + return calls === 1 ? grant : { ...grant, run_id: 'mission-run/other' } + }) + expect(gate.acceptFrame({ ...snapshot, items: [started, { ...revoked, run_id: 'mission-run/other' }] })).toEqual(refusedFrame) + expect(gate.activeRunId(grant.grant_id)).toBeUndefined() + expect(gate.enrollmentRevoked()).toBe(false) + }) + + it('ignores command rows and collection removals rather than treating absence as termination', () => { + const gate = makeGate() + gate.acceptResource(started) + expect(gate.acceptFrame({ ...snapshot, items: [Schema.encodeSync(Wire.DeviceCommand)(command())] })).toEqual({ accepted: true, decisions: [] }) + expect(gate.acceptFrame({ ...changes, upserts: [], removes: [started.id] })).toEqual({ accepted: true, decisions: [] }) + expect(gate.allows(command())).toBe(true) + expect(gate.enrollmentRevoked()).toBe(false) + }) +}) diff --git a/apps/fractal-web/src/client-control/wire.unit.test.ts b/apps/fractal-web/src/client-control/wire.unit.test.ts index fd0957027a0..1ad7b81cdd7 100644 --- a/apps/fractal-web/src/client-control/wire.unit.test.ts +++ b/apps/fractal-web/src/client-control/wire.unit.test.ts @@ -5,6 +5,8 @@ import golden from '../../../../fixtures/clients/client-control-v1.golden.json' import { CLIENT_RESULT_SIGNED_FIELDS, clientResultCanonicalBytes, + defaultWebCryptoSha256, + type ClientControlSha256, visibleStateCanonicalJson, visibleStateDigest, } from '../../../../clients/typescript/fractal-client/ClientControl.generated.ts' @@ -28,6 +30,18 @@ describe('web consumption of the repository client-control golden file', () => { expect(visibleStateCanonicalJson(vector.input.visible_state)).toBe(vector.canonical_visible_state) expect(await visibleStateDigest(vector.input.visible_state)).toBe(vector.visible_state_sha256) expect(await clientResultCanonicalBytes(vector.input)).toEqual(new TextEncoder().encode(vector.canonical_bytes)) + expect(await visibleStateDigest(vector.input.visible_state, defaultWebCryptoSha256)).toBe(vector.visible_state_sha256) + expect(await clientResultCanonicalBytes(vector.input, defaultWebCryptoSha256)).toEqual(new TextEncoder().encode(vector.canonical_bytes)) + const hashInputs: Uint8Array[] = [] + const recordingSha256: ClientControlSha256 = async input => { + hashInputs.push(input.slice()) + return defaultWebCryptoSha256(input) + } + const expectedHashInput = new TextEncoder().encode(vector.canonical_visible_state) + expect(await visibleStateDigest(vector.input.visible_state, recordingSha256)).toBe(vector.visible_state_sha256) + expect(hashInputs).toEqual([expectedHashInput]) + expect(await clientResultCanonicalBytes(vector.input, recordingSha256)).toEqual(new TextEncoder().encode(vector.canonical_bytes)) + expect(hashInputs).toEqual([expectedHashInput, expectedHashInput]) const key = await verificationKey() expect(await verifyClientResultBinding(binding, vector.signature_p1363_base64url, key)).toBe(true) for (const changed of vector.negative_bindings) { diff --git a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx index a37b97e753c..108867b9b54 100644 --- a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx +++ b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx @@ -23,7 +23,7 @@ import type { PromptAnswerPort } from './prompt-answer-port.ts' import { fixtureSource } from '../../data/fixtureSource.ts' import { DataSourceProvider } from '../../data/react.tsx' import { handleGrantedDeviceCommand } from '../../client-control/grantBoundary.ts' -import { testCommand, testGrant } from '../../client-control/command.test-support.ts' +import { testCommand, testGrant, testLifecycle } from '../../client-control/command.test-support.ts' import type { ClientControlRouteTarget } from '../../client-control/navigation.ts' const alert = { @@ -210,22 +210,24 @@ it.each(['decision', 'feedback'] as const)( let target: ClientControlRouteTarget = { _tag: 'NowRoot' } let signatures = 0 const permission = testGrant(shown.id) + const lifecycle = testLifecycle(permission) + const commandFor = (verb: string, args: unknown = {}) => testCommand(verb, args, permission) const commands = [ - { command: testCommand('phone.inspect'), refused: false }, - { command: testCommand('phone.navigate', { screen: 'Attention', params: { id: shown.id } }), refused: false }, - { command: testCommand('phone.inspect'), refused: false }, - { command: testCommand('phone.navigate', { screen: 'Attention', params: { id: 'attention/not-bound' } }), refused: true }, + { command: commandFor('phone.inspect'), refused: false }, + { command: commandFor('phone.navigate', { screen: 'Attention', params: { id: shown.id } }), refused: false }, + { command: commandFor('phone.inspect'), refused: false }, + { command: commandFor('phone.navigate', { screen: 'Attention', params: { id: 'attention/not-bound' } }), refused: true }, ...['work.done', 'prompt.respond', 'attention.answer', 'attention.close', 'phone.message.read', 'read-position.advance'] - .map(verb => ({ command: testCommand(verb, verb === 'phone.message.read' ? { message_id: 'message/unit' } : {}), refused: true })), - { command: testCommand('phone.navigate', { screen: 'NowRoot' }), refused: false }, - { command: testCommand('phone.inspect'), refused: false }, + .map(verb => ({ command: commandFor(verb, verb === 'phone.message.read' ? { message_id: 'message/unit' } : {}), refused: true })), + { command: commandFor('phone.navigate', { screen: 'NowRoot' }), refused: false }, + { command: commandFor('phone.inspect'), refused: false }, ] for (const { command, refused } of commands) { const before = signatures const previous = target await act(async () => { expect(await handleGrantedDeviceCommand({ - command, permission: () => permission, currentTarget: () => target, + command, permission: () => permission, lifecycle, currentTarget: () => target, foreground: () => true, navigate: async next => { target = next }, signVisibleState: async () => { signatures += 1 }, })).toBe(refused ? 'Refused' : 'Applied') From f92a26354aec5948899d482d06f6ea644e78a04f Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:00:38 +0100 Subject: [PATCH 25/32] fix(client-control): reject invalid sequences and expose typed canonical errors agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../ClientControlLifecycleTests.swift | 14 ++++++++++++++ .../fractal-client/client-control.test.cjs | 12 ++++++++---- clients/typescript/fractal-client/index.ts | 2 +- .../typescript/fractal-client/schema.test.cjs | 11 +++++++++++ .../templates/ClientControl.ts.in | 11 +++++++++-- .../templates/Models.swift.in | 18 ++++++++++++++++++ .../templates/client_control.rs.in | 8 ++++++++ .../tests/client_control_lifecycle.rs | 15 +++++++++++++++ docs/st3/client-v0/device-commands.md | 7 ++++++- .../clients/client-control-lifecycle-v1.json | 2 +- .../client-control-lifecycle-schema.json | 4 ++-- 11 files changed, 93 insertions(+), 11 deletions(-) diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift index 0c106c4a747..f7344082452 100644 --- a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift +++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift @@ -22,6 +22,20 @@ final class ClientControlLifecycleTests: XCTestCase { let lifecycle = try XCTUnwrap((frame.items ?? frame.upserts)?.first) guard case .clientControlGrantLifecycle = lifecycle else { return XCTFail("Frame lost typed lifecycle row") } } + for trace in try XCTUnwrap(fixture["traces"] as? [[String: Any]]) { + for step in try XCTUnwrap(trace["steps"] as? [[String: Any]]) where step["expected"] as? String == "refuse-invalid-frame" { + let resource = try XCTUnwrap(step["resource"] as? [String: Any]) + XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: resource))) + var frame = try XCTUnwrap((fixture["frames"] as? [[String: Any]])?.first) + frame["items"] = [resource] + XCTAssertThrowsError(try JSONDecoder().decode(ClientCommandsCollectionFrame.self, from: JSONSerialization.data(withJSONObject: frame))) + } + } + for sequence in [UInt64(0), UInt64(9_007_199_254_740_992)] { + var resource = try XCTUnwrap((fixture["resources"] as? [[String: Any]])?.first) + resource["sequence"] = sequence + XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: resource))) + } var invalid = try XCTUnwrap((fixture["resources"] as? [[String: Any]])?.first) invalid["state"] = "future-state" XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: invalid))) diff --git a/clients/typescript/fractal-client/client-control.test.cjs b/clients/typescript/fractal-client/client-control.test.cjs index 4d08678ceb2..b59a1011d79 100644 --- a/clients/typescript/fractal-client/client-control.test.cjs +++ b/clients/typescript/fractal-client/client-control.test.cjs @@ -13,7 +13,7 @@ const loaded = new Module(filename, module); loaded.filename = filename; loaded.paths = module.paths; loaded._compile(source, filename); -const { visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } = loaded.exports; +const { ClientResultCanonicalizationError, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } = loaded.exports; const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../fixtures/clients/client-control-v1.golden.json'), 'utf8')); if (!globalThis.crypto) globalThis.crypto = webcrypto; const point = Buffer.from(fixture.public_key_sec1_base64url, 'base64url'); @@ -37,9 +37,13 @@ test('all clients share canonical bytes, digest and purpose-specific fixed-key s assert.equal(verifies(bytes, messageSignature), false); } }); -test('JCS refuses lone surrogate strings', () => { - const state = { ...fixture.vectors[0].input.visible_state, route: '\ud800' }; - assert.throws(() => visibleStateCanonicalJson(state), /Invalid Unicode/); +test('JCS exposes typed lone-surrogate refusals through canonical byte generation', async () => { + for (const route of ['\ud800', '\udc00']) { + const state = { ...fixture.vectors[0].input.visible_state, route }; + const isInvalidUnicode = error => error instanceof ClientResultCanonicalizationError && error.code === 'invalid-unicode'; + assert.throws(() => visibleStateCanonicalJson(state), isInvalidUnicode); + await assert.rejects(clientResultCanonicalBytes({ ...fixture.vectors[0].input, visible_state: state }), isInvalidUnicode); + } }); test('injected SHA256 reproduces golden bytes without a WebCrypto dependency', async () => { diff --git a/clients/typescript/fractal-client/index.ts b/clients/typescript/fractal-client/index.ts index a30424a2063..afc4c587448 100644 --- a/clients/typescript/fractal-client/index.ts +++ b/clients/typescript/fractal-client/index.ts @@ -2,5 +2,5 @@ export * from './Models.generated.ts'; export * from './Client.generated.ts'; export * from './errors.ts'; export * from './subagent-drivers.ts'; -export { CLIENT_RESULT_PURPOSE, CLIENT_RESULT_SIGNED_FIELDS, defaultWebCryptoSha256, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts'; +export { CLIENT_RESULT_PURPOSE, CLIENT_RESULT_SIGNED_FIELDS, ClientResultCanonicalizationError, defaultWebCryptoSha256, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts'; export type { ClientControlSha256 } from './ClientControl.generated.ts'; diff --git a/clients/typescript/fractal-client/schema.test.cjs b/clients/typescript/fractal-client/schema.test.cjs index 60ece03a926..fde040b6049 100644 --- a/clients/typescript/fractal-client/schema.test.cjs +++ b/clients/typescript/fractal-client/schema.test.cjs @@ -1354,5 +1354,16 @@ test('commands Resource and CollectionFrame decode shared lifecycle vectors', as const rows = decoded.items ?? decoded.upserts; assert.equal(rows[0].kind, 'client-control-grant-lifecycle'); } + for (const trace of fixture.traces) { + for (const step of trace.steps) { + if (step.expected === 'refuse-invalid-frame') { + assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')(step.resource)); + assert.throws(() => Rich.decodeUnknownSync(Rich.CollectionFrame, 'strict')({ ...fixture.frames[0], items: [step.resource] })); + } + } + } + for (const sequence of [0, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')({ ...fixture.resources[0], sequence })); + } assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')({ ...fixture.resources[0], state: 'future-state' })); }); diff --git a/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in index 177aeb33c04..531ac1ad079 100644 --- a/crates/fractal-client-codegen/templates/ClientControl.ts.in +++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in @@ -14,14 +14,21 @@ export interface ClientControlGrant { verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string; item_id?: string; } +export class ClientResultCanonicalizationError extends Error { + readonly code = 'invalid-unicode' as const; + constructor() { + super('Invalid Unicode in client result'); + this.name = 'ClientResultCanonicalizationError'; + } +} function quote(value: string): string { // RFC 8785 requires rejection of lone surrogates rather than replacement. for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); if (c >= 0xd800 && c <= 0xdbff) { const next = value.charCodeAt(++i); - if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('Invalid Unicode in client result'); - } else if (c >= 0xdc00 && c <= 0xdfff) throw new Error('Invalid Unicode in client result'); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new ClientResultCanonicalizationError(); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError(); } return JSON.stringify(value); } diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in index 0817fabf4c8..c4366d073bb 100644 --- a/crates/fractal-client-codegen/templates/Models.swift.in +++ b/crates/fractal-client-codegen/templates/Models.swift.in @@ -1020,6 +1020,24 @@ public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiab enum CodingKeys: String, CodingKey { case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + kind = try values.decode(String.self, forKey: .kind) + revision = try values.decode(String.self, forKey: .revision) + updatedAt = try values.decode(String.self, forKey: .updatedAt) + grantID = try values.decode(String.self, forKey: .grantID) + runID = try values.decode(String.self, forKey: .runID) + device = try values.decode(String.self, forKey: .device) + session = try values.decode(String.self, forKey: .session) + itemID = try values.decodeIfPresent(String.self, forKey: .itemID) + state = try values.decode(ClientControlGrantLifecycleState.self, forKey: .state) + reason = try values.decode(String.self, forKey: .reason) + sequence = try values.decode(UInt64.self, forKey: .sequence) + guard sequence >= 1 && sequence <= 9_007_199_254_740_991 else { + throw DecodingError.dataCorruptedError(forKey: .sequence, in: values, debugDescription: "Lifecycle sequence must be a positive JSON-safe integer") + } + } } /// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding. public struct ClientCommandsCollectionFrame: Codable, Sendable { diff --git a/crates/fractal-client-codegen/templates/client_control.rs.in b/crates/fractal-client-codegen/templates/client_control.rs.in index 7bf0a78c84a..c4fde6a50bb 100644 --- a/crates/fractal-client-codegen/templates/client_control.rs.in +++ b/crates/fractal-client-codegen/templates/client_control.rs.in @@ -22,8 +22,16 @@ pub struct ClientControlGrantLifecycleEvent { pub session: String, pub state: ClientControlGrantLifecycleState, pub reason: String, + #[serde(deserialize_with = "deserialize_lifecycle_sequence")] pub sequence: u64, } +fn deserialize_lifecycle_sequence<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let sequence = u64::deserialize(deserializer)?; + if !(1..=(1_u64 << 53) - 1).contains(&sequence) { + return Err(serde::de::Error::custom("lifecycle sequence must be a positive JSON-safe integer")); + } + Ok(sequence) +} #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum ClientControlRefusal { diff --git a/crates/fractal-client/tests/client_control_lifecycle.rs b/crates/fractal-client/tests/client_control_lifecycle.rs index ebe9c48a06d..f9a27b6bdc2 100644 --- a/crates/fractal-client/tests/client_control_lifecycle.rs +++ b/crates/fractal-client/tests/client_control_lifecycle.rs @@ -21,6 +21,21 @@ fn commands_collection_decodes_typed_grant_lifecycle_vectors() { assert_eq!(collection, "commands"); assert!(matches!(&rows[0], Resource::ClientControlGrantLifecycle(_))); } + for trace in vectors["traces"].as_array().unwrap() { + for step in trace["steps"].as_array().unwrap() { + if step["expected"] == "refuse-invalid-frame" { + assert!(serde_json::from_value::(step["resource"].clone()).is_err()); + let mut frame = vectors["frames"][0].clone(); + frame["items"] = serde_json::json!([step["resource"]]); + assert!(serde_json::from_value::(frame).is_err()); + } + } + } + for sequence in [0, 1_u64 << 53] { + let mut invalid = vectors["resources"][0].clone(); + invalid["sequence"] = sequence.into(); + assert!(serde_json::from_value::(invalid).is_err()); + } let mut invalid = vectors["resources"][0].clone(); invalid["state"] = "future-state".into(); assert!(serde_json::from_value::(invalid).is_err()); diff --git a/docs/st3/client-v0/device-commands.md b/docs/st3/client-v0/device-commands.md index 7a255f9f0a6..f9467716a0c 100644 --- a/docs/st3/client-v0/device-commands.md +++ b/docs/st3/client-v0/device-commands.md @@ -231,7 +231,9 @@ The existing session-scoped `commands` subscription carries generated same snapshot/changes frames and typed `Resource` decoder. Each lifecycle row has the ordinary resource header plus `grant_id`, optional `item_id`, `run_id`, `device`, `session`, `state` (`started`, `ended`, `revoked`), `reason` and a -monotonic, positive JSON-safe integer `sequence`. +monotonic integer `sequence` in `1...9007199254740991`. All client decoders +reject zero or out-of-range sequences before lifecycle ordering checks; the +shared zero-sequence trace expects `refuse-invalid-frame`, not a stale start. These rows drive the visible indicator and termination only. They do not carry verbs, screens or budget policy; policy remains in each @@ -254,3 +256,6 @@ Pass it as the second argument to `visibleStateDigest` or `clientResultCanonicalBytes`; omitting it uses generated `defaultWebCryptoSha256`. Canonical encoding remains generated and unchanged; the injected implementation returns the raw 32 SHA-256 bytes, not hex or text. +Invalid Unicode is refused with the exported TypeScript +`ClientResultCanonicalizationError` and its `code: "invalid-unicode"`, including +through `clientResultCanonicalBytes`; consumers must not match error messages. diff --git a/fixtures/clients/client-control-lifecycle-v1.json b/fixtures/clients/client-control-lifecycle-v1.json index d8cced0481e..ef6d355fcad 100644 --- a/fixtures/clients/client-control-lifecycle-v1.json +++ b/fixtures/clients/client-control-lifecycle-v1.json @@ -209,7 +209,7 @@ "reason": "granted", "sequence": 0 }, - "expected": "ignore-stale-started" + "expected": "refuse-invalid-frame" }, { "resource": { diff --git a/release-notes/client-control-lifecycle-schema.json b/release-notes/client-control-lifecycle-schema.json index 139caafc6c1..5edbde024df 100644 --- a/release-notes/client-control-lifecycle-schema.json +++ b/release-notes/client-control-lifecycle-schema.json @@ -3,8 +3,8 @@ "summary": "Generate typed session-scoped client-control grant lifecycle resources and injectable TypeScript SHA-256", "replay": {"status":"none","detail":"Wire schema and client decoder sources only; this fragment does not change claim replay."}, "schema": {"status":"changed","detail":"Adds client-control-grant-lifecycle Resource with grant/run/device/session/item binding, started/ended/revoked state, reason and monotonic sequence on existing commands snapshot/changes frames."}, - "rules": {"status":"changed","detail":"Lifecycle rows drive indicators and termination, not command policy. Bound item IDs must match; stale started rows are ignored; terminal grants cannot reactivate; commands without active lifecycle state fail closed."}, - "compatibility": {"status":"changed","detail":"Rust, Swift and TypeScript decode the lifecycle resource through generated Resource arms. Older clients preserve the unknown resource. TypeScript canonical helpers accept an optional injected raw-byte SHA-256 implementation for Hermes; their canonical bytes remain unchanged."}, + "rules": {"status":"changed","detail":"Lifecycle rows drive indicators and termination, not command policy. Decoders reject sequence zero and values above 2^53-1 before ordering checks. Bound item IDs must match; stale valid started rows are ignored; terminal grants cannot reactivate; commands without active lifecycle state fail closed."}, + "compatibility": {"status":"changed","detail":"Rust, Swift and TypeScript decode the lifecycle resource through generated Resource arms. Older clients preserve the unknown resource. TypeScript canonical helpers accept injected raw-byte SHA-256 for Hermes and expose ClientResultCanonicalizationError with invalid-unicode code; canonical bytes remain unchanged."}, "downtime": {"status":"none","detail":"No live setup, enrollment or daemon operation is performed by these source changes."}, "manual": {"status":"none","detail":"Integrate source layers before one cargo run -p fractal-client-codegen. Shared lifecycle resources, frames and fail-closed traces are checked by client tests."}, "recovery": {"status":"none","detail":"No stored data migration is introduced by generated lifecycle decoder source."} From c6c9f69d193d502845fff7285840243058725a49 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:13:40 +0100 Subject: [PATCH 26/32] Align lifecycle negative vector with strict generated decoding agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 8 ++++---- .../src/client-control/lifecycle.unit.test.ts | 11 ++--------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 60140516fd0..0c70882d74d 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -19,7 +19,7 @@ Source preparation consuming the merged canonical generated contract. **Applicat All untrusted wire inputs use the generated `decodeUnknownSync(..., 'strict')` entry point: excess/unknown keys and forbidden null parameters are refused, never stripped into an executable command. Signature verification accepts only canonical unpadded 64-byte P1363 base64url; malformed inputs resolve false. Expected signature decode/data/operation failures and the generated malformed-Unicode refusal return false; wrong-key/algorithm and unrelated programmer errors propagate. Regression assertions remain NOT RUN. -The generated canonicalizer currently exports no refusal class, error code or predicate: its private quote helper throws a plain `Error('Invalid Unicode in client result')`. Verification therefore matches exactly that plain Error and message only around the generated canonicalizer call. No second Unicode validator or canonicalizer is added. Regression cases put lone high and low surrogates in route, other binding strings, visible IDs and opened-item fields; the published native Attention vector's valid astral pair must still verify. +The generated canonicalizer currently exports no refusal class, error code or predicate: its private quote helper throws a plain `Error('Invalid Unicode in client result')`. Verification therefore matches exactly that plain Error and message only around the generated canonicalizer call. Canonical source head `f92a26354a` adds `ClientResultCanonicalizationError` to `ClientControl.ts.in` and reexports it from the client index, but has not regenerated `ClientControl.generated.ts`; consuming that type remains blocked on the owner's generated output. No second Unicode validator or canonicalizer is added. Regression cases put lone high and low surrogates in route, other binding strings, visible IDs and opened-item fields; the published native Attention vector's valid astral pair must still verify. Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Protocol shapes come from generated schemas; route and independent DOM observer inputs remain local app concepts. @@ -42,14 +42,14 @@ Dependency decisions below refer to the read-only integration snapshot `ff956ae7 | `0d21c1286b` read-position clock-skew/remount fix | DROP from current journey blockers | `web/readPosition.ts:45,125` exposes stream seen/mark-read hooks; native ask detail does not invoke them (`web/AlertDetailsPane.tsx:37-60`). | | `45710bb1d5` last_prose_at unread fix | DROP from current journey blockers | The relevant write boundary remains `state/cells/readPositions.ts:36-39`; neither native ask surface requires it (`workspace/registry.ts:89-90`). The suppression integration must land with any introduced writer, before test-mode rendering can reach it. | | [#6108 read-position sync](https://github.com/compoundingtech/fractal/pull/6108) | DROP from current journey blockers | Inspected head `01fd722d1cdfc4beb916817ab9dc68f0307b437b` adds lastProseAt projection/types, not a web sync caller. A future writer must share the gated store boundary above. | -| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | MERGED prerequisite | Signed no-ff merges of generated result head `36592f7090093ae6fe94a87706e319fca78fca17` and lifecycle/hash head `7cafdf138e` (source `d8872e300e`). Imported generated schemas/helpers and shared result/lifecycle files; no generated file hand-edited or copied. | +| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | MERGED prerequisite; typed-error generation pending | Signed no-ff merges include generated result head `36592f7090093ae6fe94a87706e319fca78fca17`, lifecycle/hash head `7cafdf138e` (source `d8872e300e`) and corrected lifecycle-negative/typed-error source head `f92a26354a`. The latter updates the template/index but not the generated canonicalizer; its new error export requires owner regeneration. No generated file hand-edited or copied. | | Later enrollment readback/revoke PR | KEEP | Generated enrollment/revocation schemas and `FractalClient.clientTestRevoke` exist, but authoritative browser enrollment readback/controller and live revoke confirmation remain integration prerequisites. | | Authenticated grant-subscription integration | KEEP | Generated lifecycle resources/frame decoder and state gate now exist. Wiring the real session-scoped subscription to verified enrollment/key/window/budget authority and the visible indicator remains unconnected; decoding alone never enables a client. | | Later verification/receipt PR | KEEP for journey acceptance | Actual canonical result verification and durable terminal receipts cannot be established by DOM observations. Handle/module pending. | No file:line can be cited for unpublished later PR APIs; their absence remains an explicit integration blocker, not an assumed implementation. -#6278 contains signed no-ff merges of #6274's result and lifecycle generated contracts. The lifecycle delta is one merge commit followed by one web consumption commit; generated artifacts remain owned by the daemon generator. +#6278 contains signed no-ff merges of #6274's result and lifecycle contracts, followed by web consumption commits; generated artifacts remain owned by the daemon generator. ## Generated-contract consumption and vector coverage @@ -59,7 +59,7 @@ The shared JSON contains no grant/command/envelope/receipt or fingerprint golden `lifecycle.unit.test.ts` reads every resource, frame and acceptance-trace step in `fixtures/clients/client-control-lifecycle-v1.json`: started/ended/revoked, stale/reordered starts, missing/wrong bound item, command-before-start and command-after-terminal. Additional source assertions cover strict malformed/unknown frame rejection, binding verification and permanent termination. These are source assertions, NOT local behavioural/typecheck receipts. -One published fixture/schema mismatch is deliberately NOT normalized: `client-control-lifecycle-v1.json:198-212` expects `ignore-stale-started` for sequence 0, while generated `ReadSchema.generated.ts:1697` requires sequence >= 1. The trace test identifies this exception by resource identity plus its malformed sequence/expectation, never array position; asserts strict refusal and exactly one mismatch, so a fixture/schema correction fails loudly instead of silently passing. +Canonical source head `f92a26354a` corrects the sequence-0 trace to the negative expectation `refuse-invalid-frame`, consistent with the generated schema's sequence >= 1 constraint. The test now handles it as an ordinary schema-invalid negative vector: generated decoding must throw and the lifecycle gate must refuse it. There is no resource-id/sequence special case or fixture/schema mismatch allowance. Observer hashing now uses generated `ClientControlSha256`/`defaultWebCryptoSha256`, with optional injection over the exact UTF-8 rendered question bytes (not a visible-state wrapper). Result conformance asserts default and injected hashing preserve generated JCS/digest/signing bytes. The identity reader has no fingerprint hash path: it still returns the SEC1 point only, and ECDSA/SHA-256 signing remains native. No extra identity hash or second hash implementation is introduced. diff --git a/apps/fractal-web/src/client-control/lifecycle.unit.test.ts b/apps/fractal-web/src/client-control/lifecycle.unit.test.ts index c870a7bd27a..6846ddcc5cd 100644 --- a/apps/fractal-web/src/client-control/lifecycle.unit.test.ts +++ b/apps/fractal-web/src/client-control/lifecycle.unit.test.ts @@ -45,8 +45,7 @@ describe('client-control lifecycle gate', () => { expect(framesGate.enrollmentRevoked()).toBe(true) }) - it('replays every shared trace, exposing exactly the known zero-sequence schema contradiction', () => { - let contradictions = 0 + it('replays every shared trace, including schema-invalid negative resources', () => { for (const trace of vectors.traces) { const gate = makeGate() expect(trace.grant_id).toBe(grant.grant_id) @@ -54,13 +53,9 @@ describe('client-control lifecycle gate', () => { for (const step of trace.steps) { if ('resource' in step && step.resource !== undefined) { const resource = step.resource - if (resource.grant_id === trace.grant_id && resource.id === started.id && resource.sequence === 0 && - step.expected === 'ignore-stale-started') { - // Positive sequence is canonical. Do not normalize this contradictory vector. - contradictions += 1 + if (step.expected === 'refuse-invalid-frame') { expect(() => decodeLifecycle(resource)).toThrow() expect(gate.acceptResource(resource)).toBe('refused-invalid') - expect(gate.activeRunId(trace.grant_id)).toBe(grant.run_id) } else { expect(gate.acceptResource(resource)).toBe(step.expected) } @@ -72,8 +67,6 @@ describe('client-control lifecycle gate', () => { } } } - // Fail loudly if either the fixture or generated schema fixes the contradiction. - expect(contradictions).toBe(1) }) it('requires separately verified authority and an embedded command grant', () => { From 6fb398ccf5f0b7c08ea0cd1892aa26fdb7a2c006 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:41:55 +0100 Subject: [PATCH 27/32] chore(codegen): generate strict client-control decoder refusals Generated with cargo run -p fractal-client-codegen from exact source f92a26354aec5948899d482d06f6ea644e78a04f; source HEAD unchanged before and after the admitted payload. Exit 0, elapsed 4.47s, peak RSS 1146196 KiB. Only the three generated outputs were modified at commit preparation. agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../Sources/FractalClient/Models.swift | 18 ++++++++++++++++++ .../fractal-client/ClientControl.generated.ts | 11 +++++++++-- crates/fractal-schema/src/client_control.rs | 8 ++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift index de5002670fe..66785dff35f 100644 --- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift +++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift @@ -1020,6 +1020,24 @@ public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiab enum CodingKeys: String, CodingKey { case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence } + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + id = try values.decode(String.self, forKey: .id) + kind = try values.decode(String.self, forKey: .kind) + revision = try values.decode(String.self, forKey: .revision) + updatedAt = try values.decode(String.self, forKey: .updatedAt) + grantID = try values.decode(String.self, forKey: .grantID) + runID = try values.decode(String.self, forKey: .runID) + device = try values.decode(String.self, forKey: .device) + session = try values.decode(String.self, forKey: .session) + itemID = try values.decodeIfPresent(String.self, forKey: .itemID) + state = try values.decode(ClientControlGrantLifecycleState.self, forKey: .state) + reason = try values.decode(String.self, forKey: .reason) + sequence = try values.decode(UInt64.self, forKey: .sequence) + guard sequence >= 1 && sequence <= 9_007_199_254_740_991 else { + throw DecodingError.dataCorruptedError(forKey: .sequence, in: values, debugDescription: "Lifecycle sequence must be a positive JSON-safe integer") + } + } } /// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding. public struct ClientCommandsCollectionFrame: Codable, Sendable { diff --git a/clients/typescript/fractal-client/ClientControl.generated.ts b/clients/typescript/fractal-client/ClientControl.generated.ts index 177aeb33c04..531ac1ad079 100644 --- a/clients/typescript/fractal-client/ClientControl.generated.ts +++ b/clients/typescript/fractal-client/ClientControl.generated.ts @@ -14,14 +14,21 @@ export interface ClientControlGrant { verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string; item_id?: string; } +export class ClientResultCanonicalizationError extends Error { + readonly code = 'invalid-unicode' as const; + constructor() { + super('Invalid Unicode in client result'); + this.name = 'ClientResultCanonicalizationError'; + } +} function quote(value: string): string { // RFC 8785 requires rejection of lone surrogates rather than replacement. for (let i = 0; i < value.length; i++) { const c = value.charCodeAt(i); if (c >= 0xd800 && c <= 0xdbff) { const next = value.charCodeAt(++i); - if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error('Invalid Unicode in client result'); - } else if (c >= 0xdc00 && c <= 0xdfff) throw new Error('Invalid Unicode in client result'); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new ClientResultCanonicalizationError(); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError(); } return JSON.stringify(value); } diff --git a/crates/fractal-schema/src/client_control.rs b/crates/fractal-schema/src/client_control.rs index 7bf0a78c84a..c4fde6a50bb 100644 --- a/crates/fractal-schema/src/client_control.rs +++ b/crates/fractal-schema/src/client_control.rs @@ -22,8 +22,16 @@ pub struct ClientControlGrantLifecycleEvent { pub session: String, pub state: ClientControlGrantLifecycleState, pub reason: String, + #[serde(deserialize_with = "deserialize_lifecycle_sequence")] pub sequence: u64, } +fn deserialize_lifecycle_sequence<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let sequence = u64::deserialize(deserializer)?; + if !(1..=(1_u64 << 53) - 1).contains(&sequence) { + return Err(serde::de::Error::custom("lifecycle sequence must be a positive JSON-safe integer")); + } + Ok(sequence) +} #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum ClientControlRefusal { From a4132d6af5fb929dd1eb5d942a17af6e86b8c158 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 22:45:07 +0100 Subject: [PATCH 28/32] Consume generated typed canonicalization refusals agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 4 ++-- apps/fractal-web/src/client-control/wire.ts | 5 ++--- apps/fractal-web/src/client-control/wire.unit.test.ts | 4 ++++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 0c70882d74d..50b0dd45993 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -19,7 +19,7 @@ Source preparation consuming the merged canonical generated contract. **Applicat All untrusted wire inputs use the generated `decodeUnknownSync(..., 'strict')` entry point: excess/unknown keys and forbidden null parameters are refused, never stripped into an executable command. Signature verification accepts only canonical unpadded 64-byte P1363 base64url; malformed inputs resolve false. Expected signature decode/data/operation failures and the generated malformed-Unicode refusal return false; wrong-key/algorithm and unrelated programmer errors propagate. Regression assertions remain NOT RUN. -The generated canonicalizer currently exports no refusal class, error code or predicate: its private quote helper throws a plain `Error('Invalid Unicode in client result')`. Verification therefore matches exactly that plain Error and message only around the generated canonicalizer call. Canonical source head `f92a26354a` adds `ClientResultCanonicalizationError` to `ClientControl.ts.in` and reexports it from the client index, but has not regenerated `ClientControl.generated.ts`; consuming that type remains blocked on the owner's generated output. No second Unicode validator or canonicalizer is added. Regression cases put lone high and low surrogates in route, other binding strings, visible IDs and opened-item fields; the published native Attention vector's valid astral pair must still verify. +Verification consumes the generated `ClientResultCanonicalizationError` from canonical generated head `6fb398ccf5`, checking both its class and `code === 'invalid-unicode'`. Only that typed canonicalizer refusal becomes false; unrelated errors propagate. There is no message-string match or second Unicode validator/canonicalizer. Regression cases put lone high and low surrogates in route, other binding strings, visible IDs and opened-item fields, asserting the generated typed rejection before verification returns false; the published native Attention vector's valid astral pair must still verify. Colocated tests cover crypto/storage, foreground, suppression, indicator states, grant refusals, fixture refusals and independent DOM comparisons. `conversation/alerts/AnswerAlert.integration.test.tsx` exercises the SHIPPED AnswerAlert render path and asserts exact single-element title/question rendering without pressing any answer. Protocol shapes come from generated schemas; route and independent DOM observer inputs remain local app concepts. @@ -42,7 +42,7 @@ Dependency decisions below refer to the read-only integration snapshot `ff956ae7 | `0d21c1286b` read-position clock-skew/remount fix | DROP from current journey blockers | `web/readPosition.ts:45,125` exposes stream seen/mark-read hooks; native ask detail does not invoke them (`web/AlertDetailsPane.tsx:37-60`). | | `45710bb1d5` last_prose_at unread fix | DROP from current journey blockers | The relevant write boundary remains `state/cells/readPositions.ts:36-39`; neither native ask surface requires it (`workspace/registry.ts:89-90`). The suppression integration must land with any introduced writer, before test-mode rendering can reach it. | | [#6108 read-position sync](https://github.com/compoundingtech/fractal/pull/6108) | DROP from current journey blockers | Inspected head `01fd722d1cdfc4beb916817ab9dc68f0307b437b` adds lastProseAt projection/types, not a web sync caller. A future writer must share the gated store boundary above. | -| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | MERGED prerequisite; typed-error generation pending | Signed no-ff merges include generated result head `36592f7090093ae6fe94a87706e319fca78fca17`, lifecycle/hash head `7cafdf138e` (source `d8872e300e`) and corrected lifecycle-negative/typed-error source head `f92a26354a`. The latter updates the template/index but not the generated canonicalizer; its new error export requires owner regeneration. No generated file hand-edited or copied. | +| [#6274 generated contract and shared vectors](https://github.com/compoundingtech/fractal/pull/6274) | MERGED prerequisite | Signed no-ff merges include generated result head `36592f7090093ae6fe94a87706e319fca78fca17`, lifecycle/hash head `7cafdf138e` (source `d8872e300e`), corrected lifecycle-negative source head `f92a26354a`, and regenerated typed-error head `6fb398ccf5`. The generated canonicalizer exports the error class reexported by the client index; web verification consumes it. No generated file hand-edited or copied. | | Later enrollment readback/revoke PR | KEEP | Generated enrollment/revocation schemas and `FractalClient.clientTestRevoke` exist, but authoritative browser enrollment readback/controller and live revoke confirmation remain integration prerequisites. | | Authenticated grant-subscription integration | KEEP | Generated lifecycle resources/frame decoder and state gate now exist. Wiring the real session-scoped subscription to verified enrollment/key/window/budget authority and the visible indicator remains unconnected; decoding alone never enables a client. | | Later verification/receipt PR | KEEP for journey acceptance | Actual canonical result verification and durable terminal receipts cannot be established by DOM observations. Handle/module pending. | diff --git a/apps/fractal-web/src/client-control/wire.ts b/apps/fractal-web/src/client-control/wire.ts index cf40a8547fb..b023999fab2 100644 --- a/apps/fractal-web/src/client-control/wire.ts +++ b/apps/fractal-web/src/client-control/wire.ts @@ -2,6 +2,7 @@ import { Schema } from 'effect' import * as Wire from '@fractal/client/schema' import { CLIENT_RESULT_SIGNED_FIELDS, + ClientResultCanonicalizationError, clientResultCanonicalBytes, type ClientResultBinding as CanonicalBinding, } from '../../../../clients/typescript/fractal-client/ClientControl.generated.ts' @@ -62,9 +63,7 @@ export const verifyClientResultBinding = async (binding: unknown, signature: str try { bytes = await clientResultCanonicalBytes(encoded as CanonicalBinding) as Uint8Array } catch (error) { - // The generated module exports no refusal class/predicate: its quote helper throws this - // exact plain Error. Translate only that refusal here, never duplicate Unicode validation. - if (error instanceof Error && error.constructor === Error && error.message === 'Invalid Unicode in client result') return false + if (error instanceof ClientResultCanonicalizationError && error.code === 'invalid-unicode') return false throw error } try { diff --git a/apps/fractal-web/src/client-control/wire.unit.test.ts b/apps/fractal-web/src/client-control/wire.unit.test.ts index 1ad7b81cdd7..80a71825c72 100644 --- a/apps/fractal-web/src/client-control/wire.unit.test.ts +++ b/apps/fractal-web/src/client-control/wire.unit.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import golden from '../../../../fixtures/clients/client-control-v1.golden.json' with { type: 'json' } import { CLIENT_RESULT_SIGNED_FIELDS, + ClientResultCanonicalizationError, clientResultCanonicalBytes, defaultWebCryptoSha256, type ClientControlSha256, @@ -101,6 +102,9 @@ describe('web consumption of the repository client-control golden file', () => { for (const changed of bindings) { // These pass the generated String schemas: refusal must come from the canonicalizer. expect(() => decodeBinding(changed)).not.toThrow() + const canonicalization = clientResultCanonicalBytes(encodeBinding(decodeBinding(changed))) + await expect(canonicalization).rejects.toBeInstanceOf(ClientResultCanonicalizationError) + await expect(canonicalization).rejects.toMatchObject({ code: 'invalid-unicode' }) await expect(verifyClientResultBinding(changed, vector.signature_p1363_base64url, key)).resolves.toBe(false) } }) From b09ae079e1237a17ec123614ef0b6bfd0b8c1765 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:01:31 +0100 Subject: [PATCH 29/32] fix(scenarios): exclude control lifecycle from visible state folding agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- .../fractal-scenarios/src/kit/fold.ts | 5 +++- .../test/inventories.test.ts | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/clients/typescript/fractal-scenarios/src/kit/fold.ts b/clients/typescript/fractal-scenarios/src/kit/fold.ts index 0c1c7a36200..836ca82a980 100644 --- a/clients/typescript/fractal-scenarios/src/kit/fold.ts +++ b/clients/typescript/fractal-scenarios/src/kit/fold.ts @@ -11,7 +11,7 @@ const upsert = (list: readonly A[], value: A): A[] => { } /** Complete client-v0 known-kind set; unsupported collections must not become future resources. */ -const COLLECTION_OF_KIND: Record = { +const COLLECTION_OF_KIND: Record = { agent: 'agents', runtime: 'runtimes', machine: 'machines', @@ -28,6 +28,7 @@ const COLLECTION_OF_KIND: Record = { lane: 'lanes', device: 'devices', 'device-command': 'deviceCommands', + 'client-control-grant-lifecycle': null, // Control metadata is not visible scenario state. operation: 'operations', history: 'history', session: 'sessions', @@ -41,9 +42,11 @@ const COLLECTION_OF_KIND: Record = { } const applyChanges = (state: S, upserts: readonly WireResource[], removes: readonly string[], order: readonly string[] | undefined): S => { + if (upserts.length > 0 && removes.length === 0 && upserts.every((value) => value.kind === 'client-control-grant-lifecycle')) return state const next = { ...state } as Record for (const value of upserts) { const key = Object.hasOwn(COLLECTION_OF_KIND, value.kind) ? COLLECTION_OF_KIND[value.kind as Resource['kind']] : undefined + if (key === null) continue if (key === undefined) next.resources = upsert((next.resources ?? []) as Keyed[], value) else { if (!Array.isArray(next[key])) throw new Error(`a ${value.kind} upsert has no place in this slice`) diff --git a/clients/typescript/fractal-scenarios/test/inventories.test.ts b/clients/typescript/fractal-scenarios/test/inventories.test.ts index 3064f440587..4ac12381284 100644 --- a/clients/typescript/fractal-scenarios/test/inventories.test.ts +++ b/clients/typescript/fractal-scenarios/test/inventories.test.ts @@ -84,4 +84,27 @@ describe('canonical inventories', () => { expect(nextRoster.state.devices[0]).toEqual(device) expect(nextDetails.state.launches[0]).toEqual(launch) }) + + it('does not fold control lifecycle metadata into visible scenario state', () => { + const { roster } = loadWorld('fleet-mid-refactor', { now: ANCHOR_MS }).slices + for (const state of ['started', 'ended', 'revoked'] as const) { + const lifecycle = { + id: 'client-control-grant/scenario-fixture/lifecycle', kind: 'client-control-grant-lifecycle' as const, + revision: '1', updated_at: new Date(ANCHOR_MS).toISOString(), + grant_id: 'client-control-grant/scenario-fixture', run_id: 'mission-run/scenario-fixture', + device: 'device/scenario-fixture', session: 'client-session/scenario-fixture', + state, reason: state === 'started' ? 'granted' : state === 'ended' ? 'run-ended' : 'revoked', sequence: 1, + } + const next = foldSlice({ ...roster, timeline: [{ + _tag: 'changes', at_ms: 1, store: 1, upserts: [lifecycle], removes: [], order: [lifecycle.id], + }] }, 1) + expect(next.state).toEqual(roster.state) + const device = { ...roster.state.devices[0]!, revision: 'device-2' } + const mixed = foldSlice({ ...roster, timeline: [{ + _tag: 'changes', at_ms: 1, store: 1, upserts: [lifecycle, device], removes: [], + }] }, 1) + expect(mixed.state.devices[0]).toEqual(device) + expect(mixed.state.resources).toEqual(roster.state.resources) + } + }) }) From 5613f10737357b245ee2fda7187ae90d346c7758 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:01:31 +0100 Subject: [PATCH 30/32] fix(codegen): provide owned WebCrypto input without narrowing injected hashers agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- crates/fractal-client-codegen/templates/ClientControl.ts.in | 2 +- docs/st3/client-v0/device-commands.md | 4 ++++ release-notes/client-control-lifecycle-schema.json | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in index 531ac1ad079..9024c9c6c24 100644 --- a/crates/fractal-client-codegen/templates/ClientControl.ts.in +++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in @@ -41,7 +41,7 @@ export function visibleStateCanonicalJson(state: ClientVisibleState): string { } export type ClientControlSha256 = (bytes: Uint8Array) => Promise; export const defaultWebCryptoSha256: ClientControlSha256 = async bytes => - new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); + new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes).buffer)); export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state))); return Array.from(digest, b => b.toString(16).padStart(2, '0')).join(''); diff --git a/docs/st3/client-v0/device-commands.md b/docs/st3/client-v0/device-commands.md index f9467716a0c..6fef362096b 100644 --- a/docs/st3/client-v0/device-commands.md +++ b/docs/st3/client-v0/device-commands.md @@ -249,6 +249,8 @@ acceptance-trace data for all clients, covering each state, stale/reordered started rows, missing/wrong item binding, command-before-start and command-after-terminal. Context-dependent lifecycle checks belong to the client handler; decoding a resource never grants authority. +Scenario folding ignores lifecycle-only control metadata rather than changing +the visible resource state. TypeScript canonical hashing supports Hermes without WebCrypto through an injected `ClientControlSha256 = (bytes: Uint8Array) => Promise`. @@ -256,6 +258,8 @@ Pass it as the second argument to `visibleStateDigest` or `clientResultCanonicalBytes`; omitting it uses generated `defaultWebCryptoSha256`. Canonical encoding remains generated and unchanged; the injected implementation returns the raw 32 SHA-256 bytes, not hex or text. +The default WebCrypto adapter copies input into an owned `ArrayBuffer`; injected +hashers retain the general `Uint8Array` input contract. Invalid Unicode is refused with the exported TypeScript `ClientResultCanonicalizationError` and its `code: "invalid-unicode"`, including through `clientResultCanonicalBytes`; consumers must not match error messages. diff --git a/release-notes/client-control-lifecycle-schema.json b/release-notes/client-control-lifecycle-schema.json index 5edbde024df..b76c9767a4e 100644 --- a/release-notes/client-control-lifecycle-schema.json +++ b/release-notes/client-control-lifecycle-schema.json @@ -4,7 +4,7 @@ "replay": {"status":"none","detail":"Wire schema and client decoder sources only; this fragment does not change claim replay."}, "schema": {"status":"changed","detail":"Adds client-control-grant-lifecycle Resource with grant/run/device/session/item binding, started/ended/revoked state, reason and monotonic sequence on existing commands snapshot/changes frames."}, "rules": {"status":"changed","detail":"Lifecycle rows drive indicators and termination, not command policy. Decoders reject sequence zero and values above 2^53-1 before ordering checks. Bound item IDs must match; stale valid started rows are ignored; terminal grants cannot reactivate; commands without active lifecycle state fail closed."}, - "compatibility": {"status":"changed","detail":"Rust, Swift and TypeScript decode the lifecycle resource through generated Resource arms. Older clients preserve the unknown resource. TypeScript canonical helpers accept injected raw-byte SHA-256 for Hermes and expose ClientResultCanonicalizationError with invalid-unicode code; canonical bytes remain unchanged."}, + "compatibility": {"status":"changed","detail":"Rust, Swift and TypeScript decode generated lifecycle resources; older clients preserve unknown resources. Scenario folding ignores lifecycle metadata. TypeScript canonical helpers accept generic injected SHA-256 bytes for Hermes; the default WebCrypto adapter owns its input buffer and exposes ClientResultCanonicalizationError with invalid-unicode code. Canonical bytes remain unchanged."}, "downtime": {"status":"none","detail":"No live setup, enrollment or daemon operation is performed by these source changes."}, "manual": {"status":"none","detail":"Integrate source layers before one cargo run -p fractal-client-codegen. Shared lifecycle resources, frames and fail-closed traces are checked by client tests."}, "recovery": {"status":"none","detail":"No stored data migration is introduced by generated lifecycle decoder source."} From 972b65b7c4cf8e3612ffdd4d756e11d0b7b011f2 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Tue, 10 Nov 2026 23:21:17 +0100 Subject: [PATCH 31/32] Regenerate client control from 5613f10737357b245ee2fda7187ae90d346c7758 agent-identity: agent/dotfiles/fractal-product-lead agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- clients/typescript/fractal-client/ClientControl.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/typescript/fractal-client/ClientControl.generated.ts b/clients/typescript/fractal-client/ClientControl.generated.ts index 531ac1ad079..9024c9c6c24 100644 --- a/clients/typescript/fractal-client/ClientControl.generated.ts +++ b/clients/typescript/fractal-client/ClientControl.generated.ts @@ -41,7 +41,7 @@ export function visibleStateCanonicalJson(state: ClientVisibleState): string { } export type ClientControlSha256 = (bytes: Uint8Array) => Promise; export const defaultWebCryptoSha256: ClientControlSha256 = async bytes => - new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)); + new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes).buffer)); export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise { const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state))); return Array.from(digest, b => b.toString(16).padStart(2, '0')).join(''); From c0515d4cda6683a1d73daf73be2c52d30a3f4242 Mon Sep 17 00:00:00 2001 From: schickling-assistant <261620128+schickling-assistant@users.noreply.github.com> Date: Wed, 11 Nov 2026 03:21:49 +0100 Subject: [PATCH 32/32] Fix client-control test types and scoped grant authority agent-identity: agent/dotfiles/fractal-web-3 agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.12.5 agent-runtime: OMP 18.12.5 agent-model: openai-codex/gpt-6.1-sol --- apps/fractal-web/src/client-control/README.md | 2 ++ apps/fractal-web/src/client-control/lifecycle.unit.test.ts | 5 +++-- apps/fractal-web/src/client-control/wire.unit.test.ts | 6 +++++- .../conversation/alerts/AnswerAlert.integration.test.tsx | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/fractal-web/src/client-control/README.md b/apps/fractal-web/src/client-control/README.md index 50b0dd45993..b0728f10eb2 100644 --- a/apps/fractal-web/src/client-control/README.md +++ b/apps/fractal-web/src/client-control/README.md @@ -59,6 +59,8 @@ The shared JSON contains no grant/command/envelope/receipt or fingerprint golden `lifecycle.unit.test.ts` reads every resource, frame and acceptance-trace step in `fixtures/clients/client-control-lifecycle-v1.json`: started/ended/revoked, stale/reordered starts, missing/wrong bound item, command-before-start and command-after-terminal. Additional source assertions cover strict malformed/unknown frame rejection, binding verification and permanent termination. These are source assertions, NOT local behavioural/typecheck receipts. +The authority-change regression scopes its host lookup to the originally enrolled grant ID. Returning an unrelated grant for every ID incorrectly authorizes a second grant in the test; production already compares complete bindings and supports separately authorized grants. The test now checks exact refusal outcomes. Canonicalizer tests copy schema-readonly visible IDs into the generated helper's mutable-array input, and the shipped D-5 fixture explicitly requires a requesting agent before rendering its live controller. + Canonical source head `f92a26354a` corrects the sequence-0 trace to the negative expectation `refuse-invalid-frame`, consistent with the generated schema's sequence >= 1 constraint. The test now handles it as an ordinary schema-invalid negative vector: generated decoding must throw and the lifecycle gate must refuse it. There is no resource-id/sequence special case or fixture/schema mismatch allowance. Observer hashing now uses generated `ClientControlSha256`/`defaultWebCryptoSha256`, with optional injection over the exact UTF-8 rendered question bytes (not a visible-state wrapper). Result conformance asserts default and injected hashing preserve generated JCS/digest/signing bytes. The identity reader has no fingerprint hash path: it still returns the SEC1 point only, and ECDSA/SHA-256 signing remains native. No extra identity hash or second hash implementation is introduced. diff --git a/apps/fractal-web/src/client-control/lifecycle.unit.test.ts b/apps/fractal-web/src/client-control/lifecycle.unit.test.ts index 6846ddcc5cd..666dc963928 100644 --- a/apps/fractal-web/src/client-control/lifecycle.unit.test.ts +++ b/apps/fractal-web/src/client-control/lifecycle.unit.test.ts @@ -176,14 +176,15 @@ describe('client-control lifecycle gate', () => { it.each(['grant_id', 'run_id', 'device', 'session', 'item_id'] as const)('rechecks current authority and embedded grant %s', field => { let current: Wire.ClientControlGrant | undefined = grant - const gate = makeGate(() => current) + const gate = makeGate(id => id === grant.grant_id ? current : undefined) gate.acceptResource(started) expect(gate.allows(command())).toBe(true) const mismatched = { ...grant, [field]: 'other/binding' } expect(gate.allows({ ...command(), control_grant: mismatched })).toBe(false) current = mismatched expect(gate.allows(command())).toBe(false) - expect(gate.acceptResource({ ...started, [field]: 'other/binding', sequence: 2 })).not.toBe('activate') + expect(gate.acceptResource({ ...started, [field]: 'other/binding', sequence: 2 })) + .toBe(field === 'item_id' ? 'ignore-item-mismatch' : 'refused-binding') current = undefined expect(gate.allows(command())).toBe(false) }) diff --git a/apps/fractal-web/src/client-control/wire.unit.test.ts b/apps/fractal-web/src/client-control/wire.unit.test.ts index 80a71825c72..370f4b952df 100644 --- a/apps/fractal-web/src/client-control/wire.unit.test.ts +++ b/apps/fractal-web/src/client-control/wire.unit.test.ts @@ -102,7 +102,11 @@ describe('web consumption of the repository client-control golden file', () => { for (const changed of bindings) { // These pass the generated String schemas: refusal must come from the canonicalizer. expect(() => decodeBinding(changed)).not.toThrow() - const canonicalization = clientResultCanonicalBytes(encodeBinding(decodeBinding(changed))) + const encoded = encodeBinding(decodeBinding(changed)) + const canonicalization = clientResultCanonicalBytes({ + ...encoded, + visible_state: { ...encoded.visible_state, visible_item_ids: [...encoded.visible_state.visible_item_ids] }, + }) await expect(canonicalization).rejects.toBeInstanceOf(ClientResultCanonicalizationError) await expect(canonicalization).rejects.toMatchObject({ code: 'invalid-unicode' }) await expect(verifyClientResultBinding(changed, vector.signature_p1363_base64url, key)).resolves.toBe(false) diff --git a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx index 108867b9b54..f57175e1a23 100644 --- a/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx +++ b/apps/fractal-web/src/conversation/alerts/AnswerAlert.integration.test.tsx @@ -164,6 +164,8 @@ it.each(['decision', 'feedback'] as const)( ...alert, alert: true, conversation_id: alert.requester_id, conversation_ids: [alert.requester_id], request: { ...alert.request!, type: requestType }, } as Native.Attention + const agentRef = shown.requester_id + if (agentRef === undefined) throw new Error('The D-5 fixture must name its requesting agent') // Record at the real source answer-port seam used by LiveAgentAlerts (and later useAlertAnswers). // No data-hook/module mock: the provider, live controller and answer renderer are real. const actionRequests: Parameters[0][] = [] @@ -190,7 +192,7 @@ it.each(['decision', 'feedback'] as const)( cleanup = () => { act(() => root.unmount()); registry.dispose(); host.remove(); vi.unstubAllGlobals() } await act(async () => root.render( - + , )) const merge = button(host, 'Merge')!