@castlekit/castle 0.1.5 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@castlekit/castle",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "The multi-agent workspace",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "@types/node": "^25.2.1",
43
43
  "@types/react": "^19.2.13",
44
44
  "@types/react-dom": "^19.2.3",
45
+ "@types/ws": "^8.18.1",
45
46
  "clsx": "^2.1.1",
46
47
  "commander": "^14.0.3",
47
48
  "json5": "^2.2.3",
@@ -53,12 +54,15 @@
53
54
  "react": "^19.2.4",
54
55
  "react-dom": "^19.2.4",
55
56
  "recharts": "^3.7.0",
57
+ "sharp": "^0.34.5",
56
58
  "swr": "^2.4.0",
57
59
  "tailwind-merge": "^3.4.0",
58
60
  "tailwindcss": "^4.1.18",
59
61
  "tsx": "^4.21.0",
60
62
  "typescript": "^5.9.3",
61
- "ws": "^8.19.0",
62
- "@types/ws": "^8.18.1"
63
+ "ws": "^8.19.0"
64
+ },
65
+ "devDependencies": {
66
+ "@types/sharp": "^0.31.1"
63
67
  }
64
68
  }
@@ -2,13 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
2
2
  import { readFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { homedir } from "os";
5
+ import { ensureGateway } from "@/lib/gateway-connection";
5
6
 
6
7
  export const dynamic = "force-dynamic";
7
8
 
8
- const AVATAR_DIRS = [
9
- join(homedir(), ".castle", "avatars"),
10
- join(homedir(), ".openclaw", "avatars"),
11
- ];
9
+ const EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"];
12
10
 
13
11
  const MIME_TYPES: Record<string, string> = {
14
12
  ".png": "image/png",
@@ -19,10 +17,17 @@ const MIME_TYPES: Record<string, string> = {
19
17
  ".svg": "image/svg+xml",
20
18
  };
21
19
 
20
+ const CACHE_HEADERS = { "Cache-Control": "public, max-age=3600" };
21
+
22
22
  /**
23
23
  * GET /api/avatars/[id]
24
- * Serves avatar images from ~/.castle/avatars/ or ~/.openclaw/avatars/
25
- * Supports IDs with or without extension (tries .png, .jpg, .webp)
24
+ *
25
+ * Proxies avatar images so they work from any device (mobile, Tailscale, etc).
26
+ *
27
+ * Resolution order:
28
+ * 1. Stored URL from Gateway → workspace path or HTTP fetch
29
+ * 2. Local file in ~/.castle/avatars/ or ~/.openclaw/avatars/
30
+ * 3. 404
26
31
  */
27
32
  export async function GET(
28
33
  _request: NextRequest,
@@ -30,46 +35,88 @@ export async function GET(
30
35
  ) {
31
36
  const { id } = await params;
32
37
 
33
- // Sanitize -- prevent path traversal
38
+ // Sanitize
34
39
  const safeId = id.replace(/[^a-zA-Z0-9._-]/g, "");
35
40
  if (!safeId || safeId.includes("..")) {
36
41
  return new NextResponse("Not found", { status: 404 });
37
42
  }
38
43
 
39
- // Try each avatar directory
40
- for (const dir of AVATAR_DIRS) {
44
+ // 1. Try stored URL from Gateway
45
+ try {
46
+ const gw = ensureGateway();
47
+ const storedUrl = gw.getAvatarUrl(safeId);
48
+
49
+ if (storedUrl) {
50
+ // Workspace-relative: workspace:///absolute/path/to/workspace/avatars/file.png
51
+ if (storedUrl.startsWith("workspace://")) {
52
+ const pathPart = storedUrl.slice("workspace://".length);
53
+ // Resolve ~ in workspace path
54
+ const resolved = pathPart.startsWith("~")
55
+ ? join(homedir(), pathPart.slice(1))
56
+ : pathPart;
57
+ // Prevent traversal
58
+ if (!resolved.includes("..") && existsSync(resolved)) {
59
+ return serveFile(resolved);
60
+ }
61
+ }
62
+ // HTTP(S) URL: fetch server-side and proxy
63
+ else if (storedUrl.startsWith("http://") || storedUrl.startsWith("https://")) {
64
+ const response = await fetchAvatar(storedUrl);
65
+ if (response) return response;
66
+ }
67
+ }
68
+ } catch {
69
+ // Gateway unavailable
70
+ }
71
+
72
+ // 2. Local avatar directories
73
+ const localDirs = [
74
+ join(homedir(), ".castle", "avatars"),
75
+ join(homedir(), ".openclaw", "avatars"),
76
+ ];
77
+
78
+ for (const dir of localDirs) {
41
79
  if (!existsSync(dir)) continue;
42
80
 
43
- // Try exact filename first (if it has an extension)
44
- const hasExtension = /\.\w+$/.test(safeId);
45
- if (hasExtension) {
81
+ if (/\.\w+$/.test(safeId)) {
46
82
  const filePath = join(dir, safeId);
47
- if (existsSync(filePath)) {
48
- return serveFile(filePath);
49
- }
83
+ if (existsSync(filePath)) return serveFile(filePath);
50
84
  }
51
85
 
52
- // Try common extensions
53
- for (const ext of [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"]) {
86
+ for (const ext of EXTENSIONS) {
54
87
  const filePath = join(dir, `${safeId}${ext}`);
55
- if (existsSync(filePath)) {
56
- return serveFile(filePath);
57
- }
88
+ if (existsSync(filePath)) return serveFile(filePath);
58
89
  }
59
90
  }
60
91
 
61
92
  return new NextResponse("Not found", { status: 404 });
62
93
  }
63
94
 
95
+ /** Fetch an avatar from an HTTP URL with a short timeout */
96
+ async function fetchAvatar(url: string): Promise<NextResponse | null> {
97
+ try {
98
+ const resp = await fetch(url, { signal: AbortSignal.timeout(2000) });
99
+ if (!resp.ok) return null;
100
+
101
+ const contentType = resp.headers.get("content-type") || "image/png";
102
+ if (!contentType.startsWith("image/")) return null;
103
+
104
+ const data = Buffer.from(await resp.arrayBuffer());
105
+ return new NextResponse(data, {
106
+ headers: { "Content-Type": contentType, ...CACHE_HEADERS },
107
+ });
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ /** Serve a local file with appropriate content type */
64
114
  function serveFile(filePath: string): NextResponse {
65
115
  const data = readFileSync(filePath);
66
116
  const ext = filePath.substring(filePath.lastIndexOf(".")).toLowerCase();
67
117
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
68
118
 
69
119
  return new NextResponse(data, {
70
- headers: {
71
- "Content-Type": contentType,
72
- "Cache-Control": "public, max-age=86400, immutable",
73
- },
120
+ headers: { "Content-Type": contentType, ...CACHE_HEADERS },
74
121
  });
75
122
  }
@@ -0,0 +1,216 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import JSON5 from "json5";
6
+ import sharp from "sharp";
7
+ import { ensureGateway } from "@/lib/gateway-connection";
8
+ import { checkCsrf } from "@/lib/api-security";
9
+
10
+ export const dynamic = "force-dynamic";
11
+
12
+ const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; // 5MB raw upload limit
13
+ const AVATAR_SIZE = 256; // px (square)
14
+ const MAX_OUTPUT_SIZE = 100 * 1024; // 100KB after processing
15
+
16
+ const ALLOWED_TYPES = new Set([
17
+ "image/png",
18
+ "image/jpeg",
19
+ "image/webp",
20
+ "image/gif",
21
+ ]);
22
+
23
+ interface AgentConfig {
24
+ id: string;
25
+ workspace?: string;
26
+ identity?: Record<string, unknown>;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ interface OpenClawConfig {
31
+ agents?: {
32
+ list?: AgentConfig[];
33
+ [key: string]: unknown;
34
+ };
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /**
39
+ * Resize and compress an avatar image to 256x256, under 100KB.
40
+ */
41
+ async function processAvatar(input: Buffer): Promise<{ data: Buffer; ext: string }> {
42
+ const processed = sharp(input)
43
+ .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "centre" });
44
+
45
+ // Try PNG first (transparency support)
46
+ let data = await processed.png({ quality: 80, compressionLevel: 9 }).toBuffer();
47
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".png" };
48
+
49
+ // WebP (better compression, keeps transparency)
50
+ data = await processed.webp({ quality: 80 }).toBuffer();
51
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".webp" };
52
+
53
+ // JPEG with lower quality
54
+ data = await processed.jpeg({ quality: 70 }).toBuffer();
55
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".jpg" };
56
+
57
+ // Last resort
58
+ data = await processed.jpeg({ quality: 40 }).toBuffer();
59
+ return { data, ext: ".jpg" };
60
+ }
61
+
62
+ /**
63
+ * Find the OpenClaw config file path.
64
+ */
65
+ function getOpenClawConfigPath(): string | null {
66
+ const paths = [
67
+ join(homedir(), ".openclaw", "openclaw.json"),
68
+ join(homedir(), ".openclaw", "openclaw.json5"),
69
+ ];
70
+ for (const p of paths) {
71
+ if (existsSync(p)) return p;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * POST /api/openclaw/agents/[id]/avatar
78
+ *
79
+ * Upload a new avatar image for an agent.
80
+ * - Resizes to 256x256 and compresses under 100KB
81
+ * - Saves to the agent's workspace directory
82
+ * - Writes config file directly (Gateway hot-reloads identity changes, no restart needed)
83
+ */
84
+ export async function POST(
85
+ request: NextRequest,
86
+ { params }: { params: Promise<{ id: string }> }
87
+ ) {
88
+ const csrfError = checkCsrf(request);
89
+ if (csrfError) return csrfError;
90
+
91
+ const { id: agentId } = await params;
92
+
93
+ const safeId = agentId.replace(/[^a-zA-Z0-9._-]/g, "");
94
+ if (!safeId) {
95
+ return NextResponse.json({ error: "Invalid agent ID" }, { status: 400 });
96
+ }
97
+
98
+ const formData = await request.formData();
99
+ const file = formData.get("avatar") as File | null;
100
+
101
+ if (!file) {
102
+ return NextResponse.json({ error: "No file provided" }, { status: 400 });
103
+ }
104
+
105
+ if (!ALLOWED_TYPES.has(file.type)) {
106
+ return NextResponse.json(
107
+ { error: "Unsupported format. Use PNG, JPEG, WebP, or GIF." },
108
+ { status: 400 }
109
+ );
110
+ }
111
+
112
+ if (file.size > MAX_UPLOAD_SIZE) {
113
+ return NextResponse.json(
114
+ { error: "File too large (max 5MB)" },
115
+ { status: 400 }
116
+ );
117
+ }
118
+
119
+ // Process image: resize to 256x256, compress to <100KB
120
+ let processed: { data: Buffer; ext: string };
121
+ try {
122
+ const rawBuffer = Buffer.from(await file.arrayBuffer());
123
+ processed = await processAvatar(rawBuffer);
124
+ } catch (err) {
125
+ return NextResponse.json(
126
+ { error: `Failed to process image: ${err instanceof Error ? err.message : "unknown"}` },
127
+ { status: 400 }
128
+ );
129
+ }
130
+
131
+ // Read the OpenClaw config file directly
132
+ const configPath = getOpenClawConfigPath();
133
+ if (!configPath) {
134
+ return NextResponse.json(
135
+ { error: "OpenClaw config file not found" },
136
+ { status: 500 }
137
+ );
138
+ }
139
+
140
+ let config: OpenClawConfig;
141
+ try {
142
+ config = JSON5.parse(readFileSync(configPath, "utf-8"));
143
+ } catch (err) {
144
+ return NextResponse.json(
145
+ { error: `Failed to read config: ${err instanceof Error ? err.message : "unknown"}` },
146
+ { status: 500 }
147
+ );
148
+ }
149
+
150
+ const agents = config.agents?.list || [];
151
+ const agent = agents.find((a) => a.id === safeId);
152
+
153
+ if (!agent) {
154
+ return NextResponse.json({ error: "Agent not found" }, { status: 404 });
155
+ }
156
+
157
+ if (!agent.workspace) {
158
+ return NextResponse.json(
159
+ { error: "Agent has no workspace configured" },
160
+ { status: 400 }
161
+ );
162
+ }
163
+
164
+ const workspacePath = agent.workspace.startsWith("~")
165
+ ? join(homedir(), agent.workspace.slice(1))
166
+ : agent.workspace;
167
+
168
+ // Save processed avatar to workspace
169
+ const avatarsDir = join(workspacePath, "avatars");
170
+ const fileName = `avatar${processed.ext}`;
171
+ const filePath = join(avatarsDir, fileName);
172
+
173
+ try {
174
+ mkdirSync(avatarsDir, { recursive: true, mode: 0o755 });
175
+ writeFileSync(filePath, processed.data);
176
+ } catch (err) {
177
+ return NextResponse.json(
178
+ { error: `Failed to save avatar: ${err instanceof Error ? err.message : "unknown"}` },
179
+ { status: 500 }
180
+ );
181
+ }
182
+
183
+ // Update config file directly — Gateway's file watcher hot-reloads identity changes
184
+ // No config.patch, no restart, no WebSocket disconnect
185
+ try {
186
+ agent.identity = agent.identity || {};
187
+ agent.identity.avatar = `avatars/${fileName}`;
188
+ writeFileSync(configPath, JSON5.stringify(config, null, 2), "utf-8");
189
+ } catch (err) {
190
+ return NextResponse.json(
191
+ { error: `Failed to update config: ${err instanceof Error ? err.message : "unknown"}` },
192
+ { status: 500 }
193
+ );
194
+ }
195
+
196
+ // Wait briefly for Gateway's file watcher to hot-reload (~300ms debounce)
197
+ await new Promise((resolve) => setTimeout(resolve, 500));
198
+
199
+ // Refresh Castle's agent list from the Gateway
200
+ try {
201
+ const gw = ensureGateway();
202
+ if (gw.isConnected) {
203
+ // Emit a signal so SSE clients re-fetch agents
204
+ gw.emit("agentAvatarUpdated", { agentId: safeId });
205
+ }
206
+ } catch {
207
+ // Non-critical
208
+ }
209
+
210
+ return NextResponse.json({
211
+ success: true,
212
+ avatar: `avatars/${fileName}`,
213
+ size: processed.data.length,
214
+ message: "Avatar updated",
215
+ });
216
+ }
@@ -24,34 +24,66 @@ interface AgentsListPayload {
24
24
  agents: GatewayAgent[];
25
25
  }
26
26
 
27
+ interface AgentConfig {
28
+ id: string;
29
+ workspace?: string;
30
+ }
31
+
32
+ interface ConfigGetPayload {
33
+ hash: string;
34
+ parsed: {
35
+ agents?: {
36
+ list?: AgentConfig[];
37
+ };
38
+ };
39
+ }
40
+
41
+ /** Avatar URL patterns containing a hash */
42
+ const AVATAR_HASH_PATTERNS = [
43
+ /\/api\/v\d+\/avatars\/([a-f0-9]+)/i,
44
+ /\/avatars\/([a-f0-9]+)/i,
45
+ ];
46
+
27
47
  /**
28
- * Rewrite avatar URLs to local /api/avatars/ endpoint.
29
- * Handles URLs like "http://localhost:8787/api/v1/avatars/HASH" -> "/api/avatars/HASH"
48
+ * Resolve an avatar value into a Castle-proxied URL.
49
+ *
50
+ * Handles three formats:
51
+ * 1. Data URI → pass through (self-contained)
52
+ * 2. HTTP(S) URL → proxy via /api/avatars/:key
53
+ * 3. Relative path → proxy via /api/avatars/:key (resolved server-side against workspace)
30
54
  */
31
- function rewriteAvatarUrl(url: string | null): string | null {
32
- if (!url) return null;
33
-
34
- // Extract hash from known avatar URL patterns
35
- const patterns = [
36
- /\/api\/v\d+\/avatars\/([a-f0-9]+)/i,
37
- /\/avatars\/([a-f0-9]+)/i,
38
- ];
39
-
40
- for (const pattern of patterns) {
41
- const match = url.match(pattern);
42
- if (match) {
43
- return `/api/avatars/${match[1]}`;
55
+ function resolveAvatarUrl(
56
+ avatar: string | null,
57
+ agentId: string,
58
+ workspace: string | undefined,
59
+ gw: ReturnType<typeof ensureGateway>,
60
+ ): string | null {
61
+ if (!avatar) return null;
62
+
63
+ // Data URIs are self-contained
64
+ if (avatar.startsWith("data:")) return avatar;
65
+
66
+ // HTTP(S) URL — proxy through Castle
67
+ if (avatar.startsWith("http://") || avatar.startsWith("https://")) {
68
+ // Try to extract a hash for a cleaner key
69
+ for (const pattern of AVATAR_HASH_PATTERNS) {
70
+ const match = avatar.match(pattern);
71
+ if (match) {
72
+ gw.setAvatarUrl(match[1], avatar);
73
+ return `/api/avatars/${match[1]}`;
74
+ }
44
75
  }
76
+ // No hash — use agent ID as key
77
+ const key = `agent-${agentId}`;
78
+ gw.setAvatarUrl(key, avatar);
79
+ return `/api/avatars/${key}`;
45
80
  }
46
81
 
47
- // If it's already a relative path or data URI, pass through
48
- if (url.startsWith("/") || url.startsWith("data:")) {
49
- return url;
50
- }
51
-
52
- // Only allow http/https URLs through; reject file:, javascript:, etc.
53
- if (url.startsWith("http://") || url.startsWith("https://")) {
54
- return url;
82
+ // Relative path (e.g. "avatars/sam.png") resolve against workspace
83
+ if (workspace && !avatar.startsWith("/")) {
84
+ const key = `agent-${agentId}`;
85
+ gw.setAvatarUrl(key, `workspace://${workspace}/${avatar}`);
86
+ return `/api/avatars/${key}`;
55
87
  }
56
88
 
57
89
  return null;
@@ -59,7 +91,8 @@ function rewriteAvatarUrl(url: string | null): string | null {
59
91
 
60
92
  /**
61
93
  * GET /api/openclaw/agents
62
- * Discover agents from OpenClaw Gateway via agents.list
94
+ * Discover agents from OpenClaw Gateway via agents.list,
95
+ * with workspace info from config.get for avatar resolution.
63
96
  */
64
97
  export async function GET() {
65
98
  const gw = ensureGateway();
@@ -72,31 +105,34 @@ export async function GET() {
72
105
  }
73
106
 
74
107
  try {
75
- const result = await gw.request<AgentsListPayload>("agents.list", {});
108
+ // Fetch agents and config in parallel
109
+ const [agentsResult, configResult] = await Promise.all([
110
+ gw.request<AgentsListPayload>("agents.list", {}),
111
+ gw.request<ConfigGetPayload>("config.get", {}).catch(() => null),
112
+ ]);
113
+
114
+ // Build workspace lookup from config
115
+ const workspaceMap = new Map<string, string>();
116
+ if (configResult?.parsed?.agents?.list) {
117
+ for (const a of configResult.parsed.agents.list) {
118
+ if (a.workspace) workspaceMap.set(a.id, a.workspace);
119
+ }
120
+ }
76
121
 
77
- const agents = (result?.agents || []).map((agent: GatewayAgent) => {
78
- const name =
79
- agent.identity?.name || agent.name || agent.id;
80
- const rawAvatar =
81
- agent.identity?.avatarUrl || agent.identity?.avatar || null;
122
+ const agents = (agentsResult?.agents || []).map((agent: GatewayAgent) => {
123
+ const name = agent.identity?.name || agent.name || agent.id;
124
+ const rawAvatar = agent.identity?.avatarUrl || agent.identity?.avatar || null;
82
125
  const emoji = agent.identity?.emoji || null;
83
126
  const description = agent.identity?.theme || null;
127
+ const workspace = workspaceMap.get(agent.id);
128
+ const avatar = resolveAvatarUrl(rawAvatar, agent.id, workspace, gw);
84
129
 
85
- // Rewrite external avatar URLs to our local /api/avatars/ endpoint
86
- const avatar = rewriteAvatarUrl(rawAvatar);
87
-
88
- return {
89
- id: agent.id,
90
- name,
91
- description,
92
- avatar,
93
- emoji,
94
- };
130
+ return { id: agent.id, name, description, avatar, emoji };
95
131
  });
96
132
 
97
133
  return NextResponse.json({
98
134
  agents,
99
- defaultId: result?.defaultId,
135
+ defaultId: agentsResult?.defaultId,
100
136
  });
101
137
  } catch (err) {
102
138
  return NextResponse.json(
@@ -1,15 +1,53 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { readFileSync, existsSync } from "fs";
3
3
  import { join } from "path";
4
4
  import JSON5 from "json5";
5
5
  import { getOpenClawDir } from "@/lib/config";
6
6
  import { ensureGateway } from "@/lib/gateway-connection";
7
+ import { checkCsrf } from "@/lib/api-security";
7
8
 
8
9
  export const dynamic = "force-dynamic";
9
10
 
11
+ /**
12
+ * Deep-clone a config object, redacting sensitive fields so they
13
+ * are never returned over HTTP.
14
+ */
15
+ function redactSecrets(obj: unknown): unknown {
16
+ if (obj === null || obj === undefined) return obj;
17
+ if (typeof obj !== "object") return obj;
18
+ if (Array.isArray(obj)) return obj.map(redactSecrets);
19
+
20
+ const result: Record<string, unknown> = {};
21
+ for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
22
+ // Redact any key that looks like a token or secret
23
+ const lower = key.toLowerCase();
24
+ if (
25
+ lower === "token" ||
26
+ lower === "secret" ||
27
+ lower === "password" ||
28
+ lower === "apikey" ||
29
+ lower === "api_key" ||
30
+ lower === "privatekey" ||
31
+ lower === "private_key"
32
+ ) {
33
+ if (typeof value === "string" && value.length > 0) {
34
+ result[key] = value.slice(0, 4) + "***";
35
+ } else {
36
+ result[key] = "***";
37
+ }
38
+ } else if (typeof value === "object") {
39
+ result[key] = redactSecrets(value);
40
+ } else {
41
+ result[key] = value;
42
+ }
43
+ }
44
+ return result;
45
+ }
46
+
10
47
  /**
11
48
  * GET /api/openclaw/config
12
- * God mode: reads OpenClaw config from filesystem
49
+ * Reads OpenClaw config from filesystem.
50
+ * Sensitive fields (tokens, secrets, keys) are redacted before returning.
13
51
  */
14
52
  export async function GET() {
15
53
  const configPath = join(getOpenClawDir(), "openclaw.json");
@@ -27,7 +65,7 @@ export async function GET() {
27
65
  try {
28
66
  const raw = readFileSync(json5Path, "utf-8");
29
67
  const config = JSON5.parse(raw);
30
- return NextResponse.json({ config, format: "json5" });
68
+ return NextResponse.json({ config: redactSecrets(config), format: "json5" });
31
69
  } catch (err) {
32
70
  return NextResponse.json(
33
71
  { error: err instanceof Error ? err.message : "Failed to parse config" },
@@ -39,7 +77,7 @@ export async function GET() {
39
77
  try {
40
78
  const raw = readFileSync(configPath, "utf-8");
41
79
  const config = JSON5.parse(raw);
42
- return NextResponse.json({ config, format: "json" });
80
+ return NextResponse.json({ config: redactSecrets(config), format: "json" });
43
81
  } catch (err) {
44
82
  return NextResponse.json(
45
83
  { error: err instanceof Error ? err.message : "Failed to parse config" },
@@ -51,9 +89,12 @@ export async function GET() {
51
89
  /**
52
90
  * PATCH /api/openclaw/config
53
91
  * Update OpenClaw config via Gateway's config.patch method.
92
+ * Protected against CSRF — only requests from the Castle UI are allowed.
54
93
  * Body: { patch: { ... } } -- the patch to apply
55
94
  */
56
95
  export async function PATCH(request: NextRequest) {
96
+ const csrf = checkCsrf(request);
97
+ if (csrf) return csrf;
57
98
  const gw = ensureGateway();
58
99
 
59
100
  if (!gw.isConnected) {
@@ -3,6 +3,34 @@ import { ensureGateway, type GatewayEvent } from "@/lib/gateway-connection";
3
3
  export const dynamic = "force-dynamic";
4
4
  export const runtime = "nodejs";
5
5
 
6
+ /**
7
+ * Strip sensitive fields (tokens, keys) from event payloads
8
+ * before forwarding to the browser.
9
+ */
10
+ function redactEventPayload(evt: GatewayEvent): GatewayEvent {
11
+ if (!evt.payload || typeof evt.payload !== "object") return evt;
12
+
13
+ const payload = { ...(evt.payload as Record<string, unknown>) };
14
+
15
+ // Redact deviceToken from pairing events
16
+ if (typeof payload.deviceToken === "string") {
17
+ payload.deviceToken = "[REDACTED]";
18
+ }
19
+ // Redact nested auth.deviceToken
20
+ if (payload.auth && typeof payload.auth === "object") {
21
+ const auth = { ...(payload.auth as Record<string, unknown>) };
22
+ if (typeof auth.deviceToken === "string") auth.deviceToken = "[REDACTED]";
23
+ if (typeof auth.token === "string") auth.token = "[REDACTED]";
24
+ payload.auth = auth;
25
+ }
26
+ // Redact any top-level token field
27
+ if (typeof payload.token === "string") {
28
+ payload.token = "[REDACTED]";
29
+ }
30
+
31
+ return { ...evt, payload };
32
+ }
33
+
6
34
  /**
7
35
  * GET /api/openclaw/events
8
36
  * SSE endpoint -- streams OpenClaw Gateway events to the browser in real-time.
@@ -27,11 +55,12 @@ export async function GET() {
27
55
  };
28
56
  controller.enqueue(encoder.encode(`data: ${JSON.stringify(initial)}\n\n`));
29
57
 
30
- // Forward gateway events
58
+ // Forward gateway events (with sensitive fields redacted)
31
59
  const onGatewayEvent = (evt: GatewayEvent) => {
32
60
  if (closed) return;
33
61
  try {
34
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(evt)}\n\n`));
62
+ const safe = redactEventPayload(evt);
63
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(safe)}\n\n`));
35
64
  } catch {
36
65
  // Stream may have closed
37
66
  }