phoneclaw-connector 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign as cryptoSign, } from 'crypto';
2
+ import { promises as fs } from 'fs';
3
+ import { join, dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+ const IDENTITY_FILE = 'device-identity.json';
8
+ function base64UrlEncode(buf) {
9
+ return buf
10
+ .toString('base64')
11
+ .replaceAll('+', '-')
12
+ .replaceAll('/', '_')
13
+ .replace(/=+$/g, '');
14
+ }
15
+ function deriveDeviceIdFromPublicKeyPem(publicKeyPem) {
16
+ const publicKey = createPublicKey(publicKeyPem);
17
+ const spki = publicKey.export({ type: 'spki', format: 'der' });
18
+ // Ed25519 SPKI: 12-byte prefix + 32-byte raw key
19
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
20
+ let raw;
21
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
22
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
23
+ raw = spki.subarray(ED25519_SPKI_PREFIX.length);
24
+ }
25
+ else {
26
+ raw = spki;
27
+ }
28
+ // OpenClaw uses SHA-256 hex (64 lowercase hex chars) as the stable deviceId.
29
+ return createHash('sha256').update(raw).digest('hex');
30
+ }
31
+ function publicKeyRawBase64UrlFromPem(publicKeyPem) {
32
+ const publicKey = createPublicKey(publicKeyPem);
33
+ const spki = publicKey.export({ type: 'spki', format: 'der' });
34
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
35
+ let raw;
36
+ if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
37
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
38
+ raw = spki.subarray(ED25519_SPKI_PREFIX.length);
39
+ }
40
+ else {
41
+ raw = spki;
42
+ }
43
+ return base64UrlEncode(raw);
44
+ }
45
+ function generateIdentity() {
46
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
47
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' });
48
+ const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
49
+ const deviceId = deriveDeviceIdFromPublicKeyPem(publicKeyPem);
50
+ return {
51
+ deviceId,
52
+ publicKey: publicKeyRawBase64UrlFromPem(publicKeyPem),
53
+ privateKey: privateKeyPem,
54
+ publicKeyPem,
55
+ createdAt: new Date().toISOString(),
56
+ };
57
+ }
58
+ function isValidIdentity(value) {
59
+ if (!value || typeof value !== 'object')
60
+ return false;
61
+ const v = value;
62
+ return (typeof v.deviceId === 'string' &&
63
+ typeof v.publicKey === 'string' &&
64
+ typeof v.privateKey === 'string' &&
65
+ typeof v.publicKeyPem === 'string' &&
66
+ typeof v.createdAt === 'string');
67
+ }
68
+ /**
69
+ * Manages the skill's persistent Ed25519 device identity used for OpenClaw
70
+ * Gateway pairing. The identity is stored next to the skill source so that
71
+ * re-launches keep the same deviceId and can be auto-approved as a paired
72
+ * node.
73
+ */
74
+ export class DeviceIdentityStore {
75
+ identityPath;
76
+ identity = null;
77
+ constructor(configDir) {
78
+ this.identityPath = configDir
79
+ ? join(configDir, IDENTITY_FILE)
80
+ : join(__dirname, '..', IDENTITY_FILE);
81
+ }
82
+ /**
83
+ * Load existing identity or generate a new one. Persists to disk on first
84
+ * creation. Subsequent calls return the in-memory cached identity.
85
+ */
86
+ async getOrCreate() {
87
+ if (this.identity)
88
+ return this.identity;
89
+ try {
90
+ const data = await fs.readFile(this.identityPath, 'utf-8');
91
+ const parsed = JSON.parse(data);
92
+ if (isValidIdentity(parsed) && this.matchesPublicKey(parsed)) {
93
+ this.identity = parsed;
94
+ return parsed;
95
+ }
96
+ console.warn('[DeviceIdentity] Existing identity file is invalid or mismatched; regenerating.');
97
+ }
98
+ catch {
99
+ // file does not exist or is unreadable — generate new
100
+ }
101
+ const generated = generateIdentity();
102
+ await this.persist(generated);
103
+ this.identity = generated;
104
+ return generated;
105
+ }
106
+ /** Verify the persisted publicKey (base64url raw) actually matches the PEM. */
107
+ matchesPublicKey(identity) {
108
+ try {
109
+ const derivedRaw = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
110
+ return derivedRaw === identity.publicKey;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ async persist(identity) {
117
+ await fs.mkdir(dirname(this.identityPath), { recursive: true });
118
+ await fs.writeFile(this.identityPath, JSON.stringify(identity, null, 2), {
119
+ mode: 0o600,
120
+ });
121
+ }
122
+ /**
123
+ * Sign a payload string with the identity's Ed25519 private key. Returns
124
+ * base64url-encoded raw signature (no padding).
125
+ */
126
+ signPayload(payload, identity) {
127
+ const privateKey = createPrivateKey(identity.privateKey);
128
+ const signature = cryptoSign(null, Buffer.from(payload, 'utf8'), privateKey);
129
+ return base64UrlEncode(signature);
130
+ }
131
+ }
132
+ export { base64UrlEncode, publicKeyRawBase64UrlFromPem, deriveDeviceIdFromPublicKeyPem };
133
+ //# sourceMappingURL=device-identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-identity.js","sourceRoot":"","sources":["../src/device-identity.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ import { DeviceInfo } from './types.js';
2
+ import { DeviceIdentity } from './device-identity.js';
3
+ export declare class DeviceManager {
4
+ private configPath;
5
+ private configDir;
6
+ private deviceInfo;
7
+ private identityStore;
8
+ private cachedIdentity;
9
+ constructor(configDir?: string);
10
+ getDeviceInfo(): Promise<DeviceInfo>;
11
+ /**
12
+ * Returns the persistent Ed25519 device identity used for OpenClaw Gateway
13
+ * pairing. Lazily creates one on first call and caches it in memory.
14
+ */
15
+ getDeviceIdentity(): Promise<DeviceIdentity>;
16
+ private generateDeviceInfo;
17
+ private getMachineId;
18
+ private saveDeviceInfo;
19
+ resetDevice(): Promise<void>;
20
+ }
21
+ //# sourceMappingURL=device.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device.d.ts","sourceRoot":"","sources":["../src/device.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAOxC,OAAO,EAAE,cAAc,EAAuB,MAAM,sBAAsB,CAAC;AAM3E,qBAAa,aAAa;IACxB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,UAAU,CAA2B;IAC7C,OAAO,CAAC,aAAa,CAAsB;IAC3C,OAAO,CAAC,cAAc,CAA+B;gBAEzC,SAAS,CAAC,EAAE,MAAM;IAMxB,aAAa,IAAI,OAAO,CAAC,UAAU,CAAC;IAoB1C;;;OAGG;IACG,iBAAiB,IAAI,OAAO,CAAC,cAAc,CAAC;YAMpC,kBAAkB;YAsBlB,YAAY;YAmBZ,cAAc;IAQtB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;CASnC"}
package/dist/device.js ADDED
@@ -0,0 +1,104 @@
1
+ import { createHash } from 'crypto';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { promises as fs } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import os from 'os';
9
+ import { DeviceIdentityStore } from './device-identity.js';
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+ const execAsync = promisify(exec);
13
+ export class DeviceManager {
14
+ configPath;
15
+ configDir;
16
+ deviceInfo = null;
17
+ identityStore;
18
+ cachedIdentity = null;
19
+ constructor(configDir) {
20
+ this.configDir = configDir || join(__dirname, '..');
21
+ this.configPath = join(this.configDir, 'device.json');
22
+ this.identityStore = new DeviceIdentityStore(this.configDir);
23
+ }
24
+ async getDeviceInfo() {
25
+ if (this.deviceInfo) {
26
+ return this.deviceInfo;
27
+ }
28
+ // Try to load existing device info
29
+ try {
30
+ const data = await fs.readFile(this.configPath, 'utf-8');
31
+ const parsed = JSON.parse(data);
32
+ this.deviceInfo = parsed;
33
+ return parsed;
34
+ }
35
+ catch {
36
+ // Generate new device info
37
+ const generated = await this.generateDeviceInfo();
38
+ this.deviceInfo = generated;
39
+ await this.saveDeviceInfo();
40
+ return generated;
41
+ }
42
+ }
43
+ /**
44
+ * Returns the persistent Ed25519 device identity used for OpenClaw Gateway
45
+ * pairing. Lazily creates one on first call and caches it in memory.
46
+ */
47
+ async getDeviceIdentity() {
48
+ if (this.cachedIdentity)
49
+ return this.cachedIdentity;
50
+ this.cachedIdentity = await this.identityStore.getOrCreate();
51
+ return this.cachedIdentity;
52
+ }
53
+ async generateDeviceInfo() {
54
+ // Generate device ID based on machine fingerprint
55
+ const machineId = await this.getMachineId();
56
+ const hostname = os.hostname();
57
+ // 使用 "主机名-机器指纹前8位" 作为 deviceId,既唯一又易读
58
+ const fingerprint = createHash('sha256')
59
+ .update(`phoneclaw-${machineId}`)
60
+ .digest('hex')
61
+ .slice(0, 8);
62
+ const deviceId = `${hostname}-${fingerprint}`;
63
+ // Generate device key for authentication
64
+ const deviceKey = uuidv4();
65
+ return {
66
+ deviceId,
67
+ deviceKey,
68
+ platform: process.platform,
69
+ version: '1.0.0'
70
+ };
71
+ }
72
+ async getMachineId() {
73
+ try {
74
+ if (process.platform === 'darwin') {
75
+ const { stdout } = await execAsync('ioreg -rd1 -c IOPlatformExpertDevice');
76
+ const match = stdout.match(/"IOPlatformUUID" = "([^"]+)"/);
77
+ if (match)
78
+ return match[1];
79
+ }
80
+ if (process.platform === 'linux') {
81
+ const machineId = await fs.readFile('/etc/machine-id', 'utf-8');
82
+ return machineId.trim();
83
+ }
84
+ }
85
+ catch {
86
+ // Fallback to hostname
87
+ }
88
+ return os.hostname();
89
+ }
90
+ async saveDeviceInfo() {
91
+ await fs.writeFile(this.configPath, JSON.stringify(this.deviceInfo, null, 2), 'utf-8');
92
+ }
93
+ async resetDevice() {
94
+ this.deviceInfo = null;
95
+ this.cachedIdentity = null;
96
+ try {
97
+ await fs.unlink(this.configPath);
98
+ }
99
+ catch {
100
+ // File doesn't exist, ignore
101
+ }
102
+ }
103
+ }
104
+ //# sourceMappingURL=device.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device.js","sourceRoot":"","sources":["../src/device.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ import { SkillConfig } from './types.js';
2
+ import { DeviceIdentity } from './device-identity.js';
3
+ import { DeviceAuthStore } from './device-auth-store.js';
4
+ export interface GatewayClientOptions {
5
+ config: SkillConfig;
6
+ deviceIdentity: DeviceIdentity;
7
+ deviceAuthStore: DeviceAuthStore;
8
+ }
9
+ /**
10
+ * OpenClaw Gateway WebSocket client. Performs the device identity pairing
11
+ * flow (connect.challenge -> signed connect -> hello-ok with persisted
12
+ * deviceToken) so that operator.write / operator.read scopes are granted.
13
+ */
14
+ export declare class GatewayClient {
15
+ private ws;
16
+ private config;
17
+ private deviceIdentity;
18
+ private deviceAuthStore;
19
+ private isConnected;
20
+ private requestHandlers;
21
+ private requestIdCounter;
22
+ private eventHandlers;
23
+ constructor(options: GatewayClientOptions);
24
+ connect(): Promise<void>;
25
+ /**
26
+ * 1. Load any previously-persisted deviceToken for this identity.
27
+ * 2. Wait for `connect.challenge` from gateway (gives us a nonce).
28
+ * 3. Build a v3 device-auth payload, sign with our Ed25519 private key.
29
+ * 4. Send `connect` request with the signed device block + token/deviceToken.
30
+ * 5. On hello-ok, persist any newly issued deviceToken.
31
+ */
32
+ private runHandshake;
33
+ /** Wait for the gateway to emit a connect.challenge event with a nonce. */
34
+ private waitForConnectChallenge;
35
+ /**
36
+ * Build the v3 device-auth payload and sign it with the device's Ed25519
37
+ * private key. Mirrors openclaw's `buildDeviceAuthPayloadV3` exactly so
38
+ * the gateway can verify the signature.
39
+ */
40
+ private buildDeviceConnectParams;
41
+ private signDevicePayload;
42
+ /** Persist newly issued deviceToken from hello-ok response. */
43
+ private persistHelloOk;
44
+ private handleMessage;
45
+ private handleEvent;
46
+ /** Subscribe to gateway events. Pass '*' to receive all events. */
47
+ onEvent(eventName: string, handler: (event: any) => void): void;
48
+ offEvent(eventName: string): void;
49
+ private sendRequest;
50
+ agent(message: string, sessionId?: string): Promise<any>;
51
+ health(): Promise<any>;
52
+ status(): Promise<any>;
53
+ nodeList(): Promise<any>;
54
+ send(message: string): Promise<any>;
55
+ get connected(): boolean;
56
+ disconnect(): void;
57
+ }
58
+ //# sourceMappingURL=gateway.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzC,OAAO,EACL,cAAc,EAGf,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAqB,MAAM,wBAAwB,CAAC;AAwD5E,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,WAAW,CAAC;IACpB,cAAc,EAAE,cAAc,CAAC;IAC/B,eAAe,EAAE,eAAe,CAAC;CAClC;AAED;;;;GAIG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAAiB;IACvC,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,eAAe,CAA8D;IACrF,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,aAAa,CAA2C;gBAEpD,OAAO,EAAE,oBAAoB;IAMnC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAoD9B;;;;;;OAMG;YACW,YAAY;IAgD1B,2EAA2E;IAC3E,OAAO,CAAC,uBAAuB;IA+B/B;;;;OAIG;IACH,OAAO,CAAC,wBAAwB;IAiChC,OAAO,CAAC,iBAAiB;IAOzB,+DAA+D;YACjD,cAAc;IAqB5B,OAAO,CAAC,aAAa;IAyBrB,OAAO,CAAC,WAAW;IAqBnB,mEAAmE;IACnE,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,GAAG,KAAK,IAAI,GAAG,IAAI;IAI/D,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI;IAIjC,OAAO,CAAC,WAAW;IAuBb,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAkBxD,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC;IAItB,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC;IAItB,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC;IAIxB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC;IAIzC,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,UAAU,IAAI,IAAI;CAOnB"}
@@ -0,0 +1,314 @@
1
+ import WebSocket from 'ws';
2
+ import { randomUUID } from 'crypto';
3
+ import { DeviceIdentityStore, publicKeyRawBase64UrlFromPem, } from './device-identity.js';
4
+ const ROLE = 'operator';
5
+ const SCOPES = ['operator.read', 'operator.write'];
6
+ const CAPS = ['agent', 'tools', 'sessions'];
7
+ const CLIENT_ID = 'node-host';
8
+ const CLIENT_DISPLAY = 'PhoneClaw Skill';
9
+ const CLIENT_VERSION = '1.0.0';
10
+ const PROTOCOL = 4;
11
+ const CONNECT_CHALLENGE_TIMEOUT_MS = 10_000;
12
+ /**
13
+ * OpenClaw Gateway WebSocket client. Performs the device identity pairing
14
+ * flow (connect.challenge -> signed connect -> hello-ok with persisted
15
+ * deviceToken) so that operator.write / operator.read scopes are granted.
16
+ */
17
+ export class GatewayClient {
18
+ ws = null;
19
+ config;
20
+ deviceIdentity;
21
+ deviceAuthStore;
22
+ isConnected = false;
23
+ requestHandlers = new Map();
24
+ requestIdCounter = 0;
25
+ eventHandlers = new Map();
26
+ constructor(options) {
27
+ this.config = options.config;
28
+ this.deviceIdentity = options.deviceIdentity;
29
+ this.deviceAuthStore = options.deviceAuthStore;
30
+ }
31
+ async connect() {
32
+ return new Promise((resolve, reject) => {
33
+ try {
34
+ const url = this.config.gatewayToken
35
+ ? `${this.config.gatewayUrl}?token=${this.config.gatewayToken}`
36
+ : this.config.gatewayUrl;
37
+ this.ws = new WebSocket(url);
38
+ this.ws.on('open', () => {
39
+ console.log('[GatewayClient] Connected to OpenClaw Gateway');
40
+ this.isConnected = true;
41
+ });
42
+ this.ws.on('message', (data) => {
43
+ try {
44
+ const message = JSON.parse(data.toString());
45
+ this.handleMessage(message);
46
+ }
47
+ catch (error) {
48
+ console.error('[GatewayClient] Failed to parse message:', error);
49
+ }
50
+ });
51
+ this.ws.on('close', () => {
52
+ console.log('[GatewayClient] Gateway connection closed');
53
+ this.isConnected = false;
54
+ });
55
+ this.ws.on('error', (error) => {
56
+ console.error('[GatewayClient] Gateway connection error:', error);
57
+ if (this.ws) {
58
+ try {
59
+ this.ws.removeAllListeners();
60
+ }
61
+ catch {
62
+ // Ignore
63
+ }
64
+ this.ws = null;
65
+ }
66
+ reject(error);
67
+ });
68
+ // Kick off the handshake asynchronously; the 'open' resolve is
69
+ // intentionally omitted because handshake is what defines success.
70
+ this.runHandshake()
71
+ .then(() => resolve())
72
+ .catch((err) => reject(err));
73
+ }
74
+ catch (error) {
75
+ reject(error);
76
+ }
77
+ });
78
+ }
79
+ /**
80
+ * 1. Load any previously-persisted deviceToken for this identity.
81
+ * 2. Wait for `connect.challenge` from gateway (gives us a nonce).
82
+ * 3. Build a v3 device-auth payload, sign with our Ed25519 private key.
83
+ * 4. Send `connect` request with the signed device block + token/deviceToken.
84
+ * 5. On hello-ok, persist any newly issued deviceToken.
85
+ */
86
+ async runHandshake() {
87
+ const stored = await this.deviceAuthStore.loadToken(ROLE, this.deviceIdentity.deviceId);
88
+ const challenge = await this.waitForConnectChallenge();
89
+ const signedAt = Date.now();
90
+ const device = this.buildDeviceConnectParams({
91
+ nonce: challenge.nonce,
92
+ signedAt,
93
+ });
94
+ const params = {
95
+ minProtocol: PROTOCOL,
96
+ maxProtocol: PROTOCOL,
97
+ client: {
98
+ id: CLIENT_ID,
99
+ displayName: CLIENT_DISPLAY,
100
+ version: CLIENT_VERSION,
101
+ platform: process.platform,
102
+ mode: 'node',
103
+ },
104
+ role: ROLE,
105
+ scopes: SCOPES,
106
+ caps: CAPS,
107
+ auth: {
108
+ token: this.config.gatewayToken || undefined,
109
+ deviceToken: stored?.token,
110
+ },
111
+ device,
112
+ };
113
+ const response = await this.sendRequest('connect', params);
114
+ if (response?.type !== 'hello-ok') {
115
+ throw new Error(`Gateway handshake failed: unexpected payload ${JSON.stringify(response)}`);
116
+ }
117
+ await this.persistHelloOk(response);
118
+ console.log(`[GatewayClient] Gateway handshake successful, protocol=${response.protocol} ` +
119
+ `(device=${this.deviceIdentity.deviceId.slice(0, 12)}...)`);
120
+ }
121
+ /** Wait for the gateway to emit a connect.challenge event with a nonce. */
122
+ waitForConnectChallenge() {
123
+ return new Promise((resolve, reject) => {
124
+ const handler = (event) => {
125
+ if (event?.event === 'connect.challenge') {
126
+ const nonce = event?.payload?.nonce;
127
+ if (typeof nonce !== 'string' || nonce.length === 0) {
128
+ reject(new Error('Gateway connect.challenge missing nonce'));
129
+ return;
130
+ }
131
+ clearTimeout(timer);
132
+ resolve({ nonce });
133
+ }
134
+ };
135
+ // Reuse the existing request handler map by keying on event name.
136
+ this.requestHandlers.set('event:connect.challenge', {
137
+ resolve: (event) => handler(event),
138
+ reject,
139
+ });
140
+ const timer = setTimeout(() => {
141
+ this.requestHandlers.delete('event:connect.challenge');
142
+ reject(new Error(`Gateway connect.challenge timeout after ${CONNECT_CHALLENGE_TIMEOUT_MS}ms`));
143
+ }, CONNECT_CHALLENGE_TIMEOUT_MS);
144
+ });
145
+ }
146
+ /**
147
+ * Build the v3 device-auth payload and sign it with the device's Ed25519
148
+ * private key. Mirrors openclaw's `buildDeviceAuthPayloadV3` exactly so
149
+ * the gateway can verify the signature.
150
+ */
151
+ buildDeviceConnectParams(params) {
152
+ const scopesCsv = SCOPES.join(',');
153
+ const signatureToken = this.config.gatewayToken ?? '';
154
+ const platform = (process.platform ?? '').toLowerCase();
155
+ const payload = [
156
+ 'v3',
157
+ this.deviceIdentity.deviceId,
158
+ CLIENT_ID,
159
+ 'node',
160
+ ROLE,
161
+ scopesCsv,
162
+ String(params.signedAt),
163
+ signatureToken,
164
+ params.nonce,
165
+ platform,
166
+ '', // deviceFamily — not used by node-host
167
+ ].join('|');
168
+ const signature = this.deviceIdentity ? this.signDevicePayload(payload) : '';
169
+ return {
170
+ id: this.deviceIdentity.deviceId,
171
+ publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem),
172
+ signature,
173
+ signedAt: params.signedAt,
174
+ nonce: params.nonce,
175
+ };
176
+ }
177
+ signDevicePayload(payload) {
178
+ // We delegate to the DeviceIdentityStore's helper for testability and to
179
+ // keep all Ed25519 primitives in one place.
180
+ const store = new DeviceIdentityStore();
181
+ return store.signPayload(payload, this.deviceIdentity);
182
+ }
183
+ /** Persist newly issued deviceToken from hello-ok response. */
184
+ async persistHelloOk(payload) {
185
+ const auth = payload.auth;
186
+ if (!auth?.deviceToken) {
187
+ console.log('[GatewayClient] No deviceToken in hello-ok; using existing token (if any)');
188
+ return;
189
+ }
190
+ const stored = {
191
+ token: auth.deviceToken,
192
+ role: auth.role ?? ROLE,
193
+ scopes: Array.isArray(auth.scopes) && auth.scopes.length > 0
194
+ ? auth.scopes
195
+ : SCOPES,
196
+ deviceId: this.deviceIdentity.deviceId,
197
+ updatedAt: new Date().toISOString(),
198
+ };
199
+ await this.deviceAuthStore.saveToken(stored);
200
+ }
201
+ handleMessage(message) {
202
+ if (message.type === 'res' && message.id) {
203
+ const handler = this.requestHandlers.get(message.id);
204
+ if (handler) {
205
+ if (message.ok) {
206
+ handler.resolve(message.payload);
207
+ }
208
+ else {
209
+ handler.reject(new Error(JSON.stringify(message.error)));
210
+ }
211
+ this.requestHandlers.delete(message.id);
212
+ }
213
+ }
214
+ else if (message.type === 'event') {
215
+ console.log(`[GatewayClient] ← event: ${message.event} payload=${JSON.stringify(message.payload).slice(0, 500)}`);
216
+ const handler = this.requestHandlers.get(`event:${message.event}`);
217
+ if (handler) {
218
+ handler.resolve(message);
219
+ // One-shot for connect.challenge: remove after dispatch.
220
+ if (message.event === 'connect.challenge') {
221
+ this.requestHandlers.delete(`event:${message.event}`);
222
+ }
223
+ }
224
+ this.handleEvent(message);
225
+ }
226
+ }
227
+ handleEvent(event) {
228
+ // 触发通用 '*' 订阅者
229
+ const wildcard = this.eventHandlers.get('*');
230
+ if (wildcard) {
231
+ try {
232
+ wildcard(event);
233
+ }
234
+ catch (err) {
235
+ console.error('[GatewayClient] event handler * threw:', err);
236
+ }
237
+ }
238
+ // 触发按 event 名订阅者
239
+ const named = this.eventHandlers.get(event?.event ?? '');
240
+ if (named) {
241
+ try {
242
+ named(event);
243
+ }
244
+ catch (err) {
245
+ console.error(`[GatewayClient] event handler ${event?.event} threw:`, err);
246
+ }
247
+ }
248
+ }
249
+ /** Subscribe to gateway events. Pass '*' to receive all events. */
250
+ onEvent(eventName, handler) {
251
+ this.eventHandlers.set(eventName, handler);
252
+ }
253
+ offEvent(eventName) {
254
+ this.eventHandlers.delete(eventName);
255
+ }
256
+ sendRequest(method, params) {
257
+ return new Promise((resolve, reject) => {
258
+ const id = String(++this.requestIdCounter);
259
+ this.requestHandlers.set(id, { resolve, reject });
260
+ this.ws?.send(JSON.stringify({
261
+ type: 'req',
262
+ id,
263
+ method,
264
+ params,
265
+ }));
266
+ setTimeout(() => {
267
+ if (this.requestHandlers.has(id)) {
268
+ this.requestHandlers.delete(id);
269
+ reject(new Error(`Request timeout: ${method}`));
270
+ }
271
+ }, 30_000);
272
+ });
273
+ }
274
+ async agent(message, sessionId) {
275
+ // OpenClaw Gateway `agent` 协议要求:
276
+ // - 必传 idempotencyKey(用于去重,避免重复触发同一个 run)
277
+ // - 不允许 stream 字段(流式响应通过 event 通道单独推,不再走 req/res)
278
+ // - sessionKey 用于把 run 归类到同一个 session,会话续接时同一个 sessionId
279
+ // 必须使用同一个 sessionKey,否则会开新 session
280
+ const params = {
281
+ message,
282
+ idempotencyKey: randomUUID(),
283
+ timeout: 300,
284
+ };
285
+ if (sessionId) {
286
+ params.sessionId = sessionId;
287
+ params.sessionKey = `phoneclaw:${sessionId}`;
288
+ }
289
+ return this.sendRequest('agent', params);
290
+ }
291
+ async health() {
292
+ return this.sendRequest('health', {});
293
+ }
294
+ async status() {
295
+ return this.sendRequest('status', {});
296
+ }
297
+ async nodeList() {
298
+ return this.sendRequest('node.list', {});
299
+ }
300
+ async send(message) {
301
+ return this.sendRequest('send', { message });
302
+ }
303
+ get connected() {
304
+ return this.isConnected;
305
+ }
306
+ disconnect() {
307
+ if (this.ws) {
308
+ this.ws.close();
309
+ this.ws = null;
310
+ }
311
+ this.isConnected = false;
312
+ }
313
+ }
314
+ //# sourceMappingURL=gateway.js.map