From a954d7e1369005c84d6ce86d257657f04a15ef7a Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 12:55:22 -0800 Subject: [PATCH] Add Android node foundation with FCM push support --- README.md | 2 +- docs/api/PROTOCOL.md | 8 ++- ...02-16-android-node-foundation-checklist.md | 44 ++++++++++++++++ docs/plans/state.json | 28 ++++++++-- src/gateway/handlers/node.test.ts | 40 ++++++++++++++ src/gateway/handlers/node.ts | 8 +-- src/gateway/handlers/system.ts | 2 +- src/gateway/protocol.test.ts | 17 +++++- src/gateway/protocol.ts | 6 +-- src/gateway/server.test.ts | 52 +++++++++++++++++++ src/gateway/server.ts | 2 +- 11 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 docs/plans/2026-02-16-android-node-foundation-checklist.md diff --git a/README.md b/README.md index 775a16b..dafddff 100644 --- a/README.md +++ b/README.md @@ -870,7 +870,7 @@ Methods: - `node.location.set` updates the node's last-known location (when `server.nodes.location.enabled` is true). - `node.location.get` returns the node's stored location payload. - `node.status.set` publishes companion status/heartbeat fields (`platform`, `appVersion`, `batteryPct`, etc.). -- `node.push_token.set` registers node push tokens (e.g. APNs) when `server.nodes.push.enabled` is true. +- `node.push_token.set` registers node push tokens (APNs for iOS/macOS, FCM for Android) when `server.nodes.push.enabled` is true. - `system.location` provides an operator view of registered node locations. - `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status). - `system.capabilities` returns gateway protocol and node policy snapshot. diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index f357e7e..fae7210 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -669,7 +669,7 @@ Publish companion/node runtime status metadata (for example macOS menu-bar heart #### `node.push_token.set` -Register a node push token (currently APNs) for companion delivery routing. +Register a node push token (APNs or FCM) for companion delivery routing. Requires `server.nodes.push.enabled: true`. **Request:** @@ -678,10 +678,8 @@ Requires `server.nodes.push.enabled: true`. "id": 13, "method": "node.push_token.set", "params": { - "provider": "apns", - "token": "abcd1234abcd1234abcd1234abcd1234", - "topic": "com.example.flynn", - "environment": "sandbox" + "provider": "fcm", + "token": "fcm_abcdefghijklmnopqrstuvwxyz123456" } } ``` diff --git a/docs/plans/2026-02-16-android-node-foundation-checklist.md b/docs/plans/2026-02-16-android-node-foundation-checklist.md new file mode 100644 index 0000000..ba29887 --- /dev/null +++ b/docs/plans/2026-02-16-android-node-foundation-checklist.md @@ -0,0 +1,44 @@ +# Android Node Foundation Checklist + +**Date:** 2026-02-16 +**Scope:** Close OpenClaw "Android node" gap with Android push-ready companion support. + +## Goal + +Extend companion-node push registration beyond iOS so Android nodes can register FCM tokens with the same safety model and operator visibility. + +## Implemented + +- Extended node push token protocol: + - `node.push_token.set` now accepts `provider: "apns" | "fcm"`. +- Added Android/FCM support in node handler runtime: + - store provider-specific push metadata in connection state. + - `fcm` tokens are accepted without APNs environment/topic requirements. +- Preserved secret safety: + - `system.nodes` continues exposing only masked `tokenPreview`. +- Kept policy model consistent: + - still gated by `server.nodes.push.enabled`. + - role scopes unchanged (companion-only write path). + +## Tests + +- `src/gateway/protocol.test.ts` + - Added valid FCM parse coverage. +- `src/gateway/handlers/node.test.ts` + - Added FCM push-token registration test for Android companion. +- `src/gateway/server.test.ts` + - Added end-to-end Android node + `node.push_token.set` + `system.nodes` verification. +- Existing auth/config/system tests continue passing. + +## Docs Updated + +- `README.md` node method notes now mention APNs + FCM. +- `docs/api/PROTOCOL.md` now documents APNs/FCM provider support for `node.push_token.set`. + +## Validation Run + +```bash +pnpm test:run src/gateway/protocol.test.ts src/gateway/handlers/node.test.ts src/gateway/server.test.ts src/gateway/auth.test.ts src/gateway/handlers/handlers.test.ts src/config/schema.test.ts +pnpm typecheck +pnpm build +``` diff --git a/docs/plans/state.json b/docs/plans/state.json index 5c381d3..8082da8 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -572,6 +572,28 @@ ], "test_status": "pnpm test:run src/gateway/protocol.test.ts src/gateway/auth.test.ts src/gateway/handlers/node.test.ts src/gateway/handlers/handlers.test.ts src/gateway/server.test.ts src/config/schema.test.ts + pnpm typecheck + pnpm build passing" }, + "android-node-foundation": { + "file": "2026-02-16-android-node-foundation-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented Android node companion foundation by extending `node.push_token.set` to accept FCM tokens in addition to APNs, with masked operator visibility in `system.nodes` and existing node push policy/scope controls preserved.", + "files_created": [ + "docs/plans/2026-02-16-android-node-foundation-checklist.md" + ], + "files_modified": [ + "src/gateway/protocol.ts", + "src/gateway/protocol.test.ts", + "src/gateway/handlers/node.ts", + "src/gateway/handlers/node.test.ts", + "src/gateway/handlers/system.ts", + "src/gateway/server.ts", + "src/gateway/server.test.ts", + "README.md", + "docs/api/PROTOCOL.md" + ], + "test_status": "pnpm test:run src/gateway/protocol.test.ts src/gateway/handlers/node.test.ts src/gateway/server.test.ts src/gateway/auth.test.ts src/gateway/handlers/handlers.test.ts src/config/schema.test.ts + pnpm typecheck + pnpm build passing" + }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3133,7 +3155,7 @@ } }, "overall_progress": { - "total_test_count": 1792, + "total_test_count": 1795, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3148,12 +3170,12 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "122/128 match (95%), 0 partial (0%), 6 missing (5%)", + "feature_gap_scorecard": "123/128 match (96%), 0 partial (0%), 5 missing (4%)", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "OpenClaw gap: Android node (open next scoped implementation checklist)" + "next_up": "OpenClaw gap: LINE/Feishu/Zalo channel adapter set (open next scoped implementation checklist)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/gateway/handlers/node.test.ts b/src/gateway/handlers/node.test.ts index e42be41..c6a21c0 100644 --- a/src/gateway/handlers/node.test.ts +++ b/src/gateway/handlers/node.test.ts @@ -275,4 +275,44 @@ describe('node handlers', () => { expect((result as { result: { push: { tokenPreview: string } } }).result.push.tokenPreview).toContain('abcd1234'); expect(states.get('conn-1')?.pushToken?.provider).toBe('apns'); }); + + it('accepts fcm push token registration for android companions', async () => { + const states = new Map([['conn-1', { + node: { + nodeId: 'android-node', + role: 'companion', + protocolVersion: 1, + capabilities: ['notifications'], + registeredAt: Date.now(), + }, + }]]); + const handlers = createNodeHandlers({ + enabled: true, + locationEnabled: true, + pushEnabled: true, + allowedRoles: ['companion'], + featureGates: {}, + getConnectionState: (connectionId) => states.get(connectionId), + setNodeRegistration: () => {}, + setNodeLocation: () => {}, + setNodeStatus: () => {}, + setNodePushToken: (connectionId, pushToken) => { + const prior = states.get(connectionId) ?? {}; + states.set(connectionId, { ...prior, pushToken }); + }, + }); + + const result = await handlers['node.push_token.set']({ + id: 9, + method: 'node.push_token.set', + params: { + connectionId: 'conn-1', + provider: 'fcm', + token: 'fcm_abcdefghijklmnopqrstuvwxyz123456', + }, + }); + expect((result as { result: { updated: boolean } }).result.updated).toBe(true); + expect(states.get('conn-1')?.pushToken?.provider).toBe('fcm'); + expect(states.get('conn-1')?.pushToken?.environment).toBeUndefined(); + }); }); diff --git a/src/gateway/handlers/node.ts b/src/gateway/handlers/node.ts index ad05368..b599f42 100644 --- a/src/gateway/handlers/node.ts +++ b/src/gateway/handlers/node.ts @@ -50,10 +50,10 @@ export interface NodeStatus { } export interface NodePushToken { - provider: 'apns'; + provider: 'apns' | 'fcm'; token: string; topic?: string; - environment: 'sandbox' | 'production'; + environment?: 'sandbox' | 'production'; registeredAt: number; } @@ -279,10 +279,10 @@ export function createNodeHandlers(deps: NodeHandlerDeps) { } const pushToken: NodePushToken = { - provider: 'apns', + provider: parsed.provider, token: parsed.token, topic: parsed.topic || undefined, - environment: parsed.environment ?? 'production', + environment: parsed.provider === 'apns' ? (parsed.environment ?? 'production') : undefined, registeredAt: Date.now(), }; deps.setNodePushToken(parsed.connectionId, pushToken); diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index eee4bc5..5b476be 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -46,7 +46,7 @@ export interface NodePushTokenSummary { provider: NodePushToken['provider']; tokenPreview: string; topic?: string; - environment: NodePushToken['environment']; + environment?: NodePushToken['environment']; registeredAt: number; } diff --git a/src/gateway/protocol.test.ts b/src/gateway/protocol.test.ts index 36f2154..06fcce2 100644 --- a/src/gateway/protocol.test.ts +++ b/src/gateway/protocol.test.ts @@ -233,7 +233,7 @@ describe('protocol', () => { it('rejects invalid node push token params', () => { expect(parseNodePushTokenSetParams({ connectionId: 'conn-1', - provider: 'fcm', + provider: 'webpush', token: 'abcd1234abcd1234abcd1234abcd1234', })).toBeNull(); expect(parseNodePushTokenSetParams({ @@ -247,6 +247,21 @@ describe('protocol', () => { token: 'abcd1234abcd1234abcd1234abcd1234', })).toBeNull(); }); + + it('parses valid fcm token params for android nodes', () => { + const parsed = parseNodePushTokenSetParams({ + connectionId: 'conn-2', + provider: 'fcm', + token: 'fcm_abcdefghijklmnopqrstuvwxyz123456', + }); + expect(parsed).toEqual({ + connectionId: 'conn-2', + provider: 'fcm', + token: 'fcm_abcdefghijklmnopqrstuvwxyz123456', + topic: undefined, + environment: undefined, + }); + }); }); describe('makeResponse', () => { diff --git a/src/gateway/protocol.ts b/src/gateway/protocol.ts index 84e7697..70972ed 100644 --- a/src/gateway/protocol.ts +++ b/src/gateway/protocol.ts @@ -46,7 +46,7 @@ export interface NodeStatusSetParams { export interface NodePushTokenSetParams { connectionId: string; - provider: 'apns'; + provider: 'apns' | 'fcm'; token: string; topic?: string; environment?: 'sandbox' | 'production'; @@ -309,7 +309,7 @@ export function parseNodePushTokenSetParams(params: unknown): NodePushTokenSetPa if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) { return null; } - if (p.provider !== 'apns') { + if (p.provider !== 'apns' && p.provider !== 'fcm') { return null; } if (typeof p.token !== 'string' || p.token.trim().length < 16) { @@ -324,7 +324,7 @@ export function parseNodePushTokenSetParams(params: unknown): NodePushTokenSetPa return { connectionId: p.connectionId, - provider: 'apns', + provider: p.provider, token: p.token.trim(), topic: typeof p.topic === 'string' ? p.topic.trim() : undefined, environment: p.environment as NodePushTokenSetParams['environment'] | undefined, diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 4187bde..e5c64e6 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -868,4 +868,56 @@ describe('GatewayServer node registration and capability negotiation', () => { } } }); + + it('supports android fcm push token registration', async () => { + if (!LISTEN_ALLOWED) { + return; + } + + const ws = await new Promise((resolve, reject) => { + const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`); + c.on('open', () => resolve(c)); + c.on('error', reject); + }); + + try { + const registered = await sendAndReceive(ws, { + id: 40, + method: 'node.register', + params: { + nodeId: 'node-android', + role: 'companion', + protocolVersion: 1, + capabilities: ['notifications'], + }, + }); + expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true); + + const push = await sendAndReceive(ws, { + id: 41, + method: 'node.push_token.set', + params: { + provider: 'fcm', + token: 'fcm_abcdefghijklmnopqrstuvwxyz123456', + }, + }); + expect(((push as GatewayResponse).result as { updated: boolean }).updated).toBe(true); + + const nodes = await sendAndReceive(ws, { + id: 42, + method: 'system.nodes', + params: { role: 'companion', limit: 20 }, + }); + const list = ((nodes as GatewayResponse).result as { + nodes: Array<{ nodeId: string; push?: { provider: string; environment?: string } }>; + }).nodes; + const androidNode = list.find((entry) => entry.nodeId === 'node-android'); + expect(androidNode?.push?.provider).toBe('fcm'); + expect(androidNode?.push?.environment).toBeUndefined(); + } finally { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + } + }); }); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index f793eaf..262f5c3 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -236,7 +236,7 @@ export class GatewayServer { provider: NonNullable['provider']; tokenPreview: string; topic?: string; - environment: NonNullable['environment']; + environment?: NonNullable['environment']; registeredAt: number; }; }> = [];