diff --git a/apps/ios/clientControl.test.mjs b/apps/ios/clientControl.test.mjs new file mode 100644 index 00000000000..beeca42abb4 --- /dev/null +++ b/apps/ios/clientControl.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; +import { ClientControlController } from './clientControl.ts'; +const fixture = JSON.parse(readFileSync(new URL('../../fixtures/clients/client-control-v1.golden.json', import.meta.url))); +const binding = fixture.vectors[1].input; +const grant = () => ({ grant_id: binding.grant_id, run_id: binding.run_id, device: binding.device, session: binding.session, actor_agent: 'agent/fixture', state: 'active', verbs: ['phone.inspect', 'phone.navigate'], screens: ['NowRoot', 'Attention'], max_commands: 50, expires_at: new Date(Date.now() + 60_000).toISOString() }); +const command = () => ({ kind: 'device-command', id: binding.command_id, state: 'delivered', command_kind: 'phone.navigate', args: { screen: 'Attention', params: { id: binding.visible_state.opened_item.id } }, device_id: binding.device, session_actor: binding.session, issued_by: 'agent/fixture', grant_id: binding.grant_id, run_id: binding.run_id, expires_at: new Date(Date.now() + 30_000).toISOString(), control_grant: grant() }); +const harness = (t, changes = {}) => { + const effects = [], results = []; + const controller = new ClientControlController({ identity: { device: binding.device, session: binding.session }, foreground: () => true, + visible: () => binding.visible_state, navigate: async args => { effects.push(args); }, result: async (value, current) => { assert.equal(current(), true); results.push(value); }, revoke: async () => { effects.push('revoke'); }, + answer: () => assert.fail('answer callback'), close: () => assert.fail('close callback'), read: () => assert.fail('read callback'), ...changes }); + t.after(controller.dispose); + return { controller, effects, results }; +}; + +for (const [name, change] of [ + ['no grant', row => { delete row.control_grant; }], ['wrong run', row => { row.run_id += '/wrong'; }], ['wrong device', row => { row.device_id += '/wrong'; }], ['wrong session', row => { row.session_actor += '/wrong'; }], + ['wrong issuer', row => { row.issued_by += '/wrong'; }], ['non-granted screen', row => { row.control_grant.screens = ['NowRoot']; }], ['non-granted verb', row => { row.control_grant.verbs = ['phone.inspect']; }], + ['expired grant', row => { row.control_grant.expires_at = new Date(0).toISOString(); }], ['expired command', row => { row.expires_at = new Date(0).toISOString(); }], ['revoked grant', row => { row.control_grant.state = 'revoked'; }], + ['Conversation', row => { row.args = { screen: 'Conversation', params: { target: 'agent/fixture' } }; row.control_grant.screens.push('Conversation'); }], + ['wrong item_id', row => { row.control_grant.item_id = 'attention/other'; }], + ...['phone.message.read', 'answer', 'close', 'work.done', 'message.read', 'prompt.respond'].map(verb => [verb, row => { row.command_kind = verb; row.control_grant.verbs.push(verb); }]), +]) test(`${name}: no navigation, action or signed result`, async t => { + const { controller, effects, results } = harness(t), row = command(); change(row); + await controller.handle(row); assert.deepEqual(effects, []); assert.deepEqual(results, []); +}); + +test('inspect and navigate use only pure ports and committed state; optional item_id and replay', async t => { + const { controller, effects, results } = harness(t), originalFetch = globalThis.fetch; + globalThis.fetch = () => assert.fail('no answer/read/close API'); + try { + const row = command(); row.control_grant.item_id = row.args.params.id; + await controller.handle(row); await controller.handle(row); + const inspect = { ...command(), id: row.id + '/inspect', command_kind: 'phone.inspect', args: {} }; + await controller.handle(inspect); + assert.deepEqual(effects, [row.args]); assert.equal(results.length, 2); + assert.deepEqual(results[0].visible_state, binding.visible_state); + assert.equal(controller.getGrant(), undefined, 'idle indicator requires generated lifecycle authority'); + } finally { globalThis.fetch = originalFetch; } +}); +test('background command reports not-foreground without navigation', async t => { + const { controller, effects, results } = harness(t, { foreground: () => false }); + await controller.handle(command()); assert.deepEqual(effects, []); assert.equal(results[0].outcome, 'not-foreground'); +}); +test('revoke ends local authority and invokes enrollment revoke; in-flight work cannot result', async t => { + const pending = Promise.withResolvers(), entered = Promise.withResolvers(); + const { controller, effects, results } = harness(t, { navigate: async () => { entered.resolve(); await pending.promise; } }); + const applying = controller.handle(command()); await entered.promise; + await controller.revoke(); pending.resolve(); await applying; + await controller.handle({ ...command(), id: 'device-command/after-revoke' }); + assert.deepEqual(effects, ['revoke']); assert.deepEqual(results, []); +}); +test('disconnect invalidates in-flight result', async t => { + const pending = Promise.withResolvers(), entered = Promise.withResolvers(); + const { controller, results } = harness(t, { navigate: async () => { entered.resolve(); await pending.promise; } }); + const applying = controller.handle(command()); await entered.promise; controller.suspend(); pending.resolve(); await applying; + assert.deepEqual(results, []); +}); +test.todo('#6274: idle started/ended/revoked grant lifecycle resource is not in the generated collection union yet'); diff --git a/apps/ios/clientControl.ts b/apps/ios/clientControl.ts new file mode 100644 index 00000000000..a7b971c2901 --- /dev/null +++ b/apps/ios/clientControl.ts @@ -0,0 +1,70 @@ +import { CLIENT_RESULT_PURPOSE, type ClientControlGrant, type ClientResultBinding, type ClientVisibleState, type DeviceCommand, type PhoneNavigateArgs } from '../../clients/typescript/fractal-client'; + +export interface ClientControlPorts { + identity: { device: string; session: string }; + foreground(): boolean; + visible(): ClientVisibleState | undefined; + navigate(args: PhoneNavigateArgs): Promise; + result(binding: ClientResultBinding, current: () => boolean): Promise; + revoke(): Promise; +} +const v1Screen = (screen: string): boolean => screen === 'NowRoot' || screen === 'Attention'; +export class ClientControlController { + private grant: ClientControlGrant | undefined; + private listeners = new Set<() => void>(); + private expires: ReturnType | undefined; + private pending = Promise.resolve(); + private disposed = false; + private revoked = false; + private epoch = 0; + private seen = new Map>(); + constructor(private readonly ports: ClientControlPorts) {} + // TODO #6274: only a generated lifecycle resource may publish the idle run indicator. + readonly getGrant = (): ClientControlGrant | undefined => undefined; + readonly subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; + readonly suspend = (): void => { + this.epoch++; clearTimeout(this.expires); this.grant = undefined; + for (const listener of this.listeners) listener(); + }; + readonly dispose = (): void => { this.disposed = true; this.suspend(); this.listeners.clear(); }; + readonly revoke = async (): Promise => { this.revoked = true; this.suspend(); await this.ports.revoke(); }; + private authorized(command: DeviceCommand, grant: ClientControlGrant): boolean { + const now = Date.now(); + return !this.disposed && !this.revoked && grant.state === 'active' + && command.device_id === this.ports.identity.device && grant.device === this.ports.identity.device + && command.session_actor === this.ports.identity.session && grant.session === this.ports.identity.session + && command.grant_id === grant.grant_id && command.run_id === grant.run_id && command.issued_by === grant.actor_agent + && Date.parse(grant.expires_at) > now && Date.parse(command.expires_at) > now + && (command.command_kind === 'phone.inspect' || command.command_kind === 'phone.navigate') && grant.verbs.includes(command.command_kind) + && (command.command_kind !== 'phone.navigate' || v1Screen(command.args.screen) && grant.screens.includes(command.args.screen) + && (!grant.item_id || command.args.screen !== 'Attention' || command.args.params.id === grant.item_id)); + } + readonly handle = (command: DeviceCommand): Promise => { + const epoch = this.epoch; + const task = this.pending.then(() => epoch === this.epoch ? this.apply(command) : undefined); + this.pending = task.catch(() => {}); + return task; + }; + private async apply(command: DeviceCommand): Promise { + const grant = command.control_grant; + if (grant?.grant_id === this.grant?.grant_id && grant?.state !== 'active') this.suspend(); + if (!grant || command.state !== 'delivered' || !this.authorized(command, grant)) return; + const seen = this.seen.get(grant.grant_id) ?? new Set(); + if (seen.has(command.id) || seen.size >= grant.max_commands) return; + seen.add(command.id); this.seen.set(grant.grant_id, seen); + this.grant = grant; clearTimeout(this.expires); + this.expires = setTimeout(this.suspend, Math.min(Date.parse(grant.expires_at) - Date.now(), 2_147_483_647)); + for (const listener of this.listeners) listener(); + const epoch = this.epoch; + const current = () => epoch === this.epoch && this.grant === grant && this.authorized(command, grant); + const foreground = this.ports.foreground(); + if (foreground && command.command_kind === 'phone.navigate') await this.ports.navigate(command.args); + if (!current()) return; + const visible = this.ports.visible(); + if (!visible || !v1Screen(visible.screen) || !grant.screens.includes(visible.screen) + || grant.item_id && visible.screen === 'Attention' && visible.opened_item?.id !== grant.item_id) return; + await this.ports.result({ purpose: CLIENT_RESULT_PURPOSE, command_kind: command.command_kind === 'phone.inspect' ? 'phone.inspect' : 'phone.navigate', + device: grant.device, session: grant.session, grant_id: grant.grant_id, run_id: grant.run_id, command_id: command.id, + outcome: foreground && this.ports.foreground() ? 'completed' : 'not-foreground', error_code: null, visible_state: visible }, current); + } +} diff --git a/apps/ios/clientControlFeed.test.mjs b/apps/ios/clientControlFeed.test.mjs new file mode 100644 index 00000000000..c20be90b9f6 --- /dev/null +++ b/apps/ios/clientControlFeed.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { ForegroundGate } from './foreground.ts'; +import { fakeClient } from './fakeFeed.mjs'; + +const settle = () => new Promise(resolve => setTimeout(resolve, 20)); +for (const [testMode, granted] of [[undefined, true], [false, false], [false, true], [true, false], [true, true]]) { + test(`commands subscription test mode=${testMode} capability=${granted}`, async () => { + const { Feed } = await import('./feed.ts'); + const { client, sockets } = fakeClient(); + const foreground = new ForegroundGate('active'); + const feed = new Feed(client, { onWindow() {}, onConnection() {} }, foreground, () => 'action/test'); + const received = []; let resets = 0; + const stop = feed.followCommands(testMode, granted, command => received.push(command), () => { resets++; }); + await settle(); + const subscribed = () => sockets.at(-1).sent.some(frame => frame.collection === 'commands'); + assert.equal(subscribed(), testMode === true && granted); + const command = { kind: 'device-command', id: 'device-command/test', command_kind: 'phone.inspect', args: {}, state: 'delivered' }; + const frame = { kind: 'snapshot', id: 'commands', collection: 'commands', items: [command] }; + sockets[0].frame(frame); + assert.equal(received.length, testMode === true && granted ? 1 : 0); + foreground.update('background'); + assert.equal(sockets[0].closed, true); + assert.equal(resets > 0, testMode === true && granted, 'background invalidates grant authority'); + foreground.update('active'); + await settle(); + assert.equal(subscribed(), testMode === true && granted, 'same policy applies on reconnect'); + stop(); + const count = received.length; + sockets.at(-1).frame(frame); + assert.equal(received.length, count); + feed.close(); + }); +} diff --git a/apps/ios/clientControlWire.test.mjs b/apps/ios/clientControlWire.test.mjs new file mode 100644 index 00000000000..f09afc8f9cc --- /dev/null +++ b/apps/ios/clientControlWire.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; +import { clientResultCanonicalBytes, CLIENT_RESULT_SIGNED_FIELDS, visibleStateCanonicalJson, visibleStateDigest } from '../../clients/typescript/fractal-client/ClientControl.generated.ts'; +import { revokeClientControl, signClientResult } from './clientControlWire.ts'; + +const golden = JSON.parse(readFileSync(new URL('../../fixtures/clients/client-control-v1.golden.json', import.meta.url))); +const sec1 = Buffer.from(golden.public_key_sec1_base64url, 'base64url'); +const publicKey = createPublicKey({ key: Buffer.concat([ + Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex'), sec1, +]), format: 'der', type: 'spki' }); +const verifies = (bytes, signature) => verify('sha256', Buffer.from(bytes), { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url')); + +for (const vector of golden.vectors) test(`generated ${vector.name} JCS, digest, canonical bytes and P-256 binding`, async () => { + 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.deepEqual(Buffer.from(bytes), Buffer.from(vector.canonical_bytes)); + assert.equal(verifies(bytes, vector.signature_p1363_base64url), true); + for (const binding of vector.negative_bindings) { + assert.equal(verifies(await clientResultCanonicalBytes(binding), vector.signature_p1363_base64url), false); + } + assert.equal(verifies(bytes, golden.message_domain_signature_p1363_base64url), false); + assert.equal(verifies(golden.message_domain_bytes, vector.signature_p1363_base64url), false); +}); + +test('message-domain fixture signature is valid only in its own domain', () => { + assert.equal(verifies(golden.message_domain_bytes, golden.message_domain_signature_p1363_base64url), true); +}); + +test('adapter signs generated bytes with the selected key and returns the generated envelope', async () => { + const binding = golden.vectors[0].input; + const signing = { key: `p256:${golden.public_key_sec1_base64url}`, person: 'person/test', chain: ['grant/test'], storage: 'secure-enclave', handle: 'selected-key' }; + const privateKey = createPrivateKey({ key: { kty: 'EC', crv: 'P-256', + x: sec1.subarray(1, 33).toString('base64url'), y: sec1.subarray(33).toString('base64url'), + d: Buffer.from(golden.private_scalar_hex_for_tests, 'hex').toString('base64url'), + }, format: 'jwk' }); + const result = await signClientResult(binding, signing, async (text, handle) => { + assert.equal(handle, signing.handle); + assert.deepEqual(Buffer.from(text), Buffer.from(await clientResultCanonicalBytes(binding))); + return sign('sha256', Buffer.from(text), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url'); + }, 123, 'fresh-nonce'); + assert.deepEqual(result, { command_id: binding.command_id, outcome: binding.outcome, result: binding.visible_state, binding, + signature: { format: 'fields-v1', signer: signing.person, key: signing.key, chain: signing.chain, + signed_fields: [...CLIENT_RESULT_SIGNED_FIELDS], signed_at_unix_ms: 123, nonce: 'fresh-nonce', signature: result.signature.signature } }); + assert.equal(verifies(await clientResultCanonicalBytes(binding), result.signature.signature), true); +}); + +test('revoke awaits the SDK and propagates failure', async () => { + let revoked; + assert.equal(await revokeClientControl({ async clientTestRevoke(device) { revoked = device; } }, 'device/test'), undefined); + assert.equal(revoked, 'device/test'); + const failure = new Error('revoke failed'); + await assert.rejects(revokeClientControl({ async clientTestRevoke() { throw failure; } }, 'device/test'), error => error === failure); +}); diff --git a/apps/ios/clientControlWire.ts b/apps/ios/clientControlWire.ts new file mode 100644 index 00000000000..c97d93c7aad --- /dev/null +++ b/apps/ios/clientControlWire.ts @@ -0,0 +1,28 @@ +import type { DeviceKey } from '@fractal/views'; +import type { FractalClient } from '../../clients/typescript/fractal-client'; +import { clientResultCanonicalBytes, CLIENT_RESULT_SIGNED_FIELDS } from '../../clients/typescript/fractal-client/ClientControl.generated.ts'; +import type { ClientResultBinding, DeviceResult } from '../../clients/typescript/fractal-client/Models.generated.ts'; + +/** The native signer receives only the generated, domain-separated result bytes as UTF-8. */ +export const signClientResult = async ( + binding: ClientResultBinding, + signing: DeviceKey & { handle?: string }, + sign: (text: string, handle?: string) => Promise, + signedAt: number, + nonce: string, +): Promise => ({ + command_id: binding.command_id, + outcome: binding.outcome, + ...(binding.error_code === null ? {} : { error_code: binding.error_code }), + result: binding.visible_state, + binding, + signature: { + format: 'fields-v1', signer: signing.person, key: signing.key, chain: signing.chain, + signed_fields: [...CLIENT_RESULT_SIGNED_FIELDS], signed_at_unix_ms: signedAt, nonce, + signature: await sign(new TextDecoder().decode(await clientResultCanonicalBytes(binding)), signing.handle), + }, +}); + +export const revokeClientControl = async (client: Pick, device: string): Promise => { + await client.clientTestRevoke(device); +}; diff --git a/apps/ios/feed.ts b/apps/ios/feed.ts index 0838df5123e..e1c5269db90 100644 --- a/apps/ios/feed.ts +++ b/apps/ios/feed.ts @@ -6,6 +6,7 @@ import type { SidebarArrangement } from './sidebarEdits'; import type { HeartbeatSocketFactory } from './collectionSocket'; import type { ConversationHeader, HeaderField } from '@fractal/views'; import { TERMINAL_RESTARTED, withFreshTerminalFence, type Foreground, type TerminalFollowHandlers } from './terminalControls'; +import type { DeviceCommand } from '../../clients/typescript/fractal-client'; /** Unwired membership conversion: epoch must come from the daemon capability, never a snapshot ID. */ export const sidebarMembershipFrame = (token: MembershipToken, subscription: string, frame: CollectionFrame, epoch?: string): MembershipEvent | undefined => { @@ -138,6 +139,8 @@ export class Feed { private live = false; private timer: ReturnType | undefined; private stream: CollectionStream | undefined; + private commands: ((command: DeviceCommand) => void) | undefined; + private commandsReset: (() => void) | undefined; private cancelOpen: (() => void) | undefined; private windows: Partial> = {}; private terminal: TerminalFollow | undefined; @@ -181,6 +184,20 @@ export class Feed { if (foreground.active) void this.connect(); } + /** Test clients alone may consume commands, on this pairing's existing socket. */ + followCommands(testMode: boolean, granted: boolean, receive: (command: DeviceCommand) => void, reset?: () => void): () => void { + if (!testMode || !granted) return () => {}; + this.commands = receive; + this.commandsReset = reset; + this.stream?.subscribe('commands', 'commands'); + return () => { + if (this.commands !== receive) return; + this.commandsReset?.(); this.commandsReset = undefined; + this.commands = undefined; + this.stream?.unsubscribe('commands'); + }; + } + /** Open a fresh socket now, for a person who asks to reconnect. */ reconnect(): void { if (this.closed || !this.foreground.active) return; @@ -250,6 +267,7 @@ export class Feed { } private suspend(unexpected = false): void { + this.commandsReset?.(); this.attempt++; this.cancelOpen?.(); this.cancelOpen = undefined; @@ -343,6 +361,7 @@ export class Feed { this.stream = opened; this.heard(); this.inventory.open(opened); + if (this.commands) opened.subscribe('commands', 'commands'); for (const name of Object.keys(FEED_WINDOWS) as FeedWindow[]) if (name !== 'missions' || this.missions) this.subscribeWindow(name); if (this.conversation) opened.subscribeConversation(CONVERSATION, this.conversation.target); if (this.glasses) { this.glasses.window = undefined; opened.subscribeGlasses(GLASSES); } @@ -401,6 +420,14 @@ export class Feed { } private frame(frame: CollectionFrame, received: number): void { + if ((frame.kind === 'resync' || frame.kind === 'error') && frame.id === 'commands') { this.commandsReset?.(); return; } + if ((frame.kind === 'snapshot' || frame.kind === 'changes') && frame.collection === 'commands' && frame.id === 'commands') { + if (frame.kind === 'snapshot' || frame.removes.length) this.commandsReset?.(); + for (const command of frame.kind === 'snapshot' ? frame.items : frame.upserts) { + if (command.kind === 'device-command') this.commands?.(command); + } + return; + } // The app holds no ordered-membership window; its windows hold resources. if (this.inventory.frame(frame)) return; const id = 'id' in frame ? frame.id : undefined; diff --git a/release-notes/ios-client-control.json b/release-notes/ios-client-control.json new file mode 100644 index 00000000000..ff3df64b649 --- /dev/null +++ b/release-notes/ios-client-control.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Add capability-gated iOS test-client command admission and canonical signed-result support; native lifecycle integration remains disabled pending codegen", + "replay": {"status": "none", "detail": "No claim replay changes."}, + "schema": {"status": "none", "detail": "No schema changes or handwritten wire models."}, + "rules": {"status": "none", "detail": "No checkpoint rule changes."}, + "compatibility": {"status": "none", "detail": "Normal apps never subscribe to commands; test-client commands fail closed without an authoritative grant source."}, + "downtime": {"status": "none", "detail": "No deployment or native installation."}, + "manual": {"status": "none", "detail": "No pairing or enrollment performed; test-client flag, lifecycle codegen and native SHA-256 adapter are prerequisites."}, + "recovery": {"status": "none", "detail": "No persisted state changes in the command policy layer."} +}