Add Android node foundation with FCM push support
This commit is contained in:
@@ -870,7 +870,7 @@ Methods:
|
|||||||
- `node.location.set` updates the node's last-known location (when `server.nodes.location.enabled` is true).
|
- `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.location.get` returns the node's stored location payload.
|
||||||
- `node.status.set` publishes companion status/heartbeat fields (`platform`, `appVersion`, `batteryPct`, etc.).
|
- `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.location` provides an operator view of registered node locations.
|
||||||
- `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status).
|
- `system.nodes` returns registered node snapshots (role, capabilities, identity, location/status).
|
||||||
- `system.capabilities` returns gateway protocol and node policy snapshot.
|
- `system.capabilities` returns gateway protocol and node policy snapshot.
|
||||||
|
|||||||
@@ -669,7 +669,7 @@ Publish companion/node runtime status metadata (for example macOS menu-bar heart
|
|||||||
|
|
||||||
#### `node.push_token.set`
|
#### `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`.
|
Requires `server.nodes.push.enabled: true`.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
@@ -678,10 +678,8 @@ Requires `server.nodes.push.enabled: true`.
|
|||||||
"id": 13,
|
"id": 13,
|
||||||
"method": "node.push_token.set",
|
"method": "node.push_token.set",
|
||||||
"params": {
|
"params": {
|
||||||
"provider": "apns",
|
"provider": "fcm",
|
||||||
"token": "abcd1234abcd1234abcd1234abcd1234",
|
"token": "fcm_abcdefghijklmnopqrstuvwxyz123456"
|
||||||
"topic": "com.example.flynn",
|
|
||||||
"environment": "sandbox"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
+25
-3
@@ -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"
|
"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": {
|
"qmd-backend": {
|
||||||
"file": "2026-02-16-qmd-backend-checklist.md",
|
"file": "2026-02-16-qmd-backend-checklist.md",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
@@ -3133,7 +3155,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1792,
|
"total_test_count": 1795,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
@@ -3148,12 +3170,12 @@
|
|||||||
"tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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": {
|
"soul_md_and_cron_create": {
|
||||||
"date": "2026-02-11",
|
"date": "2026-02-11",
|
||||||
|
|||||||
@@ -275,4 +275,44 @@ describe('node handlers', () => {
|
|||||||
expect((result as { result: { push: { tokenPreview: string } } }).result.push.tokenPreview).toContain('abcd1234');
|
expect((result as { result: { push: { tokenPreview: string } } }).result.push.tokenPreview).toContain('abcd1234');
|
||||||
expect(states.get('conn-1')?.pushToken?.provider).toBe('apns');
|
expect(states.get('conn-1')?.pushToken?.provider).toBe('apns');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts fcm push token registration for android companions', async () => {
|
||||||
|
const states = new Map<string, NodeConnectionState>([['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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ export interface NodeStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NodePushToken {
|
export interface NodePushToken {
|
||||||
provider: 'apns';
|
provider: 'apns' | 'fcm';
|
||||||
token: string;
|
token: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
environment: 'sandbox' | 'production';
|
environment?: 'sandbox' | 'production';
|
||||||
registeredAt: number;
|
registeredAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,10 +279,10 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pushToken: NodePushToken = {
|
const pushToken: NodePushToken = {
|
||||||
provider: 'apns',
|
provider: parsed.provider,
|
||||||
token: parsed.token,
|
token: parsed.token,
|
||||||
topic: parsed.topic || undefined,
|
topic: parsed.topic || undefined,
|
||||||
environment: parsed.environment ?? 'production',
|
environment: parsed.provider === 'apns' ? (parsed.environment ?? 'production') : undefined,
|
||||||
registeredAt: Date.now(),
|
registeredAt: Date.now(),
|
||||||
};
|
};
|
||||||
deps.setNodePushToken(parsed.connectionId, pushToken);
|
deps.setNodePushToken(parsed.connectionId, pushToken);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface NodePushTokenSummary {
|
|||||||
provider: NodePushToken['provider'];
|
provider: NodePushToken['provider'];
|
||||||
tokenPreview: string;
|
tokenPreview: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
environment: NodePushToken['environment'];
|
environment?: NodePushToken['environment'];
|
||||||
registeredAt: number;
|
registeredAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ describe('protocol', () => {
|
|||||||
it('rejects invalid node push token params', () => {
|
it('rejects invalid node push token params', () => {
|
||||||
expect(parseNodePushTokenSetParams({
|
expect(parseNodePushTokenSetParams({
|
||||||
connectionId: 'conn-1',
|
connectionId: 'conn-1',
|
||||||
provider: 'fcm',
|
provider: 'webpush',
|
||||||
token: 'abcd1234abcd1234abcd1234abcd1234',
|
token: 'abcd1234abcd1234abcd1234abcd1234',
|
||||||
})).toBeNull();
|
})).toBeNull();
|
||||||
expect(parseNodePushTokenSetParams({
|
expect(parseNodePushTokenSetParams({
|
||||||
@@ -247,6 +247,21 @@ describe('protocol', () => {
|
|||||||
token: 'abcd1234abcd1234abcd1234abcd1234',
|
token: 'abcd1234abcd1234abcd1234abcd1234',
|
||||||
})).toBeNull();
|
})).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', () => {
|
describe('makeResponse', () => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export interface NodeStatusSetParams {
|
|||||||
|
|
||||||
export interface NodePushTokenSetParams {
|
export interface NodePushTokenSetParams {
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
provider: 'apns';
|
provider: 'apns' | 'fcm';
|
||||||
token: string;
|
token: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
environment?: 'sandbox' | 'production';
|
environment?: 'sandbox' | 'production';
|
||||||
@@ -309,7 +309,7 @@ export function parseNodePushTokenSetParams(params: unknown): NodePushTokenSetPa
|
|||||||
if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) {
|
if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (p.provider !== 'apns') {
|
if (p.provider !== 'apns' && p.provider !== 'fcm') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof p.token !== 'string' || p.token.trim().length < 16) {
|
if (typeof p.token !== 'string' || p.token.trim().length < 16) {
|
||||||
@@ -324,7 +324,7 @@ export function parseNodePushTokenSetParams(params: unknown): NodePushTokenSetPa
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
connectionId: p.connectionId,
|
connectionId: p.connectionId,
|
||||||
provider: 'apns',
|
provider: p.provider,
|
||||||
token: p.token.trim(),
|
token: p.token.trim(),
|
||||||
topic: typeof p.topic === 'string' ? p.topic.trim() : undefined,
|
topic: typeof p.topic === 'string' ? p.topic.trim() : undefined,
|
||||||
environment: p.environment as NodePushTokenSetParams['environment'] | undefined,
|
environment: p.environment as NodePushTokenSetParams['environment'] | undefined,
|
||||||
|
|||||||
@@ -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<WebSocket>((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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export class GatewayServer {
|
|||||||
provider: NonNullable<NodeConnectionState['pushToken']>['provider'];
|
provider: NonNullable<NodeConnectionState['pushToken']>['provider'];
|
||||||
tokenPreview: string;
|
tokenPreview: string;
|
||||||
topic?: string;
|
topic?: string;
|
||||||
environment: NonNullable<NodeConnectionState['pushToken']>['environment'];
|
environment?: NonNullable<NodeConnectionState['pushToken']>['environment'];
|
||||||
registeredAt: number;
|
registeredAt: number;
|
||||||
};
|
};
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user