@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 +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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@castlekit/castle",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
48
|
-
if (
|
|
49
|
-
|
|
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
|
-
|
|
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 = (
|
|
78
|
-
const name =
|
|
79
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
}
|