Skip to content

Commit 5005d40

Browse files
authored
chore(deps): update dependencies and devDependencies in package.json (#100)
1 parent 26ce009 commit 5005d40

File tree

4 files changed

+1266
-1004
lines changed

4 files changed

+1266
-1004
lines changed

electron/gateway/manager.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } fro
2222
import { logger } from '../utils/logger';
2323
import { getUvMirrorEnv } from '../utils/uv-env';
2424
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
25+
import {
26+
loadOrCreateDeviceIdentity,
27+
signDevicePayload,
28+
publicKeyRawBase64UrlFromPem,
29+
buildDeviceAuthPayload,
30+
type DeviceIdentity,
31+
} from '../utils/device-identity';
2532

2633
/**
2734
* Gateway connection status
@@ -120,10 +127,22 @@ export class GatewayManager extends EventEmitter {
120127
reject: (error: Error) => void;
121128
timeout: NodeJS.Timeout;
122129
}> = new Map();
130+
private deviceIdentity: DeviceIdentity | null = null;
123131

124132
constructor(config?: Partial<ReconnectConfig>) {
125133
super();
126134
this.reconnectConfig = { ...DEFAULT_RECONNECT_CONFIG, ...config };
135+
this.initDeviceIdentity();
136+
}
137+
138+
private initDeviceIdentity(): void {
139+
try {
140+
const identityPath = path.join(app.getPath('userData'), 'clawx-device-identity.json');
141+
this.deviceIdentity = loadOrCreateDeviceIdentity(identityPath);
142+
logger.debug(`Device identity loaded (deviceId=${this.deviceIdentity.deviceId})`);
143+
} catch (err) {
144+
logger.warn('Failed to load device identity, scopes will be limited:', err);
145+
}
127146
}
128147

129148
private sanitizeSpawnArgs(args: string[]): string[] {
@@ -757,7 +776,34 @@ export class GatewayManager extends EventEmitter {
757776

758777
// Send proper connect handshake as required by OpenClaw Gateway protocol
759778
// The Gateway expects: { type: "req", id: "...", method: "connect", params: ConnectParams }
779+
// Since 2026.2.15, scopes are only granted when a signed device identity is included.
760780
connectId = `connect-${Date.now()}`;
781+
const role = 'operator';
782+
const scopes = ['operator.admin'];
783+
const signedAtMs = Date.now();
784+
const clientId = 'gateway-client';
785+
const clientMode = 'ui';
786+
787+
const device = (() => {
788+
if (!this.deviceIdentity) return undefined;
789+
const payload = buildDeviceAuthPayload({
790+
deviceId: this.deviceIdentity.deviceId,
791+
clientId,
792+
clientMode,
793+
role,
794+
scopes,
795+
signedAtMs,
796+
token: gatewayToken ?? null,
797+
});
798+
const signature = signDevicePayload(this.deviceIdentity.privateKeyPem, payload);
799+
return {
800+
id: this.deviceIdentity.deviceId,
801+
publicKey: publicKeyRawBase64UrlFromPem(this.deviceIdentity.publicKeyPem),
802+
signature,
803+
signedAt: signedAtMs,
804+
};
805+
})();
806+
761807
const connectFrame = {
762808
type: 'req',
763809
id: connectId,
@@ -766,18 +812,19 @@ export class GatewayManager extends EventEmitter {
766812
minProtocol: 3,
767813
maxProtocol: 3,
768814
client: {
769-
id: 'gateway-client',
815+
id: clientId,
770816
displayName: 'ClawX',
771817
version: '0.1.0',
772818
platform: process.platform,
773-
mode: 'ui',
819+
mode: clientMode,
774820
},
775821
auth: {
776822
token: gatewayToken,
777823
},
778824
caps: [],
779-
role: 'operator',
780-
scopes: [],
825+
role,
826+
scopes,
827+
device,
781828
},
782829
};
783830

electron/utils/device-identity.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* Device identity utilities for OpenClaw Gateway authentication.
3+
*
4+
* OpenClaw Gateway 2026.2.15+ requires a signed device identity in the
5+
* connect handshake to grant scopes (operator.read, operator.write, etc.).
6+
* Without a device, the gateway strips all requested scopes.
7+
*/
8+
import crypto from 'crypto';
9+
import fs from 'fs';
10+
import path from 'path';
11+
12+
export interface DeviceIdentity {
13+
deviceId: string;
14+
publicKeyPem: string;
15+
privateKeyPem: string;
16+
}
17+
18+
export interface DeviceAuthPayloadParams {
19+
deviceId: string;
20+
clientId: string;
21+
clientMode: string;
22+
role: string;
23+
scopes: string[];
24+
signedAtMs: number;
25+
token?: string | null;
26+
nonce?: string | null;
27+
version?: 'v1' | 'v2';
28+
}
29+
30+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
31+
32+
function base64UrlEncode(buf: Buffer): string {
33+
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
34+
}
35+
36+
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
37+
const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }) as Buffer;
38+
if (
39+
spki.length === ED25519_SPKI_PREFIX.length + 32 &&
40+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
41+
) {
42+
return spki.subarray(ED25519_SPKI_PREFIX.length);
43+
}
44+
return spki;
45+
}
46+
47+
function fingerprintPublicKey(publicKeyPem: string): string {
48+
const raw = derivePublicKeyRaw(publicKeyPem);
49+
return crypto.createHash('sha256').update(raw).digest('hex');
50+
}
51+
52+
function generateIdentity(): DeviceIdentity {
53+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
54+
const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString();
55+
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
56+
return {
57+
deviceId: fingerprintPublicKey(publicKeyPem),
58+
publicKeyPem,
59+
privateKeyPem,
60+
};
61+
}
62+
63+
/**
64+
* Load device identity from disk, or create and persist a new one.
65+
* The identity file is stored at `filePath` with mode 0o600.
66+
*/
67+
export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
68+
try {
69+
if (fs.existsSync(filePath)) {
70+
const raw = fs.readFileSync(filePath, 'utf8');
71+
const parsed = JSON.parse(raw);
72+
if (
73+
parsed?.version === 1 &&
74+
typeof parsed.deviceId === 'string' &&
75+
typeof parsed.publicKeyPem === 'string' &&
76+
typeof parsed.privateKeyPem === 'string'
77+
) {
78+
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
79+
if (derivedId && derivedId !== parsed.deviceId) {
80+
const updated = { ...parsed, deviceId: derivedId };
81+
fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
82+
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
83+
}
84+
return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
85+
}
86+
}
87+
} catch {
88+
// fall through to create a new identity
89+
}
90+
91+
const identity = generateIdentity();
92+
const dir = path.dirname(filePath);
93+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
94+
const stored = { version: 1, ...identity, createdAtMs: Date.now() };
95+
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
96+
try { fs.chmodSync(filePath, 0o600); } catch { /* ignore */ }
97+
return identity;
98+
}
99+
100+
/** Sign a string payload with the Ed25519 private key, returns base64url signature. */
101+
export function signDevicePayload(privateKeyPem: string, payload: string): string {
102+
const key = crypto.createPrivateKey(privateKeyPem);
103+
return base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), key));
104+
}
105+
106+
/** Encode the raw Ed25519 public key bytes (from PEM) as base64url. */
107+
export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
108+
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
109+
}
110+
111+
/** Build the canonical payload string that must be signed for device auth. */
112+
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
113+
const version = params.version ?? (params.nonce ? 'v2' : 'v1');
114+
const scopes = params.scopes.join(',');
115+
const token = params.token ?? '';
116+
const base = [
117+
version,
118+
params.deviceId,
119+
params.clientId,
120+
params.clientMode,
121+
params.role,
122+
scopes,
123+
String(params.signedAtMs),
124+
token,
125+
];
126+
if (version === 'v2') base.push(params.nonce ?? '');
127+
return base.join('|');
128+
}

package.json

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -42,69 +42,69 @@
4242
"postversion": "git push && git push --tags"
4343
},
4444
"dependencies": {
45-
"@radix-ui/react-dialog": "^1.1.4",
46-
"@radix-ui/react-dropdown-menu": "^2.1.4",
47-
"@radix-ui/react-label": "^2.1.1",
48-
"@radix-ui/react-progress": "^1.1.1",
49-
"@radix-ui/react-radio-group": "^1.2.2",
50-
"@radix-ui/react-select": "^2.1.4",
51-
"@radix-ui/react-separator": "^1.1.1",
52-
"@radix-ui/react-slot": "^1.1.1",
53-
"@radix-ui/react-switch": "^1.1.2",
54-
"@radix-ui/react-tabs": "^1.1.2",
55-
"@radix-ui/react-toast": "^1.2.4",
56-
"@radix-ui/react-tooltip": "^1.1.6",
45+
"@radix-ui/react-dialog": "^1.1.15",
46+
"@radix-ui/react-dropdown-menu": "^2.1.16",
47+
"@radix-ui/react-label": "^2.1.8",
48+
"@radix-ui/react-progress": "^1.1.8",
49+
"@radix-ui/react-radio-group": "^1.3.8",
50+
"@radix-ui/react-select": "^2.2.6",
51+
"@radix-ui/react-separator": "^1.1.8",
52+
"@radix-ui/react-slot": "^1.2.4",
53+
"@radix-ui/react-switch": "^1.2.6",
54+
"@radix-ui/react-tabs": "^1.1.13",
55+
"@radix-ui/react-toast": "^1.2.15",
56+
"@radix-ui/react-tooltip": "^1.2.8",
5757
"class-variance-authority": "^0.7.1",
5858
"clawhub": "^0.5.0",
5959
"clsx": "^2.1.1",
6060
"electron-store": "^11.0.2",
61-
"electron-updater": "^6.8.2",
62-
"framer-motion": "^12.33.0",
63-
"i18next": "^25.8.4",
61+
"electron-updater": "^6.8.3",
62+
"framer-motion": "^12.34.1",
63+
"i18next": "^25.8.10",
6464
"lucide-react": "^0.563.0",
65-
"openclaw": "2026.2.6-3",
65+
"openclaw": "2026.2.15",
6666
"react": "^19.2.4",
6767
"react-dom": "^19.2.4",
6868
"react-i18next": "^16.5.4",
6969
"react-markdown": "^10.1.0",
7070
"react-router-dom": "^7.13.0",
7171
"remark-gfm": "^4.0.1",
7272
"sonner": "^2.0.7",
73-
"tailwind-merge": "^3.4.0",
73+
"tailwind-merge": "^3.4.1",
7474
"tailwindcss-animate": "^1.0.7",
7575
"ws": "^8.19.0",
7676
"zustand": "^5.0.11"
7777
},
7878
"devDependencies": {
7979
"@eslint/js": "^10.0.1",
80-
"@playwright/test": "^1.49.1",
80+
"@playwright/test": "^1.58.2",
8181
"@testing-library/jest-dom": "^6.9.1",
82-
"@testing-library/react": "^16.1.0",
83-
"@types/node": "^25.2.1",
84-
"@types/react": "^19.2.13",
82+
"@testing-library/react": "^16.3.2",
83+
"@types/node": "^25.2.3",
84+
"@types/react": "^19.2.14",
8585
"@types/react-dom": "^19.2.3",
86-
"@types/ws": "^8.5.13",
87-
"@typescript-eslint/eslint-plugin": "^8.54.0",
88-
"@typescript-eslint/parser": "^8.54.0",
89-
"@vitejs/plugin-react": "^5.1.3",
90-
"autoprefixer": "^10.4.20",
91-
"electron": "^40.2.1",
86+
"@types/ws": "^8.18.1",
87+
"@typescript-eslint/eslint-plugin": "^8.56.0",
88+
"@typescript-eslint/parser": "^8.56.0",
89+
"@vitejs/plugin-react": "^5.1.4",
90+
"autoprefixer": "^10.4.24",
91+
"electron": "^40.4.1",
9292
"electron-builder": "^26.7.0",
9393
"eslint": "^10.0.0",
9494
"eslint-plugin-react-hooks": "^7.0.1",
9595
"eslint-plugin-react-refresh": "^0.5.0",
9696
"globals": "^17.3.0",
97-
"jsdom": "^28.0.0",
97+
"jsdom": "^28.1.0",
9898
"png2icons": "^2.0.1",
99-
"postcss": "^8.4.49",
99+
"postcss": "^8.5.6",
100100
"sharp": "^0.34.5",
101-
"tailwindcss": "^3.4.17",
102-
"typescript": "^5.7.2",
101+
"tailwindcss": "^3.4.19",
102+
"typescript": "^5.9.3",
103103
"vite": "^7.3.1",
104104
"vite-plugin-electron": "^0.29.0",
105105
"vite-plugin-electron-renderer": "^0.14.6",
106106
"vitest": "^4.0.18",
107107
"zx": "^8.8.5"
108108
},
109109
"packageManager": "pnpm@10.29.2+sha512.bef43fa759d91fd2da4b319a5a0d13ef7a45bb985a3d7342058470f9d2051a3ba8674e629672654686ef9443ad13a82da2beb9eeb3e0221c87b8154fff9d74b8"
110-
}
110+
}

0 commit comments

Comments
 (0)