Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/ios/clientControl.test.mjs
Original file line number Diff line number Diff line change
@@ -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');
70 changes: 70 additions & 0 deletions apps/ios/clientControl.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
result(binding: ClientResultBinding, current: () => boolean): Promise<void>;
revoke(): Promise<void>;
}
const v1Screen = (screen: string): boolean => screen === 'NowRoot' || screen === 'Attention';
export class ClientControlController {
private grant: ClientControlGrant | undefined;
private listeners = new Set<() => void>();
private expires: ReturnType<typeof setTimeout> | undefined;
private pending = Promise.resolve();
private disposed = false;
private revoked = false;
private epoch = 0;
private seen = new Map<string, Set<string>>();
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<void> => { 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<void> => {
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<void> {
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<string>();
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);
}
}
34 changes: 34 additions & 0 deletions apps/ios/clientControlFeed.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
});
}
56 changes: 56 additions & 0 deletions apps/ios/clientControlWire.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
28 changes: 28 additions & 0 deletions apps/ios/clientControlWire.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
signedAt: number,
nonce: string,
): Promise<DeviceResult> => ({
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<FractalClient, 'clientTestRevoke'>, device: string): Promise<void> => {
await client.clientTestRevoke(device);
};
Loading
Loading