@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
|
@@ -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
|
}
|
|
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|
|
2
2
|
import { readFileSync, existsSync, readdirSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { getOpenClawDir } from "@/lib/config";
|
|
5
|
+
import { sanitizeForApi } from "@/lib/api-security";
|
|
5
6
|
|
|
6
7
|
export const dynamic = "force-dynamic";
|
|
7
8
|
|
|
@@ -41,8 +42,8 @@ export async function GET(request: NextRequest) {
|
|
|
41
42
|
const content = readFileSync(logPath, "utf-8");
|
|
42
43
|
const allLines = content.split("\n").filter(Boolean);
|
|
43
44
|
|
|
44
|
-
// Return last N lines
|
|
45
|
-
const tailLines = allLines.slice(-lines);
|
|
45
|
+
// Return last N lines, sanitized to strip tokens/keys
|
|
46
|
+
const tailLines = allLines.slice(-lines).map(sanitizeForApi);
|
|
46
47
|
|
|
47
48
|
return NextResponse.json({
|
|
48
49
|
logs: tailLines,
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import { NextResponse } from "next/server";
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { execSync } from "child_process";
|
|
3
|
+
import { checkCsrf } from "@/lib/api-security";
|
|
3
4
|
|
|
4
5
|
export const dynamic = "force-dynamic";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* POST /api/openclaw/restart
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Restart the OpenClaw Gateway process.
|
|
10
|
+
* Protected against CSRF — only requests from the Castle UI are allowed.
|
|
10
11
|
*/
|
|
11
|
-
export async function POST() {
|
|
12
|
+
export async function POST(request: NextRequest) {
|
|
13
|
+
const csrf = checkCsrf(request);
|
|
14
|
+
if (csrf) return csrf;
|
|
12
15
|
try {
|
|
13
16
|
// Try openclaw CLI restart first
|
|
14
17
|
try {
|
package/src/app/page.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useRef, useState, useCallback } from "react";
|
|
4
|
+
import { Bot, Wifi, WifiOff, Crown, RefreshCw, Loader2, AlertCircle, Camera } from "lucide-react";
|
|
4
5
|
import { Sidebar } from "@/components/layout/sidebar";
|
|
5
6
|
import { UserMenu } from "@/components/layout/user-menu";
|
|
6
7
|
import { PageHeader } from "@/components/layout/page-header";
|
|
@@ -14,7 +15,66 @@ function getInitials(name: string) {
|
|
|
14
15
|
return name.slice(0, 2).toUpperCase();
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
function AgentCard({
|
|
18
|
+
function AgentCard({
|
|
19
|
+
agent,
|
|
20
|
+
isPrimary,
|
|
21
|
+
isConnected,
|
|
22
|
+
onAvatarUpdated,
|
|
23
|
+
}: {
|
|
24
|
+
agent: OpenClawAgent;
|
|
25
|
+
isPrimary: boolean;
|
|
26
|
+
isConnected: boolean;
|
|
27
|
+
onAvatarUpdated: () => void;
|
|
28
|
+
}) {
|
|
29
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
30
|
+
const [uploading, setUploading] = useState(false);
|
|
31
|
+
|
|
32
|
+
const handleAvatarClick = useCallback(() => {
|
|
33
|
+
if (!isConnected) return;
|
|
34
|
+
fileInputRef.current?.click();
|
|
35
|
+
}, [isConnected]);
|
|
36
|
+
|
|
37
|
+
const handleFileChange = useCallback(
|
|
38
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
39
|
+
const file = e.target.files?.[0];
|
|
40
|
+
if (!file) return;
|
|
41
|
+
|
|
42
|
+
// Reset input so the same file can be selected again
|
|
43
|
+
e.target.value = "";
|
|
44
|
+
|
|
45
|
+
// Client-side validation
|
|
46
|
+
if (file.size > 5 * 1024 * 1024) {
|
|
47
|
+
alert("Image too large (max 5MB)");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setUploading(true);
|
|
52
|
+
try {
|
|
53
|
+
const formData = new FormData();
|
|
54
|
+
formData.append("avatar", file);
|
|
55
|
+
|
|
56
|
+
const resp = await fetch(`/api/openclaw/agents/${agent.id}/avatar`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
body: formData,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await resp.json();
|
|
62
|
+
if (!resp.ok) {
|
|
63
|
+
alert(result.error || "Failed to update avatar");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Gateway hot-reloads — just refresh agents to pick up new avatar
|
|
68
|
+
onAvatarUpdated();
|
|
69
|
+
} catch {
|
|
70
|
+
alert("Failed to upload avatar");
|
|
71
|
+
} finally {
|
|
72
|
+
setUploading(false);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[agent.id, onAvatarUpdated]
|
|
76
|
+
);
|
|
77
|
+
|
|
18
78
|
return (
|
|
19
79
|
<Card
|
|
20
80
|
variant="bordered"
|
|
@@ -27,19 +87,45 @@ function AgentCard({ agent, isPrimary, isConnected }: { agent: OpenClawAgent; is
|
|
|
27
87
|
>
|
|
28
88
|
<div className="flex items-center justify-between">
|
|
29
89
|
<div className="flex items-center gap-3">
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
90
|
+
{/* Clickable avatar with upload overlay */}
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={handleAvatarClick}
|
|
94
|
+
disabled={!isConnected || uploading}
|
|
95
|
+
className="relative group rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
96
|
+
title={isConnected ? "Click to change avatar" : undefined}
|
|
97
|
+
>
|
|
98
|
+
<Avatar size="md" status={isConnected ? "online" : "offline"}>
|
|
99
|
+
{agent.avatar ? (
|
|
100
|
+
<AvatarImage
|
|
101
|
+
src={agent.avatar}
|
|
102
|
+
alt={agent.name}
|
|
103
|
+
className={cn(!isConnected && "grayscale")}
|
|
104
|
+
/>
|
|
105
|
+
) : (
|
|
106
|
+
<AvatarFallback>
|
|
107
|
+
{agent.emoji || getInitials(agent.name)}
|
|
108
|
+
</AvatarFallback>
|
|
109
|
+
)}
|
|
110
|
+
</Avatar>
|
|
111
|
+
{isConnected && !uploading && (
|
|
112
|
+
<div className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
|
113
|
+
<Camera className="h-4 w-4 text-white" />
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
{uploading && (
|
|
117
|
+
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
|
|
118
|
+
<Loader2 className="h-4 w-4 text-white animate-spin" />
|
|
119
|
+
</div>
|
|
41
120
|
)}
|
|
42
|
-
</
|
|
121
|
+
</button>
|
|
122
|
+
<input
|
|
123
|
+
ref={fileInputRef}
|
|
124
|
+
type="file"
|
|
125
|
+
accept="image/png,image/jpeg,image/webp,image/gif"
|
|
126
|
+
className="hidden"
|
|
127
|
+
onChange={handleFileChange}
|
|
128
|
+
/>
|
|
43
129
|
<div>
|
|
44
130
|
<div className="flex items-center gap-2">
|
|
45
131
|
<p className="text-sm font-medium text-foreground">
|
|
@@ -92,7 +178,7 @@ function ConnectionCard({
|
|
|
92
178
|
if (!isConfigured) return "Run 'castle setup' to configure";
|
|
93
179
|
if (isConnected) {
|
|
94
180
|
const parts = ["Connected"];
|
|
95
|
-
if (serverVersion) parts[0] = `Connected to OpenClaw
|
|
181
|
+
if (serverVersion) parts[0] = `Connected to OpenClaw ${serverVersion}`;
|
|
96
182
|
if (latency) parts.push(`${latency}ms`);
|
|
97
183
|
return parts.join(" · ");
|
|
98
184
|
}
|
|
@@ -255,6 +341,7 @@ export default function HomePage() {
|
|
|
255
341
|
agent={agent}
|
|
256
342
|
isPrimary={idx === 0}
|
|
257
343
|
isConnected={isConnected}
|
|
344
|
+
onAvatarUpdated={refresh}
|
|
258
345
|
/>
|
|
259
346
|
))}
|
|
260
347
|
</div>
|
package/src/cli/onboarding.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
writeConfig,
|
|
15
15
|
type CastleConfig,
|
|
16
16
|
} from "../lib/config.js";
|
|
17
|
+
// Device identity is handled by gateway-connection.ts for the persistent connection.
|
|
18
|
+
// The onboarding wizard uses simple token-only auth for agent discovery.
|
|
17
19
|
|
|
18
20
|
// Read version from package.json at the project root
|
|
19
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -56,9 +58,15 @@ interface DiscoveredAgent {
|
|
|
56
58
|
|
|
57
59
|
/**
|
|
58
60
|
* Connect to Gateway and discover agents via agents.list.
|
|
61
|
+
* Supports both port-based (local) and full URL (remote) connections.
|
|
59
62
|
* Returns the list of agents or an empty array on failure.
|
|
60
63
|
*/
|
|
61
|
-
async function discoverAgents(
|
|
64
|
+
async function discoverAgents(
|
|
65
|
+
portOrUrl: number | string,
|
|
66
|
+
token: string | null
|
|
67
|
+
): Promise<DiscoveredAgent[]> {
|
|
68
|
+
const wsUrl = typeof portOrUrl === "string" ? portOrUrl : `ws://127.0.0.1:${portOrUrl}`;
|
|
69
|
+
|
|
62
70
|
return new Promise((resolve) => {
|
|
63
71
|
const timeout = setTimeout(() => {
|
|
64
72
|
try { ws.close(); } catch { /* ignore */ }
|
|
@@ -67,7 +75,7 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
|
|
|
67
75
|
|
|
68
76
|
let ws: WebSocket;
|
|
69
77
|
try {
|
|
70
|
-
ws = new WebSocket(
|
|
78
|
+
ws = new WebSocket(wsUrl);
|
|
71
79
|
} catch {
|
|
72
80
|
clearTimeout(timeout);
|
|
73
81
|
resolve([]);
|
|
@@ -81,6 +89,8 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
|
|
|
81
89
|
|
|
82
90
|
ws.on("open", () => {
|
|
83
91
|
// Send connect handshake
|
|
92
|
+
// NOTE: No device identity here — discoverAgents is just for listing agents
|
|
93
|
+
// during setup. Device auth happens in gateway-connection.ts for the real connection.
|
|
84
94
|
const connectId = randomUUID();
|
|
85
95
|
const connectFrame = {
|
|
86
96
|
type: "req",
|
|
@@ -149,6 +159,137 @@ async function discoverAgents(port: number, token: string | null): Promise<Disco
|
|
|
149
159
|
});
|
|
150
160
|
}
|
|
151
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Test a Gateway connection. Returns true if connection succeeds.
|
|
164
|
+
*/
|
|
165
|
+
async function testConnection(
|
|
166
|
+
portOrUrl: number | string,
|
|
167
|
+
token: string | null
|
|
168
|
+
): Promise<boolean> {
|
|
169
|
+
const wsUrl = typeof portOrUrl === "string" ? portOrUrl : `ws://127.0.0.1:${portOrUrl}`;
|
|
170
|
+
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
const timeout = setTimeout(() => {
|
|
173
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
174
|
+
resolve(false);
|
|
175
|
+
}, 5000);
|
|
176
|
+
|
|
177
|
+
let ws: WebSocket;
|
|
178
|
+
try {
|
|
179
|
+
ws = new WebSocket(wsUrl);
|
|
180
|
+
} catch {
|
|
181
|
+
clearTimeout(timeout);
|
|
182
|
+
resolve(false);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
ws.on("error", () => {
|
|
187
|
+
clearTimeout(timeout);
|
|
188
|
+
resolve(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
ws.on("open", () => {
|
|
192
|
+
clearTimeout(timeout);
|
|
193
|
+
ws.close();
|
|
194
|
+
resolve(true);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Prompt for manual Gateway configuration (remote or local).
|
|
201
|
+
* Returns connection details or null if cancelled.
|
|
202
|
+
*/
|
|
203
|
+
async function promptManualGateway(): Promise<{
|
|
204
|
+
port: number;
|
|
205
|
+
token: string | null;
|
|
206
|
+
gatewayUrl?: string;
|
|
207
|
+
isRemote: boolean;
|
|
208
|
+
} | null> {
|
|
209
|
+
const locationType = await p.select({
|
|
210
|
+
message: "Where is your OpenClaw Gateway?",
|
|
211
|
+
options: [
|
|
212
|
+
{
|
|
213
|
+
value: "local",
|
|
214
|
+
label: "Local machine",
|
|
215
|
+
hint: "Running on this device (127.0.0.1)",
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
value: "remote",
|
|
219
|
+
label: "Remote / Tailscale",
|
|
220
|
+
hint: "Running on another machine",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
if (p.isCancel(locationType)) return null;
|
|
226
|
+
|
|
227
|
+
let port = 18789;
|
|
228
|
+
let gatewayUrl: string | undefined;
|
|
229
|
+
const isRemote = locationType === "remote";
|
|
230
|
+
|
|
231
|
+
if (isRemote) {
|
|
232
|
+
const urlInput = await p.text({
|
|
233
|
+
message: "Gateway WebSocket URL",
|
|
234
|
+
placeholder: "ws://192.168.1.50:18789",
|
|
235
|
+
validate(value: string | undefined) {
|
|
236
|
+
if (!value?.trim()) return "URL is required";
|
|
237
|
+
if (!value.startsWith("ws://") && !value.startsWith("wss://")) {
|
|
238
|
+
return "URL must start with ws:// or wss://";
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (p.isCancel(urlInput)) return null;
|
|
244
|
+
gatewayUrl = urlInput as string;
|
|
245
|
+
|
|
246
|
+
// Extract port from URL for config compatibility
|
|
247
|
+
try {
|
|
248
|
+
const parsed = new URL(gatewayUrl);
|
|
249
|
+
port = parseInt(parsed.port, 10) || 18789;
|
|
250
|
+
} catch {
|
|
251
|
+
port = 18789;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Test the connection
|
|
255
|
+
const testSpinner = p.spinner();
|
|
256
|
+
testSpinner.start("Testing connection...");
|
|
257
|
+
const ok = await testConnection(gatewayUrl, null);
|
|
258
|
+
if (ok) {
|
|
259
|
+
testSpinner.stop(`\x1b[92m✔\x1b[0m Gateway reachable`);
|
|
260
|
+
} else {
|
|
261
|
+
testSpinner.stop(pc.dim("Could not reach Gateway — it may not be running yet"));
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
const gatewayPort = await p.text({
|
|
265
|
+
message: "OpenClaw Gateway port",
|
|
266
|
+
initialValue: "18789",
|
|
267
|
+
validate(value: string | undefined) {
|
|
268
|
+
const num = parseInt(value || "0", 10);
|
|
269
|
+
if (isNaN(num) || num < 1 || num > 65535) {
|
|
270
|
+
return "Please enter a valid port number (1-65535)";
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
if (p.isCancel(gatewayPort)) return null;
|
|
276
|
+
port = parseInt(gatewayPort as string, 10);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Token entry
|
|
280
|
+
const tokenInput = await p.text({
|
|
281
|
+
message: "Gateway auth token",
|
|
282
|
+
placeholder: "Paste your token (or press Enter to skip)",
|
|
283
|
+
defaultValue: "",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
if (p.isCancel(tokenInput)) return null;
|
|
287
|
+
|
|
288
|
+
const token = (tokenInput as string) || null;
|
|
289
|
+
|
|
290
|
+
return { port, token, gatewayUrl, isRemote };
|
|
291
|
+
}
|
|
292
|
+
|
|
152
293
|
export async function runOnboarding(): Promise<void> {
|
|
153
294
|
|
|
154
295
|
p.intro(BLUE_BOLD("Castle Setup"));
|
|
@@ -253,47 +394,65 @@ export async function runOnboarding(): Promise<void> {
|
|
|
253
394
|
}
|
|
254
395
|
}
|
|
255
396
|
|
|
256
|
-
// Step 2:
|
|
397
|
+
// Step 2: Connection mode — auto-detect or manual entry
|
|
257
398
|
let port = readOpenClawPort() || 18789;
|
|
258
399
|
let token = readOpenClawToken();
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
400
|
+
let gatewayUrl: string | undefined;
|
|
401
|
+
let isRemote = false;
|
|
402
|
+
|
|
403
|
+
// If we have auto-detected config, offer a choice
|
|
404
|
+
const hasLocalConfig = !!readOpenClawPort() || isOpenClawInstalled();
|
|
405
|
+
|
|
406
|
+
if (hasLocalConfig && token) {
|
|
407
|
+
// Both auto-detect and manual are available
|
|
408
|
+
const connectionMode = await p.select({
|
|
409
|
+
message: "How would you like to connect?",
|
|
410
|
+
options: [
|
|
411
|
+
{
|
|
412
|
+
value: "auto",
|
|
413
|
+
label: `Auto-detected local Gateway ${pc.dim(`(port ${port})`)}`,
|
|
414
|
+
hint: "Recommended for local setups",
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
value: "manual",
|
|
418
|
+
label: "Enter Gateway details manually",
|
|
419
|
+
hint: "For remote, Tailscale, or custom setups",
|
|
420
|
+
},
|
|
421
|
+
],
|
|
270
422
|
});
|
|
271
423
|
|
|
272
|
-
if (p.isCancel(
|
|
424
|
+
if (p.isCancel(connectionMode)) {
|
|
273
425
|
p.cancel("Setup cancelled.");
|
|
274
426
|
process.exit(0);
|
|
275
427
|
}
|
|
276
428
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
429
|
+
if (connectionMode === "manual") {
|
|
430
|
+
const manualResult = await promptManualGateway();
|
|
431
|
+
if (!manualResult) {
|
|
432
|
+
p.cancel("Setup cancelled.");
|
|
433
|
+
process.exit(0);
|
|
434
|
+
}
|
|
435
|
+
port = manualResult.port;
|
|
436
|
+
token = manualResult.token;
|
|
437
|
+
gatewayUrl = manualResult.gatewayUrl;
|
|
438
|
+
isRemote = manualResult.isRemote;
|
|
439
|
+
}
|
|
440
|
+
} else if (!token) {
|
|
441
|
+
// No auto-detected token — fall through to manual entry
|
|
442
|
+
const manualResult = await promptManualGateway();
|
|
443
|
+
if (!manualResult) {
|
|
288
444
|
p.cancel("Setup cancelled.");
|
|
289
445
|
process.exit(0);
|
|
290
446
|
}
|
|
291
|
-
|
|
292
|
-
token =
|
|
447
|
+
port = manualResult.port;
|
|
448
|
+
token = manualResult.token;
|
|
449
|
+
gatewayUrl = manualResult.gatewayUrl;
|
|
450
|
+
isRemote = manualResult.isRemote;
|
|
293
451
|
}
|
|
294
452
|
|
|
295
|
-
// Step
|
|
296
|
-
const
|
|
453
|
+
// Step 3: Agent Discovery (use URL if remote, port if local)
|
|
454
|
+
const agentTarget = gatewayUrl || port;
|
|
455
|
+
const agents = await discoverAgents(agentTarget, token);
|
|
297
456
|
|
|
298
457
|
let primaryAgent: string;
|
|
299
458
|
|
|
@@ -341,6 +500,8 @@ export async function runOnboarding(): Promise<void> {
|
|
|
341
500
|
openclaw: {
|
|
342
501
|
gateway_port: port,
|
|
343
502
|
gateway_token: token || undefined,
|
|
503
|
+
gateway_url: gatewayUrl,
|
|
504
|
+
is_remote: isRemote || undefined,
|
|
344
505
|
primary_agent: primaryAgent,
|
|
345
506
|
},
|
|
346
507
|
server: {
|
|
@@ -417,6 +578,10 @@ export async function runOnboarding(): Promise<void> {
|
|
|
417
578
|
// Nothing on port or lsof not available
|
|
418
579
|
}
|
|
419
580
|
|
|
581
|
+
// Escape XML special characters for plist values
|
|
582
|
+
const xmlEscape = (s: string) =>
|
|
583
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
584
|
+
|
|
420
585
|
// Install as a persistent service (auto-start on login)
|
|
421
586
|
if (process.platform === "darwin") {
|
|
422
587
|
const plistDir = join(home(), "Library", "LaunchAgents");
|
|
@@ -430,14 +595,14 @@ export async function runOnboarding(): Promise<void> {
|
|
|
430
595
|
<string>com.castlekit.castle</string>
|
|
431
596
|
<key>ProgramArguments</key>
|
|
432
597
|
<array>
|
|
433
|
-
<string>${nodePath}</string>
|
|
434
|
-
<string>${nextBin}</string>
|
|
598
|
+
<string>${xmlEscape(nodePath)}</string>
|
|
599
|
+
<string>${xmlEscape(nextBin)}</string>
|
|
435
600
|
<string>start</string>
|
|
436
601
|
<string>-p</string>
|
|
437
|
-
<string>${castlePort}</string>
|
|
602
|
+
<string>${xmlEscape(castlePort)}</string>
|
|
438
603
|
</array>
|
|
439
604
|
<key>WorkingDirectory</key>
|
|
440
|
-
<string>${PROJECT_ROOT}</string>
|
|
605
|
+
<string>${xmlEscape(PROJECT_ROOT)}</string>
|
|
441
606
|
<key>RunAtLoad</key>
|
|
442
607
|
<true/>
|
|
443
608
|
<key>KeepAlive</key>
|
|
@@ -446,15 +611,15 @@ export async function runOnboarding(): Promise<void> {
|
|
|
446
611
|
<false/>
|
|
447
612
|
</dict>
|
|
448
613
|
<key>StandardOutPath</key>
|
|
449
|
-
<string>${logsDir}/server.log</string>
|
|
614
|
+
<string>${xmlEscape(logsDir)}/server.log</string>
|
|
450
615
|
<key>StandardErrorPath</key>
|
|
451
|
-
<string>${logsDir}/server.err</string>
|
|
616
|
+
<string>${xmlEscape(logsDir)}/server.err</string>
|
|
452
617
|
<key>EnvironmentVariables</key>
|
|
453
618
|
<dict>
|
|
454
619
|
<key>NODE_ENV</key>
|
|
455
620
|
<string>production</string>
|
|
456
621
|
<key>PATH</key>
|
|
457
|
-
<string>${process.env.PATH}</string>
|
|
622
|
+
<string>${xmlEscape(process.env.PATH || "")}</string>
|
|
458
623
|
</dict>
|
|
459
624
|
</dict>
|
|
460
625
|
</plist>`;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify that a mutating API request originated from the Castle UI itself,
|
|
5
|
+
* not from a cross-origin attacker (CSRF protection).
|
|
6
|
+
*
|
|
7
|
+
* Checks the Origin or Referer header against allowed localhost origins.
|
|
8
|
+
* Returns a 403 response if the request fails the check, or null if it passes.
|
|
9
|
+
*/
|
|
10
|
+
export function checkCsrf(request: NextRequest): NextResponse | null {
|
|
11
|
+
const origin = request.headers.get("origin");
|
|
12
|
+
const referer = request.headers.get("referer");
|
|
13
|
+
|
|
14
|
+
// Build allowed origins from the request's own host
|
|
15
|
+
const host = request.headers.get("host") || "localhost:3333";
|
|
16
|
+
const allowed = new Set([
|
|
17
|
+
`http://${host}`,
|
|
18
|
+
`https://${host}`,
|
|
19
|
+
// Common localhost variants
|
|
20
|
+
"http://localhost:3333",
|
|
21
|
+
"http://127.0.0.1:3333",
|
|
22
|
+
"http://[::1]:3333",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// If Origin header is present, it must match
|
|
26
|
+
if (origin) {
|
|
27
|
+
if (allowed.has(origin)) return null;
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ error: "Forbidden — cross-origin request rejected" },
|
|
30
|
+
{ status: 403 }
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fall back to Referer (browsers always send at least one for form submissions)
|
|
35
|
+
if (referer) {
|
|
36
|
+
try {
|
|
37
|
+
const refOrigin = new URL(referer).origin;
|
|
38
|
+
if (allowed.has(refOrigin)) return null;
|
|
39
|
+
} catch {
|
|
40
|
+
// Malformed referer
|
|
41
|
+
}
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ error: "Forbidden — cross-origin request rejected" },
|
|
44
|
+
{ status: 403 }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// No Origin or Referer — likely a direct curl/CLI call, allow it.
|
|
49
|
+
// Browsers ALWAYS send Origin on cross-origin requests,
|
|
50
|
+
// so a missing Origin means it's not a browser-based CSRF attack.
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sanitize a string by redacting token patterns and key material.
|
|
56
|
+
* Use this before returning any text content (logs, errors) via API.
|
|
57
|
+
*/
|
|
58
|
+
export function sanitizeForApi(str: string): string {
|
|
59
|
+
return str
|
|
60
|
+
.replace(/rew_[a-f0-9]+/gi, "rew_***")
|
|
61
|
+
.replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, "[REDACTED KEY]")
|
|
62
|
+
.replace(/\b[a-f0-9]{32,}\b/gi, (m) => m.slice(0, 8) + "***");
|
|
63
|
+
}
|