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/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/README.md b/apps/fractal-web/src/client-control/README.md new file mode 100644 index 00000000000..b0728f10eb2 --- /dev/null +++ b/apps/fractal-web/src/client-control/README.md @@ -0,0 +1,96 @@ +# Client-control web modules + +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`: 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. +- `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. +- `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. + +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. + +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. + +| 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) | 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. | + +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 contracts, followed by web consumption commits; generated artifacts remain owned by the daemon generator. + +## Generated-contract consumption and vector coverage + +`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. + +`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. + +## 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 `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. + +## Final seed and route ruling + +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 + +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. + +#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. + +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..0992fb25425 --- /dev/null +++ b/apps/fractal-web/src/client-control/command.test-support.ts @@ -0,0 +1,45 @@ +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({ + 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 = {}, grant: Wire.ClientControlGrant = testGrant()) => ({ + kind: 'device-command', id: 'device-command/unit', revision: 'r1', + 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: 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 new file mode 100644 index 00000000000..44947c522f2 --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.ts @@ -0,0 +1,119 @@ +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' + +/** Verified manifest projection of one open native ask, not a daemon wire schema. */ +export interface FixtureSeedExpectation { + readonly askId: string + readonly title: string + /** SHA-256 of the rendered request.question text, never answer text. */ + readonly questionTextSha256: 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 askId: string + readonly title: string + readonly renderedQuestionTextSha256: string + readonly renderedItemIds: readonly string[] + readonly titleMatches: boolean + readonly questionMatches: 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 questionTextSha256: string + readonly itemIds: readonly string[] +}): { 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 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 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 + * 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: { + 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 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) + 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.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.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 => ({ 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] + }), + } + }), { 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 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 = { + format: 'client-control.dom-observation.v1', commit: input.commit, origin: boundary.origin, + 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) + 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..d58145038cc --- /dev/null +++ b/apps/fractal-web/src/client-control/fixtureDomEvidence.unit.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment node +import { describe, it } from '@effect/vitest' +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', + questionTextSha256: 'a'.repeat(64), subjectIds: ['attention/unit-seed', 'attention/unit-other'], +} +const observation = { title: seed.title, questionTextSha256: seed.questionTextSha256, itemIds: [seed.askId] } + +describe('independent rendered fixture comparison', () => { + 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, 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.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: ['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/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() + }) +}) 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/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/grantBoundary.ts b/apps/fractal-web/src/client-control/grantBoundary.ts new file mode 100644 index 00000000000..3acbba954a3 --- /dev/null +++ b/apps/fractal-web/src/client-control/grantBoundary.ts @@ -0,0 +1,57 @@ +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 + if (command.args.screen === 'NowRoot') return { _tag: 'NowRoot' } + if (command.args.screen === 'Attention') return { _tag: 'Attention', askId: command.args.params.id } + return undefined +} + +/** + * 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 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 +}): Promise<'Refused' | 'NotForeground' | 'Applied'> => { + 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() + 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 || 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() + 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..1128069f59c --- /dev/null +++ b/apps/fractal-web/src/client-control/grantBoundary.unit.test.ts @@ -0,0 +1,176 @@ +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, testLifecycle, testLifecycleResource } from './command.test-support.ts' +import { createClientControlLifecycle } from './lifecycle.ts' + +const exercise = async ( + command: unknown, + grant: Wire.ClientControlGrant | undefined = testGrant(), + initialTarget: ClientControlRouteTarget = { _tag: 'NowRoot' }, +) => { + const navigations: ClientControlRouteTarget[] = [] + let signatures = 0 + let currentTarget = initialTarget + const result = await handleGrantedDeviceCommand({ + command, permission: () => grant, lifecycle: testLifecycle(grant), foreground: () => true, + currentTarget: () => currentTarget, + navigate: async target => { navigations.push(target); currentTarget = target }, + signVisibleState: async () => { signatures += 1 }, + }) + return { result, navigations, signatures } +} + +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) + }) + + 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([ + { 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) + }, + ) + + 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 => { + 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(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(testCommand('phone.inspect'))).toEqual({ result: 'Applied', navigations: [], signatures: 1 }) + }) + + it('refuses a grant that omits the destination', async () => { + 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(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(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), 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 => { + expect(await exercise({ ...testCommand('phone.inspect'), [field]: 'other/unit' })).toEqual(refused) + }) + + it.each([ + [undefined, 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 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') }, + }) + expect(result).toBe(expected) + expect(calls).toEqual([]) + }) + + it('does not sign after grant loss during navigation', async () => { + let active: Wire.ClientControlGrant | undefined = testGrant() + 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 }, + }) + expect(result).toBe('Refused') + expect(signatures).toBe(0) + }) +}) + +// 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 new file mode 100644 index 00000000000..bdbcf75518a --- /dev/null +++ b/apps/fractal-web/src/client-control/identity.ts @@ -0,0 +1,211 @@ +// 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 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. wire.ts uses the generated canonical helper and owns + * base64url/envelope serialization; this store does not reconstruct any protocol fields. + */ + 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() + } +} + +export type ExistingPublicKeyPoint = + | { readonly _tag: 'Absent' } + | { readonly _tag: 'Present'; readonly point: Uint8Array } + +/** + * 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 new file mode 100644 index 00000000000..9ecbe57b9f4 --- /dev/null +++ b/apps/fractal-web/src/client-control/identity.unit.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node +import { IDBFactory } from 'fake-indexeddb' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ClientControlIdentityError, + generateClientControlKeyPair, + getOrCreateClientControlIdentity, + readExistingPublicKeyPoint, +} 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) + // Generated canonical signing/envelope conformance belongs to wire.unit.test.ts. + }) + + 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('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({ + _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' }) + }) +}) 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..666dc963928 --- /dev/null +++ b/apps/fractal-web/src/client-control/lifecycle.unit.test.ts @@ -0,0 +1,310 @@ +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, including schema-invalid negative resources', () => { + 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 (step.expected === 'refuse-invalid-frame') { + expect(() => decodeLifecycle(resource)).toThrow() + expect(gate.acceptResource(resource)).toBe('refused-invalid') + } 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') + } + } + } + }) + + 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(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 })) + .toBe(field === 'item_id' ? 'ignore-item-mismatch' : 'refused-binding') + 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/navigation.ts b/apps/fractal-web/src/client-control/navigation.ts new file mode 100644 index 00000000000..710828d893c --- /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: 'NowRoot' } + | { readonly _tag: 'Attention'; readonly askId: 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) + }) +}) 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..b023999fab2 --- /dev/null +++ b/apps/fractal-web/src/client-control/wire.ts @@ -0,0 +1,76 @@ +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' +import type { ClientControlIdentity } from './identity.ts' + +export const decodeCommand = Wire.decodeUnknownSync(Wire.DeviceCommand, 'strict') +export const encodeCommand = Schema.encodeSync(Wire.DeviceCommand) +export const decodeGrant = Wire.decodeUnknownSync(Wire.ClientControlGrant, 'strict') +export const encodeGrant = Schema.encodeSync(Wire.ClientControlGrant) +export const decodeBinding = Wire.decodeUnknownSync(Wire.ClientResultBinding, 'strict') +export const encodeBinding = Schema.encodeSync(Wire.ClientResultBinding) +export const decodeResult = Wire.decodeUnknownSync(Wire.DeviceResult, 'strict') +export const encodeResult = Schema.encodeSync(Wire.DeviceResult) +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 | 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: { + 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 signatureBytes = fromBase64url(signature) + if (signatureBytes === undefined) return false + const encoded = encodeBinding(decoded) + let bytes: Uint8Array + try { + bytes = await clientResultCanonicalBytes(encoded as CanonicalBinding) as Uint8Array + } catch (error) { + if (error instanceof ClientResultCanonicalizationError && error.code === 'invalid-unicode') return false + throw error + } + 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 new file mode 100644 index 00000000000..370f4b952df --- /dev/null +++ b/apps/fractal-web/src/client-control/wire.unit.test.ts @@ -0,0 +1,189 @@ +// @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, + ClientResultCanonicalizationError, + clientResultCanonicalBytes, + defaultWebCryptoSha256, + type ClientControlSha256, + 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(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) { + 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([ + '', '!', '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(['\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() + 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) + } + }) + + 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' }, + { 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() + 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 6c184eda9b1..f57175e1a23 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, @@ -15,6 +16,15 @@ 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 { handleGrantedDeviceCommand } from '../../client-control/grantBoundary.ts' +import { testCommand, testGrant, testLifecycle } from '../../client-control/command.test-support.ts' +import type { ClientControlRouteTarget } from '../../client-control/navigation.ts' const alert = { id: 'attention/ask', @@ -117,3 +127,128 @@ 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) +}) + +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 + 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][] = [] + 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 = testGrant(shown.id) + const lifecycle = testLifecycle(permission) + const commandFor = (verb: string, args: unknown = {}) => testCommand(verb, args, permission) + const commands = [ + { 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: 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, lifecycle, 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() + } + }, +) 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)} 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..66785dff35f 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) } } @@ -994,11 +1010,50 @@ 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 + } + 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 { + 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. @@ -1027,6 +1082,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)) @@ -1058,6 +1114,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) @@ -1067,7 +1124,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/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift new file mode 100644 index 00000000000..f7344082452 --- /dev/null +++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift @@ -0,0 +1,43 @@ +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") } + } + 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/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.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..9024c9c6c24 --- /dev/null +++ b/clients/typescript/fractal-client/ClientControl.generated.ts @@ -0,0 +1,60 @@ +// 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; +} +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 ClientResultCanonicalizationError(); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError(); + } + 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 type ClientControlSha256 = (bytes: Uint8Array) => Promise; +export const defaultWebCryptoSha256: ClientControlSha256 = async 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(''); +} +/** 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, 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], + ['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..7c13754ecbc 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,34 @@ 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 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"; export type ClientDiagnosticCaptureSource = "js" | "native-marker" | "metrickit"; @@ -1367,6 +1410,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 +1794,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 +1825,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 +1847,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 = { @@ -3319,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 b6b6a73dc95..f753e78ff01 100644 --- a/clients/typescript/fractal-client/ReadSchema.generated.ts +++ b/clients/typescript/fractal-client/ReadSchema.generated.ts @@ -1668,6 +1668,40 @@ 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 + +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, @@ -1896,9 +1930,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 @@ -2804,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 @@ -3048,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 bcae26143dc..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, 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, 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({ @@ -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/clients/typescript/fractal-client/client-control.test.cjs b/clients/typescript/fractal-client/client-control.test.cjs new file mode 100644 index 00000000000..b59a1011d79 --- /dev/null +++ b/clients/typescript/fractal-client/client-control.test.cjs @@ -0,0 +1,57 @@ +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, 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'), { + 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 { 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'); +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 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 () => { + 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 896488ca4e6..afc4c587448 100644 --- a/clients/typescript/fractal-client/index.ts +++ b/clients/typescript/fractal-client/index.ts @@ -2,3 +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, ClientResultCanonicalizationError, defaultWebCryptoSha256, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts'; +export type { ClientControlSha256 } 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/clients/typescript/fractal-client/schema.test.cjs b/clients/typescript/fractal-client/schema.test.cjs index b87ca5bc567..fde040b6049 100644 --- a/clients/typescript/fractal-client/schema.test.cjs +++ b/clients/typescript/fractal-client/schema.test.cjs @@ -1335,3 +1335,35 @@ 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'); + } + 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/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) + } + }) }) diff --git a/crates/fractal-client-codegen/src/main.rs b/crates/fractal-client-codegen/src/main.rs index 516ed9a5bd0..af0ef57dc8f 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")?; @@ -752,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/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..4f6ed189984 --- /dev/null +++ b/crates/fractal-client-codegen/templates/ClientControl.swift.in @@ -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/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in new file mode 100644 index 00000000000..9024c9c6c24 --- /dev/null +++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in @@ -0,0 +1,60 @@ +// 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; +} +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 ClientResultCanonicalizationError(); + } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError(); + } + 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 type ClientControlSha256 = (bytes: Uint8Array) => Promise; +export const defaultWebCryptoSha256: ClientControlSha256 = async 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(''); +} +/** 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, 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], + ['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..c4366d073bb 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) } } @@ -994,11 +1010,50 @@ 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 + } + 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 { + 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. @@ -1027,6 +1082,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)) @@ -1058,6 +1114,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) @@ -1067,7 +1124,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-v1.golden.json.in b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in new file mode 100644 index 00000000000..97bd57cb9ff --- /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": [ + "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<>/

" + } + } + } + ] + } + ] +} 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..c4fde6a50bb --- /dev/null +++ b/crates/fractal-client-codegen/templates/client_control.rs.in @@ -0,0 +1,148 @@ +// 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 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, + #[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 { + 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/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in index a2a87f21f7d..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, @@ -5263,6 +5266,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 +5290,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,7 +5318,21 @@ 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, } + +#[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-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/src/generated.rs b/crates/fractal-client/src/generated.rs index c26061f4283..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, @@ -6175,6 +6178,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 +6224,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,9 +6261,31 @@ 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, } + +#[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/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-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-client/tests/client_control_lifecycle.rs b/crates/fractal-client/tests/client_control_lifecycle.rs new file mode 100644 index 00000000000..f9a27b6bdc2 --- /dev/null +++ b/crates/fractal-client/tests/client_control_lifecycle.rs @@ -0,0 +1,42 @@ +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(_))); + } + 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/crates/fractal-schema/src/client_control.rs b/crates/fractal-schema/src/client_control.rs new file mode 100644 index 00000000000..c4fde6a50bb --- /dev/null +++ b/crates/fractal-schema/src/client_control.rs @@ -0,0 +1,148 @@ +// 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 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, + #[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 { + 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/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..6fef362096b 100644 --- a/docs/st3/client-v0/device-commands.md +++ b/docs/st3/client-v0/device-commands.md @@ -127,3 +127,139 @@ 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. + +## 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 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 +`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. +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`. +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/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json index 3daf13f9002..510f985eefd 100644 --- a/docs/st3/client-v0/schemas/client-v0.schema.json +++ b/docs/st3/client-v0/schemas/client-v0.schema.json @@ -457,18 +457,77 @@ "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"}}, + "item_id": {"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." }, + "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", @@ -481,6 +540,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": [ @@ -691,6 +752,7 @@ "machine", "device", "device-command", + "client-control-grant-lifecycle", "operation", "history", "session", @@ -3121,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..ef6d355fcad --- /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": "refuse-invalid-frame" + }, + { + "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/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<>/

" + } + } + } + ] + } + ] +} 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."} +} diff --git a/release-notes/client-control-lifecycle-schema.json b/release-notes/client-control-lifecycle-schema.json new file mode 100644 index 00000000000..b76c9767a4e --- /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. 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 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."} +}