From 732b994120bbe34c0ce5711b074207cbd8b97d52 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 22 Jun 2026 10:06:22 -0700 Subject: [PATCH] Harden agent id validation, session keys, agent-state rollback, and gateway/auth reliability Centralize OpenClaw agent id validation in a new module (src/lib/agents/agentIds.ts) and route every gateway, cron, ssh, and intent path through it. Tighten the safe-id regex to match the gateway's 64-char normalization and reserve "main" from UI creation. Add symlink-aware boundary checks and move rollback to trash/restoreAgentStateLocally and the SSH equivalent so a failed move never leaves the filesystem half-migrated, and restore refuses symlinks that escape stateDir. Validate session keys (hasMalformedAgentSessionKey, sessionKeyBelongsToAgent) and cron job fields before trusting gateway output, and compare cron agent ids case-insensitively. Refactor applyGatewayConfigPatch and exec-approvals retry to fetch the snapshot inside the retry callback, eliminating a stale-baseHash race. Harden the control-plane adapter: stop() now waits on in-flight start, times out hung sockets, and ignores stale ws event handlers via a connection epoch. Close a WebSocket upgrade auth bypass in server/index.js by routing upgrades through accessGate.allowUpgrade, make access-gate cookie values URL-safe and stop reconstructing redirect URLs from Host headers, and apply the access gate to all non-token requests rather than only /api/. Read media via realpath + boundary re-check, enforce MAX_MEDIA_BYTES on remote SSH responses, and whitelist response MIME types. Clean up SSE streams on client abort. Normalize localhost gateway URLs in studio-settings so token hints only apply when the draft URL matches, and write settings atomically. Make the Playwright port configurable (PLAYWRIGHT_PORT, default 3100) to avoid colliding with the running dev server, and ignore .worktrees in eslint. Add unit tests for the new agentIds module, gateway connect profile, disconnect-like errors, local gateway, and studio settings store, and extend existing tests to cover the new validation, rollback, retry, and normalization paths. --- eslint.config.mjs | 1 + playwright.config.ts | 11 +- scripts/probe-agent-history-latency.mjs | 44 +- scripts/studio-setup.js | 8 +- server/access-gate.js | 43 +- server/index.js | 18 +- server/install-context.js | 4 +- server/studio-settings.js | 65 +- src/app/api/intents/agent-create/route.ts | 20 +- src/app/api/intents/agent-delete/route.ts | 4 + src/app/api/intents/agent-file-set/route.ts | 8 + .../intents/agent-permissions-update/route.ts | 71 +- src/app/api/intents/agent-rename/route.ts | 4 + src/app/api/intents/agent-wait/route.ts | 12 +- src/app/api/intents/chat-abort/route.ts | 8 +- src/app/api/intents/chat-send/route.ts | 8 +- src/app/api/intents/cron-add/route.ts | 19 +- .../api/intents/cron-remove-agent/route.ts | 79 +- src/app/api/intents/cron-restore/route.ts | 25 +- .../intents/session-settings-sync/route.ts | 9 +- src/app/api/intents/sessions-reset/route.ts | 7 +- src/app/api/runtime/agent-file/route.ts | 8 + src/app/api/runtime/agent-state/route.ts | 42 +- .../runtime/agents/[agentId]/history/route.ts | 47 +- .../runtime/agents/[agentId]/preview/route.ts | 25 +- src/app/api/runtime/fleet/route.ts | 18 +- src/app/api/runtime/media/route.ts | 54 +- src/app/api/runtime/stream/route.ts | 28 +- src/app/api/studio/route.ts | 33 +- src/app/api/studio/test-connection/route.ts | 16 +- src/app/page.tsx | 19 +- .../agents/approvals/execApprovalEvents.ts | 9 +- .../execApprovalLifecycleWorkflow.ts | 15 +- .../approvals/execApprovalResolveOperation.ts | 15 +- .../agents/components/AgentChatPanel.tsx | 24 +- .../agents/components/ConnectionPanel.tsx | 19 +- .../components/GatewayConnectScreen.tsx | 46 +- .../agents/operations/agentFleetHydration.ts | 8 +- .../agentFleetHydrationDerivation.ts | 32 +- .../operations/agentReconcileOperation.ts | 24 +- .../operations/runtimeWriteTransport.ts | 58 +- .../specialLatestUpdateOperation.ts | 29 +- .../useChatInteractionController.ts | 51 +- .../operations/useRuntimeSyncController.ts | 89 ++- .../state/gatewayRuntimeEventHandler.ts | 5 +- .../agents/state/runtimeChatEventWorkflow.ts | 5 +- .../agents/state/runtimeEventPolicy.ts | 19 +- src/features/agents/state/store.tsx | 2 +- src/lib/agent-state/local.ts | 171 +++- src/lib/agents/agentIds.ts | 35 + src/lib/controlplane/exec-approvals.ts | 40 +- .../controlplane/gateway-connect-profile.ts | 8 +- src/lib/controlplane/openclaw-adapter.ts | 57 +- src/lib/controlplane/projection-store.ts | 29 +- src/lib/cron/createPayloadBuilder.ts | 4 + src/lib/cron/types.ts | 137 +++- src/lib/gateway/agentConfig.ts | 145 ++-- src/lib/gateway/agentFiles.ts | 4 + src/lib/gateway/execApprovals.ts | 132 ++-- src/lib/gateway/gateway-disconnect.ts | 2 +- src/lib/gateway/local-gateway.ts | 3 +- src/lib/gateway/session-keys.ts | 26 +- src/lib/gateway/session-settings-sync.ts | 5 + src/lib/ssh/agent-state.ts | 133 +++- src/lib/ssh/gateway-host.ts | 12 +- src/lib/studio/coordinator.ts | 1 + src/lib/studio/settings-store.ts | 73 +- src/lib/studio/settings.ts | 28 +- src/lib/studio/useStudioGatewaySettings.ts | 16 +- tests/e2e/fleet-sidebar.spec.ts | 4 +- tests/e2e/helpers/studioRoute.ts | 2 +- tests/unit/accessGate.test.ts | 93 +++ tests/unit/agentFleetHydration.test.ts | 76 ++ .../agentFleetHydrationDerivation.test.ts | 40 + tests/unit/agentIds.test.ts | 45 ++ tests/unit/agentReconcileOperation.test.ts | 23 +- tests/unit/agentStateExecutor.test.ts | 11 + tests/unit/agentStateLocal.test.ts | 243 +++++- tests/unit/agentStateRoute.test.ts | 30 + tests/unit/agentStore.test.ts | 27 + tests/unit/connectionPanel-close.test.ts | 1 + tests/unit/controlPlaneExecApprovals.test.ts | 38 + .../unit/controlPlaneProjectionStore.test.ts | 29 + tests/unit/cronGatewayClient.test.ts | 180 +++++ tests/unit/cronSelectors.test.ts | 10 + tests/unit/execApprovalEvents.test.ts | 43 +- .../execApprovalLifecycleWorkflow.test.ts | 96 +++ .../unit/execApprovalResolveOperation.test.ts | 63 ++ tests/unit/gatewayConfigPatch.test.ts | 269 ++++++- tests/unit/gatewayConnectProfile.test.ts | 29 + tests/unit/gatewayDisconnectLikeError.test.ts | 21 + tests/unit/gatewayExecApprovals.test.ts | 125 ++- tests/unit/gatewayMediaRoute.test.ts | 138 ++++ .../gatewayRuntimeEventHandler.chat.test.ts | 29 +- tests/unit/gatewaySshTarget.test.ts | 9 + tests/unit/intentRoutes.test.ts | 733 +++++++++++++++++- tests/unit/localGateway.test.ts | 13 + tests/unit/openclawAdapter.test.ts | 289 +++++++ tests/unit/probeAgentHistoryLatency.test.ts | 43 +- tests/unit/runSshJson.test.ts | 43 +- tests/unit/runtimeAgentEventWorkflow.test.ts | 21 + tests/unit/runtimeChatEventWorkflow.test.ts | 23 + tests/unit/runtimeEventPolicy.test.ts | 57 +- tests/unit/runtimeRoutes.test.ts | 490 ++++++++++++ tests/unit/runtimeWriteTransport.test.ts | 86 +- tests/unit/sessionKey.test.ts | 21 + tests/unit/sessionSettings.test.ts | 12 + .../unit/specialLatestUpdateOperation.test.ts | 91 +++ tests/unit/studioSettings.test.ts | 64 +- tests/unit/studioSettingsRoute.test.ts | 116 ++- tests/unit/studioSettingsStore.test.ts | 163 ++++ tests/unit/studioSetupPaths.test.ts | 44 +- tests/unit/studioTestConnectionRoute.test.ts | 96 ++- .../studioUpstreamGatewaySettings.test.ts | 42 +- .../unit/useChatInteractionController.test.ts | 45 ++ tests/unit/useRuntimeSyncController.test.ts | 132 ++++ tests/unit/useStudioGatewaySettings.test.ts | 131 +++- 117 files changed, 6022 insertions(+), 676 deletions(-) create mode 100644 src/lib/agents/agentIds.ts create mode 100644 tests/unit/agentIds.test.ts create mode 100644 tests/unit/gatewayConnectProfile.test.ts create mode 100644 tests/unit/gatewayDisconnectLikeError.test.ts create mode 100644 tests/unit/localGateway.test.ts create mode 100644 tests/unit/studioSettingsStore.test.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index c2dc6d4..7f92fa9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,7 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + ".worktrees/**", // Vendored third-party code (kept as-is; linting it adds noise). "src/lib/avatars/vendor/**", diff --git a/playwright.config.ts b/playwright.config.ts index fd356b4..a833f4d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,16 +1,21 @@ import { defineConfig } from "@playwright/test"; import path from "node:path"; + +const e2ePort = Number(process.env.PLAYWRIGHT_PORT ?? "3100"); +const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_EXISTING_SERVER === "1"; + export default defineConfig({ testDir: "./tests/e2e", use: { - baseURL: "http://127.0.0.1:3000", + baseURL: `http://127.0.0.1:${e2ePort}`, }, webServer: { command: "npm run dev", - port: 3000, - reuseExistingServer: !process.env.CI, + port: e2ePort, + reuseExistingServer, env: { ...process.env, + PORT: String(e2ePort), OPENCLAW_STATE_DIR: path.resolve("./tests/fixtures/openclaw-empty-state"), NEXT_PUBLIC_GATEWAY_URL: "", }, diff --git a/scripts/probe-agent-history-latency.mjs b/scripts/probe-agent-history-latency.mjs index bdddb3b..91b6156 100644 --- a/scripts/probe-agent-history-latency.mjs +++ b/scripts/probe-agent-history-latency.mjs @@ -61,7 +61,7 @@ export const parseProbeArgs = (argv) => { continue; } const value = asTrimmed(argv[index + 1]); - if (!value) { + if (!value || value.startsWith("--")) { throw new Error(`Missing value for ${token}`); } if (token === "--base-url") { @@ -189,7 +189,13 @@ export const resolveTargetFromFleet = (params) => { export const buildProbePaths = ({ agentId, sessionKey }) => { const normalizedAgentId = encodeURIComponent(asTrimmed(agentId)); - void sessionKey; + const query = new URLSearchParams({ + sessionKey: asTrimmed(sessionKey), + limit: "50", + view: "semantic", + turnLimit: "50", + scanLimit: "800", + }); return [ { name: "summary", @@ -200,7 +206,7 @@ export const buildProbePaths = ({ agentId, sessionKey }) => { { name: "semantic-history", method: "GET", - path: `/api/runtime/agents/${normalizedAgentId}/history?limit=50&view=semantic&turnLimit=50&scanLimit=800`, + path: `/api/runtime/agents/${normalizedAgentId}/history?${query.toString()}`, sloBlocking: true, }, ]; @@ -291,12 +297,34 @@ export const assessRuntimePreflight = ({ response, allowDisconnected }) => { } const payload = response.body; - const summary = payload && typeof payload === "object" ? payload.summary : null; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return { + pass: false, + connected: false, + status: null, + message: "runtime preflight failed: invalid /api/runtime/summary payload", + }; + } + const summary = payload.summary; + if (!summary || typeof summary !== "object" || Array.isArray(summary)) { + return { + pass: false, + connected: false, + status: null, + message: "runtime preflight failed: missing summary in /api/runtime/summary payload", + }; + } const runtimeStatus = - summary && typeof summary === "object" - ? asTrimmed(summary.status ?? "") - : ""; - const normalizedStatus = runtimeStatus || "unknown"; + summary && typeof summary === "object" ? asTrimmed(summary.status ?? "") : ""; + if (!runtimeStatus) { + return { + pass: false, + connected: false, + status: null, + message: "runtime preflight failed: summary.status missing in /api/runtime/summary payload", + }; + } + const normalizedStatus = runtimeStatus; const connected = normalizedStatus === "connected"; if (connected) { diff --git a/scripts/studio-setup.js b/scripts/studio-setup.js index 06b2930..0069a3d 100644 --- a/scripts/studio-setup.js +++ b/scripts/studio-setup.js @@ -1,9 +1,8 @@ const fs = require("node:fs"); -const path = require("node:path"); const { execFileSync } = require("node:child_process"); const readline = require("node:readline/promises"); -const { resolveStudioSettingsPath } = require("../server/studio-settings"); +const { resolveStudioSettingsPath, writeJsonFileAtomic } = require("../server/studio-settings"); const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; @@ -30,7 +29,6 @@ async function main() { const args = parseArgs(process.argv.slice(2)); const settingsPath = resolveStudioSettingsPath(process.env); - const settingsDir = path.dirname(settingsPath); if (fs.existsSync(settingsPath) && !args.force) { console.error( @@ -66,7 +64,6 @@ async function main() { ); } - fs.mkdirSync(settingsDir, { recursive: true }); const next = { version: 1, gateway: { @@ -74,7 +71,7 @@ async function main() { token, }, }; - fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8"); + writeJsonFileAtomic(settingsPath, next); console.info(`Wrote Studio settings to ${settingsPath}.`); } finally { @@ -87,4 +84,3 @@ main().catch((err) => { console.error(msg); process.exitCode = 1; }); - diff --git a/server/access-gate.js b/server/access-gate.js index a1dcdfa..52cbfb6 100644 --- a/server/access-gate.js +++ b/server/access-gate.js @@ -10,18 +10,29 @@ const parseCookies = (header) => { const key = part.slice(0, idx).trim(); const value = part.slice(idx + 1).trim(); if (!key) continue; - out[key] = value; + try { + out[key] = decodeURIComponent(value); + } catch { + out[key] = value; + } } return out; }; const buildRedirectUrl = (req, nextPathWithQuery) => { - const host = req.headers?.host || "localhost"; - const proto = - String(req.headers?.["x-forwarded-proto"] || "").toLowerCase() === "https" - ? "https" - : "http"; - return `${proto}://${host}${nextPathWithQuery}`; + void req; + return nextPathWithQuery || "/"; +}; + +const writeUnauthorized = (res) => { + res.statusCode = 401; + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + error: + "Studio access token required. Open /?access_token=... once to set a cookie.", + }) + ); }; function createAccessGate(options) { @@ -53,7 +64,7 @@ function createAccessGate(options) { } url.searchParams.delete(queryParam); - const cookieValue = `${cookieName}=${token}; HttpOnly; Path=/; SameSite=Lax`; + const cookieValue = `${cookieName}=${encodeURIComponent(token)}; HttpOnly; Path=/; SameSite=Lax`; res.statusCode = 302; res.setHeader("Set-Cookie", cookieValue); res.setHeader("Location", buildRedirectUrl(req, url.pathname + url.search)); @@ -61,18 +72,9 @@ function createAccessGate(options) { return true; } - if (url.pathname.startsWith("/api/")) { - if (!isAuthorized(req)) { - res.statusCode = 401; - res.setHeader("Content-Type", "application/json"); - res.end( - JSON.stringify({ - error: - "Studio access token required. Open /?access_token=... once to set a cookie.", - }) - ); - return true; - } + if (!isAuthorized(req)) { + writeUnauthorized(res); + return true; } return false; @@ -87,4 +89,3 @@ function createAccessGate(options) { } module.exports = { createAccessGate }; - diff --git a/server/index.js b/server/index.js index c9b8cb3..f3de54c 100644 --- a/server/index.js +++ b/server/index.js @@ -58,12 +58,26 @@ async function main() { }); await app.prepare(); + const handleUpgrade = app.getUpgradeHandler(); - const createServer = () => - http.createServer((req, res) => { + const createServer = () => { + const server = http.createServer((req, res) => { if (accessGate.handleHttp(req, res)) return; handle(req, res); }); + server.on("upgrade", (req, socket, head) => { + if (!accessGate.allowUpgrade(req)) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + Promise.resolve(handleUpgrade(req, socket, head)).catch((err) => { + console.error("Failed to handle upgrade request.", err); + socket.destroy(); + }); + }); + return server; + }; const servers = hostnames.map(() => createServer()); diff --git a/server/install-context.js b/server/install-context.js index 89c51a9..ffecac9 100644 --- a/server/install-context.js +++ b/server/install-context.js @@ -362,7 +362,7 @@ const normalizeDnsName = (value) => { return trimmed.replace(/\.$/, ""); }; -const probeTailscale = async (env = process.env, runner = execFileAsync) => { +const probeTailscale = async (runner = execFileAsync) => { const result = await runJsonCommand( "tailscale", ["status", "--json"], @@ -436,7 +436,7 @@ async function detectInstallContext(env = process.env, options = {}) { const localDefaults = readOpenclawGatewayDefaultsImpl(env); const [localGatewayProbe, tailscale, studioCli] = await Promise.all([ probeLocalGateway(runCommand), - probeTailscale(env, runCommand), + probeTailscale(runCommand), probeStudioCli(env, runCommand, fetchImpl), ]); diff --git a/server/studio-settings.js b/server/studio-settings.js index 7aef29d..d657a82 100644 --- a/server/studio-settings.js +++ b/server/studio-settings.js @@ -1,6 +1,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); +const { randomUUID } = require("node:crypto"); const NEW_STATE_DIRNAME = ".openclaw"; @@ -38,14 +39,69 @@ const resolveStudioSettingsPath = (env = process.env) => { const readJsonFile = (filePath) => { if (!fs.existsSync(filePath)) return null; - const raw = fs.readFileSync(filePath, "utf8"); - return JSON.parse(raw); + try { + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw); + } catch { + return null; + } +}; + +const writeJsonFileAtomic = (filePath, value) => { + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + const tmpPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + try { + fs.writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fs.renameSync(tmpPath, filePath); + } catch (err) { + try { + fs.rmSync(tmpPath, { force: true }); + } catch {} + throw err; + } }; const DEFAULT_GATEWAY_URL = "ws://localhost:18789"; const OPENCLAW_CONFIG_FILENAME = "openclaw.json"; -const isRecord = (value) => Boolean(value && typeof value === "object"); +const isRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value)); +const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "0.0.0.0"]); + +const normalizeParsedHostname = (value) => + String(value ?? "") + .trim() + .toLowerCase() + .replace(/^\[(.*)\]$/, "$1"); + +const normalizeGatewayUrl = (value) => { + const url = String(value ?? "").trim(); + if (!url) return ""; + try { + const parsed = new URL(url); + if (!LOOPBACK_HOSTNAMES.has(normalizeParsedHostname(parsed.hostname))) { + return url; + } + const auth = + parsed.username || parsed.password + ? `${parsed.username}${parsed.password ? `:${parsed.password}` : ""}@` + : ""; + const host = parsed.port ? `localhost:${parsed.port}` : "localhost"; + const dropDefaultPath = + parsed.pathname === "/" && !url.endsWith("/") && !parsed.search && !parsed.hash; + const pathname = dropDefaultPath ? "" : parsed.pathname; + return `${parsed.protocol}//${auth}${host}${pathname}${parsed.search}${parsed.hash}`; + } catch { + return url; + } +}; + +const canUseLocalGatewayDefaultsForUrl = (configuredUrl, defaultsUrl) => { + const fallbackUrl = normalizeGatewayUrl(defaultsUrl); + if (!fallbackUrl) return false; + const url = normalizeGatewayUrl(configuredUrl); + return !url || url === fallbackUrl; +}; const readOpenclawGatewayDefaults = (env = process.env) => { try { @@ -75,7 +131,7 @@ const loadUpstreamGatewaySettings = (env = process.env) => { const token = typeof gateway?.token === "string" ? gateway.token.trim() : ""; if (!token) { const defaults = readOpenclawGatewayDefaults(env); - if (defaults) { + if (defaults && canUseLocalGatewayDefaultsForUrl(url, defaults.url)) { return { url: url || defaults.url, token: defaults.token, @@ -92,4 +148,5 @@ module.exports = { resolveStudioSettingsPath, loadUpstreamGatewaySettings, readOpenclawGatewayDefaults, + writeJsonFileAtomic, }; diff --git a/src/app/api/intents/agent-create/route.ts b/src/app/api/intents/agent-create/route.ts index 7be47f1..f028940 100644 --- a/src/app/api/intents/agent-create/route.ts +++ b/src/app/api/intents/agent-create/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { ensureDomainIntentRuntime, parseIntentBody } from "@/lib/controlplane/intent-route"; import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; import { slugifyAgentName } from "@/lib/gateway/agentConfig"; +import { resolveSafeAgentId } from "@/lib/agents/agentIds"; export const runtime = "nodejs"; @@ -10,6 +11,9 @@ type GatewayConfigSnapshot = { path?: string | null; }; +const isRecord = (value: unknown): value is Record => + Boolean(value && typeof value === "object" && !Array.isArray(value)); + const dirnameLike = (value: string): string => { const lastSlash = value.lastIndexOf("/"); const lastBackslash = value.lastIndexOf("\\"); @@ -34,6 +38,13 @@ export async function POST(request: Request) { if (!name) { return NextResponse.json({ error: "name is required." }, { status: 400 }); } + let agentIdGuess: string; + try { + agentIdGuess = slugifyAgentName(name); + } catch (err) { + const message = err instanceof Error ? err.message : "Invalid agent name."; + return NextResponse.json({ error: message }, { status: 400 }); + } const runtimeOrError = await ensureDomainIntentRuntime(); if (runtimeOrError instanceof Response) { @@ -54,12 +65,17 @@ export async function POST(request: Request) { `Gateway config path "${configPath}" is missing a directory; cannot compute workspace.` ); } - const workspace = joinPathLike(stateDir, `workspace-${slugifyAgentName(name)}`); + const workspace = joinPathLike(stateDir, `workspace-${agentIdGuess}`); const payload = await runtimeOrError.callGateway("agents.create", { name, workspace, }); - return NextResponse.json({ ok: true, payload }); + const payloadRecord = isRecord(payload) ? payload : {}; + const agentId = resolveSafeAgentId(payloadRecord.agentId); + if (!agentId) { + throw new Error("Gateway returned an invalid agents.create response (missing or invalid agentId)."); + } + return NextResponse.json({ ok: true, payload: { ...payloadRecord, agentId } }); } catch (err) { if (err instanceof ControlPlaneGatewayError) { if (err.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") { diff --git a/src/app/api/intents/agent-delete/route.ts b/src/app/api/intents/agent-delete/route.ts index eb13d53..580f3ff 100644 --- a/src/app/api/intents/agent-delete/route.ts +++ b/src/app/api/intents/agent-delete/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; export const runtime = "nodejs"; @@ -13,5 +14,8 @@ export async function POST(request: Request) { if (!agentId) { return NextResponse.json({ error: "agentId is required." }, { status: 400 }); } + if (!isSafeAgentId(agentId)) { + return NextResponse.json({ error: `Invalid agentId: ${agentId}` }, { status: 400 }); + } return await executeGatewayIntent("agents.delete", { agentId }); } diff --git a/src/app/api/intents/agent-file-set/route.ts b/src/app/api/intents/agent-file-set/route.ts index 8e1226f..23695db 100644 --- a/src/app/api/intents/agent-file-set/route.ts +++ b/src/app/api/intents/agent-file-set/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; +import { isAgentFileName } from "@/lib/agents/agentFiles"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; export const runtime = "nodejs"; @@ -16,6 +18,12 @@ export async function POST(request: Request) { if (!agentId || !name || content === null) { return NextResponse.json({ error: "agentId, name, and content are required." }, { status: 400 }); } + if (!isSafeAgentId(agentId)) { + return NextResponse.json({ error: `Invalid agentId: ${agentId}` }, { status: 400 }); + } + if (!isAgentFileName(name)) { + return NextResponse.json({ error: `Unsupported agent file name: ${name}` }, { status: 400 }); + } return await executeGatewayIntent("agents.files.set", { agentId, diff --git a/src/app/api/intents/agent-permissions-update/route.ts b/src/app/api/intents/agent-permissions-update/route.ts index 0762792..6140442 100644 --- a/src/app/api/intents/agent-permissions-update/route.ts +++ b/src/app/api/intents/agent-permissions-update/route.ts @@ -4,12 +4,14 @@ import { ensureDomainIntentRuntime, parseIntentBody, } from "@/lib/controlplane/intent-route"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; import { upsertAgentExecApprovalsPolicyViaRuntime, type ExecutionRoleId, } from "@/lib/controlplane/exec-approvals"; import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime"; +import { sessionKeyBelongsToAgent } from "@/lib/gateway/session-keys"; export const runtime = "nodejs"; @@ -25,6 +27,11 @@ type GatewayAgentToolsOverrides = { alsoAllow?: string[]; deny?: string[]; }; +type ToolGroupOverrideInput = { + runtimeEnabled: boolean; + webEnabled: boolean; + fsEnabled: boolean; +}; const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -141,6 +148,19 @@ const resolveSessionExecSettingsForRole = (params: { return { execHost, execSecurity: "allowlist" as const, execAsk: "always" as const }; }; +const resolveConfigAgentSandboxMode = ( + config: Record, + agentId: string +): string => { + const list = readConfigAgentList(config); + const configEntry = list.find((entry) => entry.id === agentId) ?? null; + const sandboxRaw = + configEntry && isRecord(configEntry.sandbox) + ? (configEntry.sandbox as Record) + : null; + return typeof sandboxRaw?.mode === "string" ? sandboxRaw.mode : ""; +}; + const isConfigConflict = (err: unknown): boolean => { if (!(err instanceof ControlPlaneGatewayError)) return false; if (err.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; @@ -180,25 +200,31 @@ const applyAgentToolsOverrides = async (params: { baseConfig: Record; snapshotHash?: string; snapshotExists?: boolean; - overrides: GatewayAgentToolsOverrides; + toolGroups: ToolGroupOverrideInput; attempt?: number; -}): Promise => { +}): Promise<{ sandboxMode: string }> => { const attempt = params.attempt ?? 0; const list = readConfigAgentList(params.baseConfig); const nextList = upsertConfigAgentEntry(list, params.agentId, (entry) => { const next: ConfigAgentEntry = { ...entry, id: params.agentId }; + const overrides = resolveToolGroupOverrides({ + existingTools: next.tools, + runtimeEnabled: params.toolGroups.runtimeEnabled, + webEnabled: params.toolGroups.webEnabled, + fsEnabled: params.toolGroups.fsEnabled, + }).tools; const currentTools = isRecord(next.tools) ? { ...next.tools } : {}; - const allow = normalizeToolList(params.overrides.allow); + const allow = normalizeToolList(overrides.allow); if (allow !== undefined) { currentTools.allow = allow; delete currentTools.alsoAllow; } - const alsoAllow = normalizeToolList(params.overrides.alsoAllow); + const alsoAllow = normalizeToolList(overrides.alsoAllow); if (alsoAllow !== undefined) { currentTools.alsoAllow = alsoAllow; delete currentTools.allow; } - const deny = normalizeToolList(params.overrides.deny); + const deny = normalizeToolList(overrides.deny); if (deny !== undefined) { currentTools.deny = deny; } @@ -213,6 +239,9 @@ const applyAgentToolsOverrides = async (params: { }); try { await params.runtime.callGateway("config.set", payload); + return { + sandboxMode: resolveConfigAgentSandboxMode(nextConfig, params.agentId), + }; } catch (err) { if (attempt >= 1 || !isConfigConflict(err)) { throw err; @@ -221,7 +250,7 @@ const applyAgentToolsOverrides = async (params: { const retryConfig = isRecord(retrySnapshot.config) ? (retrySnapshot.config as Record) : {}; - await applyAgentToolsOverrides({ + return await applyAgentToolsOverrides({ ...params, baseConfig: retryConfig, snapshotHash: retrySnapshot.hash, @@ -247,6 +276,15 @@ export async function POST(request: Request) { if (!agentId || !sessionKey) { return NextResponse.json({ error: "agentId and sessionKey are required." }, { status: 400 }); } + if (!isSafeAgentId(agentId)) { + return NextResponse.json({ error: `Invalid agentId: ${agentId}` }, { status: 400 }); + } + if (!sessionKeyBelongsToAgent(sessionKey, agentId)) { + return NextResponse.json( + { error: "sessionKey does not match agentId." }, + { status: 400 } + ); + } if (commandMode !== "off" && commandMode !== "ask" && commandMode !== "auto") { return NextResponse.json({ error: "commandMode must be one of: off, ask, auto." }, { status: 400 }); } @@ -263,26 +301,17 @@ export async function POST(request: Request) { const role = resolveRoleForCommandMode(commandMode as CommandModeId); const snapshot = await runtimeOrError.callGateway("config.get", {}); const baseConfig = isRecord(snapshot.config) ? (snapshot.config as Record) : {}; - const list = readConfigAgentList(baseConfig); - const configEntry = list.find((entry) => entry.id === agentId) ?? null; - const sandboxRaw = - configEntry && isRecord(configEntry.sandbox) ? (configEntry.sandbox as Record) : null; - const sandboxMode = typeof sandboxRaw?.mode === "string" ? sandboxRaw.mode : ""; - const toolsRaw = configEntry && isRecord(configEntry.tools) ? configEntry.tools : null; - - const toolOverrides = resolveToolGroupOverrides({ - existingTools: toolsRaw, - runtimeEnabled: role !== "conservative", - webEnabled: webAccess, - fsEnabled: fileTools, - }); - await applyAgentToolsOverrides({ + const { sandboxMode } = await applyAgentToolsOverrides({ runtime: runtimeOrError, agentId, baseConfig, snapshotHash: snapshot.hash, snapshotExists: snapshot.exists, - overrides: toolOverrides.tools, + toolGroups: { + runtimeEnabled: role !== "conservative", + webEnabled: webAccess, + fsEnabled: fileTools, + }, }); const execSettings = resolveSessionExecSettingsForRole({ role, sandboxMode }); diff --git a/src/app/api/intents/agent-rename/route.ts b/src/app/api/intents/agent-rename/route.ts index a24963c..f0530ef 100644 --- a/src/app/api/intents/agent-rename/route.ts +++ b/src/app/api/intents/agent-rename/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; export const runtime = "nodejs"; @@ -14,5 +15,8 @@ export async function POST(request: Request) { if (!agentId || !name) { return NextResponse.json({ error: "agentId and name are required." }, { status: 400 }); } + if (!isSafeAgentId(agentId)) { + return NextResponse.json({ error: `Invalid agentId: ${agentId}` }, { status: 400 }); + } return await executeGatewayIntent("agents.update", { agentId, name }); } diff --git a/src/app/api/intents/agent-wait/route.ts b/src/app/api/intents/agent-wait/route.ts index 56b6618..0c88000 100644 --- a/src/app/api/intents/agent-wait/route.ts +++ b/src/app/api/intents/agent-wait/route.ts @@ -6,6 +6,16 @@ import { export const runtime = "nodejs"; +const AGENT_WAIT_TRANSPORT_TIMEOUT_OVERHEAD_MS = 5_000; + +const resolveAgentWaitTransportTimeoutMs = (timeoutMs: number | undefined): number => { + if (typeof timeoutMs !== "number") return LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS; + return Math.min( + LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS, + timeoutMs + AGENT_WAIT_TRANSPORT_TIMEOUT_OVERHEAD_MS + ); +}; + export async function POST(request: Request) { const parsed = await parseIntentBody(request); if (parsed instanceof Response) return parsed; @@ -23,6 +33,6 @@ export async function POST(request: Request) { runId, ...(typeof timeoutMs === "number" ? { timeoutMs } : {}), }, { - timeoutMs: typeof timeoutMs === "number" ? timeoutMs : LONG_RUNNING_GATEWAY_INTENT_TIMEOUT_MS, + timeoutMs: resolveAgentWaitTransportTimeoutMs(timeoutMs), }); } diff --git a/src/app/api/intents/chat-abort/route.ts b/src/app/api/intents/chat-abort/route.ts index c73a3ae..7994ae5 100644 --- a/src/app/api/intents/chat-abort/route.ts +++ b/src/app/api/intents/chat-abort/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; +import { hasMalformedAgentSessionKey, resolveSafeSessionKey } from "@/lib/gateway/session-keys"; export const runtime = "nodejs"; @@ -9,7 +10,12 @@ export async function POST(request: Request) { if (bodyOrError instanceof Response) { return bodyOrError as NextResponse; } - const sessionKey = typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : ""; + const rawSessionKey = + typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey : ""; + if (hasMalformedAgentSessionKey(rawSessionKey)) { + return NextResponse.json({ error: "Invalid sessionKey." }, { status: 400 }); + } + const sessionKey = resolveSafeSessionKey(rawSessionKey) ?? ""; if (!sessionKey) { return NextResponse.json({ error: "sessionKey is required." }, { status: 400 }); } diff --git a/src/app/api/intents/chat-send/route.ts b/src/app/api/intents/chat-send/route.ts index 0ef0846..784ba0a 100644 --- a/src/app/api/intents/chat-send/route.ts +++ b/src/app/api/intents/chat-send/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; +import { hasMalformedAgentSessionKey, resolveSafeSessionKey } from "@/lib/gateway/session-keys"; export const runtime = "nodejs"; @@ -10,7 +11,12 @@ export async function POST(request: Request) { return bodyOrError as NextResponse; } - const sessionKey = typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : ""; + const rawSessionKey = + typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey : ""; + if (hasMalformedAgentSessionKey(rawSessionKey)) { + return NextResponse.json({ error: "Invalid sessionKey." }, { status: 400 }); + } + const sessionKey = resolveSafeSessionKey(rawSessionKey) ?? ""; const message = typeof bodyOrError.message === "string" ? bodyOrError.message : ""; const idempotencyKey = typeof bodyOrError.idempotencyKey === "string" ? bodyOrError.idempotencyKey.trim() : ""; diff --git a/src/app/api/intents/cron-add/route.ts b/src/app/api/intents/cron-add/route.ts index dd37a26..3abc7f0 100644 --- a/src/app/api/intents/cron-add/route.ts +++ b/src/app/api/intents/cron-add/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; +import { resolveOptionalCronSessionKey } from "@/lib/cron/types"; export const runtime = "nodejs"; @@ -15,6 +17,21 @@ export async function POST(request: Request) { if (!name || !agentId) { return NextResponse.json({ error: "name and agentId are required." }, { status: 400 }); } + if (!isSafeAgentId(agentId)) { + return NextResponse.json({ error: `Invalid agentId: ${agentId}` }, { status: 400 }); + } + let sessionKey: string | undefined; + try { + sessionKey = resolveOptionalCronSessionKey(bodyOrError.sessionKey, agentId); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid sessionKey."; + return NextResponse.json({ error: message }, { status: 400 }); + } - return await executeGatewayIntent("cron.add", bodyOrError); + return await executeGatewayIntent("cron.add", { + ...bodyOrError, + name, + agentId, + ...(bodyOrError.sessionKey !== undefined ? { sessionKey } : {}), + }); } diff --git a/src/app/api/intents/cron-remove-agent/route.ts b/src/app/api/intents/cron-remove-agent/route.ts index bd9b90d..7cfe6db 100644 --- a/src/app/api/intents/cron-remove-agent/route.ts +++ b/src/app/api/intents/cron-remove-agent/route.ts @@ -1,8 +1,16 @@ import { NextResponse } from "next/server"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; import { ensureDomainIntentRuntime, parseIntentBody } from "@/lib/controlplane/intent-route"; import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; -import type { CronDelivery, CronJobRestoreInput, CronPayload, CronSchedule } from "@/lib/cron/types"; +import { + cronAgentIdsEqual, + resolveOptionalCronSessionKey, + type CronDelivery, + type CronJobRestoreInput, + type CronPayload, + type CronSchedule, +} from "@/lib/cron/types"; import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime"; export const runtime = "nodejs"; @@ -26,6 +34,11 @@ type CronListResult = { jobs?: unknown; }; +type CronJobRemovalPlan = { + id: string; + restoreInput: CronJobRestoreInput; +}; + const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); @@ -61,7 +74,11 @@ const parseCronJobRestoreInput = ( if (!isRecord(payload)) { throw new Error(`Cron job ${id} is missing payload.`); } - const sessionKey = typeof value.sessionKey === "string" ? value.sessionKey : undefined; + const sessionKey = resolveOptionalCronSessionKey( + value.sessionKey, + expectedAgentId, + `Cron job ${id} sessionKey` + ); const description = typeof value.description === "string" ? value.description : undefined; const deleteAfterRun = typeof value.deleteAfterRun === "boolean" ? value.deleteAfterRun : undefined; const delivery = isRecord(value.delivery) ? (value.delivery as CronDelivery) : undefined; @@ -81,6 +98,20 @@ const parseCronJobRestoreInput = ( }; }; +const buildCronJobRemovalPlan = ( + job: CronJobSummaryLike, + expectedAgentId: string +): CronJobRemovalPlan => { + const id = typeof job.id === "string" ? job.id.trim() : ""; + if (!id) { + throw new Error("Cron job id is required."); + } + return { + id, + restoreInput: parseCronJobRestoreInput(job, expectedAgentId), + }; +}; + const restoreJobsBestEffort = async ( runtime: ControlPlaneRuntime, jobs: CronJobRestoreInput[] @@ -129,6 +160,9 @@ export async function POST(request: Request) { if (!agentId) { return NextResponse.json({ error: "agentId is required." }, { status: 400 }); } + if (!isSafeAgentId(agentId)) { + return NextResponse.json({ error: `Invalid agentId: ${agentId}` }, { status: 400 }); + } const runtimeOrError = await ensureDomainIntentRuntime(); if (runtimeOrError instanceof Response) { @@ -142,36 +176,27 @@ export async function POST(request: Request) { const jobs = Array.isArray(listResult.jobs) ? listResult.jobs.filter((entry): entry is CronJobSummaryLike => isRecord(entry)) : []; - const jobsForAgent = jobs.filter((job) => { - const jobAgentId = typeof job.agentId === "string" ? job.agentId.trim() : ""; - return jobAgentId === agentId; - }); + const jobsForAgent = jobs.filter((job) => cronAgentIdsEqual(job.agentId, agentId)); + const jobsToRemove = jobsForAgent.map((job) => buildCronJobRemovalPlan(job, agentId)); const removedJobs: CronJobRestoreInput[] = []; - for (const job of jobsForAgent) { - const jobId = typeof job.id === "string" ? job.id.trim() : ""; - if (!jobId) { - throw new Error("Cron job id is required."); - } + try { + for (const job of jobsToRemove) { + const removeResult = await runtimeOrError.callGateway("cron.remove", { id: job.id }); - let removeResult: unknown; - try { - removeResult = await runtimeOrError.callGateway("cron.remove", { id: jobId }); - } catch (error) { - await restoreJobsBestEffort(runtimeOrError, removedJobs); - throw error; - } + const ok = isRecord(removeResult) && removeResult.ok === true; + if (!ok) { + throw new Error(`Failed to delete cron job \"${job.id}\".`); + } - const ok = isRecord(removeResult) && removeResult.ok === true; - if (!ok) { - await restoreJobsBestEffort(runtimeOrError, removedJobs); - throw new Error(`Failed to delete cron job \"${jobId}\".`); - } - - const removed = isRecord(removeResult) && removeResult.removed === true; - if (removed) { - removedJobs.push(parseCronJobRestoreInput(job, agentId)); + const removed = isRecord(removeResult) && removeResult.removed === true; + if (removed) { + removedJobs.push(job.restoreInput); + } } + } catch (error) { + await restoreJobsBestEffort(runtimeOrError, removedJobs); + throw error; } return NextResponse.json({ diff --git a/src/app/api/intents/cron-restore/route.ts b/src/app/api/intents/cron-restore/route.ts index a46a210..959086f 100644 --- a/src/app/api/intents/cron-restore/route.ts +++ b/src/app/api/intents/cron-restore/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from "next/server"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; import { ensureDomainIntentRuntime, parseIntentBody } from "@/lib/controlplane/intent-route"; import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; -import type { CronDelivery, CronPayload, CronSchedule } from "@/lib/cron/types"; +import { + resolveOptionalCronSessionKey, + type CronDelivery, + type CronPayload, + type CronSchedule, +} from "@/lib/cron/types"; export const runtime = "nodejs"; @@ -36,6 +42,9 @@ const parseRestoreJob = (value: unknown, index: number): CronJobRestoreInput => if (!agentId) { throw new Error(`jobs[${index}].agentId is required.`); } + if (!isSafeAgentId(agentId)) { + throw new Error(`jobs[${index}].agentId is invalid.`); + } if (typeof value.enabled !== "boolean") { throw new Error(`jobs[${index}].enabled must be boolean.`); } @@ -56,7 +65,11 @@ const parseRestoreJob = (value: unknown, index: number): CronJobRestoreInput => throw new Error(`jobs[${index}].payload is required.`); } - const sessionKey = typeof value.sessionKey === "string" ? value.sessionKey : undefined; + const sessionKey = resolveOptionalCronSessionKey( + value.sessionKey, + agentId, + `jobs[${index}].sessionKey` + ); const description = typeof value.description === "string" ? value.description : undefined; const deleteAfterRun = typeof value.deleteAfterRun === "boolean" ? value.deleteAfterRun : undefined; const delivery = isRecord(value.delivery) ? (value.delivery as CronDelivery) : undefined; @@ -111,7 +124,13 @@ export async function POST(request: Request) { if (!Array.isArray(jobsRaw)) { return NextResponse.json({ error: "jobs must be an array." }, { status: 400 }); } - const jobs = jobsRaw.map((job, index) => parseRestoreJob(job, index)); + let jobs: CronJobRestoreInput[]; + try { + jobs = jobsRaw.map((job, index) => parseRestoreJob(job, index)); + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid cron restore payload."; + return NextResponse.json({ error: message }, { status: 400 }); + } const runtimeOrError = await ensureDomainIntentRuntime(); if (runtimeOrError instanceof Response) { diff --git a/src/app/api/intents/session-settings-sync/route.ts b/src/app/api/intents/session-settings-sync/route.ts index fe6afb4..b398f77 100644 --- a/src/app/api/intents/session-settings-sync/route.ts +++ b/src/app/api/intents/session-settings-sync/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route"; +import { hasMalformedAgentSessionKey, resolveSafeSessionKey } from "@/lib/gateway/session-keys"; export const runtime = "nodejs"; @@ -13,8 +14,12 @@ export async function POST(request: Request) { return bodyOrError as NextResponse; } - const sessionKey = - typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey.trim() : ""; + const rawSessionKey = + typeof bodyOrError.sessionKey === "string" ? bodyOrError.sessionKey : ""; + if (hasMalformedAgentSessionKey(rawSessionKey)) { + return NextResponse.json({ error: "Invalid sessionKey." }, { status: 400 }); + } + const sessionKey = resolveSafeSessionKey(rawSessionKey) ?? ""; if (!sessionKey) { return NextResponse.json({ error: "sessionKey is required." }, { status: 400 }); } diff --git a/src/app/api/intents/sessions-reset/route.ts b/src/app/api/intents/sessions-reset/route.ts index b150a0b..e98e2cd 100644 --- a/src/app/api/intents/sessions-reset/route.ts +++ b/src/app/api/intents/sessions-reset/route.ts @@ -1,4 +1,5 @@ import { parseIntentBody, executeGatewayIntent } from "@/lib/controlplane/intent-route"; +import { hasMalformedAgentSessionKey, resolveSafeSessionKey } from "@/lib/gateway/session-keys"; export const runtime = "nodejs"; @@ -6,7 +7,11 @@ export async function POST(request: Request) { const parsed = await parseIntentBody(request); if (parsed instanceof Response) return parsed; - const key = typeof parsed.key === "string" ? parsed.key.trim() : ""; + const rawKey = typeof parsed.key === "string" ? parsed.key : ""; + if (hasMalformedAgentSessionKey(rawKey)) { + return Response.json({ error: "Invalid key." }, { status: 400 }); + } + const key = resolveSafeSessionKey(rawKey) ?? ""; if (!key) { return Response.json({ error: "key is required." }, { status: 400 }); } diff --git a/src/app/api/runtime/agent-file/route.ts b/src/app/api/runtime/agent-file/route.ts index e1577ae..8a7bbe1 100644 --- a/src/app/api/runtime/agent-file/route.ts +++ b/src/app/api/runtime/agent-file/route.ts @@ -1,6 +1,8 @@ import { NextResponse } from "next/server"; import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route"; +import { isAgentFileName } from "@/lib/agents/agentFiles"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; export const runtime = "nodejs"; @@ -11,6 +13,12 @@ export async function GET(request: Request) { if (!agentId || !name) { return NextResponse.json({ error: "agentId and name are required." }, { status: 400 }); } + if (!isSafeAgentId(agentId)) { + return NextResponse.json({ error: `Invalid agentId: ${agentId}` }, { status: 400 }); + } + if (!isAgentFileName(name)) { + return NextResponse.json({ error: `Unsupported agent file name: ${name}` }, { status: 400 }); + } return await executeRuntimeGatewayRead("agents.files.get", { agentId, diff --git a/src/app/api/runtime/agent-state/route.ts b/src/app/api/runtime/agent-state/route.ts index 2bc6304..ed905e6 100644 --- a/src/app/api/runtime/agent-state/route.ts +++ b/src/app/api/runtime/agent-state/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local"; import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; import { @@ -23,8 +24,6 @@ type RestoreAgentStateRequest = { trashDir: string; }; -const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value); - const resolveAgentStateSshTarget = (): string | null => { const configured = resolveConfiguredSshTarget(process.env); if (configured) return configured; @@ -34,13 +33,29 @@ const resolveAgentStateSshTarget = (): string | null => { return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); }; -export async function POST(request: Request) { +const parseAgentStateBody = async ( + request: Request +): Promise | NextResponse> => { + let body: unknown; try { - const body = (await request.json()) as unknown; - if (!body || typeof body !== "object") { - return NextResponse.json({ error: "Invalid request payload." }, { status: 400 }); - } - const { agentId } = body as Partial; + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON payload." }, { status: 400 }); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Invalid request payload." }, { status: 400 }); + } + return body as Record; +}; + +export async function POST(request: Request) { + const bodyOrError = await parseAgentStateBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + + try { + const { agentId } = bodyOrError as Partial; const trimmed = typeof agentId === "string" ? agentId.trim() : ""; if (!trimmed) { return NextResponse.json({ error: "agentId is required." }, { status: 400 }); @@ -63,12 +78,13 @@ export async function POST(request: Request) { } export async function PUT(request: Request) { + const bodyOrError = await parseAgentStateBody(request); + if (bodyOrError instanceof Response) { + return bodyOrError as NextResponse; + } + try { - const body = (await request.json()) as unknown; - if (!body || typeof body !== "object") { - return NextResponse.json({ error: "Invalid request payload." }, { status: 400 }); - } - const { agentId, trashDir } = body as Partial; + const { agentId, trashDir } = bodyOrError as Partial; const trimmedAgent = typeof agentId === "string" ? agentId.trim() : ""; const trimmedTrash = typeof trashDir === "string" ? trashDir.trim() : ""; if (!trimmedAgent) { diff --git a/src/app/api/runtime/agents/[agentId]/history/route.ts b/src/app/api/runtime/agents/[agentId]/history/route.ts index c725c87..a4bf224 100644 --- a/src/app/api/runtime/agents/[agentId]/history/route.ts +++ b/src/app/api/runtime/agents/[agentId]/history/route.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { NextResponse } from "next/server"; import { deriveRuntimeFreshness } from "@/lib/controlplane/degraded-read"; @@ -13,6 +15,9 @@ import { clampGatewayChatHistoryLimit, GATEWAY_CHAT_HISTORY_MAX_LIMIT, } from "@/lib/gateway/chatHistoryLimits"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; +import { sessionKeyBelongsToAgent } from "@/lib/gateway/session-keys"; +import { loadStudioSettings } from "@/lib/studio/settings-store"; export const runtime = "nodejs"; @@ -52,6 +57,9 @@ const HISTORY_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test( (process.env.NEXT_PUBLIC_STUDIO_TRANSCRIPT_DEBUG ?? "").trim() ); +const hashCacheSecret = (value: string): string => + value ? createHash("sha256").update(value).digest("hex") : ""; + const logHistoryRouteMetric = (metric: string, meta: Record) => { if (!HISTORY_DEBUG_ENABLED) return; console.debug(`[history-route] ${metric}`, meta); @@ -184,6 +192,7 @@ const compactConversationMessages = (messages: SemanticHistoryMessage[]): Semant }; const buildHistoryCacheKey = (params: { + gatewayScope: string; agentId: string; sessionKey: string; view: HistoryView; @@ -194,6 +203,7 @@ const buildHistoryCacheKey = (params: { includeTools: boolean; }): string => { return [ + params.gatewayScope, params.agentId, params.sessionKey, params.view, @@ -263,6 +273,9 @@ const readHistoryCacheEntry = (params: { return entry; }; +const resolveHistoryCacheAgeMs = (entry: HistoryCacheEntry, nowMs: number): number => + Math.max(0, nowMs - entry.cachedAtMs); + const mapGatewayError = (error: unknown): NextResponse => { if (error instanceof ControlPlaneGatewayError) { if (error.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") { @@ -359,6 +372,7 @@ const resolveHistorySelectionPayload = async (params: { ) => Array<{ id: number }>; }; agentId: string; + gatewayScope: string; fallbackRevision: number; sessionKey: string; view: HistoryView; @@ -373,6 +387,7 @@ const resolveHistorySelectionPayload = async (params: { cacheAgeMs: number | null; }> => { const cacheKey = buildHistoryCacheKey({ + gatewayScope: params.gatewayScope, agentId: params.agentId, sessionKey: params.sessionKey, view: params.view, @@ -398,18 +413,20 @@ const resolveHistorySelectionPayload = async (params: { return { payload: cached.payload, cacheStatus: "hit", - cacheAgeMs: nowMs - cached.cachedAtMs, + cacheAgeMs: resolveHistoryCacheAgeMs(cached, nowMs), }; } const inFlight = historyInFlight.get(cacheKey) ?? null; if (inFlight) { const shared = await inFlight; - if (shared.agentRevision === agentRevision && nowMs - shared.cachedAtMs <= HISTORY_CACHE_TTL_MS) { + const sharedNowMs = Date.now(); + const sharedAgeMs = resolveHistoryCacheAgeMs(shared, sharedNowMs); + if (shared.agentRevision === agentRevision && sharedAgeMs <= HISTORY_CACHE_TTL_MS) { return { payload: shared.payload, cacheStatus: "coalesced", - cacheAgeMs: nowMs - shared.cachedAtMs, + cacheAgeMs: sharedAgeMs, }; } } @@ -456,16 +473,19 @@ export async function GET( context: { params: Promise<{ agentId: string }> } ) { const routeStartedAt = Date.now(); - const bootstrap = await bootstrapDomainRuntime(); - if (bootstrap.kind === "mode-disabled") { - return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 }); - } - const { agentId } = await context.params; const normalizedAgentId = agentId.trim(); if (!normalizedAgentId) { return NextResponse.json({ error: "agentId is required." }, { status: 400 }); } + if (!isSafeAgentId(normalizedAgentId)) { + return NextResponse.json({ error: `Invalid agentId: ${normalizedAgentId}` }, { status: 400 }); + } + + const bootstrap = await bootstrapDomainRuntime(); + if (bootstrap.kind === "mode-disabled") { + return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 }); + } if (bootstrap.kind === "runtime-init-failed") { return NextResponse.json( @@ -482,6 +502,12 @@ export async function GET( const url = new URL(request.url); const sessionKeyRaw = (url.searchParams.get("sessionKey") ?? "").trim(); const sessionKey = sessionKeyRaw || `agent:${normalizedAgentId}:main`; + if (!sessionKeyBelongsToAgent(sessionKey, normalizedAgentId)) { + return NextResponse.json( + { error: "sessionKey does not match agentId." }, + { status: 400 } + ); + } const view = resolveView(url.searchParams.get("view")); const limit = resolveRawLimit(url.searchParams.get("limit")); const turnLimit = resolveTurnLimit(url.searchParams.get("turnLimit")); @@ -492,6 +518,10 @@ export async function GET( ); const includeTools = resolveBooleanQueryParam(url.searchParams.get("includeTools"), true); const snapshot = controlPlane.snapshot(); + const settings = loadStudioSettings(); + const gatewayUrl = settings.gateway?.url?.trim() ?? ""; + const gatewayToken = settings.gateway?.token?.trim() ?? ""; + const gatewayScope = `${gatewayUrl}\u001f${hashCacheSecret(gatewayToken)}`; let payload: HistorySelectionPayload; let cacheStatus: HistoryCacheStatus = "miss"; let cacheAgeMs: number | null = null; @@ -499,6 +529,7 @@ export async function GET( const result = await resolveHistorySelectionPayload({ controlPlane, agentId: normalizedAgentId, + gatewayScope, fallbackRevision: snapshot.outboxHead, sessionKey, view, diff --git a/src/app/api/runtime/agents/[agentId]/preview/route.ts b/src/app/api/runtime/agents/[agentId]/preview/route.ts index d05cb88..fe33bf6 100644 --- a/src/app/api/runtime/agents/[agentId]/preview/route.ts +++ b/src/app/api/runtime/agents/[agentId]/preview/route.ts @@ -5,6 +5,8 @@ import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors"; import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; import { extractText, stripUiMetadata } from "@/lib/text/message-extract"; +import { isSafeAgentId } from "@/lib/agents/agentIds"; +import { sessionKeyBelongsToAgent } from "@/lib/gateway/session-keys"; export const runtime = "nodejs"; @@ -106,6 +108,15 @@ export async function GET( request: Request, context: { params: Promise<{ agentId: string }> } ) { + const { agentId } = await context.params; + const normalizedAgentId = agentId.trim(); + if (!normalizedAgentId) { + return NextResponse.json({ error: "agentId is required." }, { status: 400 }); + } + if (!isSafeAgentId(normalizedAgentId)) { + return NextResponse.json({ error: `Invalid agentId: ${normalizedAgentId}` }, { status: 400 }); + } + const bootstrap = await bootstrapDomainRuntime(); if (bootstrap.kind === "mode-disabled") { return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 }); @@ -122,15 +133,15 @@ export async function GET( const controlPlane = bootstrap.runtime; const startError = bootstrap.kind === "start-failed" ? bootstrap.message : null; - const { agentId } = await context.params; - const normalizedAgentId = agentId.trim(); - if (!normalizedAgentId) { - return NextResponse.json({ error: "agentId is required." }, { status: 400 }); - } - const url = new URL(request.url); const sessionKeyRaw = (url.searchParams.get("sessionKey") ?? "").trim(); const sessionKey = sessionKeyRaw || `agent:${normalizedAgentId}:main`; + if (!sessionKeyBelongsToAgent(sessionKey, normalizedAgentId)) { + return NextResponse.json( + { error: "sessionKey does not match agentId." }, + { status: 400 } + ); + } const limit = resolveBoundedPositiveInt({ raw: url.searchParams.get("limit"), fallback: DEFAULT_LIMIT, @@ -161,7 +172,7 @@ export async function GET( previews.find((entry) => { const key = typeof entry?.key === "string" ? entry.key.trim() : ""; return key === sessionKey; - }) ?? previews[0]; + }) ?? null; const rawItems = Array.isArray(matched?.items) ? matched.items : []; return NextResponse.json({ diff --git a/src/app/api/runtime/fleet/route.ts b/src/app/api/runtime/fleet/route.ts index 01f9f32..39a7bd8 100644 --- a/src/app/api/runtime/fleet/route.ts +++ b/src/app/api/runtime/fleet/route.ts @@ -8,18 +8,19 @@ import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-err import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter"; import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap"; import { loadStudioSettings } from "@/lib/studio/settings-store"; +import { resolveSafeAgentId } from "@/lib/agents/agentIds"; +import { parseAgentIdFromSessionKey } from "@/lib/gateway/session-keys"; export const runtime = "nodejs"; const DEGRADED_FLEET_OUTBOX_SCAN_LIMIT = 5000; -const AGENT_SESSION_KEY_RE = /^agent:([^:]+):/i; const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object" && !Array.isArray(value)); const normalizeAgentId = (value: unknown): string => { - if (typeof value !== "string") return ""; - return value.trim().toLowerCase(); + const resolved = resolveSafeAgentId(value); + return resolved ? resolved.toLowerCase() : ""; }; const normalizeAgentName = (value: unknown): string => { @@ -27,10 +28,9 @@ const normalizeAgentName = (value: unknown): string => { return value.trim(); }; -const parseAgentIdFromSessionKey = (value: unknown): string => { +const normalizeSessionAgentId = (value: unknown): string => { if (typeof value !== "string") return ""; - const match = value.trim().match(AGENT_SESSION_KEY_RE); - return match?.[1]?.trim().toLowerCase() ?? ""; + return parseAgentIdFromSessionKey(value)?.toLowerCase() ?? ""; }; const resolveAgentIdentityFromOutboxEntry = ( @@ -42,9 +42,9 @@ const resolveAgentIdentityFromOutboxEntry = ( const directAgentId = normalizeAgentId(payload.agentId); const sessionAgentId = - parseAgentIdFromSessionKey(payload.sessionKey) || - parseAgentIdFromSessionKey(payload.key) || - parseAgentIdFromSessionKey(payload.runSessionKey); + normalizeSessionAgentId(payload.sessionKey) || + normalizeSessionAgentId(payload.key) || + normalizeSessionAgentId(payload.runSessionKey); const agentId = directAgentId || sessionAgentId; if (!agentId) return null; diff --git a/src/app/api/runtime/media/route.ts b/src/app/api/runtime/media/route.ts index bd48e1c..9480a77 100644 --- a/src/app/api/runtime/media/route.ts +++ b/src/app/api/runtime/media/route.ts @@ -6,6 +6,7 @@ import { resolveGatewaySshTargetFromGatewayUrl, runSshJson, } from "@/lib/ssh/gateway-host"; +import { resolveStateDir } from "@/lib/clawdbot/paths"; import { loadStudioSettings } from "@/lib/studio/settings-store"; import * as fs from "node:fs/promises"; import * as os from "node:os"; @@ -23,6 +24,8 @@ const MIME_BY_EXT: Record = { ".webp": "image/webp", }; +const ALLOWED_MEDIA_MIMES = new Set(Object.values(MIME_BY_EXT)); + const expandTildeLocal = (value: string): string => { const trimmed = value.trim(); if (trimmed === "~") return os.homedir(); @@ -43,7 +46,9 @@ const validateRawMediaPath = (raw: string): { trimmed: string; mime: string } => return { trimmed, mime }; }; -const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime: string } => { +const resolveAndValidateLocalMediaPath = ( + raw: string +): { resolved: string; allowedRoot: string; mime: string } => { const { trimmed, mime } = validateRawMediaPath(raw); const expanded = expandTildeLocal(trimmed); @@ -53,13 +58,13 @@ const resolveAndValidateLocalMediaPath = (raw: string): { resolved: string; mime const resolved = path.resolve(expanded); - const allowedRoot = path.join(os.homedir(), ".openclaw"); + const allowedRoot = path.resolve(resolveStateDir()); const allowedPrefix = `${allowedRoot}${path.sep}`; if (!(resolved === allowedRoot || resolved.startsWith(allowedPrefix))) { throw new Error(`Refusing to read media outside ${allowedRoot}`); } - return { resolved, mime }; + return { resolved, allowedRoot, mime }; }; const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: string } => { @@ -83,15 +88,38 @@ const validateRemoteMediaPath = (raw: string): { remotePath: string; mime: strin return { remotePath: trimmed, mime }; }; -const readLocalMedia = async (resolvedPath: string): Promise<{ bytes: Buffer; size: number }> => { - const stat = await fs.stat(resolvedPath); +const resolveExistingRealPath = async (candidate: string): Promise => { + try { + return await fs.realpath(candidate); + } catch { + return path.resolve(candidate); + } +}; + +const assertPathUnderRoot = (candidate: string, allowedRoot: string) => { + const allowedPrefix = allowedRoot.endsWith(path.sep) ? allowedRoot : `${allowedRoot}${path.sep}`; + if (candidate !== allowedRoot && !candidate.startsWith(allowedPrefix)) { + throw new Error(`Refusing to read media outside ${allowedRoot}`); + } +}; + +const readLocalMedia = async ( + resolvedPath: string, + allowedRoot: string +): Promise<{ bytes: Buffer; size: number }> => { + const [realAllowedRoot, realResolvedPath] = await Promise.all([ + resolveExistingRealPath(allowedRoot), + fs.realpath(resolvedPath), + ]); + assertPathUnderRoot(realResolvedPath, realAllowedRoot); + const stat = await fs.stat(realResolvedPath); if (!stat.isFile()) { throw new Error("path is not a file"); } if (stat.size > MAX_MEDIA_BYTES) { throw new Error(`media file too large (${stat.size} bytes)`); } - const buf = await fs.readFile(resolvedPath); + const buf = await fs.readFile(realResolvedPath); return { bytes: buf, size: stat.size }; }; @@ -149,11 +177,11 @@ PY `; const resolveSshTarget = (): string | null => { + const configured = resolveConfiguredSshTarget(process.env); + if (configured) return configured; const settings = loadStudioSettings(); const gatewayUrl = settings.gateway?.url ?? ""; if (isLocalGatewayUrl(gatewayUrl)) return null; - const configured = resolveConfiguredSshTarget(process.env); - if (configured) return configured; return resolveGatewaySshTargetFromGatewayUrl(gatewayUrl, process.env); }; @@ -165,8 +193,8 @@ export async function GET(request: Request) { const sshTarget = resolveSshTarget(); if (!sshTarget) { - const { resolved, mime } = resolveAndValidateLocalMediaPath(rawPath); - const { bytes, size } = await readLocalMedia(resolved); + const { resolved, allowedRoot, mime } = resolveAndValidateLocalMediaPath(rawPath); + const { bytes, size } = await readLocalMedia(resolved, allowedRoot); const body = new Blob([Uint8Array.from(bytes)], { type: mime }); return new Response(body, { headers: { @@ -199,7 +227,11 @@ export async function GET(request: Request) { } const buf = Buffer.from(b64, "base64"); - const responseMime = payload.mime || mime; + if (buf.length > MAX_MEDIA_BYTES) { + throw new Error(`media file too large (${buf.length} bytes)`); + } + const remoteMime = typeof payload.mime === "string" ? payload.mime : ""; + const responseMime = ALLOWED_MEDIA_MIMES.has(remoteMime) ? remoteMime : mime; const body = new Blob([Uint8Array.from(buf)], { type: responseMime }); return new Response(body, { diff --git a/src/app/api/runtime/stream/route.ts b/src/app/api/runtime/stream/route.ts index 8ef0bb9..ab25d0b 100644 --- a/src/app/api/runtime/stream/route.ts +++ b/src/app/api/runtime/stream/route.ts @@ -61,6 +61,7 @@ export async function GET(request: Request) { } const controlPlane = bootstrap.runtime; const lastSeenId = parseLastEventIdFromRequest(request); + let cleanupStream: () => void = () => {}; const stream = new ReadableStream({ start(controller) { @@ -70,20 +71,32 @@ export async function GET(request: Request) { let startupPhase = true; let lastDeliveredId = lastSeenId; const startupLiveBuffer: ControlPlaneOutboxEntry[] = []; - const close = () => { - if (closed) return; + const abortListener = () => { + close(); + }; + const cleanup = (): boolean => { + if (closed) return false; closed = true; + request.signal.removeEventListener("abort", abortListener); unsubscribe(); if (heartbeat) { clearInterval(heartbeat); heartbeat = null; } + cleanupStream = () => {}; + return true; + }; + const close = () => { + if (!cleanup()) return; try { controller.close(); } catch (err) { console.error("Failed to close runtime stream controller.", err); } }; + cleanupStream = () => { + cleanup(); + }; const enqueueFrame = (frame: Uint8Array): boolean => { if (closed) return false; try { @@ -106,6 +119,12 @@ export async function GET(request: Request) { return true; }; + request.signal.addEventListener("abort", abortListener, { once: true }); + if (request.signal.aborted) { + close(); + return; + } + unsubscribe = controlPlane.subscribe((entry) => { if (closed) { return; @@ -168,8 +187,9 @@ export async function GET(request: Request) { heartbeat = setInterval(() => { enqueueFrame(heartbeatFrame()); }, HEARTBEAT_INTERVAL_MS); - - request.signal.addEventListener("abort", close, { once: true }); + }, + cancel() { + cleanupStream(); }, }); diff --git a/src/app/api/studio/route.ts b/src/app/api/studio/route.ts index 87d83b7..e6955ce 100644 --- a/src/app/api/studio/route.ts +++ b/src/app/api/studio/route.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { NextResponse } from "next/server"; import { type StudioSettingsPatch } from "@/lib/studio/settings"; @@ -9,6 +11,7 @@ import { } from "@/lib/controlplane/runtime"; import { applyStudioSettingsPatch, + loadPersistedStudioSettings, loadLocalGatewayDefaults, loadStudioSettings, redactLocalGatewayDefaultsSecrets, @@ -19,7 +22,7 @@ import { detectInstallContext } from "../../../../server/install-context"; export const runtime = "nodejs"; const isPatch = (value: unknown): value is StudioSettingsPatch => - Boolean(value && typeof value === "object"); + Boolean(value && typeof value === "object" && !Array.isArray(value)); type RuntimeReconnectMetadata = { attempted: boolean; @@ -51,6 +54,16 @@ const hasGatewayConfiguration = (settings: ReturnType return Boolean(gateway.url && gateway.token); }; +const buildGatewayCredentialScope = (settings: ReturnType) => { + const gateway = normalizeGatewaySettings(settings); + if (!gateway.url || !gateway.token) return ""; + return createHash("sha256") + .update(gateway.url) + .update("\0") + .update(gateway.token) + .digest("hex"); +}; + const reconnectRuntimeForGatewaySettingsChange = async ( previous: ReturnType, next: ReturnType @@ -123,6 +136,7 @@ const reconnectRuntimeForGatewaySettingsChange = async ( const buildSettingsResponseBody = async (metadata?: RuntimeReconnectMetadata | null) => { const settings = loadStudioSettings(); + const persistedSettings = loadPersistedStudioSettings(); const localGatewayDefaults = loadLocalGatewayDefaults(); let installContext = defaultStudioInstallContext(); try { @@ -137,7 +151,8 @@ const buildSettingsResponseBody = async (metadata?: RuntimeReconnectMetadata | n hasToken: Boolean(localGatewayDefaults?.token?.trim()), }, gatewayMeta: { - hasStoredToken: Boolean(settings.gateway?.token?.trim()), + hasStoredToken: Boolean(persistedSettings.gateway?.token?.trim()), + credentialScope: buildGatewayCredentialScope(settings), }, installContext, domainApiModeEnabled: isStudioDomainApiModeEnabled(), @@ -156,11 +171,17 @@ export async function GET() { } export async function PUT(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON payload." }, { status: 400 }); + } + if (!isPatch(body)) { + return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 }); + } + try { - const body = (await request.json()) as unknown; - if (!isPatch(body)) { - return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 }); - } const previousSettings = loadStudioSettings(); const nextSettings = applyStudioSettingsPatch({ ...body, diff --git a/src/app/api/studio/test-connection/route.ts b/src/app/api/studio/test-connection/route.ts index 5f47765..c35d084 100644 --- a/src/app/api/studio/test-connection/route.ts +++ b/src/app/api/studio/test-connection/route.ts @@ -4,7 +4,7 @@ import { OpenClawGatewayAdapter, serializeControlPlaneGatewayConnectFailure, } from "@/lib/controlplane/openclaw-adapter"; -import { loadStudioSettings } from "@/lib/studio/settings-store"; +import { resolveGatewayTokenForUrl } from "@/lib/studio/settings-store"; export const runtime = "nodejs"; @@ -18,14 +18,16 @@ type TestConnectionRequestBody = { const readString = (value: unknown): string => (typeof value === "string" ? value.trim() : ""); -const resolveStoredToken = (): string => { - return readString(loadStudioSettings().gateway?.token); -}; - export async function POST(request: Request) { let adapter: OpenClawGatewayAdapter | null = null; + let body: TestConnectionRequestBody; + try { + body = (await request.json()) as TestConnectionRequestBody; + } catch { + return NextResponse.json({ ok: false, error: "Invalid JSON payload." }, { status: 400 }); + } + try { - const body = (await request.json()) as TestConnectionRequestBody; const url = readString(body?.gateway?.url); if (!url) { return NextResponse.json({ ok: false, error: "Gateway URL is required." }, { status: 400 }); @@ -33,7 +35,7 @@ export async function POST(request: Request) { const tokenInput = readString(body?.gateway?.token); const useStoredToken = body?.useStoredToken !== false; - const token = tokenInput || (useStoredToken ? resolveStoredToken() : ""); + const token = tokenInput || (useStoredToken ? resolveGatewayTokenForUrl(url) : ""); if (!token) { return NextResponse.json( { diff --git a/src/app/page.tsx b/src/app/page.tsx index b45cb75..f2a6b97 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -223,6 +223,7 @@ const AgentStudioPage = () => { localGatewayDefaults, localGatewayDefaultsHasToken, hasStoredToken, + gatewayCredentialScope, hasUnsavedChanges, installContext, statusReason, @@ -251,8 +252,15 @@ const AgentStudioPage = () => { const runtimeStreamResumeKey = useMemo(() => { const normalizedGatewayUrl = gatewayUrl.trim(); if (!normalizedGatewayUrl) return null; - return `domain:${normalizedGatewayUrl}`; - }, [gatewayUrl]); + const credentialScope = gatewayCredentialScope.trim(); + return `domain:${normalizedGatewayUrl}:${credentialScope || "anonymous"}`; + }, [gatewayCredentialScope, gatewayUrl]); + const runtimeHistoryCacheScope = useMemo(() => { + const normalizedGatewayUrl = gatewayUrl.trim(); + if (!normalizedGatewayUrl) return ""; + const credentialScope = gatewayCredentialScope.trim(); + return `${normalizedGatewayUrl}\u001f${credentialScope || "anonymous"}`; + }, [gatewayCredentialScope, gatewayUrl]); const runtimeWriteTransport = useMemo( () => createRuntimeWriteTransport({ @@ -842,10 +850,14 @@ const AgentStudioPage = () => { const { loadAgentHistory, loadMoreAgentHistory, clearHistoryInFlight } = useRuntimeSyncController({ status: coreStatus, - gatewayUrl, + gatewayUrl: runtimeHistoryCacheScope, agents, focusedAgentId, dispatch, + runtimeWriteTransport, + clearRunTracking: (runId) => { + runtimeEventHandlerRef.current?.clearRunTracking(runId); + }, isDisconnectLikeError: isGatewayDisconnectLikeError, }); @@ -1492,6 +1504,7 @@ const AgentStudioPage = () => { draftGatewayUrl={draftGatewayUrl} token={token} hasStoredToken={hasStoredToken} + localGatewayDefaults={localGatewayDefaults} localGatewayDefaultsHasToken={localGatewayDefaultsHasToken} hasUnsavedChanges={hasUnsavedChanges} status={gatewayStatus} diff --git a/src/features/agents/approvals/execApprovalEvents.ts b/src/features/agents/approvals/execApprovalEvents.ts index e130842..88e6f19 100644 --- a/src/features/agents/approvals/execApprovalEvents.ts +++ b/src/features/agents/approvals/execApprovalEvents.ts @@ -1,6 +1,8 @@ import type { AgentState } from "@/features/agents/state/store"; import type { EventFrame } from "@/lib/gateway/gateway-frames"; import type { ExecApprovalDecision } from "@/features/agents/approvals/types"; +import { resolveSafeAgentId } from "@/lib/agents/agentIds"; +import { resolveSafeSessionKey } from "@/lib/gateway/session-keys"; type RequestedPayload = { id: string; @@ -61,9 +63,9 @@ export const parseExecApprovalRequested = (event: EventFrame): RequestedPayload host: asOptionalString(request.host), security: asOptionalString(request.security), ask: asOptionalString(request.ask), - agentId: asOptionalString(request.agentId), + agentId: resolveSafeAgentId(request.agentId), resolvedPath: asOptionalString(request.resolvedPath), - sessionKey: asOptionalString(request.sessionKey), + sessionKey: resolveSafeSessionKey(request.sessionKey), }, createdAtMs, expiresAtMs, @@ -95,7 +97,8 @@ export const resolveExecApprovalAgentId = (params: { }): string | null => { const requestedAgentId = params.requested.request.agentId; if (requestedAgentId) { - return requestedAgentId; + const matchedByAgentId = params.agents.find((agent) => agent.agentId === requestedAgentId); + if (matchedByAgentId) return matchedByAgentId.agentId; } const requestedSessionKey = params.requested.request.sessionKey; if (!requestedSessionKey) return null; diff --git a/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts index 82ffe94..b13c5f2 100644 --- a/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts +++ b/src/features/agents/approvals/execApprovalLifecycleWorkflow.ts @@ -7,6 +7,8 @@ import { import type { AgentState } from "@/features/agents/state/store"; import type { EventFrame } from "@/lib/gateway/gateway-frames"; import { GatewayResponseError } from "@/lib/gateway/errors"; +import { resolveSafeAgentId } from "@/lib/agents/agentIds"; +import { resolveSafeSessionKey } from "@/lib/gateway/session-keys"; export type ExecApprovalEventEffects = { scopedUpserts: Array<{ agentId: string; approval: PendingExecApproval }>; @@ -96,19 +98,22 @@ export const resolveExecApprovalFollowUpIntent = (params: { if (!params.approval) { return NO_FOLLOW_UP_INTENT; } - const scopedAgentId = params.approval.agentId?.trim() ?? ""; + const scopedAgentId = resolveSafeAgentId(params.approval.agentId) ?? ""; + const scopedAgent = + scopedAgentId ? params.agents.find((agent) => agent.agentId === scopedAgentId) ?? null : null; + const approvalSessionKey = resolveSafeSessionKey(params.approval.sessionKey) ?? ""; const sessionAgentId = - params.approval.sessionKey?.trim() + approvalSessionKey ? (params.agents.find( - (agent) => agent.sessionKey.trim() === params.approval?.sessionKey?.trim() + (agent) => agent.sessionKey.trim() === approvalSessionKey )?.agentId ?? "") : ""; - const targetAgentId = scopedAgentId || sessionAgentId; + const targetAgentId = scopedAgent?.agentId ?? sessionAgentId; if (!targetAgentId) { return NO_FOLLOW_UP_INTENT; } const targetSessionKey = - params.approval.sessionKey?.trim() || + approvalSessionKey || params.agents.find((agent) => agent.agentId === targetAgentId)?.sessionKey?.trim() || ""; const followUpMessage = params.followUpMessage.trim(); diff --git a/src/features/agents/approvals/execApprovalResolveOperation.ts b/src/features/agents/approvals/execApprovalResolveOperation.ts index 7e550eb..8d3769e 100644 --- a/src/features/agents/approvals/execApprovalResolveOperation.ts +++ b/src/features/agents/approvals/execApprovalResolveOperation.ts @@ -6,6 +6,8 @@ import { } from "@/features/agents/approvals/pendingStore"; import { shouldTreatExecApprovalResolveErrorAsUnknownId } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport"; +import { resolveSafeAgentId } from "@/lib/agents/agentIds"; +import { resolveSafeSessionKey } from "@/lib/gateway/session-keys"; type SetState = (next: T | ((current: T) => T)) => void; @@ -50,13 +52,14 @@ export const resolveExecApprovalViaStudio = async (params: { const resolveApprovalTargetAgentId = (approval: PendingExecApproval | null): string | null => { if (!approval) return null; - const scopedAgentId = approval.agentId?.trim() ?? ""; - if (scopedAgentId) return scopedAgentId; - const scopedSessionKey = approval.sessionKey?.trim() ?? ""; + const agents = params.getAgents(); + const scopedAgentId = resolveSafeAgentId(approval.agentId) ?? ""; + if (scopedAgentId && agents.some((agent) => agent.agentId === scopedAgentId)) { + return scopedAgentId; + } + const scopedSessionKey = resolveSafeSessionKey(approval.sessionKey) ?? ""; if (!scopedSessionKey) return null; - const matched = params - .getAgents() - .find((agent) => agent.sessionKey.trim() === scopedSessionKey); + const matched = agents.find((agent) => agent.sessionKey.trim() === scopedSessionKey); return matched?.agentId ?? null; }; diff --git a/src/features/agents/components/AgentChatPanel.tsx b/src/features/agents/components/AgentChatPanel.tsx index a95978a..66668df 100644 --- a/src/features/agents/components/AgentChatPanel.tsx +++ b/src/features/agents/components/AgentChatPanel.tsx @@ -667,7 +667,12 @@ const AgentChatTranscript = memo(function AgentChatTranscript({ const chatRef = useRef(null); const scrollFrameRef = useRef(null); const pinnedRef = useRef(true); - const [isPinned, setIsPinned] = useState(true); + const [pinnedState, setPinnedState] = useState({ + key: scrollToBottomOnOpenKey, + value: true, + }); + const isPinned = + pinnedState.key === scrollToBottomOnOpenKey ? pinnedState.value : true; const [isAtTop, setIsAtTop] = useState(false); const [nowMs, setNowMs] = useState(null); @@ -678,10 +683,17 @@ const AgentChatTranscript = memo(function AgentChatTranscript({ }, []); const setPinned = useCallback((nextPinned: boolean) => { - if (pinnedRef.current === nextPinned) return; pinnedRef.current = nextPinned; - setIsPinned(nextPinned); - }, []); + setPinnedState((current) => { + if (current.key === scrollToBottomOnOpenKey && current.value === nextPinned) { + return current; + } + return { + key: scrollToBottomOnOpenKey, + value: nextPinned, + }; + }); + }, [scrollToBottomOnOpenKey]); const updatePinnedFromScroll = useCallback(() => { const el = chatRef.current; @@ -709,9 +721,9 @@ const AgentChatTranscript = memo(function AgentChatTranscript({ }, [scrollChatToBottom]); useEffect(() => { - setPinned(true); + pinnedRef.current = true; scheduleScrollToBottom(); - }, [scheduleScrollToBottom, scrollToBottomOnOpenKey, setPinned]); + }, [scheduleScrollToBottom, scrollToBottomOnOpenKey]); useEffect(() => { updatePinnedFromScroll(); diff --git a/src/features/agents/components/ConnectionPanel.tsx b/src/features/agents/components/ConnectionPanel.tsx index b7d030b..1e29004 100644 --- a/src/features/agents/components/ConnectionPanel.tsx +++ b/src/features/agents/components/ConnectionPanel.tsx @@ -1,4 +1,9 @@ import type { GatewayStatus } from "@/lib/gateway/gateway-status"; +import { + canUseLocalGatewayDefaultsForUrl, + normalizeGatewayUrl, + type StudioGatewaySettings, +} from "@/lib/studio/settings"; import { X } from "lucide-react"; import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics"; @@ -7,6 +12,7 @@ type ConnectionPanelProps = { draftGatewayUrl: string; token: string; hasStoredToken: boolean; + localGatewayDefaults: StudioGatewaySettings | null; localGatewayDefaultsHasToken: boolean; hasUnsavedChanges: boolean; status: GatewayStatus; @@ -34,6 +40,7 @@ export const ConnectionPanel = ({ draftGatewayUrl, token, hasStoredToken, + localGatewayDefaults, localGatewayDefaultsHasToken, hasUnsavedChanges, status, @@ -51,10 +58,16 @@ export const ConnectionPanel = ({ onClose, }: ConnectionPanelProps) => { const actionBusy = saving || testing || disconnecting; - const tokenHelper = hasStoredToken + const localGatewayDefaultsApplyToDraft = + localGatewayDefaultsHasToken && + canUseLocalGatewayDefaultsForUrl(draftGatewayUrl || savedGatewayUrl, localGatewayDefaults?.url); + const storedTokenAppliesToDraft = + hasStoredToken && + normalizeGatewayUrl(draftGatewayUrl || savedGatewayUrl) === normalizeGatewayUrl(savedGatewayUrl); + const tokenHelper = storedTokenAppliesToDraft ? "Stored token available on this Studio host. Leave blank to keep it." - : localGatewayDefaultsHasToken - ? "A local OpenClaw token is available on this host. Leave blank to use it." + : localGatewayDefaultsApplyToDraft + ? "A local OpenClaw token is available for this localhost gateway. Leave blank to use it." : "Enter the token Studio should use for this upstream."; return ( diff --git a/src/features/agents/components/GatewayConnectScreen.tsx b/src/features/agents/components/GatewayConnectScreen.tsx index 971ed08..ed7d0c8 100644 --- a/src/features/agents/components/GatewayConnectScreen.tsx +++ b/src/features/agents/components/GatewayConnectScreen.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react"; import type { GatewayStatus } from "@/lib/gateway/gateway-status"; import { @@ -9,7 +9,11 @@ import { type StudioInstallContext, type StudioSetupScenario, } from "@/lib/studio/install-context"; -import type { StudioGatewaySettings } from "@/lib/studio/settings"; +import { + canUseLocalGatewayDefaultsForUrl, + normalizeGatewayUrl, + type StudioGatewaySettings, +} from "@/lib/studio/settings"; import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics"; type GatewayConnectScreenProps = { @@ -83,12 +87,9 @@ export const GatewayConnectScreen = ({ }), [draftGatewayUrl, installContext, savedGatewayUrl] ); - const [selectedScenario, setSelectedScenario] = useState(inferredScenario); - const [scenarioTouched, setScenarioTouched] = useState(false); - useEffect(() => { - if (scenarioTouched) return; - setSelectedScenario(inferredScenario); - }, [inferredScenario, scenarioTouched]); + const [selectedScenarioOverride, setSelectedScenarioOverride] = + useState(null); + const selectedScenario = selectedScenarioOverride ?? inferredScenario; const localPort = useMemo( () => resolveLocalGatewayPort(draftGatewayUrl || savedGatewayUrl), [draftGatewayUrl, savedGatewayUrl] @@ -97,6 +98,12 @@ export const GatewayConnectScreen = ({ () => `openclaw gateway --port ${localPort}`, [localPort] ); + const localGatewayDefaultsApplyToDraft = + localGatewayDefaultsHasToken && + canUseLocalGatewayDefaultsForUrl(draftGatewayUrl || savedGatewayUrl, localGatewayDefaults?.url); + const storedTokenAppliesToDraft = + hasStoredToken && + normalizeGatewayUrl(draftGatewayUrl || savedGatewayUrl) === normalizeGatewayUrl(savedGatewayUrl); const gatewayServeCommand = useMemo( () => `tailscale serve --yes --bg --https 443 http://127.0.0.1:${localPort}`, [localPort] @@ -117,15 +124,15 @@ export const GatewayConnectScreen = ({ gatewayUrl: draftGatewayUrl, installContext, scenario: selectedScenario, - hasStoredToken, - hasLocalGatewayToken: localGatewayDefaultsHasToken, + hasStoredToken: storedTokenAppliesToDraft, + hasLocalGatewayToken: localGatewayDefaultsApplyToDraft, }), [ draftGatewayUrl, - hasStoredToken, installContext, - localGatewayDefaultsHasToken, + localGatewayDefaultsApplyToDraft, selectedScenario, + storedTokenAppliesToDraft, ] ); const studioCliUpdateWarning = useMemo(() => { @@ -174,16 +181,15 @@ export const GatewayConnectScreen = ({ : status === "connecting" || status === "reconnecting" ? "ui-dot-status-connecting" : "ui-dot-status-disconnected"; - const tokenHelper = hasStoredToken + const tokenHelper = storedTokenAppliesToDraft ? "A token is already stored on this Studio host. Leave this blank to keep it." - : localGatewayDefaultsHasToken - ? "A local OpenClaw token is available on this host. Leave this blank to use it." + : localGatewayDefaultsApplyToDraft + ? "A local OpenClaw token is available for this localhost gateway. Leave this blank to use it." : "Enter the gateway token Studio should use."; const remoteStudio = isStudioLikelyRemote(installContext); const setScenario = (value: StudioSetupScenario) => { - setScenarioTouched(true); - setSelectedScenario(value); + setSelectedScenarioOverride(value); }; const applyLoopbackUrl = () => { @@ -291,7 +297,11 @@ export const GatewayConnectScreen = ({ type={showToken ? "text" : "password"} value={token} onChange={(event) => onTokenChange(event.target.value)} - placeholder={hasStoredToken || localGatewayDefaultsHasToken ? "keep existing token" : "gateway token"} + placeholder={ + storedTokenAppliesToDraft || localGatewayDefaultsApplyToDraft + ? "keep existing token" + : "gateway token" + } spellCheck={false} />