From b003e15e6c52d2f5fd9be508915a8d14b373f0a0 Mon Sep 17 00:00:00 2001
From: schickling-assistant
<261620128+schickling-assistant@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:10:48 +0100
Subject: [PATCH] feat(ios): bind test-client commands to native identity and
controls
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 | 112 +++++++++++++++++++++++++
apps/ios/clientControlProfile.test.mjs | 18 ++++
apps/ios/clientControlVisible.ts | 27 ++++++
apps/ios/store.tsx | 22 ++++-
release-notes/ios-client-control.json | 2 +-
7 files changed, 217 insertions(+), 5 deletions(-)
create mode 100644 apps/ios/ClientControlIndicator.tsx
create mode 100644 apps/ios/ClientControlNative.tsx
create mode 100644 apps/ios/clientControlProfile.test.mjs
create mode 100644 apps/ios/clientControlVisible.ts
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..389d2b7575a
--- /dev/null
+++ b/apps/ios/ClientControlNative.tsx
@@ -0,0 +1,112 @@
+import * as React from 'react';
+import { AppState } from 'react-native';
+import Constants from 'expo-constants';
+import * as Crypto from 'expo-crypto';
+import { base64url } from '@fractal/views';
+import type { PhoneNavigateArgs } from '../../clients/typescript/fractal-client/Models.generated';
+import type { ClientVisibleState } from '../../clients/typescript/fractal-client/ClientControl.generated';
+import { ClientControlController } from './clientControl';
+import { ClientControlIndicator } from './ClientControlIndicator';
+import { revokeClientControl, signClientResult } from './clientControlWire';
+import { committedVisible, subscribeVisible } from './clientControlVisible';
+import { signWithDeviceKey } from './modules/fractal-device-key';
+import { navigationRef } from './navigation';
+import { useStore, type Store } from './store';
+
+// Route keys bind the renderer snapshot to the currently committed navigator leaf.
+const visible = (): ClientVisibleState | undefined => {
+ if (!navigationRef.isReady()) return;
+ const route = navigationRef.getCurrentRoute();
+ if (!route || (route.name !== 'NowRoot' && route.name !== 'Attention')) return;
+ return committedVisible(route.key);
+};
+
+const navigationPort = () => {
+ const pending = new Set<() => void>();
+ return {
+ dispose: () => { for (const cancel of pending) cancel(); },
+ navigate: (args: PhoneNavigateArgs): Promise => {
+ const { promise, resolve, reject } = Promise.withResolvers();
+ if (!navigationRef.isReady() || (args.screen !== 'NowRoot' && args.screen !== 'Attention')) {
+ reject(new Error('Navigation unavailable'));
+ return promise;
+ }
+ const destination = args.screen === 'Attention' ? args.params.id : undefined;
+ let stopState = () => {};
+ let stopVisible = () => {};
+ const finish = (error?: Error) => {
+ clearTimeout(timeout);
+ stopState(); stopVisible(); pending.delete(cancel);
+ if (error) reject(error); else resolve();
+ };
+ const cancel = () => finish(new Error('Navigation binding disposed'));
+ const check = () => {
+ const state = visible();
+ if (state?.screen === args.screen && (destination === undefined || state.opened_item?.id === destination)) finish();
+ };
+ const timeout = setTimeout(() => finish(new Error('Navigation commit timed out')), 5_000);
+ pending.add(cancel);
+ stopState = navigationRef.addListener('state', check);
+ stopVisible = subscribeVisible(check);
+ try {
+ if (args.screen === 'NowRoot') navigationRef.navigate('Now', { screen: 'NowRoot' });
+ else if (args.screen === 'Attention') navigationRef.navigate('Now', { screen: 'Attention', params: args.params, initial: false });
+ check();
+ } catch (error) { finish(error instanceof Error ? error : new Error('Navigation failed')); }
+ return promise;
+ },
+ };
+};
+
+type Binding = { controller: ClientControlController; dispose(): void };
+type SubscriptionProps = { binding: Binding; feed: NonNullable; granted: boolean; reportError: Store['setError'] };
+class CommandSubscription extends React.Component {
+ 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)));
+ }, binding.controller.suspend);
+ };
+ componentDidMount() { this.start(); }
+ componentDidUpdate(previous: SubscriptionProps) {
+ if (previous.binding !== this.props.binding || previous.feed !== this.props.feed || previous.granted !== this.props.granted) {
+ this.stop?.();
+ if (previous.binding !== this.props.binding) previous.binding.dispose();
+ this.start();
+ }
+ }
+ componentWillUnmount() { this.stop?.(); this.props.binding.dispose(); }
+ render() { return ; }
+}
+const ControlIndicator = ({ controller, reportError }: { controller: ClientControlController; reportError: Store['setError'] }) => {
+ const grant = React.useSyncExternalStore(controller.subscribe, controller.getGrant);
+ return { void controller.revoke().catch(error => reportError(String(error))); }} />;
+};
+
+export const ClientControlNative = () => {
+ const { feed, caps, client, controlProfile, setError } = 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 binding = React.useMemo((): Binding | undefined => {
+ if (!testMode || !feed || !client || !controlProfile) return;
+ const navigation = navigationPort();
+ const controller = new ClientControlController({
+ identity: controlProfile.identity,
+ foreground: () => AppState.currentState === 'active',
+ visible,
+ 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;
+ await client.deviceResult(result);
+ },
+ revoke: () => revokeClientControl(client, controlProfile.identity.device),
+ });
+ return { controller, dispose: () => { navigation.dispose(); controller.dispose(); } };
+ }, [testMode, feed, client, controlProfile]);
+ if (!binding || !feed) return null;
+ return ;
+};
diff --git a/apps/ios/clientControlProfile.test.mjs b/apps/ios/clientControlProfile.test.mjs
new file mode 100644
index 00000000000..c0f52005f5c
--- /dev/null
+++ b/apps/ios/clientControlProfile.test.mjs
@@ -0,0 +1,18 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { readFileSync } from 'node:fs';
+import ts from 'typescript';
+const file = ts.createSourceFile('store.tsx', readFileSync(new URL('./store.tsx', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+const find = (node, predicate) => predicate(node) ? node : ts.forEachChild(node, child => find(child, predicate));
+const pairing = find(file, node => ts.isFunctionDeclaration(node) && node.name?.text === 'completePairing');
+const declarations = ['testControl', 'signs', 'next'].map(name => find(pairing, node => ts.isVariableDeclaration(node) && node.name.getText(file) === name).getText(file));
+const build = new Function('Constants', 'result', 'made', 'pin', 'member', 'pairDraft', 'old', 'gateway', 'routing', ts.transpileModule(declarations.map(value => `const ${value};`).join('\n') + '\nreturn { next, signs };', { compilerOptions: { target: ts.ScriptTarget.ESNext } }).outputText);
+for (const flag of [undefined, false, true]) for (const messageScope of [false, true]) test(`pairing key retention: testMode=${flag} messages=${messageScope}`, () => {
+ const session = { device_id: 'device/fixture', session_actor: 'client-session/fixture', person_id: 'person/fixture', credential: 'fixture-bearer', scopes: messageScope ? ['control.messages'] : ['read.projections'], device_key_chain: [] };
+ const made = { key: 'p256:fixture', handle: 'fixture-handle', storage: 'secure-enclave' };
+ const { next, signs } = build({ expoConfig: { extra: { testClientMode: flag } } }, { value: session }, made, 'pin', undefined, null, undefined, 'https://fixture.invalid', {});
+ assert.equal(signs, messageScope || flag === true);
+ assert.equal(!!next.signing, messageScope || flag === true);
+ assert.deepEqual(next.controlIdentity, flag === true ? { device_id: session.device_id, session_actor: session.session_actor } : undefined);
+ assert.equal(next.credential, session.credential);
+});
diff --git a/apps/ios/clientControlVisible.ts b/apps/ios/clientControlVisible.ts
new file mode 100644
index 00000000000..2d94f831c82
--- /dev/null
+++ b/apps/ios/clientControlVisible.ts
@@ -0,0 +1,27 @@
+import type { ClientVisibleState } from '../../clients/typescript/fractal-client/ClientControl.generated';
+import { report, spaced } from '@fractal/views';
+import { markdown } from './markdown';
+
+/** Only report exact text when the normal question renderer preserves it. */
+export const observedQuestion = (text: string): string | undefined => {
+ if (report(text)) return;
+ const blocks = markdown(spaced(text));
+ if (blocks.length !== 1 || blocks[0].kind !== 'text') return;
+ const runs = blocks[0].runs;
+ return runs.every(run => run.style === 'plain') && runs.map(run => run.text).join('') === text ? text : undefined;
+};
+
+// Published only by committed normal renderers, never by command dispatch.
+const snapshots = new Map();
+const listeners = new Set<() => void>();
+const notify = () => { for (const listener of listeners) listener(); };
+export const committedVisible = (routeKey: string): ClientVisibleState | undefined => snapshots.get(routeKey);
+export const publishVisible = (routeKey: string, state: ClientVisibleState | undefined): void => {
+ if (state) snapshots.set(routeKey, state);
+ else snapshots.delete(routeKey);
+ notify();
+};
+export const subscribeVisible = (listener: () => void): (() => void) => {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+};
diff --git a/apps/ios/store.tsx b/apps/ios/store.tsx
index 61d1d8f612c..16280c10fe0 100644
--- a/apps/ios/store.tsx
+++ b/apps/ios/store.tsx
@@ -3,6 +3,8 @@ import { AppState, unstable_batchedUpdates } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import * as Crypto from 'expo-crypto';
+import Constants from 'expo-constants';
+import type { PersonPairedSession } from '../../clients/typescript/fractal-client';
import { submitAnswer } from './answer';
import { resendBoundMessage, sendBoundMessage, type BoundMessage, type SendOutcome } from './messageSend';
import { API_VERSION, ClientError, FractalClient, isTransient, notApplied, plainError, promptGone, retryTransient, type Arrangement, type ArrangementOperation, type Runtime, type Attention, type AttachmentInput, type Capabilities, type ConversationSearch, type Glass, type Launch, type LaunchVariant, type Mission, type Resource, type ResourceObservation, type Snapshot, type TimelineEntry } from '../../clients/typescript/fractal-client';
@@ -62,7 +64,7 @@ const SIGNING_KEY = 'st3.device.signing';
// signing key together. Old separate credential/signing entries remain readable until re-pair.
const PROFILE_KEY = 'st3.device.profile';
type PhoneSigning = DeviceKey & { handle?: string };
-type PhoneProfile = { url: string; credential: string; signing?: PhoneSigning; personRootFingerprint?: string; routing?: PairingRouting };
+type PhoneProfile = { url: string; credential: string; signing?: PhoneSigning; personRootFingerprint?: string; routing?: PairingRouting; controlIdentity?: Pick };
// Simplified conversations: this phone's own choice, on unless turned off, never synced.
const SIMPLE_KEY = 'st3.conversation.simple';
// Notifications: off by default, stored only on this phone. APNs tokens are never persisted here.
@@ -204,6 +206,7 @@ function useAppStore(proof?: FabricProfile) {
const [fabricMemberList, setFabricMemberList] = useState([]);
// The members paired on their own, by Keychain key: each one's bearer goes only to that member.
const [ownProfiles, setOwnProfiles] = useState>(new Map());
+ const [controlDeviceProfile, setControlDeviceProfile] = useState();
// The member the person is pairing this phone with, while the pairing form is open.
const [pairingMember, setPairingMember] = useState(null);
const fabricTarget = fabricMemberList.find(member => member.device) ?? fabricMemberList[0] ?? null;
@@ -247,6 +250,14 @@ function useAppStore(proof?: FabricProfile) {
const clientCredential = credentialFor(selected.route.kind === 'fabric', fabricMember, memberProfile?.credential, credential);
const signingKeyRef = useRef(PROFILE_KEY);
signingKeyRef.current = selected.route.kind === 'fabric' && memberProfile && fabricMember ? memberProfileKey(fabricMember) : PROFILE_KEY;
+ const controlProfileKey = signingKeyRef.current;
+ const testClientMode = Constants.expoConfig?.extra?.testClientMode === true;
+ const controlProfile = useMemo(() => {
+ const profile = controlProfileKey === PROFILE_KEY ? controlDeviceProfile : ownProfiles.get(controlProfileKey);
+ if (!testClientMode || proof || profile?.credential !== clientCredential || !profile?.signing
+ || !profile.controlIdentity || profile.controlIdentity.session_actor !== caps?.session_actor) return;
+ return { identity: { device: profile.controlIdentity.device_id, session: profile.controlIdentity.session_actor }, signing: profile.signing };
+ }, [testClientMode, proof, controlProfileKey, controlDeviceProfile, ownProfiles, clientCredential, caps?.session_actor]);
const routeRef = useRef(selected.route); routeRef.current = selected.route;
const feedMisses = useRef(0);
// What the phone measures of its own service (see observations.ts): timed where the app already
@@ -474,6 +485,7 @@ function useAppStore(proof?: FabricProfile) {
try { paired = JSON.parse(saved.value); } catch { setError('The saved device profile could not be read.'); return; }
}
if (saved.status === 'rejected') { setError('Secure credential storage is unavailable on this build.'); return; }
+ if (Constants.expoConfig?.extra?.testClientMode === true) setControlDeviceProfile(paired);
const gateway = paired?.url ?? (u.status === 'fulfilled' ? u.value : null);
const bearer = paired?.credential ?? (c.status === 'fulfilled' ? c.value : null);
if (paired?.routing) {
@@ -791,12 +803,14 @@ function useAppStore(proof?: FabricProfile) {
hash: async bytes => new Uint8Array(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, new Uint8Array(bytes))),
verify: verifyGrantSignature,
});
- const signs = result.value.scopes.includes('control.messages');
+ const testControl = Constants.expoConfig?.extra?.testClientMode === true;
+ const signs = result.value.scopes.includes('control.messages') || testControl;
const routing = member ? undefined : pairingCommitRouting({ carrier: carrierChoice, members: fabricMemberList }, !!pairDraft?.scanned);
const next: PhoneProfile = { url: gateway, credential: result.value.credential,
...(!member && (pairDraft?.scanned || old?.routing) ? { routing } : {}),
...(pin ? { personRootFingerprint: pin } : {}),
- ...(signs ? { signing: { key: made.key, storage: made.storage, handle: made.handle, person: result.value.person_id, chain: result.value.device_key_chain! } } : {}),
+ ...(testControl ? { controlIdentity: { device_id: result.value.device_id, session_actor: result.value.session_actor } } : {}),
+ ...(signs ? { signing: { key: made.key, storage: made.storage, handle: made.handle, person: result.value.person_id, chain: result.value.device_key_chain ?? [] } } : {}),
};
// This single secure write is the commit. Before it, the old bearer, key and pin survive.
await SecureStore.setItemAsync(profileKey, JSON.stringify(next), { keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY });
@@ -813,6 +827,7 @@ function useAppStore(proof?: FabricProfile) {
await SecureStore.deleteItemAsync(SIGNING_KEY).catch(() => {});
await clearCachedProjection().catch(() => {});
unstable_batchedUpdates(() => {
+ if (testControl) setControlDeviceProfile(next);
if (next.routing) {
setFabricMemberList(next.routing.members);
setCarrierChoice(next.routing.carrier);
@@ -1281,6 +1296,7 @@ function useAppStore(proof?: FabricProfile) {
return {
actor: caps?.session_actor ?? cachedActor.current, cachedAt: cacheSavedAt.current, order, url, urlDraft, setUrlDraft, credential, data, truncated, loadErrors, feed, connectionIssue, caps, snapshot, status, hasSynced, sync, syncStale, missionsLoaded,
error, setError, pairingIssue, setPairingIssue, pairDraft, busy, historicalSessions, conversationCache, draftCache, draftsReady, client,
+ controlProfile,
renewal: renewals[0]?.offer, renewalIssue, renewalBusy,
gatewayMachineId, gatewayHost, canControlTerminal, loadLists, watchLists, inventoryGranted, actions,
saveDrafts: () => { if (!proof) void localDrafts.save(draftsOwner.current, draftCache.current); },
diff --git a/release-notes/ios-client-control.json b/release-notes/ios-client-control.json
index ff3df64b649..1aa3e983be3 100644
--- a/release-notes/ios-client-control.json
+++ b/release-notes/ios-client-control.json
@@ -7,5 +7,5 @@
"compatibility": {"status": "none", "detail": "Normal apps never subscribe to commands; test-client commands fail closed without an authoritative grant source."},
"downtime": {"status": "none", "detail": "No deployment or native installation."},
"manual": {"status": "none", "detail": "No pairing or enrollment performed; test-client flag, lifecycle codegen and native SHA-256 adapter are prerequisites."},
- "recovery": {"status": "none", "detail": "No persisted state changes in the command policy layer."}
+ "recovery": {"status": "changed", "detail": "Test-mode pairing retains its device signing key and optional device/session identity; existing profiles without identity fail closed. Normal pairing is unchanged."}
}