@castlekit/castle 0.1.4 → 0.1.6
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.
- package/README.md +16 -0
- package/bin/castle.js +22 -5
- package/package.json +7 -3
- package/src/app/api/avatars/[id]/route.ts +71 -24
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +3 -2
- package/src/app/api/openclaw/restart/route.ts +7 -4
- package/src/app/page.tsx +102 -15
- package/src/cli/onboarding.ts +202 -37
- package/src/lib/api-security.ts +63 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
package/src/lib/config.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
|
+
import { platform } from "os";
|
|
4
5
|
import JSON5 from "json5";
|
|
5
6
|
|
|
6
7
|
export interface CastleConfig {
|
|
7
8
|
openclaw: {
|
|
8
9
|
gateway_port: number;
|
|
9
10
|
gateway_token?: string;
|
|
11
|
+
gateway_url?: string; // Full WebSocket URL for remote Gateways (e.g. ws://192.168.1.50:18789)
|
|
12
|
+
is_remote?: boolean; // True when connecting to a non-local Gateway
|
|
10
13
|
primary_agent?: string;
|
|
11
14
|
};
|
|
12
15
|
server: {
|
|
@@ -34,11 +37,28 @@ export function getConfigPath(): string {
|
|
|
34
37
|
export function ensureCastleDir(): void {
|
|
35
38
|
const dir = getCastleDir();
|
|
36
39
|
if (!existsSync(dir)) {
|
|
37
|
-
mkdirSync(dir, { recursive: true });
|
|
40
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
41
|
+
}
|
|
42
|
+
// Tighten existing directories that may have been created with default perms
|
|
43
|
+
if (platform() !== "win32") {
|
|
44
|
+
try { chmodSync(dir, 0o700); } catch { /* ignore */ }
|
|
38
45
|
}
|
|
39
46
|
const dataDir = join(dir, "data");
|
|
40
47
|
if (!existsSync(dataDir)) {
|
|
41
|
-
mkdirSync(dataDir, { recursive: true });
|
|
48
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
49
|
+
}
|
|
50
|
+
const logsDir = join(dir, "logs");
|
|
51
|
+
if (!existsSync(logsDir)) {
|
|
52
|
+
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
53
|
+
}
|
|
54
|
+
// Tighten log file permissions — LaunchAgent creates them with default umask
|
|
55
|
+
if (platform() !== "win32") {
|
|
56
|
+
for (const logFile of ["server.log", "server.err"]) {
|
|
57
|
+
const logPath = join(logsDir, logFile);
|
|
58
|
+
if (existsSync(logPath)) {
|
|
59
|
+
try { chmodSync(logPath, 0o600); } catch { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
42
62
|
}
|
|
43
63
|
}
|
|
44
64
|
|
|
@@ -70,6 +90,14 @@ export function writeConfig(config: CastleConfig): void {
|
|
|
70
90
|
const configPath = getConfigPath();
|
|
71
91
|
const content = JSON5.stringify(config, null, 2);
|
|
72
92
|
writeFileSync(configPath, content, "utf-8");
|
|
93
|
+
// Restrict permissions — castle.json may contain gateway_token
|
|
94
|
+
if (platform() !== "win32") {
|
|
95
|
+
try {
|
|
96
|
+
chmodSync(configPath, 0o600);
|
|
97
|
+
} catch {
|
|
98
|
+
// May fail on some filesystems
|
|
99
|
+
}
|
|
100
|
+
}
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
/**
|
|
@@ -177,13 +205,17 @@ export function readOpenClawPort(): number | null {
|
|
|
177
205
|
|
|
178
206
|
/**
|
|
179
207
|
* Get the Gateway WebSocket URL.
|
|
180
|
-
*
|
|
208
|
+
* Priority: env var > config gateway_url > config port on localhost.
|
|
181
209
|
*/
|
|
182
210
|
export function getGatewayUrl(): string {
|
|
183
211
|
if (process.env.OPENCLAW_GATEWAY_URL) {
|
|
184
212
|
return process.env.OPENCLAW_GATEWAY_URL;
|
|
185
213
|
}
|
|
186
214
|
const config = readConfig();
|
|
215
|
+
// Explicit URL takes priority (remote Gateways, Tailscale, etc.)
|
|
216
|
+
if (config.openclaw.gateway_url) {
|
|
217
|
+
return config.openclaw.gateway_url;
|
|
218
|
+
}
|
|
187
219
|
return `ws://127.0.0.1:${config.openclaw.gateway_port}`;
|
|
188
220
|
}
|
|
189
221
|
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { generateKeyPairSync, sign, createHash } from "crypto";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync, chmodSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { platform } from "os";
|
|
5
|
+
import { getCastleDir, ensureCastleDir } from "./config";
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Types
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
export interface DeviceIdentity {
|
|
12
|
+
deviceId: string;
|
|
13
|
+
publicKey: string; // PEM-encoded Ed25519 public key
|
|
14
|
+
privateKey: string; // PEM-encoded Ed25519 private key
|
|
15
|
+
createdAt: string; // ISO-8601
|
|
16
|
+
deviceToken?: string;
|
|
17
|
+
pairedAt?: string; // ISO-8601
|
|
18
|
+
gatewayUrl?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DeviceInfo {
|
|
22
|
+
deviceId: string;
|
|
23
|
+
publicKey: string;
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
isPaired: boolean;
|
|
27
|
+
pairedAt?: string;
|
|
28
|
+
gatewayUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Paths
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
function getDevicePath(): string {
|
|
36
|
+
return join(getCastleDir(), "device.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================================
|
|
40
|
+
// Helpers
|
|
41
|
+
// ============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check if a stored key is PEM format (vs old hex format).
|
|
45
|
+
*/
|
|
46
|
+
function isPem(key: string): boolean {
|
|
47
|
+
return key.startsWith("-----BEGIN ");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Derive device ID from public key per Gateway protocol.
|
|
52
|
+
* Gateway expects: SHA-256 hash of raw Ed25519 public key bytes, hex-encoded.
|
|
53
|
+
*
|
|
54
|
+
* The PEM contains a DER-encoded SPKI structure:
|
|
55
|
+
* [12 bytes algorithm info] + [32 bytes raw Ed25519 key]
|
|
56
|
+
*/
|
|
57
|
+
function deriveDeviceId(publicKeyPem: string): string {
|
|
58
|
+
const base64 = publicKeyPem
|
|
59
|
+
.split("\n")
|
|
60
|
+
.filter(line => !line.includes("BEGIN") && !line.includes("END") && line.trim())
|
|
61
|
+
.join("");
|
|
62
|
+
const der = Buffer.from(base64, "base64");
|
|
63
|
+
// SPKI for Ed25519: 12-byte header + 32-byte raw key
|
|
64
|
+
const rawPublicKey = der.slice(12);
|
|
65
|
+
return createHash("sha256").update(rawPublicKey).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Write identity to disk with restrictive permissions.
|
|
70
|
+
*/
|
|
71
|
+
let _windowsPermWarnShown = false;
|
|
72
|
+
|
|
73
|
+
function persistIdentity(identity: DeviceIdentity): void {
|
|
74
|
+
const devicePath = getDevicePath();
|
|
75
|
+
ensureCastleDir();
|
|
76
|
+
writeFileSync(devicePath, JSON.stringify(identity, null, 2), "utf-8");
|
|
77
|
+
|
|
78
|
+
if (platform() === "win32") {
|
|
79
|
+
// chmod is a no-op on Windows — warn once
|
|
80
|
+
if (!_windowsPermWarnShown) {
|
|
81
|
+
console.warn("[Device] Warning: On Windows, device.json file permissions cannot be restricted.");
|
|
82
|
+
console.warn("[Device] Keep your user account secure to protect your device private key.");
|
|
83
|
+
_windowsPermWarnShown = true;
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
try {
|
|
87
|
+
chmodSync(devicePath, 0o600);
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore — may fail on some filesystems
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Core API
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load existing device identity or generate a new Ed25519 keypair.
|
|
100
|
+
* Keys are stored in PEM format as required by the Gateway protocol.
|
|
101
|
+
* Identity is persisted in ~/.castle/device.json with mode 0600.
|
|
102
|
+
*/
|
|
103
|
+
export function getOrCreateIdentity(): DeviceIdentity {
|
|
104
|
+
const devicePath = getDevicePath();
|
|
105
|
+
|
|
106
|
+
// Try loading existing identity
|
|
107
|
+
if (existsSync(devicePath)) {
|
|
108
|
+
try {
|
|
109
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
110
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
111
|
+
if (identity.deviceId && identity.publicKey && identity.privateKey) {
|
|
112
|
+
// Auto-upgrade: if keys are in old hex/DER format, regenerate entirely
|
|
113
|
+
if (!isPem(identity.publicKey)) {
|
|
114
|
+
console.log("[Device] Upgrading key format from hex to PEM — regenerating keypair");
|
|
115
|
+
return generateIdentity();
|
|
116
|
+
}
|
|
117
|
+
// Auto-fix: if deviceId is a UUID instead of derived from public key, re-derive
|
|
118
|
+
const expectedId = deriveDeviceId(identity.publicKey);
|
|
119
|
+
if (identity.deviceId !== expectedId) {
|
|
120
|
+
console.log("[Device] Fixing deviceId — deriving from public key per Gateway protocol");
|
|
121
|
+
identity.deviceId = expectedId;
|
|
122
|
+
persistIdentity(identity);
|
|
123
|
+
}
|
|
124
|
+
return identity;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Corrupted file — regenerate
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return generateIdentity();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Generate a new Ed25519 keypair and persist it.
|
|
136
|
+
*/
|
|
137
|
+
function generateIdentity(): DeviceIdentity {
|
|
138
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
|
|
139
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
140
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const pubKeyStr = publicKey as unknown as string;
|
|
144
|
+
|
|
145
|
+
const identity: DeviceIdentity = {
|
|
146
|
+
deviceId: deriveDeviceId(pubKeyStr),
|
|
147
|
+
publicKey: pubKeyStr,
|
|
148
|
+
privateKey: privateKey as unknown as string,
|
|
149
|
+
createdAt: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
persistIdentity(identity);
|
|
153
|
+
return identity;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parameters for signing a device auth payload.
|
|
158
|
+
* Must match the Gateway's buildDeviceAuthPayload() format exactly.
|
|
159
|
+
*/
|
|
160
|
+
export interface DeviceAuthSignParams {
|
|
161
|
+
nonce: string;
|
|
162
|
+
clientId: string;
|
|
163
|
+
clientMode: string;
|
|
164
|
+
role: string;
|
|
165
|
+
scopes: string[];
|
|
166
|
+
token: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Sign a device auth payload per the Gateway protocol.
|
|
171
|
+
*
|
|
172
|
+
* The Gateway builds a pipe-delimited string:
|
|
173
|
+
* v2|deviceId|clientId|clientMode|role|scopes|signedAtMs|token|nonce
|
|
174
|
+
* and verifies the Ed25519 signature against that string.
|
|
175
|
+
*
|
|
176
|
+
* Returns { signature (base64url), signedAt (ms) }.
|
|
177
|
+
*/
|
|
178
|
+
export function signDeviceAuth(params: DeviceAuthSignParams): {
|
|
179
|
+
signature: string;
|
|
180
|
+
signedAt: number;
|
|
181
|
+
} {
|
|
182
|
+
const identity = getOrCreateIdentity();
|
|
183
|
+
const signedAt = Date.now();
|
|
184
|
+
|
|
185
|
+
// Build the exact same payload string the Gateway builds
|
|
186
|
+
const payload = [
|
|
187
|
+
"v2", // version (v2 when nonce is present)
|
|
188
|
+
identity.deviceId,
|
|
189
|
+
params.clientId,
|
|
190
|
+
params.clientMode,
|
|
191
|
+
params.role,
|
|
192
|
+
params.scopes.join(","),
|
|
193
|
+
String(signedAt),
|
|
194
|
+
params.token,
|
|
195
|
+
params.nonce,
|
|
196
|
+
].join("|");
|
|
197
|
+
|
|
198
|
+
// Ed25519 doesn't use a digest algorithm (pass null)
|
|
199
|
+
const sig = sign(null, Buffer.from(payload, "utf-8"), identity.privateKey);
|
|
200
|
+
|
|
201
|
+
// Gateway expects base64url encoding
|
|
202
|
+
const signature = sig
|
|
203
|
+
.toString("base64")
|
|
204
|
+
.replaceAll("+", "-")
|
|
205
|
+
.replaceAll("/", "_")
|
|
206
|
+
.replace(/=+$/g, "");
|
|
207
|
+
|
|
208
|
+
return { signature, signedAt };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Save a device token received after successful pairing.
|
|
213
|
+
*/
|
|
214
|
+
export function saveDeviceToken(token: string, gatewayUrl?: string): void {
|
|
215
|
+
const identity = getOrCreateIdentity();
|
|
216
|
+
identity.deviceToken = token;
|
|
217
|
+
identity.pairedAt = new Date().toISOString();
|
|
218
|
+
if (gatewayUrl) {
|
|
219
|
+
identity.gatewayUrl = gatewayUrl;
|
|
220
|
+
}
|
|
221
|
+
persistIdentity(identity);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get the saved device token, or null if not yet paired.
|
|
226
|
+
*/
|
|
227
|
+
export function getDeviceToken(): string | null {
|
|
228
|
+
const devicePath = getDevicePath();
|
|
229
|
+
if (!existsSync(devicePath)) return null;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
233
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
234
|
+
return identity.deviceToken || null;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Clear the saved device token without deleting the identity.
|
|
242
|
+
* Used when a device token is rejected (e.g. Gateway was reset).
|
|
243
|
+
* The device keypair is preserved so it can re-pair.
|
|
244
|
+
*/
|
|
245
|
+
export function clearDeviceToken(): void {
|
|
246
|
+
const devicePath = getDevicePath();
|
|
247
|
+
if (!existsSync(devicePath)) return;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
251
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
252
|
+
delete identity.deviceToken;
|
|
253
|
+
delete identity.pairedAt;
|
|
254
|
+
delete identity.gatewayUrl;
|
|
255
|
+
persistIdentity(identity);
|
|
256
|
+
console.log("[Device] Cleared device token");
|
|
257
|
+
} catch {
|
|
258
|
+
// If we can't read/parse, nothing to clear
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Delete device identity entirely. Forces re-pairing on next connection.
|
|
264
|
+
*/
|
|
265
|
+
export function resetIdentity(): boolean {
|
|
266
|
+
const devicePath = getDevicePath();
|
|
267
|
+
if (existsSync(devicePath)) {
|
|
268
|
+
unlinkSync(devicePath);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get a summary of device identity for display (no private key).
|
|
276
|
+
*/
|
|
277
|
+
export function getDeviceInfo(): DeviceInfo | null {
|
|
278
|
+
const devicePath = getDevicePath();
|
|
279
|
+
if (!existsSync(devicePath)) return null;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const raw = readFileSync(devicePath, "utf-8");
|
|
283
|
+
const identity = JSON.parse(raw) as DeviceIdentity;
|
|
284
|
+
|
|
285
|
+
// Create a fingerprint from the public key
|
|
286
|
+
const fingerprint = createHash("sha256")
|
|
287
|
+
.update(identity.publicKey)
|
|
288
|
+
.digest("hex")
|
|
289
|
+
.slice(0, 16);
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
deviceId: identity.deviceId,
|
|
293
|
+
publicKey: identity.publicKey,
|
|
294
|
+
fingerprint,
|
|
295
|
+
createdAt: identity.createdAt,
|
|
296
|
+
isPaired: !!identity.deviceToken,
|
|
297
|
+
pairedAt: identity.pairedAt,
|
|
298
|
+
gatewayUrl: identity.gatewayUrl,
|
|
299
|
+
};
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|