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
14 changes: 9 additions & 5 deletions apps/ios/ClientControlNative.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ class CommandSubscription extends React.Component<SubscriptionProps> {
private stop: (() => void) | undefined;
private start = () => {
const { binding, feed, granted, reportError } = this.props;
this.stop = feed.followCommands(true, granted, command => {
void binding.controller.handle(command).catch(error => reportError(String(error)));
this.stop = feed.followCommands(true, granted, (command, snapshot) => {
void binding.controller.handle(command, snapshot).catch(error => reportError(String(error)));
}, binding.controller.suspend);
};
componentDidMount() { this.start(); }
Expand Down Expand Up @@ -98,9 +98,13 @@ export const ClientControlNative = () => {
navigate: navigation.navigate,
result: async (binding, current) => {
if (!current()) return;
if (!globalThis.crypto?.subtle) throw new Error('Client-control signing needs the generated native SHA-256 port');
const result = await signClientResult(binding, controlProfile.signing, signWithDeviceKey, Date.now(), base64url(Crypto.getRandomBytes(16)));
if (!current()) return;
const result = await signClientResult(binding, controlProfile.signing, signWithDeviceKey, Date.now(), base64url(Crypto.getRandomBytes(16)),
async bytes => {
const input = bytes.buffer instanceof ArrayBuffer
? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Uint8Array.from(bytes);
return new Uint8Array(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, input));
});
if (!result || !current()) return;
await client.deviceResult(result);
},
revoke: () => revokeClientControl(client, controlProfile.identity.device),
Expand Down
4 changes: 3 additions & 1 deletion apps/ios/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ The app holds one WebSocket to the gateway, the collections socket (`docs/st3/cl

The fifteen-second collections opening deadline includes the WebSocket handshake, not just creation of its carrier. The receive-heartbeat deadline starts after the handshake; only a base-window snapshot marks the feed live and resets retry backoff. Opening attempts close their pending carrier when timed out, superseded, or backgrounded. Native socket failures retain only the NSError domain and numeric code, never its localized message. Transient iOS `inactive` states such as the Share sheet preserve the previous foreground state: a live feed keeps receiving and retrying, while a backgrounded feed waits for `active` before resuming.

Commands use this same session-scoped socket only when Expo extra `testClientMode` is exactly `true`, `phone-commands` v1 is granted, and the saved test profile binds the current credential, device key and session. Missing test mode means the normal app: no commands subscription. Each command requires its generated `control_grant` with matching device/session/grant/run/issuer, verb/screen, optional item ID and active windows. Commands are single-use and bounded by the grant budget; disconnect/revoke/disposal cancels in-flight results. The generated typed idle grant lifecycle is still unavailable: the indicator stays hidden pending that contract, not inferred from a command. Native signing fails closed without WebCrypto until codegen accepts an Expo SHA-256 callback; no crypto polyfill is installed.
Commands use this same session-scoped socket only when Expo extra `testClientMode` is exactly `true`, `phone-commands` v1 is granted, and the saved test profile binds the current credential, device key and session. Missing test mode means the normal app: no commands subscription. Each command requires a matching started lifecycle grant and generated `control_grant` with matching device/session/grant/run/issuer, verb/screen, optional item ID and active windows. Commands are single-use and bounded by the grant budget; disconnect/revoke/disposal cancels in-flight results. Generated lifecycle events update the run indicator even while idle; terminal events clear authority. Sequences must be positive safe integers; stale/duplicate events cannot reopen authority. Signing passes Expo's native SHA-256 digest to generated canonicalization, without WebCrypto or a polyfill. Invalid Unicode is refused without posting a result.

Suspension clears current authority, not lifecycle history or terminal tombstones. A fresh commands snapshot explicitly reconciles an unchanged current started lifecycle; incremental duplicates and lower-sequence snapshots remain ignored, and terminal grants never reopen.

The local v1 policy permits only inspect and navigation to `NowRoot` (inbox) or `Attention` with `params: {id}`. Conversation and answer/read/close operations are excluded. The seeded request uses the normal request renderer, not a test-only view: plain single-paragraph text renders as one containing native text root; rich Markdown or JSON reports can render multiple elements. Source-level JSX tests cover the fixture body and request title; native accessibility-tree observation remains a separate XCUITest proof.

Expand Down
88 changes: 84 additions & 4 deletions apps/ios/clientControl.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,31 @@ 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 lifecycleFixture = JSON.parse(readFileSync(new URL('../../fixtures/clients/client-control-lifecycle-v1.json', import.meta.url)));
const lifecycle = (changes = {}) => ({ ...lifecycleFixture.resources[0], device: binding.device, session: binding.session, grant_id: binding.grant_id, run_id: binding.run_id, item_id: undefined, ...changes });
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 harness = (t, changes = {}, started = lifecycle()) => {
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);
if (started) void controller.handle(started);
return { controller, effects, results };
};

for (const scenario of ['unchanged', 'revoked', 'stale']) test(`snapshot reconciliation after reset: ${scenario}`, async t => {
const started = lifecycle({ sequence: 10 }), { controller, effects, results } = harness(t, {}, started);
if (scenario === 'revoked') await controller.handle({ ...started, state: 'revoked', sequence: 11 });
controller.suspend();
await controller.handle({ ...started, sequence: scenario === 'stale' ? 9 : 10 }, true);
assert.equal(controller.getGrant()?.run_id, scenario === 'unchanged' ? started.run_id : undefined);
await controller.handle(command());
assert.equal(effects.length, scenario === 'unchanged' ? 1 : 0);
assert.equal(results.length, scenario === 'unchanged' ? 1 : 0);
});

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']; }],
Expand All @@ -28,16 +42,18 @@ for (const [name, change] of [
});

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;
const started = lifecycle({ item_id: binding.visible_state.opened_item.id });
const { controller, effects, results } = harness(t, {}, started), 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: {} };
inspect.control_grant.item_id = row.control_grant.item_id;
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');
assert.equal(controller.getGrant(), started, 'only lifecycle publishes the run indicator');
} finally { globalThis.fetch = originalFetch; }
});
test('background command reports not-foreground without navigation', async t => {
Expand All @@ -58,4 +74,68 @@ test('disconnect invalidates in-flight result', async t => {
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');
test('commands never infer an active lifecycle', async t => {
const { controller, effects, results } = harness(t, {}, false);
await controller.handle(command());
assert.equal(controller.getGrant(), undefined); assert.deepEqual(effects, []); assert.deepEqual(results, []);
});

test('idle lifecycle enforces own identity, ordering, item binding and terminal finality', async t => {
const { controller } = harness(t, {}, false);
const started = lifecycle({ item_id: 'attention/bound', sequence: 2 });
for (const change of [{ device: 'device/other' }, { session: 'session/other' }]) {
await controller.handle({ ...started, ...change }); assert.equal(controller.getGrant(), undefined);
}
void controller.handle(started); assert.equal(controller.getGrant(), started);
for (const change of [{ sequence: 1 }, { sequence: 2 }, { item_id: undefined, sequence: 3 }, { item_id: 'other', sequence: 3 }, { run_id: 'other', sequence: 3 }]) {
await controller.handle({ ...started, ...change }); assert.equal(controller.getGrant(), started);
}
void controller.handle({ ...started, state: 'ended', reason: 'claim-ended', sequence: 3 });
assert.equal(controller.getGrant(), undefined);
await controller.handle({ ...started, sequence: 4 }); assert.equal(controller.getGrant(), undefined);
});

test('golden sequence zero and unsafe sequences fail closed', async t => {
const invalid = lifecycleFixture.traces[0].steps.find(step => step.expected === 'refuse-invalid-frame').resource;
assert.equal(invalid.sequence, 0);
const golden = harness(t, { identity: { device: invalid.device, session: invalid.session } }, lifecycleFixture.resources[0]);
void golden.controller.handle(invalid);
assert.equal(golden.controller.getGrant(), undefined, 'shared invalid resource immediately clears active authority');
for (const sequence of [invalid.sequence, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
const { controller, effects, results } = harness(t);
void controller.handle(lifecycle({ sequence })); assert.equal(controller.getGrant(), undefined);
await controller.handle(command()); assert.deepEqual(effects, []); assert.deepEqual(results, []);
}
const { controller } = harness(t);
await controller.handle(lifecycle({ sequence: Number.MAX_SAFE_INTEGER }));
assert.equal(controller.getGrant().sequence, Number.MAX_SAFE_INTEGER);
});

for (const [state, reason] of [['ended', 'run-ended'], ['ended', 'claim-ended'], ['revoked', 'revoked']]) test(`${reason} clears idle and in-flight authority synchronously`, async t => {
const idle = harness(t);
void idle.controller.handle(lifecycle({ state, reason, sequence: 2 }));
assert.equal(idle.controller.getGrant(), undefined);
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;
const queued = controller.handle({ ...command(), id: 'queued' });
void controller.handle(lifecycle({ state, reason, sequence: 2 }));
assert.equal(controller.getGrant(), undefined);
pending.resolve(); await applying; await queued; assert.deepEqual(results, []);
});

test('lifecycle must match every command grant field, including optional item', async t => {
for (const change of [{ grant_id: 'other' }, { run_id: 'other' }, { device: 'other' }, { session: 'other' }, { item_id: 'other' }]) {
const { controller, effects, results } = harness(t, {}, lifecycle(change));
await controller.handle(command()); assert.deepEqual(effects, []); assert.deepEqual(results, []);
}
});

test('command budget is preserved across lifecycle updates and duplicate delivery', async t => {
const { controller, effects, results } = harness(t);
const row = command(); row.control_grant.max_commands = 1;
await controller.handle(row); await controller.handle(row);
await controller.handle(lifecycle({ sequence: 2 }));
await controller.handle({ ...row, id: 'over-budget' });
assert.equal(effects.length, 1); assert.equal(results.length, 1);
});
36 changes: 27 additions & 9 deletions apps/ios/clientControl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CLIENT_RESULT_PURPOSE, type ClientControlGrant, type ClientResultBinding, type ClientVisibleState, type DeviceCommand, type PhoneNavigateArgs } from '../../clients/typescript/fractal-client';
import { CLIENT_RESULT_PURPOSE, type ClientControlGrant, type ClientControlGrantLifecycle, type ClientResultBinding, type ClientVisibleState, type DeviceCommand, type PhoneNavigateArgs } from '../../clients/typescript/fractal-client';

export interface ClientControlPorts {
identity: { device: string; session: string };
Expand All @@ -10,7 +10,8 @@ export interface ClientControlPorts {
}
const v1Screen = (screen: string): boolean => screen === 'NowRoot' || screen === 'Attention';
export class ClientControlController {
private grant: ClientControlGrant | undefined;
private grant: ClientControlGrantLifecycle | undefined;
private lifecycles = new Map<string, ClientControlGrantLifecycle>();
private listeners = new Set<() => void>();
private expires: ReturnType<typeof setTimeout> | undefined;
private pending = Promise.resolve();
Expand All @@ -19,18 +20,35 @@ export class ClientControlController {
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 getGrant = (): ClientControlGrantLifecycle | undefined => this.grant;
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 lifecycle(row: ClientControlGrantLifecycle, snapshot: boolean): void {
if (this.disposed || this.revoked || row.device !== this.ports.identity.device || row.session !== this.ports.identity.session) return;
if (!Number.isSafeInteger(row.sequence) || row.sequence <= 0
|| !['started', 'ended', 'revoked'].includes(row.state)) { this.suspend(); return; }
const previous = this.lifecycles.get(row.grant_id);
if (previous && (row.run_id !== previous.run_id || row.item_id !== previous.item_id
|| row.sequence < previous.sequence || previous.state !== 'started'
|| row.sequence === previous.sequence && (!snapshot || row.state !== 'started'))) return;
this.lifecycles.set(row.grant_id, row);
if (row.state === 'started') {
this.suspend(); this.grant = row;
for (const listener of this.listeners) listener();
} else if (this.grant?.grant_id === row.grant_id) this.suspend();
}
private authorized(command: DeviceCommand, grant: ClientControlGrant): boolean {
const now = Date.now();
return !this.disposed && !this.revoked && grant.state === 'active'
const lifecycle = this.grant;
return !!lifecycle && lifecycle.state === 'started' && lifecycle.grant_id === grant.grant_id
&& lifecycle.device === grant.device && lifecycle.session === grant.session
&& lifecycle.run_id === grant.run_id && lifecycle.item_id === grant.item_id
&& !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
Expand All @@ -39,7 +57,8 @@ export class ClientControlController {
&& (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> => {
readonly handle = (command: DeviceCommand | ClientControlGrantLifecycle, snapshot = false): Promise<void> => {
if (command.kind === 'client-control-grant-lifecycle') { this.lifecycle(command, snapshot); return Promise.resolve(); }
const epoch = this.epoch;
const task = this.pending.then(() => epoch === this.epoch ? this.apply(command) : undefined);
this.pending = task.catch(() => {});
Expand All @@ -52,11 +71,10 @@ export class ClientControlController {
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);
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 current = () => epoch === this.epoch && 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;
Expand Down
Loading
Loading