From 49f7fe9b004ebe76a9df97f26d8c0dbabc69d6ff Mon Sep 17 00:00:00 2001
From: schickling-assistant
<261620128+schickling-assistant@users.noreply.github.com>
Date: Tue, 10 Nov 2026 16:55:44 +0100
Subject: [PATCH 1/3] feat(ios): gate test client control pending canonical
codegen
agent-identity: agent/compoundingtech/st-ios
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.12.5
agent-runtime: OMP 18.12.5
agent-model: openai-codex/gpt-6.1-sol
---
apps/ios/App.tsx | 3 +-
apps/ios/ClientControlIndicator.tsx | 38 ++++++++++++
apps/ios/ClientControlNative.tsx | 36 +++++++++++
apps/ios/README.md | 2 +
apps/ios/clientControl.test.mjs | 86 +++++++++++++++++++++++++++
apps/ios/clientControl.ts | 52 ++++++++++++++++
apps/ios/clientControlFeed.test.mjs | 33 ++++++++++
apps/ios/clientControlWire.ts | 21 +++++++
apps/ios/feed.ts | 21 +++++++
release-notes/ios-client-control.json | 11 ++++
10 files changed, 302 insertions(+), 1 deletion(-)
create mode 100644 apps/ios/ClientControlIndicator.tsx
create mode 100644 apps/ios/ClientControlNative.tsx
create mode 100644 apps/ios/clientControl.test.mjs
create mode 100644 apps/ios/clientControl.ts
create mode 100644 apps/ios/clientControlFeed.test.mjs
create mode 100644 apps/ios/clientControlWire.ts
create mode 100644 release-notes/ios-client-control.json
diff --git a/apps/ios/App.tsx b/apps/ios/App.tsx
index a4deff4a3aa..05481036c31 100644
--- a/apps/ios/App.tsx
+++ b/apps/ios/App.tsx
@@ -32,6 +32,7 @@ import { quality } from './qualityNative';
import { qualityVisibility } from './qualityView';
import { parseFabricProofLink, type FabricProofInput } from './fabricProof';
import { DiagnosticsBoundary } from './DiagnosticsBoundary';
+import { ClientControlNative } from './ClientControlNative';
// The chrome is native: one UITabBarController (react-native-screens' tabs, through
// @react-navigation/bottom-tabs' native navigator) holding a UINavigationController per tab
@@ -244,7 +245,7 @@ function AppContent() {
} /> :
-
+
}
;
}
diff --git a/apps/ios/ClientControlIndicator.tsx b/apps/ios/ClientControlIndicator.tsx
new file mode 100644
index 00000000000..dfa1bb10399
--- /dev/null
+++ b/apps/ios/ClientControlIndicator.tsx
@@ -0,0 +1,38 @@
+import { StyleSheet, View } from 'react-native';
+import { theme } from './theme';
+import { Button, T, useTextAppearance } from './ui';
+
+/** The app-internal view of an active client-control grant; not a wire type. */
+export type ClientControlGrant = { runId: string };
+
+/**
+ * Shown while a run may drive this client: what is controlling it, and how to stop it.
+ * Renders nothing without a grant. Test-mode only until a generated grant source exists.
+ */
+export function ClientControlIndicator({ grant, onRevoke }: { grant: ClientControlGrant | undefined; onRevoke: () => void }) {
+ const appearance = useTextAppearance();
+ if (!grant) return null;
+ return (
+
+
+ Controlled by run
+ {grant.runId}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ bar: { flexDirection: 'row', alignItems: 'center', gap: 12, borderLeftWidth: 3, paddingHorizontal: 12, paddingVertical: 8 },
+ label: { flex: 1, minWidth: 0 },
+ revoke: { marginTop: 0, alignSelf: 'center' },
+});
diff --git a/apps/ios/ClientControlNative.tsx b/apps/ios/ClientControlNative.tsx
new file mode 100644
index 00000000000..7b381223483
--- /dev/null
+++ b/apps/ios/ClientControlNative.tsx
@@ -0,0 +1,36 @@
+import { useEffect, useMemo, useSyncExternalStore } from 'react';
+import { AppState } from 'react-native';
+import Constants from 'expo-constants';
+import { ClientControlController, type VisibleState } from './clientControl';
+import { ClientControlIndicator } from './ClientControlIndicator';
+import { navigationRef } from './navigation';
+import { useStore } from './store';
+
+// Read the committed navigator, never echo the command's requested destination.
+const visible = (): VisibleState | undefined => {
+ if (!navigationRef.isReady()) return;
+ const route = navigationRef.getCurrentRoute();
+ if (route?.name === 'NowRoot') return { screen: 'NowRoot' };
+ const params = route?.params;
+ if (route?.name !== 'Conversation' || !params || !('target' in params) || typeof params.target !== 'string') return;
+ return { screen: 'Conversation', target: params.target,
+ ...('sessionId' in params && typeof params.sessionId === 'string' ? { sessionId: params.sessionId } : {}) };
+};
+
+export const ClientControlNative = () => {
+ const { feed, caps } = useStore();
+ const testMode = Constants.expoConfig?.extra?.testClientMode === true;
+ const granted = caps?.capabilities.some(capability => capability.id === 'phone-commands' && capability.version === 1 && capability.state === 'granted') === true;
+ const controller = useMemo(() => new ClientControlController({
+ foreground: () => AppState.currentState === 'active', visible,
+ navigate: args => {
+ if (!navigationRef.isReady()) return;
+ if (args.screen === 'NowRoot') navigationRef.navigate('Now', { screen: 'NowRoot' });
+ else if (args.screen === 'Conversation') navigationRef.navigate('Now', { screen: 'Conversation', params: args.params, initial: false });
+ },
+ }), [feed]);
+ useEffect(() => feed?.followCommands(testMode, granted, command => { void controller.handle(command); }), [feed, testMode, granted, controller]);
+ const grant = useSyncExternalStore(controller.subscribe, controller.getGrant);
+ if (!testMode) return null;
+ return { void controller.revoke(); }} />;
+};
diff --git a/apps/ios/README.md b/apps/ios/README.md
index d675550e14c..133b7913cc9 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -49,6 +49,8 @@ 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` and `phone-commands` v1 is granted. Missing test mode means the normal app: no commands subscription. Client control currently fails closed: the generated grant lifecycle, enrollment revoke and canonical signed-result contract are unavailable, so delivered commands neither navigate nor post results. The single `clientControlWire.ts` boundary owns those bindings. The internal run indicator stays hidden without an active grant; route observation reads committed navigation, not a requested destination. Pending codegen tests document the unavailable wire behaviors.
+
Observation reporting holds one send slot during both batch preparation and transport. Closing a measured minute can synchronously notify the reporter; that notification cannot prepare a second batch while the first is being formed. A null or empty batch releases the slot without delaying the next measurement.
## Layout
diff --git a/apps/ios/clientControl.test.mjs b/apps/ios/clientControl.test.mjs
new file mode 100644
index 00000000000..87b8d5c736d
--- /dev/null
+++ b/apps/ios/clientControl.test.mjs
@@ -0,0 +1,86 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { ClientControlController } from './clientControl.ts';
+import { authorizeClientControl, revokeClientControl } from './clientControlWire.ts';
+
+const command = (command_kind, args) => ({
+ kind: 'device-command',
+ id: `device-command/${command_kind}`,
+ revision: 'revision/one',
+ updated_at: '2026-11-10T12:00:00Z',
+ device_id: 'device/phone',
+ command_kind,
+ args,
+ state: 'delivered',
+ issued_at: '2026-11-10T12:00:00Z',
+ expires_at: '2026-11-10T12:00:30Z',
+ issued_by: 'person/operator',
+ session_actor: 'agent/operator',
+});
+const commands = [
+ command('phone.inspect', {}),
+ command('phone.navigate', { screen: 'NowRoot' }),
+ command('phone.navigate', { screen: 'Conversation', params: { target: 'agent/one', sessionId: 'session/one' } }),
+ command('phone.message.read', { message_id: 'message/one' }),
+];
+
+for (const foreground of [true, false]) {
+ test(`absent grant source denies every existing command (foreground=${foreground}) without effects or results`, async () => {
+ const effects = [];
+ const controller = new ClientControlController({
+ navigate: args => effects.push(['navigate', args]),
+ visible: () => { effects.push(['visible']); return { screen: 'NowRoot' }; },
+ foreground: () => { effects.push(['foreground']); return foreground; },
+ });
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (...args) => {
+ effects.push(['result/network', args]);
+ assert.fail('closed control must not post wire results');
+ };
+ try {
+ for (const row of commands) assert.equal(await controller.handle(row), undefined);
+ assert.equal(controller.getGrant(), undefined);
+ assert.deepEqual(effects, [], 'denial must not navigate, inspect, read foreground, or send results');
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+}
+
+test('wire adapter never treats a UI projection as grant authority', async () => {
+ const context = {
+ grant: { runId: 'run/untrusted-ui-projection', revokeState: 'available' },
+ foreground: () => assert.fail('closed adapter must not evaluate app ports'),
+ };
+ for (const row of commands) assert.equal(authorizeClientControl(row, context), false);
+ assert.equal(await revokeClientControl(context.grant), false);
+ assert.equal(await revokeClientControl(undefined), false);
+});
+
+test('grant store remains absent through revoke and subscription disposal', async () => {
+ const controller = new ClientControlController({
+ navigate: () => assert.fail('unexpected navigation'),
+ visible: () => assert.fail('unexpected inspection'),
+ foreground: () => assert.fail('unexpected foreground read'),
+ });
+ let updates = 0;
+ const unsubscribe = controller.subscribe(() => updates++);
+ assert.equal(controller.getGrant(), undefined);
+ await controller.revoke();
+ assert.equal(controller.getGrant(), undefined, 'failed wire revoke must not manufacture a grant or enrollment transition');
+ assert.equal(updates, 0);
+ unsubscribe();
+ await controller.revoke();
+ assert.equal(updates, 0);
+});
+
+for (const behavior of [
+ 'authoritative grant lifecycle: start, expiry, removal, reconnect and device binding',
+ 'wrong-run, non-granted screen/verb, stale, replayed and revoked command rejection',
+ 'canonical signed command/result vectors and signature verification',
+ 'revoke acknowledgement and failure without claiming enrollment ended',
+ 'not-foreground signed wire results without navigation',
+ 'inspect/navigate signed wire results from the committed visible route',
+]) {
+ test.todo(`#6274 codegen required: ${behavior}`);
+}
diff --git a/apps/ios/clientControl.ts b/apps/ios/clientControl.ts
new file mode 100644
index 00000000000..a42c7f9108e
--- /dev/null
+++ b/apps/ios/clientControl.ts
@@ -0,0 +1,52 @@
+import type { DeviceCommand, PhoneNavigateArgs } from '../../clients/typescript/fractal-client';
+import { authorizeClientControl, revokeClientControl } from './clientControlWire.ts';
+
+/** App-local committed route; item IDs are present only when actually observed. */
+export type VisibleState =
+ | { readonly screen: 'NowRoot'; readonly itemIds?: readonly string[] }
+ | { readonly screen: 'Conversation'; readonly target: string; readonly sessionId?: string; readonly itemIds?: readonly string[] };
+
+/** UI projection only. This is not a wire grant and cannot establish authority. */
+export interface ActiveControlGrant {
+ readonly runId: string;
+ readonly revokeState: 'available' | 'pending' | 'failed';
+}
+
+export interface ClientControlPorts {
+ navigate(args: PhoneNavigateArgs): void;
+ visible(): VisibleState | undefined;
+ foreground(): boolean;
+}
+
+export class ClientControlController {
+ constructor(private readonly ports: ClientControlPorts) {}
+
+ // #6274: no authoritative generated grant source exists. The snapshot is
+ // permanently absent, so there are no store changes to subscribe to yet.
+ readonly getGrant = (): ActiveControlGrant | undefined => undefined;
+ readonly subscribe = (_listener: () => void): (() => void) => () => {};
+
+ readonly handle = async (command: DeviceCommand): Promise => {
+ const grant = this.getGrant();
+ if (!authorizeClientControl(command, { grant, foreground: this.ports.foreground })) return;
+ if (!grant || !this.ports.foreground()) return;
+ switch (command.command_kind) {
+ case 'phone.inspect':
+ this.ports.visible();
+ break;
+ case 'phone.navigate':
+ if (command.args.screen !== 'NowRoot' && command.args.screen !== 'Conversation') return;
+ this.ports.navigate(command.args);
+ break;
+ case 'phone.message.read':
+ // Message bodies are outside this controller's visible-route ports.
+ break;
+ }
+ // No result is posted: signing/encoding must come from #6274 codegen.
+ };
+
+ readonly revoke = async (): Promise => {
+ // Do not clear UI state or claim enrollment ended when wire revoke is absent.
+ await revokeClientControl(this.getGrant());
+ };
+}
diff --git a/apps/ios/clientControlFeed.test.mjs b/apps/ios/clientControlFeed.test.mjs
new file mode 100644
index 00000000000..2bdbb4d6e5d
--- /dev/null
+++ b/apps/ios/clientControlFeed.test.mjs
@@ -0,0 +1,33 @@
+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}`, { todo: '#6274 codegen: base client index exports absent ClientControl.generated.ts' }, 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 = [];
+ const stop = feed.followCommands(testMode, granted, command => received.push(command));
+ 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);
+ 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.ts b/apps/ios/clientControlWire.ts
new file mode 100644
index 00000000000..00fc7f8a018
--- /dev/null
+++ b/apps/ios/clientControlWire.ts
@@ -0,0 +1,21 @@
+import type { DeviceCommand } from '../../clients/typescript/fractal-client';
+import type { ActiveControlGrant } from './clientControl.ts';
+
+/** Internal authorization inputs, not an alternate wire grant or signature schema. */
+export interface ControlAuthorizationContext {
+ readonly grant: ActiveControlGrant | undefined;
+ readonly foreground: () => boolean;
+}
+
+/**
+ * #6274 must generate the authoritative grant, lifecycle, signature and result
+ * contracts before this boundary can authorize anything. Existing DeviceCommand
+ * rows alone are not proof of a grant. Do not infer authority from their fields.
+ */
+export const authorizeClientControl = (
+ _command: DeviceCommand,
+ _context: ControlAuthorizationContext,
+): boolean => false;
+
+/** No wire revoke exists yet; false must never be presented as a successful revoke. */
+export const revokeClientControl = async (_grant: ActiveControlGrant | undefined): Promise => false;
diff --git a/apps/ios/feed.ts b/apps/ios/feed.ts
index 0838df5123e..25b82d7bcdf 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,7 @@ export class Feed {
private live = false;
private timer: ReturnType | undefined;
private stream: CollectionStream | undefined;
+ private commands: ((command: DeviceCommand) => void) | undefined;
private cancelOpen: (() => void) | undefined;
private windows: Partial> = {};
private terminal: TerminalFollow | undefined;
@@ -181,6 +183,18 @@ 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): () => void {
+ if (!testMode || !granted) return () => {};
+ this.commands = receive;
+ this.stream?.subscribe('commands', 'commands');
+ return () => {
+ if (this.commands !== receive) return;
+ 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;
@@ -343,6 +357,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 +416,12 @@ export class Feed {
}
private frame(frame: CollectionFrame, received: number): void {
+ if ((frame.kind === 'snapshot' || frame.kind === 'changes') && frame.collection === 'commands' && frame.id === 'commands') {
+ for (const command of frame.kind === 'snapshot' ? frame.items : frame.upserts) {
+ if (command.kind === 'device-command' && command.state === 'delivered') 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..37539e6dd38
--- /dev/null
+++ b/release-notes/ios-client-control.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "summary": "Gate iOS test-client commands on test mode and capability; keep control disabled until the generated grant and signed-result contract is available",
+ "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 and canonical codegen are prerequisites."},
+ "recovery": {"status": "none", "detail": "No persisted state changes."}
+}
From d44453db835ad5f8ee17f9903aed0d2dfea98fdf Mon Sep 17 00:00:00 2001
From: schickling-assistant
<261620128+schickling-assistant@users.noreply.github.com>
Date: Tue, 10 Nov 2026 17:19:35 +0100
Subject: [PATCH 2/3] feat(ios): restrict client control to fixture request
navigation
agent-identity: agent/compoundingtech/st-ios
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.12.5
agent-runtime: OMP 18.12.5
agent-model: openai-codex/gpt-6.1-sol
---
apps/ios/ClientControlNative.tsx | 7 +-
apps/ios/README.md | 2 +
apps/ios/clientControl.test.mjs | 103 ++++++++------------------
apps/ios/clientControl.ts | 32 ++++----
apps/ios/clientControlRender.test.mjs | 41 ++++++++++
5 files changed, 93 insertions(+), 92 deletions(-)
create mode 100644 apps/ios/clientControlRender.test.mjs
diff --git a/apps/ios/ClientControlNative.tsx b/apps/ios/ClientControlNative.tsx
index 7b381223483..42b0049aef0 100644
--- a/apps/ios/ClientControlNative.tsx
+++ b/apps/ios/ClientControlNative.tsx
@@ -12,9 +12,8 @@ const visible = (): VisibleState | undefined => {
const route = navigationRef.getCurrentRoute();
if (route?.name === 'NowRoot') return { screen: 'NowRoot' };
const params = route?.params;
- if (route?.name !== 'Conversation' || !params || !('target' in params) || typeof params.target !== 'string') return;
- return { screen: 'Conversation', target: params.target,
- ...('sessionId' in params && typeof params.sessionId === 'string' ? { sessionId: params.sessionId } : {}) };
+ if (route?.name !== 'Attention' || !params || !('id' in params) || typeof params.id !== 'string') return;
+ return { screen: 'Attention', id: params.id };
};
export const ClientControlNative = () => {
@@ -26,7 +25,7 @@ export const ClientControlNative = () => {
navigate: args => {
if (!navigationRef.isReady()) return;
if (args.screen === 'NowRoot') navigationRef.navigate('Now', { screen: 'NowRoot' });
- else if (args.screen === 'Conversation') navigationRef.navigate('Now', { screen: 'Conversation', params: args.params, initial: false });
+ else if (args.screen === 'Attention') navigationRef.navigate('Now', { screen: 'Attention', params: args.params, initial: false });
},
}), [feed]);
useEffect(() => feed?.followCommands(testMode, granted, command => { void controller.handle(command); }), [feed, testMode, granted, controller]);
diff --git a/apps/ios/README.md b/apps/ios/README.md
index 133b7913cc9..2d5c9a815dd 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -51,6 +51,8 @@ The fifteen-second collections opening deadline includes the WebSocket handshake
Commands use this same session-scoped socket only when Expo extra `testClientMode` is exactly `true` and `phone-commands` v1 is granted. Missing test mode means the normal app: no commands subscription. Client control currently fails closed: the generated grant lifecycle, enrollment revoke and canonical signed-result contract are unavailable, so delivered commands neither navigate nor post results. The single `clientControlWire.ts` boundary owns those bindings. The internal run indicator stays hidden without an active grant; route observation reads committed navigation, not a requested destination. Pending codegen tests document the unavailable wire behaviors.
+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.
+
Observation reporting holds one send slot during both batch preparation and transport. Closing a measured minute can synchronously notify the reporter; that notification cannot prepare a second batch while the first is being formed. A null or empty batch releases the slot without delaying the next measurement.
## Layout
diff --git a/apps/ios/clientControl.test.mjs b/apps/ios/clientControl.test.mjs
index 87b8d5c736d..9bd5a345363 100644
--- a/apps/ios/clientControl.test.mjs
+++ b/apps/ios/clientControl.test.mjs
@@ -1,86 +1,41 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { ClientControlController } from './clientControl.ts';
+import { ClientControlController, applyGrantedClientControl } from './clientControl.ts';
import { authorizeClientControl, revokeClientControl } from './clientControlWire.ts';
-const command = (command_kind, args) => ({
- kind: 'device-command',
- id: `device-command/${command_kind}`,
- revision: 'revision/one',
- updated_at: '2026-11-10T12:00:00Z',
- device_id: 'device/phone',
- command_kind,
- args,
- state: 'delivered',
- issued_at: '2026-11-10T12:00:00Z',
- expires_at: '2026-11-10T12:00:30Z',
- issued_by: 'person/operator',
- session_actor: 'agent/operator',
-});
-const commands = [
- command('phone.inspect', {}),
- command('phone.navigate', { screen: 'NowRoot' }),
- command('phone.navigate', { screen: 'Conversation', params: { target: 'agent/one', sessionId: 'session/one' } }),
- command('phone.message.read', { message_id: 'message/one' }),
-];
-
-for (const foreground of [true, false]) {
- test(`absent grant source denies every existing command (foreground=${foreground}) without effects or results`, async () => {
- const effects = [];
- const controller = new ClientControlController({
- navigate: args => effects.push(['navigate', args]),
- visible: () => { effects.push(['visible']); return { screen: 'NowRoot' }; },
- foreground: () => { effects.push(['foreground']); return foreground; },
- });
- const originalFetch = globalThis.fetch;
- globalThis.fetch = async (...args) => {
- effects.push(['result/network', args]);
- assert.fail('closed control must not post wire results');
- };
- try {
- for (const row of commands) assert.equal(await controller.handle(row), undefined);
- assert.equal(controller.getGrant(), undefined);
- assert.deepEqual(effects, [], 'denial must not navigate, inspect, read foreground, or send results');
- } finally {
- globalThis.fetch = originalFetch;
- }
- });
-}
+const command = (command_kind, args = {}) => ({ kind: 'device-command', id: 'device-command/fixture', command_kind, args, state: 'delivered' });
+const scope = { verbs: ['phone.inspect', 'phone.navigate'], screens: ['NowRoot', 'Attention'] };
+const requests = [command('phone.inspect'), command('phone.navigate', { screen: 'NowRoot' }), command('phone.navigate', { screen: 'Attention', params: { id: 'attention/fixture' } })];
+const refused = [command('phone.navigate', { screen: 'Conversation', params: { target: 'agent/fixture' } }), ...['phone.message.read', 'work.done', 'prompt.respond', 'answer', 'close', 'message.read'].map(verb => command(verb))];
+const portsFor = effects => ({ foreground: () => true, visible: () => { effects.push('inspect'); return { screen: 'NowRoot' }; }, navigate: args => effects.push(args), answer: () => assert.fail('answer'), close: () => assert.fail('close'), read: () => assert.fail('read') });
-test('wire adapter never treats a UI projection as grant authority', async () => {
- const context = {
- grant: { runId: 'run/untrusted-ui-projection', revokeState: 'available' },
- foreground: () => assert.fail('closed adapter must not evaluate app ports'),
- };
- for (const row of commands) assert.equal(authorizeClientControl(row, context), false);
- assert.equal(await revokeClientControl(context.grant), false);
- assert.equal(await revokeClientControl(undefined), false);
+test('local grant permits only pure inspect/NowRoot/Attention; no answer, close, read or result API', () => {
+ const effects = [], ports = portsFor(effects), originalFetch = globalThis.fetch;
+ globalThis.fetch = () => assert.fail('no answer/read/close/signed-result API call');
+ try {
+ for (const row of refused) applyGrantedClientControl(row, scope, ports);
+ assert.deepEqual(effects, [], 'Conversation and every mutating verb are refused without results');
+ for (const row of requests) applyGrantedClientControl(row, scope, ports);
+ assert.deepEqual(effects, ['inspect', requests[1].args, requests[2].args]);
+ effects.length = 0;
+ applyGrantedClientControl(requests[2], { ...scope, screens: ['NowRoot'] }, ports);
+ applyGrantedClientControl(requests[0], { ...scope, verbs: [] }, ports);
+ applyGrantedClientControl(requests[2], scope, { ...ports, foreground: () => false });
+ assert.deepEqual(effects, []);
+ } finally { globalThis.fetch = originalFetch; }
});
-test('grant store remains absent through revoke and subscription disposal', async () => {
- const controller = new ClientControlController({
- navigate: () => assert.fail('unexpected navigation'),
- visible: () => assert.fail('unexpected inspection'),
- foreground: () => assert.fail('unexpected foreground read'),
- });
- let updates = 0;
- const unsubscribe = controller.subscribe(() => updates++);
+for (const foreground of [true, false]) test(`absent authoritative source denies all commands (foreground=${foreground})`, async () => {
+ const effects = [], controller = new ClientControlController({ ...portsFor(effects), foreground: () => foreground });
+ for (const row of [...requests, ...refused]) await controller.handle(row);
+ assert.deepEqual(effects, []);
assert.equal(controller.getGrant(), undefined);
- await controller.revoke();
- assert.equal(controller.getGrant(), undefined, 'failed wire revoke must not manufacture a grant or enrollment transition');
- assert.equal(updates, 0);
- unsubscribe();
- await controller.revoke();
- assert.equal(updates, 0);
+ const stop = controller.subscribe(() => assert.fail('no grant source'));
+ await controller.revoke(); stop();
+ assert.equal(await revokeClientControl(undefined), false);
+ assert.equal(authorizeClientControl(requests[0], { grant: { runId: 'run/ui-only', revokeState: 'available' }, foreground: () => foreground }), false);
});
-for (const behavior of [
- 'authoritative grant lifecycle: start, expiry, removal, reconnect and device binding',
- 'wrong-run, non-granted screen/verb, stale, replayed and revoked command rejection',
- 'canonical signed command/result vectors and signature verification',
- 'revoke acknowledgement and failure without claiming enrollment ended',
- 'not-foreground signed wire results without navigation',
- 'inspect/navigate signed wire results from the committed visible route',
-]) {
+for (const behavior of ['grant start/end/expiry/revoke and device/session binding', 'wrong run and generated grant-scoped screen/verb refusals', 'canonical signed vectors and replay rejection', 'enrollment revoke acknowledgement', 'not-foreground signed result', 'committed visible state and local-policy wire-binding variants']) {
test.todo(`#6274 codegen required: ${behavior}`);
}
diff --git a/apps/ios/clientControl.ts b/apps/ios/clientControl.ts
index a42c7f9108e..0e883fe39d4 100644
--- a/apps/ios/clientControl.ts
+++ b/apps/ios/clientControl.ts
@@ -4,7 +4,7 @@ import { authorizeClientControl, revokeClientControl } from './clientControlWire
/** App-local committed route; item IDs are present only when actually observed. */
export type VisibleState =
| { readonly screen: 'NowRoot'; readonly itemIds?: readonly string[] }
- | { readonly screen: 'Conversation'; readonly target: string; readonly sessionId?: string; readonly itemIds?: readonly string[] };
+ | { readonly screen: 'Attention'; readonly id: string; readonly itemIds?: readonly string[] };
/** UI projection only. This is not a wire grant and cannot establish authority. */
export interface ActiveControlGrant {
@@ -18,6 +18,21 @@ export interface ClientControlPorts {
foreground(): boolean;
}
+/** Pure navigation only: no answer, close, read-state or result capability. */
+export const navigateClientControl = (args: PhoneNavigateArgs, navigate: ClientControlPorts['navigate']): boolean => {
+ if (args.screen !== 'NowRoot' && args.screen !== 'Attention') return false;
+ navigate(args);
+ return true;
+};
+
+/** Local policy only; the wire adapter must independently establish grant authority. */
+export const applyGrantedClientControl = (command: DeviceCommand, scope: { verbs: readonly string[]; screens: readonly string[] }, ports: ClientControlPorts): void => {
+ if (!scope.verbs.includes(command.command_kind) || !ports.foreground()) return;
+ if (command.command_kind === 'phone.inspect') { ports.visible(); return; }
+ if (command.command_kind !== 'phone.navigate' || !scope.screens.includes(command.args.screen)) return;
+ navigateClientControl(command.args, ports.navigate);
+};
+
export class ClientControlController {
constructor(private readonly ports: ClientControlPorts) {}
@@ -29,19 +44,8 @@ export class ClientControlController {
readonly handle = async (command: DeviceCommand): Promise => {
const grant = this.getGrant();
if (!authorizeClientControl(command, { grant, foreground: this.ports.foreground })) return;
- if (!grant || !this.ports.foreground()) return;
- switch (command.command_kind) {
- case 'phone.inspect':
- this.ports.visible();
- break;
- case 'phone.navigate':
- if (command.args.screen !== 'NowRoot' && command.args.screen !== 'Conversation') return;
- this.ports.navigate(command.args);
- break;
- case 'phone.message.read':
- // Message bodies are outside this controller's visible-route ports.
- break;
- }
+ if (!grant) return;
+ applyGrantedClientControl(command, { verbs: ['phone.inspect', 'phone.navigate'], screens: ['NowRoot', 'Attention'] }, this.ports);
// No result is posted: signing/encoding must come from #6274 codegen.
};
diff --git a/apps/ios/clientControlRender.test.mjs b/apps/ios/clientControlRender.test.mjs
new file mode 100644
index 00000000000..95d91ed1d3b
--- /dev/null
+++ b/apps/ios/clientControlRender.test.mjs
@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { readFileSync } from 'node:fs';
+import ts from 'typescript';
+import { markdown } from './markdown.ts';
+import { report, spaced } from '../../clients/typescript/fractal-views/requestView.ts';
+
+// Execute the shipped JSX definitions; host Text roots model native staticText
+// (nested styled Text runs belong to their containing native text element).
+const source = path => ts.createSourceFile(path, readFileSync(new URL(path, import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+const find = (node, predicate) => {
+ if (predicate(node)) return node;
+ return ts.forEachChild(node, child => find(child, predicate));
+};
+const definitions = (file, names) => names.map(name => {
+ const node = find(file, node => ts.isFunctionDeclaration(node) && node.name?.text === name);
+ assert.ok(node, `shipped function ${name}`);
+ return `export ${node.getText(file).replace(/^export /, '')}`;
+}).join('\n');
+const React = { Fragment: 'Fragment', createElement: (type, props, ...children) => typeof type === 'function' ? type({ ...props, children }) : { type, children } };
+const evaluate = (code, environment) => new Function(...Object.keys(environment), 'exports', ts.transpileModule(code, { compilerOptions: { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS } }).outputText + '\nreturn exports;')(...Object.values(environment), {});
+const flatten = node => Array.isArray(node) ? node.map(flatten).join('') : typeof node === 'string' ? node : node?.children ? flatten(node.children) : '';
+const textElements = node => Array.isArray(node) ? node.flatMap(textElements) : node?.type === 'Text' ? [flatten(node)] : node?.children ? textElements(node.children) : [];
+
+test('fixture question and request title render as one exact native text root on shipped paths', () => {
+ const ui = source('./ui.tsx'), alerts = source('./screens/Alerts.tsx'), now = source('./screens/Now.tsx');
+ const appearance = { system: true, color: color => color, colors: {}, palette: {} };
+ const environment = { React, Text: 'Text', View: 'View', markdown, report, spaced, useTextAppearance: () => appearance, theme: {}, styles: { text: {}, bold: {} }, openDocumentLink() {} };
+ const components = evaluate(definitions(ui, ['T', 'Runs', 'Markdown']), environment);
+ const { RequestQuestion } = evaluate(definitions(alerts, ['RequestQuestion']), { ...environment, ...components });
+ const question = 'client-control fixture 0001';
+ assert.equal(report(question), null);
+ assert.deepEqual(textElements(RequestQuestion({ text: question })), [question]);
+ const screen = find(now, node => ts.isFunctionDeclaration(node) && node.name?.text === 'AttentionScreen');
+ const requestBranch = find(screen, node => ts.isIfStatement(node) && node.expression.getText(now).startsWith('isRequest(item.attention_kind)'));
+ const title = find(requestBranch, node => ts.isJsxElement(node) && node.getText(now) === '{row.title}');
+ assert.ok(title, 'existing request title element at Now.tsx request branch');
+ const titleText = 'client-control fixture ask';
+ const rendered = evaluate(`export const title = () => (${title.getText(now)});`, { ...environment, ...components, row: { title: titleText } }).title();
+ assert.deepEqual(textElements(rendered), [titleText]);
+});
From bc2e3076a4b972b64a19b2ec1ee57588dc8b604c Mon Sep 17 00:00:00 2001
From: schickling-assistant
<261620128+schickling-assistant@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:09:49 +0100
Subject: [PATCH 3/3] feat(ios): validate canonical command grants and signed
results
agent-identity: agent/compoundingtech/st-ios
agent-persona: generalist
agent-supervisor: unavailable
agent-tool: OMP
agent-tool-version: 18.12.5
agent-runtime: OMP 18.12.5
agent-model: openai-codex/gpt-6.1-sol
---
apps/ios/App.tsx | 3 +-
apps/ios/ClientControlIndicator.tsx | 38 ---------
apps/ios/ClientControlNative.tsx | 35 ---------
apps/ios/README.md | 4 -
apps/ios/clientControl.test.mjs | 86 ++++++++++++--------
apps/ios/clientControl.ts | 108 +++++++++++++++-----------
apps/ios/clientControlFeed.test.mjs | 7 +-
apps/ios/clientControlRender.test.mjs | 41 ----------
apps/ios/clientControlWire.test.mjs | 56 +++++++++++++
apps/ios/clientControlWire.ts | 45 ++++++-----
apps/ios/feed.ts | 10 ++-
release-notes/ios-client-control.json | 6 +-
12 files changed, 212 insertions(+), 227 deletions(-)
delete mode 100644 apps/ios/ClientControlIndicator.tsx
delete mode 100644 apps/ios/ClientControlNative.tsx
delete mode 100644 apps/ios/clientControlRender.test.mjs
create mode 100644 apps/ios/clientControlWire.test.mjs
diff --git a/apps/ios/App.tsx b/apps/ios/App.tsx
index 05481036c31..a4deff4a3aa 100644
--- a/apps/ios/App.tsx
+++ b/apps/ios/App.tsx
@@ -32,7 +32,6 @@ import { quality } from './qualityNative';
import { qualityVisibility } from './qualityView';
import { parseFabricProofLink, type FabricProofInput } from './fabricProof';
import { DiagnosticsBoundary } from './DiagnosticsBoundary';
-import { ClientControlNative } from './ClientControlNative';
// The chrome is native: one UITabBarController (react-native-screens' tabs, through
// @react-navigation/bottom-tabs' native navigator) holding a UINavigationController per tab
@@ -245,7 +244,7 @@ function AppContent() {
} /> :
-
+
}
;
}
diff --git a/apps/ios/ClientControlIndicator.tsx b/apps/ios/ClientControlIndicator.tsx
deleted file mode 100644
index dfa1bb10399..00000000000
--- a/apps/ios/ClientControlIndicator.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { StyleSheet, View } from 'react-native';
-import { theme } from './theme';
-import { Button, T, useTextAppearance } from './ui';
-
-/** The app-internal view of an active client-control grant; not a wire type. */
-export type ClientControlGrant = { runId: string };
-
-/**
- * Shown while a run may drive this client: what is controlling it, and how to stop it.
- * Renders nothing without a grant. Test-mode only until a generated grant source exists.
- */
-export function ClientControlIndicator({ grant, onRevoke }: { grant: ClientControlGrant | undefined; onRevoke: () => void }) {
- const appearance = useTextAppearance();
- if (!grant) return null;
- return (
-
-
- Controlled by run
- {grant.runId}
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- bar: { flexDirection: 'row', alignItems: 'center', gap: 12, borderLeftWidth: 3, paddingHorizontal: 12, paddingVertical: 8 },
- label: { flex: 1, minWidth: 0 },
- revoke: { marginTop: 0, alignSelf: 'center' },
-});
diff --git a/apps/ios/ClientControlNative.tsx b/apps/ios/ClientControlNative.tsx
deleted file mode 100644
index 42b0049aef0..00000000000
--- a/apps/ios/ClientControlNative.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { useEffect, useMemo, useSyncExternalStore } from 'react';
-import { AppState } from 'react-native';
-import Constants from 'expo-constants';
-import { ClientControlController, type VisibleState } from './clientControl';
-import { ClientControlIndicator } from './ClientControlIndicator';
-import { navigationRef } from './navigation';
-import { useStore } from './store';
-
-// Read the committed navigator, never echo the command's requested destination.
-const visible = (): VisibleState | undefined => {
- if (!navigationRef.isReady()) return;
- const route = navigationRef.getCurrentRoute();
- if (route?.name === 'NowRoot') return { screen: 'NowRoot' };
- const params = route?.params;
- if (route?.name !== 'Attention' || !params || !('id' in params) || typeof params.id !== 'string') return;
- return { screen: 'Attention', id: params.id };
-};
-
-export const ClientControlNative = () => {
- const { feed, caps } = useStore();
- const testMode = Constants.expoConfig?.extra?.testClientMode === true;
- const granted = caps?.capabilities.some(capability => capability.id === 'phone-commands' && capability.version === 1 && capability.state === 'granted') === true;
- const controller = useMemo(() => new ClientControlController({
- foreground: () => AppState.currentState === 'active', visible,
- navigate: args => {
- if (!navigationRef.isReady()) return;
- if (args.screen === 'NowRoot') navigationRef.navigate('Now', { screen: 'NowRoot' });
- else if (args.screen === 'Attention') navigationRef.navigate('Now', { screen: 'Attention', params: args.params, initial: false });
- },
- }), [feed]);
- useEffect(() => feed?.followCommands(testMode, granted, command => { void controller.handle(command); }), [feed, testMode, granted, controller]);
- const grant = useSyncExternalStore(controller.subscribe, controller.getGrant);
- if (!testMode) return null;
- return { void controller.revoke(); }} />;
-};
diff --git a/apps/ios/README.md b/apps/ios/README.md
index 2d5c9a815dd..d675550e14c 100644
--- a/apps/ios/README.md
+++ b/apps/ios/README.md
@@ -49,10 +49,6 @@ 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` and `phone-commands` v1 is granted. Missing test mode means the normal app: no commands subscription. Client control currently fails closed: the generated grant lifecycle, enrollment revoke and canonical signed-result contract are unavailable, so delivered commands neither navigate nor post results. The single `clientControlWire.ts` boundary owns those bindings. The internal run indicator stays hidden without an active grant; route observation reads committed navigation, not a requested destination. Pending codegen tests document the unavailable wire behaviors.
-
-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.
-
Observation reporting holds one send slot during both batch preparation and transport. Closing a measured minute can synchronously notify the reporter; that notification cannot prepare a second batch while the first is being formed. A null or empty batch releases the slot without delaying the next measurement.
## Layout
diff --git a/apps/ios/clientControl.test.mjs b/apps/ios/clientControl.test.mjs
index 9bd5a345363..beeca42abb4 100644
--- a/apps/ios/clientControl.test.mjs
+++ b/apps/ios/clientControl.test.mjs
@@ -1,41 +1,61 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { ClientControlController, applyGrantedClientControl } from './clientControl.ts';
-import { authorizeClientControl, revokeClientControl } from './clientControlWire.ts';
+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 };
+};
-const command = (command_kind, args = {}) => ({ kind: 'device-command', id: 'device-command/fixture', command_kind, args, state: 'delivered' });
-const scope = { verbs: ['phone.inspect', 'phone.navigate'], screens: ['NowRoot', 'Attention'] };
-const requests = [command('phone.inspect'), command('phone.navigate', { screen: 'NowRoot' }), command('phone.navigate', { screen: 'Attention', params: { id: 'attention/fixture' } })];
-const refused = [command('phone.navigate', { screen: 'Conversation', params: { target: 'agent/fixture' } }), ...['phone.message.read', 'work.done', 'prompt.respond', 'answer', 'close', 'message.read'].map(verb => command(verb))];
-const portsFor = effects => ({ foreground: () => true, visible: () => { effects.push('inspect'); return { screen: 'NowRoot' }; }, navigate: args => effects.push(args), answer: () => assert.fail('answer'), close: () => assert.fail('close'), read: () => assert.fail('read') });
+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('local grant permits only pure inspect/NowRoot/Attention; no answer, close, read or result API', () => {
- const effects = [], ports = portsFor(effects), originalFetch = globalThis.fetch;
- globalThis.fetch = () => assert.fail('no answer/read/close/signed-result API call');
+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 {
- for (const row of refused) applyGrantedClientControl(row, scope, ports);
- assert.deepEqual(effects, [], 'Conversation and every mutating verb are refused without results');
- for (const row of requests) applyGrantedClientControl(row, scope, ports);
- assert.deepEqual(effects, ['inspect', requests[1].args, requests[2].args]);
- effects.length = 0;
- applyGrantedClientControl(requests[2], { ...scope, screens: ['NowRoot'] }, ports);
- applyGrantedClientControl(requests[0], { ...scope, verbs: [] }, ports);
- applyGrantedClientControl(requests[2], scope, { ...ports, foreground: () => false });
- assert.deepEqual(effects, []);
+ 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; }
});
-
-for (const foreground of [true, false]) test(`absent authoritative source denies all commands (foreground=${foreground})`, async () => {
- const effects = [], controller = new ClientControlController({ ...portsFor(effects), foreground: () => foreground });
- for (const row of [...requests, ...refused]) await controller.handle(row);
- assert.deepEqual(effects, []);
- assert.equal(controller.getGrant(), undefined);
- const stop = controller.subscribe(() => assert.fail('no grant source'));
- await controller.revoke(); stop();
- assert.equal(await revokeClientControl(undefined), false);
- assert.equal(authorizeClientControl(requests[0], { grant: { runId: 'run/ui-only', revokeState: 'available' }, foreground: () => foreground }), false);
+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');
});
-
-for (const behavior of ['grant start/end/expiry/revoke and device/session binding', 'wrong run and generated grant-scoped screen/verb refusals', 'canonical signed vectors and replay rejection', 'enrollment revoke acknowledgement', 'not-foreground signed result', 'committed visible state and local-policy wire-binding variants']) {
- test.todo(`#6274 codegen required: ${behavior}`);
-}
+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
index 0e883fe39d4..a7b971c2901 100644
--- a/apps/ios/clientControl.ts
+++ b/apps/ios/clientControl.ts
@@ -1,56 +1,70 @@
-import type { DeviceCommand, PhoneNavigateArgs } from '../../clients/typescript/fractal-client';
-import { authorizeClientControl, revokeClientControl } from './clientControlWire.ts';
-
-/** App-local committed route; item IDs are present only when actually observed. */
-export type VisibleState =
- | { readonly screen: 'NowRoot'; readonly itemIds?: readonly string[] }
- | { readonly screen: 'Attention'; readonly id: string; readonly itemIds?: readonly string[] };
-
-/** UI projection only. This is not a wire grant and cannot establish authority. */
-export interface ActiveControlGrant {
- readonly runId: string;
- readonly revokeState: 'available' | 'pending' | 'failed';
-}
+import { CLIENT_RESULT_PURPOSE, type ClientControlGrant, type ClientResultBinding, type ClientVisibleState, type DeviceCommand, type PhoneNavigateArgs } from '../../clients/typescript/fractal-client';
export interface ClientControlPorts {
- navigate(args: PhoneNavigateArgs): void;
- visible(): VisibleState | undefined;
+ identity: { device: string; session: string };
foreground(): boolean;
+ visible(): ClientVisibleState | undefined;
+ navigate(args: PhoneNavigateArgs): Promise;
+ result(binding: ClientResultBinding, current: () => boolean): Promise;
+ revoke(): Promise;
}
-
-/** Pure navigation only: no answer, close, read-state or result capability. */
-export const navigateClientControl = (args: PhoneNavigateArgs, navigate: ClientControlPorts['navigate']): boolean => {
- if (args.screen !== 'NowRoot' && args.screen !== 'Attention') return false;
- navigate(args);
- return true;
-};
-
-/** Local policy only; the wire adapter must independently establish grant authority. */
-export const applyGrantedClientControl = (command: DeviceCommand, scope: { verbs: readonly string[]; screens: readonly string[] }, ports: ClientControlPorts): void => {
- if (!scope.verbs.includes(command.command_kind) || !ports.foreground()) return;
- if (command.command_kind === 'phone.inspect') { ports.visible(); return; }
- if (command.command_kind !== 'phone.navigate' || !scope.screens.includes(command.args.screen)) return;
- navigateClientControl(command.args, ports.navigate);
-};
-
+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) {}
-
- // #6274: no authoritative generated grant source exists. The snapshot is
- // permanently absent, so there are no store changes to subscribe to yet.
- readonly getGrant = (): ActiveControlGrant | undefined => undefined;
- readonly subscribe = (_listener: () => void): (() => void) => () => {};
-
- readonly handle = async (command: DeviceCommand): Promise => {
- const grant = this.getGrant();
- if (!authorizeClientControl(command, { grant, foreground: this.ports.foreground })) return;
- if (!grant) return;
- applyGrantedClientControl(command, { verbs: ['phone.inspect', 'phone.navigate'], screens: ['NowRoot', 'Attention'] }, this.ports);
- // No result is posted: signing/encoding must come from #6274 codegen.
+ // 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 revoke = async (): Promise => {
- // Do not clear UI state or claim enrollment ended when wire revoke is absent.
- await revokeClientControl(this.getGrant());
+ 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
index 2bdbb4d6e5d..c20be90b9f6 100644
--- a/apps/ios/clientControlFeed.test.mjs
+++ b/apps/ios/clientControlFeed.test.mjs
@@ -5,13 +5,13 @@ 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}`, { todo: '#6274 codegen: base client index exports absent ClientControl.generated.ts' }, async () => {
+ 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 = [];
- const stop = feed.followCommands(testMode, granted, command => received.push(command));
+ 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);
@@ -21,6 +21,7 @@ for (const [testMode, granted] of [[undefined, true], [false, false], [false, tr
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');
diff --git a/apps/ios/clientControlRender.test.mjs b/apps/ios/clientControlRender.test.mjs
deleted file mode 100644
index 95d91ed1d3b..00000000000
--- a/apps/ios/clientControlRender.test.mjs
+++ /dev/null
@@ -1,41 +0,0 @@
-import assert from 'node:assert/strict';
-import { test } from 'node:test';
-import { readFileSync } from 'node:fs';
-import ts from 'typescript';
-import { markdown } from './markdown.ts';
-import { report, spaced } from '../../clients/typescript/fractal-views/requestView.ts';
-
-// Execute the shipped JSX definitions; host Text roots model native staticText
-// (nested styled Text runs belong to their containing native text element).
-const source = path => ts.createSourceFile(path, readFileSync(new URL(path, import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
-const find = (node, predicate) => {
- if (predicate(node)) return node;
- return ts.forEachChild(node, child => find(child, predicate));
-};
-const definitions = (file, names) => names.map(name => {
- const node = find(file, node => ts.isFunctionDeclaration(node) && node.name?.text === name);
- assert.ok(node, `shipped function ${name}`);
- return `export ${node.getText(file).replace(/^export /, '')}`;
-}).join('\n');
-const React = { Fragment: 'Fragment', createElement: (type, props, ...children) => typeof type === 'function' ? type({ ...props, children }) : { type, children } };
-const evaluate = (code, environment) => new Function(...Object.keys(environment), 'exports', ts.transpileModule(code, { compilerOptions: { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS } }).outputText + '\nreturn exports;')(...Object.values(environment), {});
-const flatten = node => Array.isArray(node) ? node.map(flatten).join('') : typeof node === 'string' ? node : node?.children ? flatten(node.children) : '';
-const textElements = node => Array.isArray(node) ? node.flatMap(textElements) : node?.type === 'Text' ? [flatten(node)] : node?.children ? textElements(node.children) : [];
-
-test('fixture question and request title render as one exact native text root on shipped paths', () => {
- const ui = source('./ui.tsx'), alerts = source('./screens/Alerts.tsx'), now = source('./screens/Now.tsx');
- const appearance = { system: true, color: color => color, colors: {}, palette: {} };
- const environment = { React, Text: 'Text', View: 'View', markdown, report, spaced, useTextAppearance: () => appearance, theme: {}, styles: { text: {}, bold: {} }, openDocumentLink() {} };
- const components = evaluate(definitions(ui, ['T', 'Runs', 'Markdown']), environment);
- const { RequestQuestion } = evaluate(definitions(alerts, ['RequestQuestion']), { ...environment, ...components });
- const question = 'client-control fixture 0001';
- assert.equal(report(question), null);
- assert.deepEqual(textElements(RequestQuestion({ text: question })), [question]);
- const screen = find(now, node => ts.isFunctionDeclaration(node) && node.name?.text === 'AttentionScreen');
- const requestBranch = find(screen, node => ts.isIfStatement(node) && node.expression.getText(now).startsWith('isRequest(item.attention_kind)'));
- const title = find(requestBranch, node => ts.isJsxElement(node) && node.getText(now) === '{row.title}');
- assert.ok(title, 'existing request title element at Now.tsx request branch');
- const titleText = 'client-control fixture ask';
- const rendered = evaluate(`export const title = () => (${title.getText(now)});`, { ...environment, ...components, row: { title: titleText } }).title();
- assert.deepEqual(textElements(rendered), [titleText]);
-});
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
index 00fc7f8a018..c97d93c7aad 100644
--- a/apps/ios/clientControlWire.ts
+++ b/apps/ios/clientControlWire.ts
@@ -1,21 +1,28 @@
-import type { DeviceCommand } from '../../clients/typescript/fractal-client';
-import type { ActiveControlGrant } from './clientControl.ts';
+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';
-/** Internal authorization inputs, not an alternate wire grant or signature schema. */
-export interface ControlAuthorizationContext {
- readonly grant: ActiveControlGrant | undefined;
- readonly foreground: () => boolean;
-}
+/** 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),
+ },
+});
-/**
- * #6274 must generate the authoritative grant, lifecycle, signature and result
- * contracts before this boundary can authorize anything. Existing DeviceCommand
- * rows alone are not proof of a grant. Do not infer authority from their fields.
- */
-export const authorizeClientControl = (
- _command: DeviceCommand,
- _context: ControlAuthorizationContext,
-): boolean => false;
-
-/** No wire revoke exists yet; false must never be presented as a successful revoke. */
-export const revokeClientControl = async (_grant: ActiveControlGrant | undefined): Promise => false;
+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 25b82d7bcdf..e1c5269db90 100644
--- a/apps/ios/feed.ts
+++ b/apps/ios/feed.ts
@@ -140,6 +140,7 @@ export class Feed {
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;
@@ -184,12 +185,14 @@ export class Feed {
}
/** Test clients alone may consume commands, on this pairing's existing socket. */
- followCommands(testMode: boolean, granted: boolean, receive: (command: DeviceCommand) => void): () => void {
+ 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');
};
@@ -264,6 +267,7 @@ export class Feed {
}
private suspend(unexpected = false): void {
+ this.commandsReset?.();
this.attempt++;
this.cancelOpen?.();
this.cancelOpen = undefined;
@@ -416,9 +420,11 @@ 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' && command.state === 'delivered') this.commands?.(command);
+ if (command.kind === 'device-command') this.commands?.(command);
}
return;
}
diff --git a/release-notes/ios-client-control.json b/release-notes/ios-client-control.json
index 37539e6dd38..ff3df64b649 100644
--- a/release-notes/ios-client-control.json
+++ b/release-notes/ios-client-control.json
@@ -1,11 +1,11 @@
{
"version": 1,
- "summary": "Gate iOS test-client commands on test mode and capability; keep control disabled until the generated grant and signed-result contract is available",
+ "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 and canonical codegen are prerequisites."},
- "recovery": {"status": "none", "detail": "No persisted state changes."}
+ "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."}
}