@castlekit/castle 0.1.6 → 0.3.1

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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/drizzle.config.ts +7 -0
  3. package/install.sh +20 -1
  4. package/next.config.ts +1 -0
  5. package/package.json +35 -3
  6. package/src/app/api/avatars/[id]/route.ts +57 -7
  7. package/src/app/api/openclaw/agents/route.ts +7 -1
  8. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  9. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  10. package/src/app/api/openclaw/chat/channels/route.ts +217 -0
  11. package/src/app/api/openclaw/chat/route.ts +283 -0
  12. package/src/app/api/openclaw/chat/search/route.ts +150 -0
  13. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  14. package/src/app/api/openclaw/config/route.ts +2 -0
  15. package/src/app/api/openclaw/events/route.ts +23 -8
  16. package/src/app/api/openclaw/logs/route.ts +17 -3
  17. package/src/app/api/openclaw/ping/route.ts +5 -0
  18. package/src/app/api/openclaw/restart/route.ts +6 -1
  19. package/src/app/api/openclaw/session/context/route.ts +163 -0
  20. package/src/app/api/openclaw/session/status/route.ts +210 -0
  21. package/src/app/api/openclaw/sessions/route.ts +2 -0
  22. package/src/app/api/settings/avatar/route.ts +190 -0
  23. package/src/app/api/settings/route.ts +88 -0
  24. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  25. package/src/app/chat/[channelId]/page.tsx +385 -0
  26. package/src/app/chat/layout.tsx +96 -0
  27. package/src/app/chat/page.tsx +52 -0
  28. package/src/app/globals.css +99 -2
  29. package/src/app/layout.tsx +7 -1
  30. package/src/app/page.tsx +59 -25
  31. package/src/app/settings/page.tsx +300 -0
  32. package/src/components/chat/agent-mention-popup.tsx +89 -0
  33. package/src/components/chat/archived-channels.tsx +190 -0
  34. package/src/components/chat/channel-list.tsx +140 -0
  35. package/src/components/chat/chat-input.tsx +328 -0
  36. package/src/components/chat/create-channel-dialog.tsx +171 -0
  37. package/src/components/chat/markdown-content.tsx +205 -0
  38. package/src/components/chat/message-bubble.tsx +168 -0
  39. package/src/components/chat/message-list.tsx +666 -0
  40. package/src/components/chat/message-queue.tsx +68 -0
  41. package/src/components/chat/session-divider.tsx +61 -0
  42. package/src/components/chat/session-stats-panel.tsx +444 -0
  43. package/src/components/chat/storage-indicator.tsx +76 -0
  44. package/src/components/layout/sidebar.tsx +126 -45
  45. package/src/components/layout/user-menu.tsx +29 -4
  46. package/src/components/providers/presence-provider.tsx +8 -0
  47. package/src/components/providers/search-provider.tsx +110 -0
  48. package/src/components/search/search-dialog.tsx +269 -0
  49. package/src/components/ui/avatar.tsx +11 -9
  50. package/src/components/ui/dialog.tsx +10 -4
  51. package/src/components/ui/tooltip.tsx +25 -8
  52. package/src/components/ui/twemoji-text.tsx +37 -0
  53. package/src/lib/api-security.ts +125 -0
  54. package/src/lib/date-utils.ts +79 -0
  55. package/src/lib/db/index.ts +652 -0
  56. package/src/lib/db/queries.ts +1144 -0
  57. package/src/lib/db/schema.ts +164 -0
  58. package/src/lib/gateway-connection.ts +24 -3
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +753 -0
  61. package/src/lib/hooks/use-compaction-events.ts +132 -0
  62. package/src/lib/hooks/use-context-boundary.ts +82 -0
  63. package/src/lib/hooks/use-openclaw.ts +122 -100
  64. package/src/lib/hooks/use-search.ts +114 -0
  65. package/src/lib/hooks/use-session-stats.ts +60 -0
  66. package/src/lib/hooks/use-user-settings.ts +46 -0
  67. package/src/lib/sse-singleton.ts +184 -0
  68. package/src/lib/types/chat.ts +202 -0
  69. package/src/lib/types/search.ts +60 -0
  70. package/src/middleware.ts +52 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Castle Kit Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/lib/db/schema.ts",
5
+ out: "./src/lib/db/migrations",
6
+ dialect: "sqlite",
7
+ });
package/install.sh CHANGED
@@ -569,8 +569,27 @@ install_castle() {
569
569
  echo -e "${WARN}→${NC} Installing Castle (${INFO}${CASTLE_VERSION}${NC})..."
570
570
  fi
571
571
 
572
- if ! npm --loglevel "$NPM_LOGLEVEL" ${NPM_SILENT_FLAG:+$NPM_SILENT_FLAG} --no-fund --no-audit install -g "$install_spec"; then
572
+ # Run npm install in background with a spinner so the user knows it's working
573
+ local npm_log
574
+ npm_log="$(mktempfile)"
575
+ npm --loglevel "$NPM_LOGLEVEL" ${NPM_SILENT_FLAG:+$NPM_SILENT_FLAG} --no-fund --no-audit install -g "$install_spec" > "$npm_log" 2>&1 &
576
+ local npm_pid=$!
577
+
578
+ local spin_chars='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
579
+ local i=0
580
+ while kill -0 "$npm_pid" 2>/dev/null; do
581
+ local c="${spin_chars:i%${#spin_chars}:1}"
582
+ printf "\r ${ACCENT}%s${NC} Installing..." "$c"
583
+ ((i++)) || true
584
+ sleep 0.1
585
+ done
586
+ printf "\r \r"
587
+
588
+ wait "$npm_pid"
589
+ local npm_exit=$?
590
+ if [[ "$npm_exit" -ne 0 ]]; then
573
591
  echo -e "${ERROR}npm install failed${NC}"
592
+ cat "$npm_log"
574
593
  echo -e "Try: ${INFO}npm install -g --force ${install_spec}${NC}"
575
594
  exit 1
576
595
  fi
package/next.config.ts CHANGED
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
2
2
 
3
3
  const nextConfig: NextConfig = {
4
4
  devIndicators: false,
5
+ serverExternalPackages: ["better-sqlite3"],
5
6
  };
6
7
 
7
8
  export default nextConfig;
package/package.json CHANGED
@@ -1,16 +1,33 @@
1
1
  {
2
2
  "name": "@castlekit/castle",
3
- "version": "0.1.6",
3
+ "version": "0.3.1",
4
4
  "description": "The multi-agent workspace",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "castle": "./bin/castle.js"
8
8
  },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "index.js",
13
+ "install.sh",
14
+ "next.config.ts",
15
+ "postcss.config.mjs",
16
+ "tsconfig.json",
17
+ "drizzle.config.ts",
18
+ "LICENSE",
19
+ "README.md",
20
+ "!src/**/__tests__/**",
21
+ "!src/**/*.test.*"
22
+ ],
9
23
  "scripts": {
10
24
  "dev": "next dev -p 3333",
11
25
  "build": "next build",
12
26
  "start": "next start -p 3333",
13
- "lint": "next lint"
27
+ "lint": "next lint",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "ci": "npm audit --omit=dev --audit-level=high && npm test && npm run build"
14
31
  },
15
32
  "repository": {
16
33
  "type": "git",
@@ -39,12 +56,15 @@
39
56
  "@dnd-kit/utilities": "^3.2.2",
40
57
  "@radix-ui/react-slider": "^1.3.6",
41
58
  "@tailwindcss/postcss": "^4.1.18",
59
+ "@twemoji/api": "^17.0.2",
42
60
  "@types/node": "^25.2.1",
43
61
  "@types/react": "^19.2.13",
44
62
  "@types/react-dom": "^19.2.3",
45
63
  "@types/ws": "^8.18.1",
64
+ "better-sqlite3": "^12.6.2",
46
65
  "clsx": "^2.1.1",
47
66
  "commander": "^14.0.3",
67
+ "drizzle-orm": "^0.45.1",
48
68
  "json5": "^2.2.3",
49
69
  "lucide-react": "^0.563.0",
50
70
  "next": "^16.1.6",
@@ -53,16 +73,28 @@
53
73
  "picocolors": "^1.1.1",
54
74
  "react": "^19.2.4",
55
75
  "react-dom": "^19.2.4",
76
+ "react-markdown": "^10.1.0",
77
+ "react-syntax-highlighter": "^16.1.0",
56
78
  "recharts": "^3.7.0",
79
+ "remark-gfm": "^4.0.1",
57
80
  "sharp": "^0.34.5",
58
81
  "swr": "^2.4.0",
59
82
  "tailwind-merge": "^3.4.0",
60
83
  "tailwindcss": "^4.1.18",
61
84
  "tsx": "^4.21.0",
62
85
  "typescript": "^5.9.3",
86
+ "uuid": "^13.0.0",
63
87
  "ws": "^8.19.0"
64
88
  },
65
89
  "devDependencies": {
66
- "@types/sharp": "^0.31.1"
90
+ "@testing-library/jest-dom": "^6.9.1",
91
+ "@testing-library/react": "^16.3.2",
92
+ "@types/better-sqlite3": "^7.6.13",
93
+ "@types/react-syntax-highlighter": "^15.5.13",
94
+ "@types/sharp": "^0.31.1",
95
+ "@types/uuid": "^10.0.0",
96
+ "drizzle-kit": "^0.31.8",
97
+ "jsdom": "^28.0.0",
98
+ "vitest": "^4.0.18"
67
99
  }
68
100
  }
@@ -1,6 +1,6 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { readFileSync, existsSync } from "fs";
3
- import { join } from "path";
3
+ import { join, resolve } from "path";
4
4
  import { homedir } from "os";
5
5
  import { ensureGateway } from "@/lib/gateway-connection";
6
6
 
@@ -51,11 +51,16 @@ export async function GET(
51
51
  if (storedUrl.startsWith("workspace://")) {
52
52
  const pathPart = storedUrl.slice("workspace://".length);
53
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)) {
54
+ const resolved = resolve(
55
+ pathPart.startsWith("~") ? join(homedir(), pathPart.slice(1)) : pathPart
56
+ );
57
+ // Prevent traversal: resolved path must not escape the workspace
58
+ // and must be a simple file path (no symlink-based escapes for known dirs)
59
+ if (
60
+ !resolved.includes("..") &&
61
+ !resolved.includes("\0") &&
62
+ existsSync(resolved)
63
+ ) {
59
64
  return serveFile(resolved);
60
65
  }
61
66
  }
@@ -92,8 +97,50 @@ export async function GET(
92
97
  return new NextResponse("Not found", { status: 404 });
93
98
  }
94
99
 
95
- /** Fetch an avatar from an HTTP URL with a short timeout */
100
+ /**
101
+ * Check if a URL resolves to a private/internal IP range (SSRF protection).
102
+ * Blocks access to localhost, private networks, link-local, and cloud metadata.
103
+ */
104
+ function isPrivateUrl(urlStr: string): boolean {
105
+ try {
106
+ const parsed = new URL(urlStr);
107
+ const hostname = parsed.hostname;
108
+
109
+ // Block cloud metadata endpoints
110
+ if (hostname === "169.254.169.254" || hostname === "metadata.google.internal") {
111
+ return true;
112
+ }
113
+
114
+ // Block localhost variants
115
+ if (
116
+ hostname === "localhost" ||
117
+ hostname === "127.0.0.1" ||
118
+ hostname === "::1" ||
119
+ hostname === "0.0.0.0"
120
+ ) {
121
+ return true;
122
+ }
123
+
124
+ // Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
125
+ const parts = hostname.split(".").map(Number);
126
+ if (parts.length === 4 && parts.every((p) => !isNaN(p))) {
127
+ if (parts[0] === 10) return true;
128
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
129
+ if (parts[0] === 192 && parts[1] === 168) return true;
130
+ if (parts[0] === 169 && parts[1] === 254) return true; // link-local
131
+ }
132
+
133
+ return false;
134
+ } catch {
135
+ return true; // Reject malformed URLs
136
+ }
137
+ }
138
+
139
+ /** Fetch an avatar from an HTTP URL with a short timeout and SSRF protection */
96
140
  async function fetchAvatar(url: string): Promise<NextResponse | null> {
141
+ // Block requests to private/internal networks
142
+ if (isPrivateUrl(url)) return null;
143
+
97
144
  try {
98
145
  const resp = await fetch(url, { signal: AbortSignal.timeout(2000) });
99
146
  if (!resp.ok) return null;
@@ -101,7 +148,10 @@ async function fetchAvatar(url: string): Promise<NextResponse | null> {
101
148
  const contentType = resp.headers.get("content-type") || "image/png";
102
149
  if (!contentType.startsWith("image/")) return null;
103
150
 
151
+ // Limit response size to prevent memory exhaustion
104
152
  const data = Buffer.from(await resp.arrayBuffer());
153
+ if (data.length > 5 * 1024 * 1024) return null; // 5MB max
154
+
105
155
  return new NextResponse(data, {
106
156
  headers: { "Content-Type": contentType, ...CACHE_HEADERS },
107
157
  });
@@ -105,10 +105,14 @@ export async function GET() {
105
105
  }
106
106
 
107
107
  try {
108
+ const _start = Date.now();
108
109
  // Fetch agents and config in parallel
109
110
  const [agentsResult, configResult] = await Promise.all([
110
111
  gw.request<AgentsListPayload>("agents.list", {}),
111
- gw.request<ConfigGetPayload>("config.get", {}).catch(() => null),
112
+ gw.request<ConfigGetPayload>("config.get", {}).catch((err) => {
113
+ console.warn("[Agents API] config.get failed (non-fatal):", (err as Error).message);
114
+ return null;
115
+ }),
112
116
  ]);
113
117
 
114
118
  // Build workspace lookup from config
@@ -130,11 +134,13 @@ export async function GET() {
130
134
  return { id: agent.id, name, description, avatar, emoji };
131
135
  });
132
136
 
137
+ console.log(`[Agents API] GET list OK — ${agents.length} agents (${Date.now() - _start}ms)`);
133
138
  return NextResponse.json({
134
139
  agents,
135
140
  defaultId: agentsResult?.defaultId,
136
141
  });
137
142
  } catch (err) {
143
+ console.error("[Agents API] GET list FAILED:", err instanceof Error ? err.message : "Unknown error");
138
144
  return NextResponse.json(
139
145
  { error: err instanceof Error ? err.message : "Failed to list agents", agents: [] },
140
146
  { status: 500 }
@@ -0,0 +1,55 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAgentStatuses, setAgentStatus, type AgentStatusValue } from "@/lib/db/queries";
3
+ import { checkCsrf } from "@/lib/api-security";
4
+
5
+ const VALID_STATUSES: AgentStatusValue[] = ["idle", "thinking", "active"];
6
+
7
+ // ============================================================================
8
+ // GET /api/openclaw/agents/status — Get all agent statuses
9
+ // ============================================================================
10
+
11
+ export async function GET() {
12
+ try {
13
+ const statuses = getAgentStatuses();
14
+ return NextResponse.json({ statuses });
15
+ } catch (err) {
16
+ console.error("[Agent Status] GET failed:", (err as Error).message);
17
+ return NextResponse.json({ error: "Failed to get agent statuses" }, { status: 500 });
18
+ }
19
+ }
20
+
21
+ // ============================================================================
22
+ // POST /api/openclaw/agents/status — Set an agent's status
23
+ // ============================================================================
24
+
25
+ export async function POST(request: NextRequest) {
26
+ const csrfError = checkCsrf(request);
27
+ if (csrfError) return csrfError;
28
+
29
+ let body: { agentId?: string; status?: string };
30
+
31
+ try {
32
+ body = await request.json();
33
+ } catch {
34
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
35
+ }
36
+
37
+ if (!body.agentId || typeof body.agentId !== "string") {
38
+ return NextResponse.json({ error: "agentId is required" }, { status: 400 });
39
+ }
40
+
41
+ if (!body.status || !VALID_STATUSES.includes(body.status as AgentStatusValue)) {
42
+ return NextResponse.json(
43
+ { error: `status must be one of: ${VALID_STATUSES.join(", ")}` },
44
+ { status: 400 }
45
+ );
46
+ }
47
+
48
+ try {
49
+ setAgentStatus(body.agentId, body.status as AgentStatusValue);
50
+ return NextResponse.json({ ok: true });
51
+ } catch (err) {
52
+ console.error("[Agent Status] POST failed:", (err as Error).message);
53
+ return NextResponse.json({ error: "Failed to set agent status" }, { status: 500 });
54
+ }
55
+ }
@@ -0,0 +1,230 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from "fs";
3
+ import { join, resolve } from "path";
4
+ import { platform } from "os";
5
+ import { v4 as uuid } from "uuid";
6
+ import { checkCsrf } from "@/lib/api-security";
7
+ import { getCastleDir } from "@/lib/config";
8
+ import { createAttachment, getAttachmentsByMessage } from "@/lib/db/queries";
9
+
10
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
11
+ const ALLOWED_MIME_TYPES = new Set([
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ "audio/mpeg",
17
+ "audio/ogg",
18
+ "audio/wav",
19
+ ]);
20
+
21
+ function getAttachmentsDir(): string {
22
+ return join(getCastleDir(), "data", "attachments");
23
+ }
24
+
25
+ /** Validate that resolved path stays within the attachments directory */
26
+ function isPathSafe(filePath: string, baseDir: string): boolean {
27
+ const resolved = resolve(filePath);
28
+ const resolvedBase = resolve(baseDir);
29
+ return resolved.startsWith(resolvedBase);
30
+ }
31
+
32
+ function getMimeForExt(ext: string): string {
33
+ const map: Record<string, string> = {
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".jpeg": "image/jpeg",
37
+ ".webp": "image/webp",
38
+ ".gif": "image/gif",
39
+ ".mp3": "audio/mpeg",
40
+ ".ogg": "audio/ogg",
41
+ ".wav": "audio/wav",
42
+ };
43
+ return map[ext.toLowerCase()] || "application/octet-stream";
44
+ }
45
+
46
+ // ============================================================================
47
+ // POST /api/openclaw/chat/attachments — Upload attachment
48
+ // ============================================================================
49
+
50
+ export async function POST(request: NextRequest) {
51
+ const csrf = checkCsrf(request);
52
+ if (csrf) return csrf;
53
+
54
+ const formData = await request.formData();
55
+ const file = formData.get("file") as File | null;
56
+ const channelId = formData.get("channelId") as string | null;
57
+ const messageId = formData.get("messageId") as string | null;
58
+
59
+ if (!file || !channelId) {
60
+ return NextResponse.json(
61
+ { error: "file and channelId are required" },
62
+ { status: 400 }
63
+ );
64
+ }
65
+
66
+ // Validate file size
67
+ if (file.size > MAX_FILE_SIZE) {
68
+ return NextResponse.json(
69
+ { error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024}MB)` },
70
+ { status: 400 }
71
+ );
72
+ }
73
+
74
+ // Validate MIME type
75
+ if (!ALLOWED_MIME_TYPES.has(file.type)) {
76
+ return NextResponse.json(
77
+ { error: `Unsupported file type: ${file.type}` },
78
+ { status: 400 }
79
+ );
80
+ }
81
+
82
+ try {
83
+ // Determine file extension from MIME type
84
+ const extMap: Record<string, string> = {
85
+ "image/png": ".png",
86
+ "image/jpeg": ".jpg",
87
+ "image/webp": ".webp",
88
+ "image/gif": ".gif",
89
+ "audio/mpeg": ".mp3",
90
+ "audio/ogg": ".ogg",
91
+ "audio/wav": ".wav",
92
+ };
93
+ const ext = extMap[file.type] || ".bin";
94
+
95
+ // Create UUID-based filename (never use user-supplied names)
96
+ const filename = `${uuid()}${ext}`;
97
+ const channelDir = join(getAttachmentsDir(), channelId);
98
+
99
+ // Ensure directory exists
100
+ if (!existsSync(channelDir)) {
101
+ mkdirSync(channelDir, { recursive: true, mode: 0o700 });
102
+ }
103
+
104
+ const filePath = join(channelDir, filename);
105
+
106
+ // Path traversal check
107
+ if (!isPathSafe(filePath, getAttachmentsDir())) {
108
+ return NextResponse.json(
109
+ { error: "Invalid path" },
110
+ { status: 400 }
111
+ );
112
+ }
113
+
114
+ // Write file
115
+ const buffer = Buffer.from(await file.arrayBuffer());
116
+ writeFileSync(filePath, buffer);
117
+
118
+ // Secure permissions
119
+ if (platform() !== "win32") {
120
+ try {
121
+ const { chmodSync } = await import("fs");
122
+ chmodSync(filePath, 0o600);
123
+ } catch {
124
+ // May fail on some filesystems
125
+ }
126
+ }
127
+
128
+ // Persist to DB (if messageId provided)
129
+ const attachmentType = file.type.startsWith("image/") ? "image" : "audio";
130
+ let attachmentRecord = null;
131
+
132
+ if (messageId) {
133
+ attachmentRecord = createAttachment({
134
+ messageId,
135
+ attachmentType: attachmentType as "image" | "audio",
136
+ filePath: `${channelId}/${filename}`,
137
+ mimeType: file.type,
138
+ fileSize: file.size,
139
+ originalName: file.name,
140
+ });
141
+ }
142
+
143
+ return NextResponse.json({
144
+ id: attachmentRecord?.id ?? uuid(),
145
+ filePath: `${channelId}/${filename}`,
146
+ mimeType: file.type,
147
+ fileSize: file.size,
148
+ originalName: file.name,
149
+ }, { status: 201 });
150
+ } catch (err) {
151
+ console.error("[Attachments] Upload failed:", (err as Error).message);
152
+ return NextResponse.json(
153
+ { error: "Upload failed" },
154
+ { status: 500 }
155
+ );
156
+ }
157
+ }
158
+
159
+ // ============================================================================
160
+ // GET /api/openclaw/chat/attachments?path=... — Serve attachment
161
+ // ============================================================================
162
+
163
+ export async function GET(request: NextRequest) {
164
+ const { searchParams } = new URL(request.url);
165
+ const filePath = searchParams.get("path");
166
+ const messageId = searchParams.get("messageId");
167
+
168
+ // If messageId provided, return all attachments for that message
169
+ if (messageId) {
170
+ try {
171
+ const attachments = getAttachmentsByMessage(messageId);
172
+ return NextResponse.json({ attachments });
173
+ } catch (err) {
174
+ console.error("[Attachments] List failed:", (err as Error).message);
175
+ return NextResponse.json(
176
+ { error: "Failed to list attachments" },
177
+ { status: 500 }
178
+ );
179
+ }
180
+ }
181
+
182
+ // Serve individual file
183
+ if (!filePath) {
184
+ return NextResponse.json(
185
+ { error: "path or messageId parameter required" },
186
+ { status: 400 }
187
+ );
188
+ }
189
+
190
+ const baseDir = getAttachmentsDir();
191
+ const fullPath = join(baseDir, filePath);
192
+
193
+ // Path traversal check
194
+ if (!isPathSafe(fullPath, baseDir)) {
195
+ return NextResponse.json(
196
+ { error: "Invalid path" },
197
+ { status: 400 }
198
+ );
199
+ }
200
+
201
+ if (!existsSync(fullPath)) {
202
+ return NextResponse.json(
203
+ { error: "Attachment not found" },
204
+ { status: 404 }
205
+ );
206
+ }
207
+
208
+ try {
209
+ const data = readFileSync(fullPath);
210
+ const ext = fullPath.substring(fullPath.lastIndexOf("."));
211
+ const mimeType = getMimeForExt(ext);
212
+ const stat = statSync(fullPath);
213
+
214
+ return new NextResponse(data, {
215
+ status: 200,
216
+ headers: {
217
+ "Content-Type": mimeType,
218
+ "Content-Length": String(stat.size),
219
+ "Content-Disposition": "inline",
220
+ "Cache-Control": "public, max-age=86400", // 24h cache
221
+ },
222
+ });
223
+ } catch (err) {
224
+ console.error("[Attachments] Serve failed:", (err as Error).message);
225
+ return NextResponse.json(
226
+ { error: "Failed to serve attachment" },
227
+ { status: 500 }
228
+ );
229
+ }
230
+ }