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.
This commit is contained in:
George Pickett
2026-06-22 10:06:22 -07:00
parent 49bb80f1a0
commit 732b994120
117 changed files with 6022 additions and 676 deletions
+1
View File
@@ -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/**",
+8 -3
View File
@@ -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: "",
},
+36 -8
View File
@@ -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) {
+2 -6
View File
@@ -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;
});
+22 -21
View File
@@ -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 };
+16 -2
View File
@@ -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());
+2 -2
View File
@@ -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),
]);
+61 -4
View File
@@ -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,
};
+18 -2
View File
@@ -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<string, unknown> =>
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") {
@@ -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 });
}
@@ -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,
@@ -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<string, unknown> =>
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<string, unknown>,
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<string, unknown>)
: 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<string, unknown>;
snapshotHash?: string;
snapshotExists?: boolean;
overrides: GatewayAgentToolsOverrides;
toolGroups: ToolGroupOverrideInput;
attempt?: number;
}): Promise<void> => {
}): 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<string, unknown>)
: {};
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<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? (snapshot.config as Record<string, unknown>) : {};
const list = readConfigAgentList(baseConfig);
const configEntry = list.find((entry) => entry.id === agentId) ?? null;
const sandboxRaw =
configEntry && isRecord(configEntry.sandbox) ? (configEntry.sandbox as Record<string, unknown>) : 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 });
@@ -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 });
}
+11 -1
View File
@@ -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),
});
}
+7 -1
View File
@@ -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 });
}
+7 -1
View File
@@ -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() : "";
+18 -1
View File
@@ -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 } : {}),
});
}
+52 -27
View File
@@ -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<string, unknown> =>
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({
+22 -3
View File
@@ -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) {
@@ -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 });
}
+6 -1
View File
@@ -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 });
}
+8
View File
@@ -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,
+29 -13
View File
@@ -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<Record<string, unknown> | 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<TrashAgentStateRequest>;
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<string, unknown>;
};
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<TrashAgentStateRequest>;
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<RestoreAgentStateRequest>;
const { agentId, trashDir } = bodyOrError as Partial<RestoreAgentStateRequest>;
const trimmedAgent = typeof agentId === "string" ? agentId.trim() : "";
const trimmedTrash = typeof trashDir === "string" ? trashDir.trim() : "";
if (!trimmedAgent) {
@@ -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<string, unknown>) => {
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,
@@ -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({
+9 -9
View File
@@ -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<string, unknown> =>
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;
+43 -11
View File
@@ -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<string, string> = {
".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<string> => {
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, {
+24 -4
View File
@@ -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<Uint8Array>({
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();
},
});
+27 -6
View File
@@ -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<typeof loadStudioSettings>
return Boolean(gateway.url && gateway.token);
};
const buildGatewayCredentialScope = (settings: ReturnType<typeof loadStudioSettings>) => {
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<typeof loadStudioSettings>,
next: ReturnType<typeof loadStudioSettings>
@@ -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,
+9 -7
View File
@@ -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(
{
+16 -3
View File
@@ -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}
@@ -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;
@@ -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();
@@ -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<T> = (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;
};
@@ -667,7 +667,12 @@ const AgentChatTranscript = memo(function AgentChatTranscript({
const chatRef = useRef<HTMLDivElement | null>(null);
const scrollFrameRef = useRef<number | null>(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<number | null>(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();
@@ -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 (
@@ -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<StudioSetupScenario>(inferredScenario);
const [scenarioTouched, setScenarioTouched] = useState(false);
useEffect(() => {
if (scenarioTouched) return;
setSelectedScenario(inferredScenario);
}, [inferredScenario, scenarioTouched]);
const [selectedScenarioOverride, setSelectedScenarioOverride] =
useState<StudioSetupScenario | null>(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}
/>
<button
@@ -7,7 +7,10 @@ import {
type SummaryStatusSnapshot,
} from "@/features/agents/state/runtimeEventBridge";
import type { AgentStoreSeed } from "@/features/agents/state/store";
import { deriveHydrateAgentFleetResult } from "@/features/agents/operations/agentFleetHydrationDerivation";
import {
deriveHydrateAgentFleetResult,
normalizeAgentsListResultForHydration,
} from "@/features/agents/operations/agentFleetHydrationDerivation";
type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
@@ -127,7 +130,8 @@ export async function hydrateAgentFleetFromGateway(params: {
}
}
const agentsResult = await callGateway<AgentsListResult>(params.client, "agents.list", {});
const agentsResultRaw = await callGateway<AgentsListResult>(params.client, "agents.list", {});
const agentsResult = normalizeAgentsListResultForHydration(agentsResultRaw);
const mainKey = agentsResult.mainKey?.trim() || "main";
const mainSessionKeyByAgent = new Map<string, SessionsListEntry | null>();
@@ -1,6 +1,7 @@
import { buildAgentMainSessionKey } from "@/lib/gateway/session-keys";
import { resolveConfiguredModelKey, type GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import { resolveAgentAvatarSeed, type StudioSettings } from "@/lib/studio/settings";
import { resolveSafeAgentId } from "@/lib/agents/agentIds";
import {
buildSummarySnapshotPatches,
type SummaryPreviewSnapshot,
@@ -10,7 +11,7 @@ import {
} from "@/features/agents/state/runtimeEventBridge";
import type { AgentStoreSeed } from "@/features/agents/state/store";
type AgentsListResult = {
export type AgentsListResult = {
defaultId: string;
mainKey: string;
scope?: string;
@@ -60,6 +61,26 @@ type SandboxMode = "off" | "non-main" | "all";
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
export const normalizeAgentsListResultForHydration = (
agentsResult: AgentsListResult
): AgentsListResult => {
const seenAgentIds = new Set<string>();
const agents: AgentsListResult["agents"] = [];
for (const agent of agentsResult.agents) {
const agentId = resolveSafeAgentId(agent.id);
if (!agentId) continue;
const dedupeKey = agentId.toLowerCase();
if (seenAgentIds.has(dedupeKey)) continue;
seenAgentIds.add(dedupeKey);
agents.push(agentId === agent.id ? agent : { ...agent, id: agentId });
}
return {
...agentsResult,
defaultId: resolveSafeAgentId(agentsResult.defaultId) ?? "",
agents,
};
};
const resolveAgentSandboxMode = (
agentId: string,
snapshot: GatewayModelPolicySnapshot | null
@@ -182,9 +203,12 @@ type DerivedHydrateAgentFleetResult = {
export const deriveHydrateAgentFleetResult = (
input: DeriveFleetHydrationInput
): DerivedHydrateAgentFleetResult => {
const agentsResult = normalizeAgentsListResultForHydration(input.agentsResult);
const execPolicyByAgentId = new Map<string, ExecPolicyEntry>();
const execAgents = input.execApprovalsSnapshot?.file?.agents ?? {};
for (const [agentId, entry] of Object.entries(execAgents)) {
for (const [agentIdRaw, entry] of Object.entries(execAgents)) {
const agentId = resolveSafeAgentId(agentIdRaw);
if (!agentId) continue;
const normalizedSecurity = normalizeExecSecurity(entry?.security);
const normalizedAsk = normalizeExecAsk(entry?.ask);
if (!normalizedSecurity && !normalizedAsk) continue;
@@ -194,11 +218,11 @@ export const deriveHydrateAgentFleetResult = (
});
}
const mainKey = input.agentsResult.mainKey?.trim() || "main";
const mainKey = agentsResult.mainKey?.trim() || "main";
const gatewayKey = input.gatewayUrl.trim();
const needsSessionSettingsSync = new Set<string>();
const seeds: AgentStoreSeed[] = input.agentsResult.agents.map((agent) => {
const seeds: AgentStoreSeed[] = agentsResult.agents.map((agent) => {
const persistedSeed =
input.settings && gatewayKey ? resolveAgentAvatarSeed(input.settings, gatewayKey, agent.id) : null;
const avatarSeed = persistedSeed ?? agent.id;
@@ -5,23 +5,7 @@ import {
resolveReconcileWaitOutcome,
} from "@/features/agents/operations/fleetLifecycleWorkflow";
type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
};
const callGateway = async <T>(
client: GatewayClientLike,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Gateway call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
type AgentRunWaiter = (params: { runId: string; timeoutMs: number }) => Promise<unknown>;
type ReconcileCommand =
| { kind: "clearRunTracking"; runId: string }
@@ -72,7 +56,7 @@ export const executeAgentReconcileCommands = (params: {
};
export const runAgentReconcileOperation = async (params: {
client: GatewayClientLike;
waitForAgentRun: AgentRunWaiter;
agents: AgentState[];
getLatestAgent: (agentId: string) => AgentState | null;
claimRunId: (runId: string) => boolean;
@@ -95,10 +79,10 @@ export const runAgentReconcileOperation = async (params: {
if (!params.claimRunId(runId)) continue;
try {
const result = await callGateway<{ status?: unknown }>(params.client, "agent.wait", {
const result = (await params.waitForAgentRun({
runId,
timeoutMs: 1,
});
})) as { status?: unknown } | null;
const outcome = resolveReconcileWaitOutcome(result?.status);
if (!outcome) {
continue;
@@ -1,7 +1,10 @@
import { postStudioIntent } from "@/lib/controlplane/intents-client";
import type { ExecApprovalDecision } from "@/features/agents/approvals/types";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { syncGatewaySessionSettings } from "@/lib/gateway/session-settings-sync";
import { createGatewayAgent, deleteGatewayAgent, renameGatewayAgent } from "@/lib/gateway/agentConfig";
import { hasMalformedAgentSessionKey } from "@/lib/gateway/session-keys";
import { resolveSafeAgentId } from "@/lib/agents/agentIds";
import {
readGatewayAgentExecApprovals,
upsertGatewayAgentExecApprovals,
@@ -30,7 +33,7 @@ export type RuntimeWriteTransport = {
sessionsReset: (params: { key: string }) => Promise<void>;
agentRename: (params: { agentId: string; name: string }) => Promise<void>;
agentDelete: (params: { agentId: string }) => Promise<void>;
execApprovalResolve: (params: { id: string; decision: string }) => Promise<void>;
execApprovalResolve: (params: { id: string; decision: ExecApprovalDecision }) => Promise<void>;
execApprovalsSet: (params: { agentId: string; role: RuntimeWriteExecutionRole }) => Promise<void>;
agentPermissionsUpdate: (params: {
agentId: string;
@@ -39,7 +42,7 @@ export type RuntimeWriteTransport = {
webAccess: boolean;
fileTools: boolean;
}) => Promise<void>;
agentWait: (params: { runId: string; timeoutMs?: number }) => Promise<void>;
agentWait: (params: { runId: string; timeoutMs?: number }) => Promise<unknown>;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -53,6 +56,22 @@ const requireNonEmpty = (value: string, fieldLabel: string): string => {
return trimmed;
};
const requireSafeSessionKey = (value: string): string => {
const sessionKey = requireNonEmpty(value, "Session key");
if (hasMalformedAgentSessionKey(sessionKey)) {
throw new Error("Invalid sessionKey.");
}
return sessionKey;
};
const resolveExecApprovalDecision = (value: string): ExecApprovalDecision => {
const decision = value.trim();
if (decision === "allow-once" || decision === "allow-always" || decision === "deny") {
return decision;
}
throw new Error("Exec approval decision must be allow-once, allow-always, or deny.");
};
const callLegacyGateway = async <T>(
client: GatewayClient,
method: string,
@@ -101,7 +120,7 @@ export function createRuntimeWriteTransport(params: {
return {
useDomainIntents: params.useDomainIntents,
chatSend: async (input) => {
const normalizedSessionKey = requireNonEmpty(input.sessionKey, "Session key");
const normalizedSessionKey = requireSafeSessionKey(input.sessionKey);
const normalizedIdempotencyKey = requireNonEmpty(input.idempotencyKey, "Idempotency key");
const payload = {
...input,
@@ -122,7 +141,7 @@ export function createRuntimeWriteTransport(params: {
execSecurity,
execAsk,
}) => {
const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key");
const normalizedSessionKey = requireSafeSessionKey(sessionKey);
const includeModel = model !== undefined;
const includeThinkingLevel = thinkingLevel !== undefined;
const includeExecHost = execHost !== undefined;
@@ -164,9 +183,9 @@ export function createRuntimeWriteTransport(params: {
const payload = unwrapIntentPayload<{ agentId?: unknown; name?: unknown }>(
await postIntent("/api/intents/agent-create", { name: normalizedName })
);
const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : "";
const agentId = resolveSafeAgentId(payload?.agentId) ?? "";
if (!agentId) {
throw new Error("Agent create response missing agentId.");
throw new Error("Agent create response missing or invalid agentId.");
}
const resolvedName =
typeof payload?.name === "string" && payload.name.trim()
@@ -185,7 +204,7 @@ export function createRuntimeWriteTransport(params: {
return { id: created.id, name: createdName };
},
chatAbort: async ({ sessionKey, runId }) => {
const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key");
const normalizedSessionKey = requireSafeSessionKey(sessionKey);
const normalizedRunId = typeof runId === "string" ? runId.trim() : "";
const payload = normalizedRunId
? { sessionKey: normalizedSessionKey, runId: normalizedRunId }
@@ -197,7 +216,7 @@ export function createRuntimeWriteTransport(params: {
await callLegacyGateway(params.client, "chat.abort", payload);
},
sessionsReset: async ({ key }) => {
const normalizedSessionKey = requireNonEmpty(key, "Session key");
const normalizedSessionKey = requireSafeSessionKey(key);
if (params.useDomainIntents) {
await postIntent("/api/intents/sessions-reset", { key: normalizedSessionKey });
return;
@@ -230,13 +249,17 @@ export function createRuntimeWriteTransport(params: {
},
execApprovalResolve: async ({ id, decision }) => {
const normalizedId = requireNonEmpty(id, "Approval id");
const normalizedDecision = resolveExecApprovalDecision(decision);
if (params.useDomainIntents) {
await postIntent("/api/intents/exec-approval-resolve", { id: normalizedId, decision });
await postIntent("/api/intents/exec-approval-resolve", {
id: normalizedId,
decision: normalizedDecision,
});
return;
}
await callLegacyGateway(params.client, "exec.approval.resolve", {
id: normalizedId,
decision,
decision: normalizedDecision,
});
},
execApprovalsSet: async ({ agentId, role }) => {
@@ -263,7 +286,7 @@ export function createRuntimeWriteTransport(params: {
},
agentPermissionsUpdate: async ({ agentId, sessionKey, commandMode, webAccess, fileTools }) => {
const normalizedAgentId = requireNonEmpty(agentId, "Agent id");
const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key");
const normalizedSessionKey = requireSafeSessionKey(sessionKey);
if (params.useDomainIntents) {
await postIntent("/api/intents/agent-permissions-update", {
agentId: normalizedAgentId,
@@ -279,13 +302,14 @@ export function createRuntimeWriteTransport(params: {
agentWait: async ({ runId, timeoutMs }) => {
const normalizedRunId = requireNonEmpty(runId, "Run id");
if (params.useDomainIntents) {
await postIntent("/api/intents/agent-wait", {
runId: normalizedRunId,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
});
return;
return unwrapIntentPayload<unknown>(
await postIntent("/api/intents/agent-wait", {
runId: normalizedRunId,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
})
);
}
await callLegacyGateway(params.client, "agent.wait", {
return await callLegacyGateway(params.client, "agent.wait", {
runId: normalizedRunId,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
});
@@ -58,7 +58,17 @@ type SpecialLatestUpdateOperation = {
export function createSpecialLatestUpdateOperation(
deps: SpecialLatestUpdateDeps
): SpecialLatestUpdateOperation {
const inFlight = new Set<string>();
const inFlightTokenByAgent = new Map<string, number>();
let nextInFlightToken = 1;
const dispatchIfCurrent = (
agentId: string,
token: number,
patch: { latestOverride: string | null; latestOverrideKind: "heartbeat" | "cron" | null }
) => {
if (inFlightTokenByAgent.get(agentId) !== token) return;
deps.dispatchUpdateAgent(agentId, patch);
};
const update: SpecialLatestUpdateOperation["update"] = async (agentId, agent, message) => {
const intent = resolveLatestUpdateIntent({
@@ -69,13 +79,16 @@ export function createSpecialLatestUpdateOperation(
});
if (intent.kind === "noop") return;
if (intent.kind === "reset") {
inFlightTokenByAgent.delete(agentId);
deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(""));
return;
}
const key = agentId;
if (inFlight.has(key)) return;
inFlight.add(key);
if (inFlightTokenByAgent.has(key)) return;
const token = nextInFlightToken;
nextInFlightToken += 1;
inFlightTokenByAgent.set(key, token);
try {
if (intent.kind === "fetch-heartbeat") {
@@ -86,7 +99,7 @@ export function createSpecialLatestUpdateOperation(
limit: intent.historyLimit,
});
const content = findLatestHeartbeatResponse(history.messages) ?? "";
deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(content, "heartbeat"));
dispatchIfCurrent(agent.agentId, token, buildLatestUpdatePatch(content, "heartbeat"));
return;
}
@@ -94,7 +107,7 @@ export function createSpecialLatestUpdateOperation(
const cronResult = await deps.listCronJobs();
const job = deps.resolveCronJobForAgent(cronResult.jobs, intent.agentId);
const content = job ? deps.formatCronJobDisplay(job) : "";
deps.dispatchUpdateAgent(agent.agentId, buildLatestUpdatePatch(content, "cron"));
dispatchIfCurrent(agent.agentId, token, buildLatestUpdatePatch(content, "cron"));
}
} catch (err) {
if (!deps.isDisconnectLikeError(err)) {
@@ -103,12 +116,14 @@ export function createSpecialLatestUpdateOperation(
deps.logError(message);
}
} finally {
inFlight.delete(key);
if (inFlightTokenByAgent.get(key) === token) {
inFlightTokenByAgent.delete(key);
}
}
};
const clearInFlight: SpecialLatestUpdateOperation["clearInFlight"] = (agentId) => {
inFlight.delete(agentId);
inFlightTokenByAgent.delete(agentId);
};
return { update, clearInFlight };
@@ -71,9 +71,10 @@ export function useChatInteractionController(
const flushPendingDraft = useCallback(
(agentId: string | null) => {
const hasPendingValue = Boolean(agentId && pendingDraftValuesRef.current.has(agentId));
const key = agentId?.trim() ?? "";
const hasPendingValue = Boolean(key && pendingDraftValuesRef.current.has(key));
const flushIntent = planDraftFlushIntent({
agentId,
agentId: key || null,
hasPendingValue,
});
if (flushIntent.kind !== "flush") return;
@@ -150,39 +151,52 @@ export function useChatInteractionController(
}
}, []);
const discardPendingDraft = useCallback((agentId: string) => {
const key = agentId.trim();
if (!key) return;
const timer = pendingDraftTimersRef.current.get(key) ?? null;
if (timer !== null) {
window.clearTimeout(timer);
pendingDraftTimersRef.current.delete(key);
}
pendingDraftValuesRef.current.delete(key);
}, []);
const handleDraftChange = useCallback(
(agentId: string, value: string) => {
pendingDraftValuesRef.current.set(agentId, value);
const existingTimer = pendingDraftTimersRef.current.get(agentId) ?? null;
const key = agentId.trim();
if (!key) return;
pendingDraftValuesRef.current.set(key, value);
const existingTimer = pendingDraftTimersRef.current.get(key) ?? null;
if (existingTimer !== null) {
window.clearTimeout(existingTimer);
}
const timerIntent = planDraftTimerIntent({
agentId,
agentId: key,
delayMs: params.draftDebounceMs,
});
if (timerIntent.kind !== "schedule") {
pendingDraftTimersRef.current.delete(agentId);
pendingDraftTimersRef.current.delete(key);
return;
}
const timer = window.setTimeout(() => {
pendingDraftTimersRef.current.delete(agentId);
const pendingValue = pendingDraftValuesRef.current.get(agentId);
pendingDraftTimersRef.current.delete(key);
const pendingValue = pendingDraftValuesRef.current.get(key);
const flushIntent = planDraftFlushIntent({
agentId,
agentId: key,
hasPendingValue: pendingValue !== undefined,
});
if (flushIntent.kind !== "flush" || pendingValue === undefined) return;
pendingDraftValuesRef.current.delete(agentId);
pendingDraftValuesRef.current.delete(key);
params.dispatch({
type: "updateAgent",
agentId,
agentId: key,
patch: { draft: pendingValue },
});
}, timerIntent.delayMs);
pendingDraftTimersRef.current.set(agentId, timer);
pendingDraftTimersRef.current.set(key, timer);
},
[params]
);
@@ -191,12 +205,7 @@ export function useChatInteractionController(
async (agentId: string, sessionKey: string, message: string) => {
const trimmed = message.trim();
if (!trimmed) return;
const pendingDraftTimer = pendingDraftTimersRef.current.get(agentId) ?? null;
if (pendingDraftTimer !== null) {
window.clearTimeout(pendingDraftTimer);
pendingDraftTimersRef.current.delete(agentId);
}
pendingDraftValuesRef.current.delete(agentId);
discardPendingDraft(agentId);
const agent =
params.agents.find((entry) => entry.agentId === agentId) ??
params.getAgents().find((entry) => entry.agentId === agentId) ??
@@ -230,7 +239,7 @@ export function useChatInteractionController(
clearRunTracking: (runId) => params.clearRunTracking(runId),
});
},
[clearPendingLivePatch, params]
[clearPendingLivePatch, discardPendingDraft, params]
);
const removeQueuedMessage = useCallback(
@@ -359,6 +368,8 @@ export function useChatInteractionController(
key: newSessionIntent.sessionKey,
});
const patch = buildNewSessionAgentPatch(agent);
discardPendingDraft(agentId);
clearPendingLivePatch(agentId);
params.clearRunTracking(agent.runId);
params.clearHistoryInFlight(newSessionIntent.sessionKey);
params.clearSpecialUpdateMarker(agentId);
@@ -380,7 +391,7 @@ export function useChatInteractionController(
});
}
},
[params]
[clearPendingLivePatch, discardPendingDraft, params]
);
return {
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useRef } from "react";
import { hydrateDomainHistoryWindow } from "@/features/agents/operations/domainHistoryHydration";
import {
executeAgentReconcileCommands,
runAgentReconcileOperation,
} from "@/features/agents/operations/agentReconcileOperation";
import {
buildCachedHistoryWindowKey,
readCachedHistoryWindow,
@@ -11,7 +15,9 @@ import {
RUNTIME_SYNC_MAX_HISTORY_LIMIT,
resolveRuntimeSyncBootstrapHistoryAgentIds,
resolveRuntimeSyncLoadMoreHistoryLimit,
resolveRuntimeSyncReconcilePollingIntent,
} from "@/features/agents/operations/runtimeSyncControlWorkflow";
import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
import type { AgentState } from "@/features/agents/state/store";
import { logTranscriptDebugMetric } from "@/features/agents/state/transcript";
import {
@@ -36,6 +42,8 @@ type UseRuntimeSyncControllerParams = {
agents: AgentState[];
focusedAgentId: string | null;
dispatch: (action: RuntimeSyncDispatchAction) => void;
runtimeWriteTransport: Pick<RuntimeWriteTransport, "agentWait">;
clearRunTracking: (runId: string) => void;
isDisconnectLikeError: (error: unknown) => boolean;
defaultHistoryLimit?: number;
maxHistoryLimit?: number;
@@ -115,18 +123,26 @@ const resolveScanLimitForReason = (params: {
return Math.min(params.maxHistoryLimit, Math.max(floor, params.requestedLimit * 3));
};
const HISTORY_MEMORY_CACHE_KEY_SEPARATOR = "\u001f";
const buildHistoryMemoryCacheKey = (params: {
gatewayUrl: string;
sessionKey: string;
sessionEpoch: number;
includeTraceHistory: boolean;
includeTools: boolean;
}): string => {
return [
params.gatewayUrl.trim(),
params.sessionKey.trim(),
String(normalizeSessionEpoch(params.sessionEpoch)),
params.includeTraceHistory ? "trace:1" : "trace:0",
params.includeTools ? "tools:1" : "tools:0",
].join("\u001f");
].join(HISTORY_MEMORY_CACHE_KEY_SEPARATOR);
};
const historyMemoryCacheKeyMatchesSession = (key: string, sessionKey: string): boolean => {
return key.split(HISTORY_MEMORY_CACHE_KEY_SEPARATOR)[1] === sessionKey;
};
const resolveVisibleHistoryLimit = (params: {
@@ -185,6 +201,8 @@ export function useRuntimeSyncController(
agents,
focusedAgentId,
dispatch,
runtimeWriteTransport,
clearRunTracking,
isDisconnectLikeError,
} = params;
const agentsRef = useRef(agents);
@@ -196,6 +214,7 @@ export function useRuntimeSyncController(
const historyPrefetchRef = useRef<Map<string, ScheduledPrefetchEntry>>(new Map());
const previewInFlightRef = useRef<Set<string>>(new Set());
const previewBootstrapAttemptedRef = useRef<Set<string>>(new Set());
const reconcileRunIdsInFlightRef = useRef<Set<string>>(new Set());
const defaultHistoryLimit = params.defaultHistoryLimit ?? RUNTIME_SYNC_DEFAULT_HISTORY_LIMIT;
const maxHistoryLimit = params.maxHistoryLimit ?? RUNTIME_SYNC_MAX_HISTORY_LIMIT;
@@ -208,7 +227,7 @@ export function useRuntimeSyncController(
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) return;
for (const key of historyCacheRef.current.keys()) {
if (!key.startsWith(`${normalizedSessionKey}\u001f`)) continue;
if (!historyMemoryCacheKeyMatchesSession(key, normalizedSessionKey)) continue;
historyCacheRef.current.delete(key);
}
}, []);
@@ -351,6 +370,7 @@ export function useRuntimeSyncController(
const requestedLimit = Math.max(1, Math.min(maxHistoryLimit, requestedLimitRaw));
const includeTraceHistory = targetAgent.showThinkingTraces === true;
const memoryCacheKey = buildHistoryMemoryCacheKey({
gatewayUrl,
sessionKey,
sessionEpoch: normalizeSessionEpoch(targetAgent.sessionEpoch),
includeTraceHistory,
@@ -675,14 +695,68 @@ export function useRuntimeSyncController(
);
const reconcileRunningAgents = useCallback(async () => {
return;
}, []);
if (status !== "connected") return;
const commands = await runAgentReconcileOperation({
waitForAgentRun: runtimeWriteTransport.agentWait,
agents: agentsRef.current,
getLatestAgent: (agentId) =>
agentsRef.current.find((entry) => entry.agentId === agentId) ?? null,
claimRunId: (runId) => {
if (reconcileRunIdsInFlightRef.current.has(runId)) return false;
reconcileRunIdsInFlightRef.current.add(runId);
return true;
},
releaseRunId: (runId) => {
reconcileRunIdsInFlightRef.current.delete(runId);
},
isDisconnectLikeError,
});
executeAgentReconcileCommands({
commands,
dispatch,
clearRunTracking,
requestHistoryRefresh: (agentId) => {
void loadAgentHistory(agentId, { reason: "refresh" });
},
logInfo: (message) => console.info(message),
logWarn: (message, error) => console.warn(message, error),
});
}, [
clearRunTracking,
dispatch,
isDisconnectLikeError,
loadAgentHistory,
runtimeWriteTransport.agentWait,
status,
]);
useEffect(() => {
if (status !== "connected") return;
void loadSummarySnapshot();
}, [loadSummarySnapshot, status]);
useEffect(() => {
const intent = resolveRuntimeSyncReconcilePollingIntent({ status });
if (intent.kind === "stop") {
reconcileRunIdsInFlightRef.current.clear();
return;
}
let cancelled = false;
const run = () => {
if (cancelled) return;
void reconcileRunningAgents();
};
if (intent.runImmediately) {
run();
}
const intervalId = window.setInterval(run, intent.intervalMs);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [reconcileRunningAgents, status]);
useEffect(() => {
const normalizedFocusedAgentId = focusedAgentId?.trim() ?? "";
for (const [sessionKey, context] of historyRequestContextRef.current.entries()) {
@@ -784,7 +858,7 @@ export function useRuntimeSyncController(
if (transcriptEntries.length === 0) continue;
const sessionEpoch = normalizeSessionEpoch(agent.sessionEpoch);
const includeThinking = agent.showThinkingTraces === true;
const includeTools = agent.showThinkingTraces === true || agent.toolCallingEnabled === true;
const includeTools = includeThinking;
const cacheKey = buildCachedHistoryWindowKey({
gatewayUrl: gatewayCacheKey,
agentId: agent.agentId,
@@ -918,11 +992,12 @@ export function useRuntimeSyncController(
}),
};
historyPrefetchRef.current.set(sessionKey, scheduled);
const historyPrefetches = historyPrefetchRef.current;
return () => {
const current = historyPrefetchRef.current.get(sessionKey) ?? null;
const current = historyPrefetches.get(sessionKey) ?? null;
if (!current || current.targetLimit !== nextLimit) return;
cancelIdlePrefetch(current.handle);
historyPrefetchRef.current.delete(sessionKey);
historyPrefetches.delete(sessionKey);
};
}, [
agents,
@@ -288,14 +288,11 @@ export function createGatewayRuntimeEventHandler(
const activeRunId = agent?.runId?.trim() ?? "";
const role = resolveRole(payload.message);
const nowMs = now();
const allowAbortedRunMismatchRecovery =
payload.state === "aborted" && agent?.status === "running";
if (
payload.runId &&
activeRunId &&
activeRunId !== payload.runId &&
!allowAbortedRunMismatchRecovery
activeRunId !== payload.runId
) {
clearRunTracking(payload.runId);
return;
@@ -171,7 +171,10 @@ export const planRuntimeChatEvent = (
const shouldUpdateLastResult =
payload.state === "final" && !isToolRole && typeof finalAssistantText === "string";
const shouldQueueLatestUpdate =
payload.state === "final" && Boolean(agent?.lastUserMessage && !agent.latestOverride);
payload.state === "final" &&
role === "assistant" &&
!isToolRole &&
Boolean(agent?.lastUserMessage && !agent.latestOverride);
const terminalSeq = payload.state === "final" ? resolveTerminalSeq(payload) : null;
const chatTerminalDecision =
payload.state === "final"
@@ -112,9 +112,7 @@ export const decideRuntimeChatEvent = (
return intents;
}
const allowAbortedRunMismatchRecovery =
input.state === "aborted" && input.agentStatus === "running";
if (runId && activeRunId && activeRunId !== runId && !allowAbortedRunMismatchRecovery) {
if (runId && activeRunId && activeRunId !== runId) {
return [{ kind: "clearRunTracking", runId }];
}
if (runId && input.isStaleTerminal) {
@@ -174,18 +172,21 @@ export const decideRuntimeChatEvent = (
export const decideRuntimeAgentEvent = (
input: RuntimeAgentPolicyInput
): RuntimePolicyIntent[] => {
if (!isLifecycleStart(input.stream, input.phase) && input.isClosedRun) {
const lifecycleStart = isLifecycleStart(input.stream, input.phase);
if (input.isClosedRun) {
return [{ kind: "ignore", reason: "closed-run-event" }];
}
if (input.activeRunId && input.activeRunId !== input.runId) {
if (!isLifecycleStart(input.stream, input.phase)) {
if (lifecycleStart) {
if (input.activeRunId && input.activeRunId !== input.runId) {
return [{ kind: "clearRunTracking", runId: input.runId }];
}
return [];
}
if (input.activeRunId && input.activeRunId !== input.runId) {
return [{ kind: "clearRunTracking", runId: input.runId }];
}
if (!input.activeRunId && input.agentStatus !== "running") {
if (!isLifecycleStart(input.stream, input.phase)) {
return [{ kind: "clearRunTracking", runId: input.runId }];
}
return [{ kind: "clearRunTracking", runId: input.runId }];
}
return [];
};
+1 -1
View File
@@ -377,7 +377,7 @@ const reducer = (state: AgentStoreState, action: Action): AgentStoreState => {
sequenceKey: nextSequence,
});
if (!nextEntry) {
return { ...agent, outputLines: [...agent.outputLines, action.line] };
return agent;
}
const nextEntryId = nextEntry.entryId.trim();
const existingIndex =
+148 -23
View File
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { isSafeAgentId } from "@/lib/agents/agentIds";
import { resolveStateDir } from "@/lib/clawdbot/paths";
type GatewayAgentStateMove = { from: string; to: string };
@@ -15,20 +16,57 @@ type RestoreAgentStateResult = {
restored: GatewayAgentStateMove[];
};
const isSafeAgentId = (value: string) => /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(value);
const utcStamp = (now: Date = new Date()) => {
const iso = now.toISOString(); // 2026-02-11T00:24:00.123Z
return iso.replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z"); // 20260211T002400Z
};
const pathExists = (target: string): boolean => {
try {
fs.lstatSync(target);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
};
const moveIfExists = (src: string, dest: string, moves: GatewayAgentStateMove[]) => {
if (!fs.existsSync(src)) return;
if (!pathExists(src)) return;
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.renameSync(src, dest);
moves.push({ from: src, to: dest });
};
const rollbackMoves = (moves: GatewayAgentStateMove[]): Error[] => {
const errors: Error[] = [];
for (const move of [...moves].reverse()) {
try {
if (!pathExists(move.to)) continue;
if (pathExists(move.from)) {
errors.push(new Error(`Rollback target already exists: ${move.from}`));
continue;
}
fs.mkdirSync(path.dirname(move.from), { recursive: true });
fs.renameSync(move.to, move.from);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(new Error(`Failed to rollback ${move.to} -> ${move.from}: ${message}`));
}
}
return errors;
};
const throwWithRollbackContext = (error: unknown, rollbackErrors: Error[]): never => {
if (rollbackErrors.length === 0) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`${message} Rollback also failed: ${rollbackErrors.map((entry) => entry.message).join("; ")}`
);
};
export const trashAgentStateLocally = (params: { agentId: string }): TrashAgentStateResult => {
const agentId = params.agentId.trim();
if (!agentId) {
@@ -46,26 +84,98 @@ export const trashAgentStateLocally = (params: { agentId: string }): TrashAgentS
fs.mkdirSync(path.join(trashDir, "workspaces"), { recursive: true });
const moves: GatewayAgentStateMove[] = [];
moveIfExists(
path.join(base, `workspace-${agentId}`),
path.join(trashDir, "workspaces", `workspace-${agentId}`),
moves
);
moveIfExists(path.join(base, "agents", agentId), path.join(trashDir, "agents", agentId), moves);
try {
moveIfExists(
path.join(base, `workspace-${agentId}`),
path.join(trashDir, "workspaces", `workspace-${agentId}`),
moves
);
moveIfExists(path.join(base, "agents", agentId), path.join(trashDir, "agents", agentId), moves);
} catch (error) {
throwWithRollbackContext(error, rollbackMoves(moves));
}
return { trashDir, moved: moves };
};
const ensureUnderBase = (base: string, candidate: string) => {
const resolvedBase = fs.existsSync(base) ? fs.realpathSync(base) : path.resolve(base);
const ensureUnderRoot = (root: string, candidate: string, label: string) => {
const resolvedBase = fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root);
const resolvedCandidate = fs.realpathSync(candidate);
const prefix = resolvedBase.endsWith(path.sep) ? resolvedBase : `${resolvedBase}${path.sep}`;
if (resolvedCandidate !== resolvedBase && !resolvedCandidate.startsWith(prefix)) {
throw new Error(`trashDir is not under ${base}: ${candidate}`);
throw new Error(`${label} is not under ${root}: ${candidate}`);
}
return { resolvedBase, resolvedCandidate };
};
const resolveSymlinkTarget = (linkPath: string, linkTarget: string): string => {
if (path.isAbsolute(linkTarget)) return path.resolve(linkTarget);
return path.resolve(path.dirname(linkPath), linkTarget);
};
const resolvePathForBoundaryCheck = (candidate: string, symlinkDepth = 0): string => {
if (symlinkDepth > 32) {
throw new Error(`Too many symlinks while resolving path: ${candidate}`);
}
let current = path.resolve(candidate);
const missingParts: string[] = [];
while (true) {
try {
const stat = fs.lstatSync(current);
if (stat.isSymbolicLink()) {
const linkTarget = fs.readlinkSync(current);
const resolvedTarget = resolveSymlinkTarget(current, linkTarget);
const resolvedCandidate = path.join(resolvedTarget, ...missingParts.reverse());
return resolvePathForBoundaryCheck(resolvedCandidate, symlinkDepth + 1);
}
return path.join(fs.realpathSync(current), ...missingParts.reverse());
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
const parent = path.dirname(current);
if (parent === current) {
return path.resolve(candidate);
}
missingParts.push(path.basename(current));
current = parent;
}
}
};
const pathResolvesUnderRoot = (root: string, candidate: string): boolean => {
const resolvedRoot = fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root);
const resolvedCandidate = resolvePathForBoundaryCheck(candidate);
const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(prefix);
};
const ensureRestoreSourceUnderTrash = (params: {
trashDir: string;
candidate: string;
dest: string;
stateRoot: string;
}) => {
const stat = fs.lstatSync(params.candidate);
if (stat.isSymbolicLink()) {
const linkTarget = fs.readlinkSync(params.candidate);
const restoredTarget = resolveSymlinkTarget(params.dest, linkTarget);
if (!pathResolvesUnderRoot(params.stateRoot, restoredTarget)) {
throw new Error(
`Refusing to restore symlink outside stateDir: ${params.candidate}`
);
}
return;
}
const resolvedCandidate = fs.realpathSync(params.candidate);
const prefix = params.trashDir.endsWith(path.sep)
? params.trashDir
: `${params.trashDir}${path.sep}`;
if (resolvedCandidate !== params.trashDir && !resolvedCandidate.startsWith(prefix)) {
throw new Error(`Refusing to restore source outside trashDir: ${params.candidate}`);
}
};
export const restoreAgentStateLocally = (params: {
agentId: string;
trashDir: string;
@@ -83,15 +193,26 @@ export const restoreAgentStateLocally = (params: {
}
const base = resolveStateDir();
const trashRoot = path.join(base, "trash", "studio-delete-agent");
if (!fs.existsSync(trashDirRaw)) {
throw new Error(`trashDir does not exist: ${trashDirRaw}`);
}
const { resolvedCandidate: resolvedTrashDir } = ensureUnderBase(base, trashDirRaw);
const { resolvedCandidate: resolvedTrashDir } = ensureUnderRoot(
trashRoot,
trashDirRaw,
"trashDir"
);
const moves: GatewayAgentStateMove[] = [];
const restoreIfExists = (src: string, dest: string) => {
if (!fs.existsSync(src)) return;
if (fs.existsSync(dest)) {
if (!pathExists(src)) return;
ensureRestoreSourceUnderTrash({
trashDir: resolvedTrashDir,
candidate: src,
dest,
stateRoot: base,
});
if (pathExists(dest)) {
throw new Error(`Refusing to restore over existing path: ${dest}`);
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
@@ -99,14 +220,18 @@ export const restoreAgentStateLocally = (params: {
moves.push({ from: src, to: dest });
};
restoreIfExists(
path.join(resolvedTrashDir, "workspaces", `workspace-${agentId}`),
path.join(base, `workspace-${agentId}`)
);
restoreIfExists(
path.join(resolvedTrashDir, "agents", agentId),
path.join(base, "agents", agentId)
);
try {
restoreIfExists(
path.join(resolvedTrashDir, "workspaces", `workspace-${agentId}`),
path.join(base, `workspace-${agentId}`)
);
restoreIfExists(
path.join(resolvedTrashDir, "agents", agentId),
path.join(base, "agents", agentId)
);
} catch (error) {
throwWithRollbackContext(error, rollbackMoves(moves));
}
return { restored: moves };
};
+35
View File
@@ -0,0 +1,35 @@
const SAFE_AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const DEFAULT_AGENT_ID = "main";
const INVALID_AGENT_ID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
export const isSafeAgentId = (value: string): boolean => SAFE_AGENT_ID_RE.test(value.trim());
export const resolveSafeAgentId = (value: unknown): string | null => {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) return null;
return isSafeAgentId(trimmed) ? trimmed : null;
};
export const normalizeOpenClawAgentId = (value: unknown): string => {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) return DEFAULT_AGENT_ID;
const normalized = trimmed.toLowerCase();
if (isSafeAgentId(trimmed)) return normalized;
return (
normalized
.replace(INVALID_AGENT_ID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
};
export const resolveCreatableOpenClawAgentId = (name: string): string => {
const agentId = normalizeOpenClawAgentId(name);
if (agentId === DEFAULT_AGENT_ID) {
throw new Error('Agent name resolves to reserved agent id "main".');
}
return agentId;
};
+31 -9
View File
@@ -1,5 +1,6 @@
import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime";
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
import { resolveSafeAgentId } from "@/lib/agents/agentIds";
type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full";
type GatewayExecApprovalAsk = "off" | "on-miss" | "always";
@@ -124,29 +125,50 @@ const buildNextExecApprovalsFile = (
};
};
const resolveAgentId = (value: string) => {
const agentId = resolveSafeAgentId(value);
if (!agentId) {
const trimmed = value.trim();
throw new Error(trimmed ? `Invalid agentId: ${trimmed}` : "Agent id is required.");
}
return agentId;
};
const buildExecApprovalsSetPayload = (
snapshot: ExecApprovalsSnapshot,
file: ExecApprovalsFile
) => {
const requiresBaseHash = snapshot.exists !== false;
const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Exec approvals hash unavailable; re-run exec.approvals.get.");
}
return {
file,
...(baseHash ? { baseHash } : {}),
};
};
export const upsertAgentExecApprovalsPolicyViaRuntime = async (params: {
runtime: ControlPlaneRuntime;
agentId: string;
role: ExecutionRoleId;
}): Promise<void> => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const agentId = resolveAgentId(params.agentId);
const snapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
const nextFile = buildNextExecApprovalsFile(snapshot.file, agentId, params.role);
const setPayload = { file: nextFile, ...(snapshot.exists ? { baseHash: snapshot.hash } : {}) };
const setPayload = buildExecApprovalsSetPayload(snapshot, nextFile);
try {
await params.runtime.callGateway("exec.approvals.set", setPayload);
} catch (err) {
if (!isRetryableSetError(err)) throw err;
const retrySnapshot = await params.runtime.callGateway<ExecApprovalsSnapshot>("exec.approvals.get", {});
const retryNextFile = buildNextExecApprovalsFile(retrySnapshot.file, agentId, params.role);
await params.runtime.callGateway("exec.approvals.set", {
file: retryNextFile,
...(retrySnapshot.exists ? { baseHash: retrySnapshot.hash } : {}),
});
await params.runtime.callGateway(
"exec.approvals.set",
buildExecApprovalsSetPayload(retrySnapshot, retryNextFile)
);
}
};
@@ -40,11 +40,17 @@ const OPERATOR_SCOPES = [
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const normalizeParsedHostname = (hostname: string): string =>
hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1");
const resolveOriginForUpstream = (upstreamUrl: string): string => {
const url = new URL(upstreamUrl);
const proto = url.protocol === "wss:" ? "https:" : "http:";
const normalizedHostname = normalizeParsedHostname(url.hostname);
const hostname =
url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0"
normalizedHostname === "127.0.0.1" ||
normalizedHostname === "::1" ||
normalizedHostname === "0.0.0.0"
? "localhost"
: url.hostname;
const host = url.port ? `${hostname}:${url.port}` : hostname;
+49 -8
View File
@@ -19,6 +19,7 @@ import {
import { loadStudioSettings } from "@/lib/studio/settings-store";
const CONNECT_TIMEOUT_MS = 8_000;
const STOP_CLOSE_TIMEOUT_MS = 1_000;
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const INITIAL_RECONNECT_DELAY_MS = 1_000;
const MAX_RECONNECT_DELAY_MS = 15_000;
@@ -236,6 +237,7 @@ export class OpenClawGatewayAdapter {
async stop(): Promise<void> {
this.stopping = true;
const inFlightStart = this.startPromise;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
@@ -249,14 +251,31 @@ export class OpenClawGatewayAdapter {
this.ws = null;
this.connectRequestId = null;
this.connectionEpoch = null;
if (ws && ws.readyState === WebSocket.OPEN) {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CLOSING)) {
await new Promise<void>((resolve) => {
ws.once("close", () => resolve());
ws.close(1000, "controlplane stopping");
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
ws.off("close", finish);
resolve();
};
const timer = setTimeout(() => {
ws.terminate();
finish();
}, STOP_CLOSE_TIMEOUT_MS);
ws.once("close", finish);
if (ws.readyState === WebSocket.OPEN) {
ws.close(1000, "controlplane stopping");
}
});
} else {
ws?.terminate();
}
if (inFlightStart) {
await inFlightStart.catch(() => {});
}
if (!this.preserveConnectProfileOnStop) {
this.connectProfileId = "backend-local";
}
@@ -296,12 +315,19 @@ export class OpenClawGatewayAdapter {
);
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify(frame), (err) => {
if (!err) return;
const rejectSendFailure = () => {
clearTimeout(timer);
this.pending.delete(id);
reject(new Error(`Failed to send gateway request for method: ${normalizedMethod}`));
});
};
try {
ws.send(JSON.stringify(frame), (err) => {
if (!err) return;
rejectSendFailure();
});
} catch {
rejectSendFailure();
}
});
return response as T;
} catch (error) {
@@ -326,11 +352,13 @@ export class OpenClawGatewayAdapter {
protocol: CONNECT_PROTOCOL,
capabilities: CONNECT_CAPABILITIES,
});
this.connectionEpoch = randomUUID();
const connectionEpoch = randomUUID();
this.connectionEpoch = connectionEpoch;
const ws = this.createWebSocket(settings.url, profile.socketOptions);
this.ws = ws;
this.connectRequestId = null;
this.updateStatus(this.reconnectAttempt > 0 ? "reconnecting" : "connecting", null);
const isActiveConnection = () => this.ws === ws && this.connectionEpoch === connectionEpoch;
await new Promise<void>((resolve, reject) => {
let settled = false;
@@ -360,6 +388,7 @@ export class OpenClawGatewayAdapter {
}, CONNECT_TIMEOUT_MS);
ws.on("message", (raw) => {
if (this.stopping || !isActiveConnection()) return;
const parsed = this.parseFrame(String(raw ?? ""));
if (!parsed) return;
if (parsed.type === "event") {
@@ -404,8 +433,14 @@ export class OpenClawGatewayAdapter {
});
ws.on("close", () => {
if (this.stopping) return;
const activeConnection = isActiveConnection();
if (!activeConnection && !this.stopping) return;
if (!settled) {
if (this.stopping) {
settle(() => reject(new Error("Control-plane adapter stopped.")));
return;
}
if (!activeConnection) return;
settle(() =>
reject(
new ControlPlaneGatewayConnectError({
@@ -418,6 +453,8 @@ export class OpenClawGatewayAdapter {
);
return;
}
if (this.stopping) return;
if (!activeConnection) return;
this.rejectPending("Control-plane gateway connection closed.");
this.connectionEpoch = null;
if (!allowReconnectAfterClose) {
@@ -429,6 +466,7 @@ export class OpenClawGatewayAdapter {
ws.on("error", (error) => {
if (this.stopping) return;
if (!isActiveConnection()) return;
if (!settled) {
settle(() =>
reject(
@@ -444,6 +482,9 @@ export class OpenClawGatewayAdapter {
});
}).catch((err) => {
this.connectionEpoch = null;
if (this.stopping) {
throw err;
}
this.updateStatus("error", err instanceof Error ? err.message : "connect_error");
if (!isConnectRejectionError(err)) {
this.scheduleReconnect();
+14 -15
View File
@@ -9,8 +9,10 @@ import type {
ControlPlaneOutboxEntry,
ControlPlaneRuntimeSnapshot,
} from "@/lib/controlplane/contracts";
import { resolveSafeAgentId } from "@/lib/agents/agentIds";
import { deriveControlPlaneEventKey } from "@/lib/controlplane/outbox";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import { parseAgentIdFromSessionKey } from "@/lib/gateway/session-keys";
const require = createRequire(import.meta.url);
@@ -20,7 +22,6 @@ const RUNTIME_DB_FILENAME = "runtime.db";
const DEFAULT_STATUS = "stopped" as const;
const NO_AGENT_SENTINEL = "";
const DEFAULT_BACKFILL_BATCH_LIMIT = 500;
const AGENT_SESSION_KEY_RE = /^agent:([^:]+):(.+)$/i;
type OutboxRow = {
id: number;
@@ -59,29 +60,27 @@ const parseDomainEvent = (raw: string): ControlPlaneDomainEvent => {
const isObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const parseAgentIdFromSessionKey = (value: unknown): string | null => {
const resolveEventAgentId = (value: unknown): string | null => {
const resolved = resolveSafeAgentId(value);
return resolved ? resolved.toLowerCase() : null;
};
const resolveEventSessionAgentId = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const raw = value.trim();
if (!raw) return null;
const match = raw.match(AGENT_SESSION_KEY_RE);
if (!match) return null;
const agentId = match[1]?.trim().toLowerCase() ?? "";
const rest = match[2]?.trim() ?? "";
if (!agentId || !rest) return null;
return agentId;
const resolved = parseAgentIdFromSessionKey(value);
return resolved ? resolved.toLowerCase() : null;
};
const resolveAgentIdFromControlPlaneEvent = (event: ControlPlaneDomainEvent): string | null => {
if (event.type !== "gateway.event") return null;
const payload = event.payload;
if (!isObject(payload)) return null;
const directAgentId =
typeof payload.agentId === "string" ? payload.agentId.trim().toLowerCase() : "";
const directAgentId = resolveEventAgentId(payload.agentId);
if (directAgentId) return directAgentId;
return (
parseAgentIdFromSessionKey(payload.sessionKey) ??
parseAgentIdFromSessionKey(payload.key) ??
parseAgentIdFromSessionKey(payload.runSessionKey)
resolveEventSessionAgentId(payload.sessionKey) ??
resolveEventSessionAgentId(payload.key) ??
resolveEventSessionAgentId(payload.runSessionKey)
);
};
+4
View File
@@ -5,6 +5,7 @@ import type {
CronSessionTarget,
CronWakeMode,
} from "@/lib/cron/types";
import { isSafeAgentId } from "@/lib/agents/agentIds";
export type CronCreateTemplateId =
| "morning-brief"
@@ -53,6 +54,9 @@ const resolveAgentId = (agentId: string) => {
if (!trimmed) {
throw new Error("Agent id is required.");
}
if (!isSafeAgentId(trimmed)) {
throw new Error(`Invalid agentId: ${trimmed}`);
}
return trimmed;
};
+116 -21
View File
@@ -1,4 +1,6 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { isSafeAgentId } from "@/lib/agents/agentIds";
import { parseAgentIdFromSessionKey } from "@/lib/gateway/session-keys";
export type CronSchedule =
| { kind: "at"; at: string }
@@ -78,10 +80,21 @@ export type CronJobCreateInput = {
delivery?: CronDelivery;
};
const normalizeCronAgentId = (value: unknown): string => {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed || !isSafeAgentId(trimmed)) return "";
return trimmed.toLowerCase();
};
export const cronAgentIdsEqual = (left: unknown, right: unknown): boolean => {
const normalizedLeft = normalizeCronAgentId(left);
const normalizedRight = normalizeCronAgentId(right);
return Boolean(normalizedLeft && normalizedLeft === normalizedRight);
};
export const filterCronJobsForAgent = (jobs: CronJobSummary[], agentId: string): CronJobSummary[] => {
const trimmedAgentId = agentId.trim();
if (!trimmedAgentId) return [];
return jobs.filter((job) => job.agentId?.trim() === trimmedAgentId);
if (!normalizeCronAgentId(agentId)) return [];
return jobs.filter((job) => cronAgentIdsEqual(job.agentId, agentId));
};
export const resolveLatestCronJobForAgent = (
@@ -155,6 +168,14 @@ export type CronJobRestoreInput = {
delivery?: CronDelivery;
};
type CronJobRemovalPlan = {
id: string;
restoreInput: CronJobRestoreInput;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const resolveJobId = (jobId: string): string => {
const trimmed = jobId.trim();
if (!trimmed) {
@@ -168,6 +189,9 @@ const resolveAgentId = (agentId: string): string => {
if (!trimmed) {
throw new Error("Agent id is required.");
}
if (!isSafeAgentId(trimmed)) {
throw new Error(`Invalid agentId: ${trimmed}`);
}
return trimmed;
};
@@ -179,6 +203,34 @@ const resolveCronJobName = (name: string): string => {
return trimmed;
};
export const resolveOptionalCronSessionKey = (
value: unknown,
agentId: string,
label = "sessionKey"
): string | undefined => {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`${label} must be string.`);
}
const sessionKey = value.trim();
if (!sessionKey) {
return undefined;
}
const explicitAgentId = parseAgentIdFromSessionKey(sessionKey);
if (explicitAgentId) {
if (explicitAgentId.trim().toLowerCase() !== agentId.trim().toLowerCase()) {
throw new Error(`${label} does not match agentId.`);
}
return sessionKey;
}
if (/^agent:/i.test(sessionKey)) {
throw new Error(`${label} is invalid.`);
}
return sessionKey;
};
export const listCronJobs = async (
client: GatewayClient,
params: CronListParams = {}
@@ -213,26 +265,68 @@ export const createCronJob = async (
): Promise<CronJobSummary> => {
const name = resolveCronJobName(input.name);
const agentId = resolveAgentId(input.agentId);
const sessionKey = resolveOptionalCronSessionKey(input.sessionKey, agentId);
return client.call<CronJobSummary>("cron.add", {
...input,
name,
agentId,
...(input.sessionKey !== undefined ? { sessionKey } : {}),
});
};
const toCronJobRestoreInput = (job: CronJobSummary, agentId: string): CronJobRestoreInput => ({
name: job.name,
agentId,
sessionKey: job.sessionKey,
description: job.description,
enabled: job.enabled,
deleteAfterRun: job.deleteAfterRun,
schedule: job.schedule,
sessionTarget: job.sessionTarget,
wakeMode: job.wakeMode,
payload: job.payload,
delivery: job.delivery,
});
const toCronJobRestoreInput = (job: CronJobSummary, agentId: string): CronJobRestoreInput => {
const id = resolveJobId(job.id);
const name = typeof job.name === "string" ? job.name.trim() : "";
if (!name) {
throw new Error(`Cron job ${id} is missing name.`);
}
if (typeof job.enabled !== "boolean") {
throw new Error(`Cron job ${id} is missing enabled flag.`);
}
if (job.sessionTarget !== "main" && job.sessionTarget !== "isolated") {
throw new Error(`Cron job ${id} has invalid sessionTarget.`);
}
if (job.wakeMode !== "next-heartbeat" && job.wakeMode !== "now") {
throw new Error(`Cron job ${id} has invalid wakeMode.`);
}
if (!isRecord(job.schedule)) {
throw new Error(`Cron job ${id} is missing schedule.`);
}
if (!isRecord(job.payload)) {
throw new Error(`Cron job ${id} is missing payload.`);
}
const sessionKey = resolveOptionalCronSessionKey(
job.sessionKey,
agentId,
`Cron job ${id} sessionKey`
);
const description = typeof job.description === "string" ? job.description : undefined;
const deleteAfterRun = typeof job.deleteAfterRun === "boolean" ? job.deleteAfterRun : undefined;
const delivery = isRecord(job.delivery) ? job.delivery : undefined;
return {
name,
agentId,
sessionKey,
description,
enabled: job.enabled,
deleteAfterRun,
schedule: job.schedule,
sessionTarget: job.sessionTarget,
wakeMode: job.wakeMode,
payload: job.payload,
delivery: delivery as CronDelivery | undefined,
};
};
const toCronJobRemovalPlan = (job: CronJobSummary, agentId: string): CronJobRemovalPlan => {
const id = resolveJobId(job.id);
return {
id,
restoreInput: toCronJobRestoreInput(job, agentId),
};
};
const restoreRemovedJobsBestEffort = async (
client: GatewayClient,
@@ -266,22 +360,23 @@ export const removeCronJobsForAgentWithBackup = async (
): Promise<CronJobRestoreInput[]> => {
const id = resolveAgentId(agentId);
const result = await listCronJobs(client, { includeDisabled: true });
const jobs = result.jobs.filter((job) => job.agentId?.trim() === id);
const jobs = result.jobs.filter((job) => cronAgentIdsEqual(job.agentId, id));
const plans = jobs.map((job) => toCronJobRemovalPlan(job, id));
const removedJobs: CronJobRestoreInput[] = [];
for (const job of jobs) {
for (const plan of plans) {
let removeResult: CronRemoveResult;
try {
removeResult = await removeCronJob(client, job.id);
removeResult = await removeCronJob(client, plan.id);
} catch (err) {
await restoreRemovedJobsBestEffort(client, removedJobs);
throw err;
}
if (!removeResult.ok) {
await restoreRemovedJobsBestEffort(client, removedJobs);
throw new Error(`Failed to delete cron job "${job.name}" (${job.id}).`);
throw new Error(`Failed to delete cron job "${plan.restoreInput.name}" (${plan.id}).`);
}
if (removeResult.removed) {
removedJobs.push(toCronJobRestoreInput(job, id));
removedJobs.push(plan.restoreInput);
}
}
return removedJobs;
+73 -72
View File
@@ -1,3 +1,4 @@
import { isSafeAgentId, resolveCreatableOpenClawAgentId } from "@/lib/agents/agentIds";
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
type AgentHeartbeatActiveHours = {
@@ -148,15 +149,7 @@ export const upsertConfigAgentEntry = (
};
export const slugifyAgentName = (name: string): string => {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (!slug) {
throw new Error("Name produced an empty folder name.");
}
return slug;
return resolveCreatableOpenClawAgentId(name);
};
const coerceString = (value: unknown) => (typeof value === "string" ? value : undefined);
@@ -259,11 +252,14 @@ type GatewayStatusSnapshot = {
};
};
const resolveHeartbeatAgentId = (agentId: string) => {
const resolveGatewayAgentId = (agentId: string) => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
if (!isSafeAgentId(trimmed)) {
throw new Error(`Invalid agentId: ${trimmed}`);
}
return trimmed;
};
@@ -284,7 +280,7 @@ export const listHeartbeatsForAgent = async (
client: GatewayClient,
agentId: string
): Promise<HeartbeatListResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
const resolvedAgentId = resolveGatewayAgentId(agentId);
const [snapshot, status] = await Promise.all([
callGateway<GatewayConfigSnapshot>(client, "config.get", {}),
callGateway<GatewayStatusSnapshot>(client, "status", {}),
@@ -315,7 +311,7 @@ export const triggerHeartbeatNow = async (
client: GatewayClient,
agentId: string
): Promise<HeartbeatWakeResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
const resolvedAgentId = resolveGatewayAgentId(agentId);
return callGateway<HeartbeatWakeResult>(client, "wake", {
mode: "now",
text: `OpenClaw Studio heartbeat trigger (${resolvedAgentId}).`,
@@ -329,30 +325,33 @@ const shouldRetryConfigWrite = (err: unknown) => {
const applyGatewayConfigPatch = async (params: {
client: GatewayClient;
patch: Record<string, unknown>;
baseHash?: string | null;
exists?: boolean;
buildPatch: (snapshot: GatewayConfigSnapshot) => {
patch: Record<string, unknown>;
resolvedConfig: Record<string, unknown>;
} | null;
attempt?: number;
}): Promise<void> => {
}): Promise<Record<string, unknown> | null> => {
const attempt = params.attempt ?? 0;
const requiresBaseHash = params.exists !== false;
const baseHash = requiresBaseHash ? params.baseHash?.trim() : undefined;
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const snapshotConfig = isRecord(snapshot.config) ? snapshot.config : {};
const plan = params.buildPatch(snapshot);
if (!plan) return snapshotConfig;
const requiresBaseHash = snapshot.exists !== false;
const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
const payload: Record<string, unknown> = {
raw: JSON.stringify(params.patch, null, 2),
raw: JSON.stringify(plan.patch, null, 2),
};
if (baseHash) payload.baseHash = baseHash;
try {
await callGateway(params.client, "config.patch", payload);
return plan.resolvedConfig;
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
return applyGatewayConfigPatch({
...params,
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
attempt: attempt + 1,
});
}
@@ -365,15 +364,16 @@ export const renameGatewayAgent = async (params: {
agentId: string;
name: string;
}) => {
const agentId = resolveGatewayAgentId(params.agentId);
const trimmed = params.name.trim();
if (!trimmed) {
throw new Error("Agent name is required.");
}
await callGateway(params.client, "agents.update", {
agentId: params.agentId,
agentId,
name: trimmed,
});
return { id: params.agentId, name: trimmed };
return { id: agentId, name: trimmed };
};
const dirnameLike = (value: string): string => {
@@ -398,6 +398,7 @@ export const createGatewayAgent = async (params: {
if (!trimmed) {
throw new Error("Agent name is required.");
}
const idGuess = slugifyAgentName(trimmed);
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const configPath = typeof snapshot.path === "string" ? snapshot.path.trim() : "";
@@ -412,21 +413,21 @@ export const createGatewayAgent = async (params: {
`Gateway config path "${configPath}" is missing a directory; cannot compute workspace.`,
);
}
const idGuess = slugifyAgentName(trimmed);
const workspace = joinPathLike(stateDir, `workspace-${idGuess}`);
const result = await callGateway<{ ok?: boolean; agentId?: string; name?: string; workspace?: string }>(
params.client,
"agents.create",
{
name: trimmed,
workspace,
name: trimmed,
workspace,
}
);
const agentId = typeof result?.agentId === "string" ? result.agentId.trim() : "";
if (!agentId) {
const agentIdRaw = typeof result?.agentId === "string" ? result.agentId.trim() : "";
if (!agentIdRaw) {
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
}
const agentId = resolveGatewayAgentId(agentIdRaw);
return { id: agentId, name: trimmed };
};
@@ -434,9 +435,10 @@ export const deleteGatewayAgent = async (params: {
client: GatewayClient;
agentId: string;
}) => {
const agentId = resolveGatewayAgentId(params.agentId);
try {
const result = await callGateway<{ ok?: boolean; removedBindings?: unknown }>(params.client, "agents.delete", {
agentId: params.agentId,
agentId,
});
const removedBindings =
typeof result?.removedBindings === "number" && Number.isFinite(result.removedBindings)
@@ -456,54 +458,56 @@ export const updateGatewayHeartbeat = async (params: {
agentId: string;
payload: AgentHeartbeatUpdatePayload;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
const next = { ...entry };
if (params.payload.override) {
next.heartbeat = buildHeartbeatOverride(params.payload.heartbeat);
} else if ("heartbeat" in next) {
delete next.heartbeat;
}
return next;
});
const nextConfig = writeConfigAgentList(baseConfig, nextList);
await applyGatewayConfigPatch({
const agentId = resolveGatewayAgentId(params.agentId);
const nextConfig = await applyGatewayConfigPatch({
client: params.client,
patch: { agents: { list: nextList } },
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
buildPatch: (snapshot) => {
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const { list: nextList } = upsertConfigAgentEntry(list, agentId, (entry) => {
const next = { ...entry };
if (params.payload.override) {
next.heartbeat = buildHeartbeatOverride(params.payload.heartbeat);
} else if ("heartbeat" in next) {
delete next.heartbeat;
}
return next;
});
return {
patch: { agents: { list: nextList } },
resolvedConfig: writeConfigAgentList(baseConfig, nextList),
};
},
});
return resolveHeartbeatSettings(nextConfig, params.agentId);
return resolveHeartbeatSettings(nextConfig ?? {}, agentId);
};
export const removeGatewayHeartbeatOverride = async (params: {
client: GatewayClient;
agentId: string;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const nextList = list.map((entry) => {
if (entry.id !== params.agentId) return entry;
if (!("heartbeat" in entry)) return entry;
const next = { ...entry };
delete next.heartbeat;
return next;
});
const changed = nextList.some((entry, index) => entry !== list[index]);
if (!changed) {
return resolveHeartbeatSettings(baseConfig, params.agentId);
}
const nextConfig = writeConfigAgentList(baseConfig, nextList);
await applyGatewayConfigPatch({
const agentId = resolveGatewayAgentId(params.agentId);
const nextConfig = await applyGatewayConfigPatch({
client: params.client,
patch: { agents: { list: nextList } },
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
buildPatch: (currentSnapshot) => {
const baseConfig = isRecord(currentSnapshot.config) ? currentSnapshot.config : {};
const list = readConfigAgentList(baseConfig);
const nextList = list.map((entry) => {
if (entry.id !== agentId) return entry;
if (!("heartbeat" in entry)) return entry;
const next = { ...entry };
delete next.heartbeat;
return next;
});
const changed = nextList.some((entry, index) => entry !== list[index]);
if (!changed) return null;
return {
patch: { agents: { list: nextList } },
resolvedConfig: writeConfigAgentList(baseConfig, nextList),
};
},
});
return resolveHeartbeatSettings(nextConfig, params.agentId);
return resolveHeartbeatSettings(nextConfig ?? {}, agentId);
};
const normalizeToolList = (values: string[] | undefined): string[] | undefined => {
@@ -519,10 +523,7 @@ export const updateGatewayAgentOverrides = async (params: {
agentId: string;
overrides: GatewayAgentOverrides;
}): Promise<void> => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const agentId = resolveGatewayAgentId(params.agentId);
if (params.overrides.tools?.allow !== undefined && params.overrides.tools?.alsoAllow !== undefined) {
throw new Error("Agent tools overrides cannot set both allow and alsoAllow.");
}
+4
View File
@@ -1,4 +1,5 @@
import type { AgentFileName } from "@/lib/agents/agentFiles";
import { isSafeAgentId } from "@/lib/agents/agentIds";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
type AgentsFilesGetResponse = {
@@ -24,6 +25,9 @@ const resolveAgentId = (value: string) => {
if (!trimmed) {
throw new Error("agentId is required.");
}
if (!isSafeAgentId(trimmed)) {
throw new Error(`Invalid agentId: ${trimmed}`);
}
return trimmed;
};
+79 -53
View File
@@ -1,4 +1,5 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
import { resolveSafeAgentId } from "@/lib/agents/agentIds";
type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full";
type GatewayExecApprovalAsk = "off" | "on-miss" | "always";
@@ -67,20 +68,82 @@ const normalizeAllowlist = (patterns: Array<{ pattern: string }>): Array<{ patte
return Array.from(new Set(next)).map((pattern) => ({ pattern }));
};
const resolveAgentId = (value: string) => {
const agentId = resolveSafeAgentId(value);
if (!agentId) {
const trimmed = value.trim();
throw new Error(trimmed ? `Invalid agentId: ${trimmed}` : "Agent id is required.");
}
return agentId;
};
const buildNextExecApprovalsFile = (params: {
snapshotFile?: ExecApprovalsFile;
agentId: string;
policy: {
security: GatewayExecApprovalSecurity;
ask: GatewayExecApprovalAsk;
allowlist: Array<{ pattern: string }>;
} | null;
}): ExecApprovalsFile | null => {
const baseFile: ExecApprovalsFile =
params.snapshotFile && typeof params.snapshotFile === "object"
? {
version: 1,
socket: params.snapshotFile.socket,
defaults: params.snapshotFile.defaults,
agents: { ...(params.snapshotFile.agents ?? {}) },
}
: { version: 1, agents: {} };
const nextAgents = { ...(baseFile.agents ?? {}) };
if (!params.policy) {
if (!(params.agentId in nextAgents)) {
return null;
}
delete nextAgents[params.agentId];
} else {
const existing = nextAgents[params.agentId] ?? {};
nextAgents[params.agentId] = {
...existing,
security: params.policy.security,
ask: params.policy.ask,
allowlist: normalizeAllowlist(params.policy.allowlist),
};
}
return {
...baseFile,
version: 1,
agents: nextAgents,
};
};
const setExecApprovalsWithRetry = async (params: {
client: GatewayClient;
file: ExecApprovalsFile;
baseHash?: string | null;
exists?: boolean;
snapshot: ExecApprovalsSnapshot;
agentId: string;
policy: {
security: GatewayExecApprovalSecurity;
ask: GatewayExecApprovalAsk;
allowlist: Array<{ pattern: string }>;
} | null;
attempt?: number;
}): Promise<void> => {
const attempt = params.attempt ?? 0;
const requiresBaseHash = params.exists !== false;
const baseHash = requiresBaseHash ? params.baseHash?.trim() : undefined;
const file = buildNextExecApprovalsFile({
snapshotFile: params.snapshot.file,
agentId: params.agentId,
policy: params.policy,
});
if (!file) return;
const requiresBaseHash = params.snapshot.exists !== false;
const baseHash = requiresBaseHash ? params.snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Exec approvals hash unavailable; re-run exec.approvals.get.");
}
const payload: Record<string, unknown> = { file: params.file };
const payload: Record<string, unknown> = { file };
if (baseHash) payload.baseHash = baseHash;
try {
await callGateway(params.client, "exec.approvals.set", payload);
@@ -92,9 +155,10 @@ const setExecApprovalsWithRetry = async (params: {
{}
);
return setExecApprovalsWithRetry({
...params,
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
client: params.client,
snapshot,
agentId: params.agentId,
policy: params.policy,
attempt: attempt + 1,
});
}
@@ -109,55 +173,20 @@ export async function upsertGatewayAgentExecApprovals(params: {
security: GatewayExecApprovalSecurity;
ask: GatewayExecApprovalAsk;
allowlist: Array<{ pattern: string }>;
} | null;
} | null;
}): Promise<void> {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const agentId = resolveAgentId(params.agentId);
const snapshot = await callGateway<ExecApprovalsSnapshot>(
params.client,
"exec.approvals.get",
{}
);
const baseFile: ExecApprovalsFile =
snapshot.file && typeof snapshot.file === "object"
? {
version: 1,
socket: snapshot.file.socket,
defaults: snapshot.file.defaults,
agents: { ...(snapshot.file.agents ?? {}) },
}
: { version: 1, agents: {} };
const nextAgents = { ...(baseFile.agents ?? {}) };
if (!params.policy) {
if (!(agentId in nextAgents)) {
return;
}
delete nextAgents[agentId];
} else {
const existing = nextAgents[agentId] ?? {};
nextAgents[agentId] = {
...existing,
security: params.policy.security,
ask: params.policy.ask,
allowlist: normalizeAllowlist(params.policy.allowlist),
};
}
const nextFile: ExecApprovalsFile = {
...baseFile,
version: 1,
agents: nextAgents,
};
await setExecApprovalsWithRetry({
client: params.client,
file: nextFile,
baseHash: snapshot.hash,
exists: snapshot.exists,
snapshot,
agentId,
policy: params.policy,
});
}
@@ -169,10 +198,7 @@ export async function readGatewayAgentExecApprovals(params: {
ask: GatewayExecApprovalAsk | null;
allowlist: Array<{ pattern: string }>;
} | null> {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const agentId = resolveAgentId(params.agentId);
const snapshot = await callGateway<ExecApprovalsSnapshot>(
params.client,
+1 -1
View File
@@ -12,7 +12,7 @@ export const isGatewayDisconnectLikeError = (err: unknown): boolean => {
return true;
}
const match = msg.match(/gateway closed \\((\\d+)\\)/);
const match = msg.match(/gateway closed \((\d+)\)/);
if (!match) return false;
const code = Number(match[1]);
return Number.isFinite(code) && code === 1012;
+1 -2
View File
@@ -11,7 +11,7 @@ const parseHostname = (gatewayUrl: string): string | null => {
export const isLocalGatewayUrl = (gatewayUrl: string): boolean => {
const hostname = parseHostname(gatewayUrl);
if (!hostname) return false;
const normalized = hostname.trim().toLowerCase();
const normalized = hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1");
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
@@ -19,4 +19,3 @@ export const isLocalGatewayUrl = (gatewayUrl: string): boolean => {
normalized === "0.0.0.0"
);
};
+24 -2
View File
@@ -1,3 +1,5 @@
import { isSafeAgentId } from "@/lib/agents/agentIds";
export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => {
const trimmedAgent = agentId.trim();
const trimmedKey = mainKey.trim() || "main";
@@ -5,8 +7,28 @@ export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => {
};
export const parseAgentIdFromSessionKey = (sessionKey: string): string | null => {
const match = sessionKey.match(/^agent:([^:]+):/);
return match ? match[1] : null;
const match = sessionKey.trim().match(/^agent:([^:]+):(.+)$/i);
const agentId = match?.[1]?.trim() ?? "";
const rest = match?.[2]?.trim() ?? "";
if (!agentId || !rest || !isSafeAgentId(agentId)) return null;
return agentId;
};
export const hasMalformedAgentSessionKey = (sessionKey: string): boolean => {
const trimmed = sessionKey.trim();
return /^agent:/i.test(trimmed) && parseAgentIdFromSessionKey(trimmed) === null;
};
export const resolveSafeSessionKey = (value: unknown): string | null => {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) return null;
return hasMalformedAgentSessionKey(trimmed) ? null : trimmed;
};
export const sessionKeyBelongsToAgent = (sessionKey: string, agentId: string): boolean => {
const parsedAgentId = parseAgentIdFromSessionKey(sessionKey);
if (!parsedAgentId) return false;
return parsedAgentId.trim().toLowerCase() === agentId.trim().toLowerCase();
};
export const isSameSessionKey = (a: string, b: string) => {
+5
View File
@@ -1,3 +1,5 @@
import { hasMalformedAgentSessionKey } from "@/lib/gateway/session-keys";
type SessionSettingsPatchPayload = {
key: string;
model?: string | null;
@@ -46,6 +48,9 @@ export const syncGatewaySessionSettings = async ({
if (!key) {
throw new Error("Session key is required.");
}
if (hasMalformedAgentSessionKey(key)) {
throw new Error("Invalid sessionKey.");
}
const includeModel = model !== undefined;
const includeThinkingLevel = thinkingLevel !== undefined;
const includeExecHost = execHost !== undefined;
+109 -24
View File
@@ -1,4 +1,5 @@
import { runSshJson } from "@/lib/ssh/gateway-host";
import { resolveSafeAgentId } from "@/lib/agents/agentIds";
type GatewayAgentStateMove = { from: string; to: string };
@@ -27,7 +28,7 @@ import uuid
agent_id = sys.argv[1].strip()
if not agent_id:
raise SystemExit("agentId is required.")
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", agent_id):
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", agent_id):
raise SystemExit(f"Invalid agentId: {agent_id}")
base = pathlib.Path.home() / ".openclaw"
@@ -39,15 +40,42 @@ trash_dir = trash_root / f"{stamp}-{agent_id}-{uuid.uuid4()}"
moves = []
def path_exists(path: pathlib.Path):
return path.exists() or path.is_symlink()
def rollback_moves():
errors = []
for move in reversed(moves):
src = pathlib.Path(move["from"])
dest = pathlib.Path(move["to"])
try:
if not path_exists(dest):
continue
if path_exists(src):
errors.append(f"Rollback target already exists: {src}")
continue
src.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(dest), str(src))
except Exception as exc:
errors.append(f"Failed to rollback {dest} -> {src}: {exc}")
return errors
def move_if_exists(src: pathlib.Path, dest: pathlib.Path):
if not src.exists():
if not path_exists(src):
return
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
moves.append({"from": str(src), "to": str(dest)})
move_if_exists(base / f"workspace-{agent_id}", trash_dir / "workspaces" / f"workspace-{agent_id}")
move_if_exists(base / "agents" / agent_id, trash_dir / "agents" / agent_id)
try:
move_if_exists(base / f"workspace-{agent_id}", trash_dir / "workspaces" / f"workspace-{agent_id}")
move_if_exists(base / "agents" / agent_id, trash_dir / "agents" / agent_id)
except Exception as exc:
rollback_errors = rollback_moves()
message = str(exc)
if rollback_errors:
message = f"{message} Rollback also failed: {'; '.join(rollback_errors)}"
raise SystemExit(message)
print(json.dumps({"trashDir": str(trash_dir), "moved": moves}))
PY
@@ -58,6 +86,7 @@ set -euo pipefail
python3 - "$1" "$2" <<'PY'
import json
import os
import pathlib
import re
import shutil
@@ -68,12 +97,13 @@ trash_dir_raw = sys.argv[2].strip()
if not agent_id:
raise SystemExit("agentId is required.")
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}", agent_id):
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}", agent_id):
raise SystemExit(f"Invalid agentId: {agent_id}")
if not trash_dir_raw:
raise SystemExit("trashDir is required.")
base = pathlib.Path.home() / ".openclaw"
trash_root = base / "trash" / "studio-delete-agent"
trash_dir = pathlib.Path(trash_dir_raw).expanduser()
try:
@@ -82,28 +112,76 @@ except FileNotFoundError:
raise SystemExit(f"trashDir does not exist: {trash_dir_raw}")
resolved_base = base.resolve(strict=False)
if resolved_base not in resolved_trash.parents:
raise SystemExit(f"trashDir is not under {base}: {trash_dir_raw}")
resolved_trash_root = trash_root.resolve(strict=False)
if resolved_trash != resolved_trash_root and resolved_trash_root not in resolved_trash.parents:
raise SystemExit(f"trashDir is not under {trash_root}: {trash_dir_raw}")
moves = []
def restore_if_exists(src: pathlib.Path, dest: pathlib.Path):
if not src.exists():
def path_exists(path: pathlib.Path):
return path.exists() or path.is_symlink()
def rollback_moves():
errors = []
for move in reversed(moves):
src = pathlib.Path(move["from"])
dest = pathlib.Path(move["to"])
try:
if not path_exists(dest):
continue
if path_exists(src):
errors.append(f"Rollback target already exists: {src}")
continue
src.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(dest), str(src))
except Exception as exc:
errors.append(f"Failed to rollback {dest} -> {src}: {exc}")
return errors
def path_under(root: pathlib.Path, candidate: pathlib.Path):
return candidate == root or root in candidate.parents
def resolve_restored_symlink_target(dest: pathlib.Path, link_target: str):
target = pathlib.Path(link_target)
if target.is_absolute():
return target.resolve(strict=False)
return (dest.parent / target).resolve(strict=False)
def ensure_source_allowed(src: pathlib.Path, dest: pathlib.Path):
if src.is_symlink():
restored_target = resolve_restored_symlink_target(dest, os.readlink(src))
if not path_under(resolved_base, restored_target):
raise RuntimeError(f"Refusing to restore symlink outside stateDir: {src}")
return
if dest.exists():
raise SystemExit(f"Refusing to restore over existing path: {dest}")
resolved_src = src.resolve(strict=True)
if not path_under(resolved_trash, resolved_src):
raise RuntimeError(f"Refusing to restore source outside trashDir: {src}")
def restore_if_exists(src: pathlib.Path, dest: pathlib.Path):
if not path_exists(src):
return
ensure_source_allowed(src, dest)
if path_exists(dest):
raise RuntimeError(f"Refusing to restore over existing path: {dest}")
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
moves.append({"from": str(src), "to": str(dest)})
restore_if_exists(
resolved_trash / "workspaces" / f"workspace-{agent_id}",
base / f"workspace-{agent_id}",
)
restore_if_exists(
resolved_trash / "agents" / agent_id,
base / "agents" / agent_id,
)
try:
restore_if_exists(
resolved_trash / "workspaces" / f"workspace-{agent_id}",
base / f"workspace-{agent_id}",
)
restore_if_exists(
resolved_trash / "agents" / agent_id,
base / "agents" / agent_id,
)
except Exception as exc:
rollback_errors = rollback_moves()
message = str(exc)
if rollback_errors:
message = f"{message} Rollback also failed: {'; '.join(rollback_errors)}"
raise SystemExit(message)
print(json.dumps({"restored": moves}))
PY
@@ -113,11 +191,15 @@ export const trashAgentStateOverSsh = (params: {
sshTarget: string;
agentId: string;
}): TrashAgentStateResult => {
const agentId = resolveSafeAgentId(params.agentId);
if (!agentId) {
throw new Error(`Invalid agentId: ${params.agentId}`);
}
const result = runSshJson({
sshTarget: params.sshTarget,
argv: ["bash", "-s", "--", params.agentId],
argv: ["bash", "-s", "--", agentId],
input: TRASH_SCRIPT,
label: `trash agent state (${params.agentId})`,
label: `trash agent state (${agentId})`,
});
return result as TrashAgentStateResult;
};
@@ -127,12 +209,15 @@ export const restoreAgentStateOverSsh = (params: {
agentId: string;
trashDir: string;
}): RestoreAgentStateResult => {
const agentId = resolveSafeAgentId(params.agentId);
if (!agentId) {
throw new Error(`Invalid agentId: ${params.agentId}`);
}
const result = runSshJson({
sshTarget: params.sshTarget,
argv: ["bash", "-s", "--", params.agentId, params.trashDir],
argv: ["bash", "-s", "--", agentId, params.trashDir],
input: RESTORE_SCRIPT,
label: `restore agent state (${params.agentId})`,
label: `restore agent state (${agentId})`,
});
return result as RestoreAgentStateResult;
};
+10 -2
View File
@@ -3,6 +3,9 @@ import * as childProcess from "node:child_process";
const SSH_TARGET_ENV = "OPENCLAW_GATEWAY_SSH_TARGET";
const SSH_USER_ENV = "OPENCLAW_GATEWAY_SSH_USER";
const normalizeSshHostname = (value: string): string =>
value.trim().replace(/^\[(.*)\]$/, "$1");
export const resolveConfiguredSshTarget = (env: NodeJS.ProcessEnv = process.env): string | null => {
const configuredTarget = env[SSH_TARGET_ENV]?.trim() ?? "";
const configuredUser = env[SSH_USER_ENV]?.trim() ?? "";
@@ -31,7 +34,7 @@ export const resolveGatewaySshTargetFromGatewayUrl = (
}
let hostname: string;
try {
hostname = new URL(trimmed).hostname;
hostname = normalizeSshHostname(new URL(trimmed).hostname);
} catch {
throw new Error(`Invalid gateway URL: ${trimmed}`);
}
@@ -91,7 +94,12 @@ export const runSshJson = (params: {
options.maxBuffer = params.maxBuffer;
}
const result = childProcess.spawnSync("ssh", ["-o", "BatchMode=yes", params.sshTarget, ...params.argv], {
const sshTarget = params.sshTarget.trim();
if (!sshTarget) {
throw new Error("SSH target is required.");
}
const result = childProcess.spawnSync("ssh", ["-o", "BatchMode=yes", "--", sshTarget, ...params.argv], {
...options,
});
if (result.error) {
+1
View File
@@ -15,6 +15,7 @@ export type StudioSettingsResponse = {
};
gatewayMeta?: {
hasStoredToken: boolean;
credentialScope?: string;
};
installContext?: StudioInstallContext;
domainApiModeEnabled?: boolean;
+56 -17
View File
@@ -1,10 +1,13 @@
import fs from "node:fs";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { resolveStateDir } from "@/lib/clawdbot/paths";
import {
canUseLocalGatewayDefaultsForUrl,
defaultStudioSettings,
mergeStudioSettings,
normalizeGatewayUrl,
normalizeStudioSettings,
type StudioSettings,
type StudioSettingsPatch,
@@ -18,14 +21,21 @@ const resolveStudioSettingsPath = () =>
path.join(resolveStateDir(), SETTINGS_DIRNAME, SETTINGS_FILENAME);
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
Boolean(value && typeof value === "object" && !Array.isArray(value));
const readJsonFile = (filePath: string): unknown | null => {
if (!fs.existsSync(filePath)) return null;
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
} catch {
return null;
}
};
const readOpenclawGatewayDefaults = (): { url: string; token: string } | null => {
try {
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
if (!fs.existsSync(configPath)) return null;
const raw = fs.readFileSync(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const parsed = readJsonFile(configPath);
if (!isRecord(parsed)) return null;
const gateway = isRecord(parsed.gateway) ? parsed.gateway : null;
if (!gateway) return null;
@@ -45,6 +55,15 @@ export const loadLocalGatewayDefaults = () => {
return readOpenclawGatewayDefaults();
};
export const loadPersistedStudioSettings = (): StudioSettings => {
const settingsPath = resolveStudioSettingsPath();
const parsed = readJsonFile(settingsPath);
if (parsed === null) {
return defaultStudioSettings();
}
return normalizeStudioSettings(parsed);
};
export const redactStudioSettingsSecrets = (settings: StudioSettings): StudioSettings => {
if (!settings.gateway) return settings;
return {
@@ -67,18 +86,13 @@ export const redactLocalGatewayDefaultsSecrets = (
};
export const loadStudioSettings = (): StudioSettings => {
const settingsPath = resolveStudioSettingsPath();
if (!fs.existsSync(settingsPath)) {
const defaults = defaultStudioSettings();
const gateway = loadLocalGatewayDefaults();
return gateway ? { ...defaults, gateway } : defaults;
}
const raw = fs.readFileSync(settingsPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
const settings = normalizeStudioSettings(parsed);
const settings = loadPersistedStudioSettings();
if (!settings.gateway?.token) {
const gateway = loadLocalGatewayDefaults();
if (gateway) {
if (
gateway &&
canUseLocalGatewayDefaultsForUrl(settings.gateway?.url ?? "", gateway.url)
) {
return {
...settings,
gateway: settings.gateway?.url?.trim()
@@ -90,18 +104,43 @@ export const loadStudioSettings = (): StudioSettings => {
return settings;
};
export const resolveGatewayTokenForUrl = (gatewayUrl: unknown): string => {
const settings = loadPersistedStudioSettings();
const persistedToken = settings.gateway?.token?.trim() ?? "";
const persistedUrl = settings.gateway?.url ?? "";
if (persistedToken && normalizeGatewayUrl(gatewayUrl) === normalizeGatewayUrl(persistedUrl)) {
return persistedToken;
}
const defaults = loadLocalGatewayDefaults();
if (defaults && canUseLocalGatewayDefaultsForUrl(gatewayUrl, defaults.url)) {
return defaults.token;
}
return "";
};
const saveStudioSettings = (next: StudioSettings) => {
const settingsPath = resolveStudioSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(next, null, 2), "utf8");
const tmpPath = path.join(dir, `${SETTINGS_FILENAME}.${process.pid}.${randomUUID()}.tmp`);
try {
fs.writeFileSync(tmpPath, JSON.stringify(next, null, 2), "utf8");
fs.renameSync(tmpPath, settingsPath);
} catch (error) {
try {
fs.rmSync(tmpPath, { force: true });
} catch {
// Best-effort cleanup only; preserve the original write error.
}
throw error;
}
};
export const applyStudioSettingsPatch = (patch: StudioSettingsPatch): StudioSettings => {
const current = loadStudioSettings();
const current = loadPersistedStudioSettings();
const next = mergeStudioSettings(current, patch);
saveStudioSettings(next);
return next;
return loadStudioSettings();
};
+24 -4
View File
@@ -35,17 +35,20 @@ export type StudioSettingsPatch = {
const SETTINGS_VERSION = 1 as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
Boolean(value && typeof value === "object" && !Array.isArray(value));
const coerceString = (value: unknown) => (typeof value === "string" ? value.trim() : "");
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "0.0.0.0"]);
const normalizeGatewayUrl = (value: unknown) => {
const normalizeParsedHostname = (value: string) =>
value.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1");
export const normalizeGatewayUrl = (value: unknown) => {
const url = coerceString(value);
if (!url) return "";
try {
const parsed = new URL(url);
if (!LOOPBACK_HOSTNAMES.has(parsed.hostname.toLowerCase())) {
if (!LOOPBACK_HOSTNAMES.has(normalizeParsedHostname(parsed.hostname))) {
return url;
}
const auth =
@@ -62,6 +65,16 @@ const normalizeGatewayUrl = (value: unknown) => {
}
};
export const canUseLocalGatewayDefaultsForUrl = (
configuredUrl: unknown,
defaultsUrl: unknown
) => {
const fallbackUrl = normalizeGatewayUrl(defaultsUrl);
if (!fallbackUrl) return false;
const url = normalizeGatewayUrl(configuredUrl);
return !url || url === fallbackUrl;
};
const normalizeGatewayKey = (value: unknown) => {
const key = normalizeGatewayUrl(value);
return key ? key : null;
@@ -142,8 +155,15 @@ const mergeGatewaySettings = (
if (patch === null) return null;
if (!isRecord(patch)) return current;
const patchHasUrl = hasOwn(patch, "url");
const patchHasToken = hasOwn(patch, "token");
const nextUrl = hasOwn(patch, "url") ? normalizeGatewayUrl(patch.url) : current?.url ?? "";
const nextToken = hasOwn(patch, "token") ? coerceString(patch.token) : current?.token ?? "";
const sameGatewayUrl = normalizeGatewayUrl(current?.url ?? "") === nextUrl;
const nextToken = patchHasToken
? coerceString(patch.token)
: patchHasUrl && !sameGatewayUrl
? ""
: current?.token ?? "";
if (!nextUrl) return null;
return { url: nextUrl, token: nextToken };
};
+15 -1
View File
@@ -10,7 +10,9 @@ import {
type StudioInstallContext,
} from "@/lib/studio/install-context";
import {
canUseLocalGatewayDefaultsForUrl,
defaultStudioSettings,
normalizeGatewayUrl,
type StudioGatewaySettings,
type StudioSettings,
type StudioSettingsPatch,
@@ -128,6 +130,7 @@ type StudioGatewaySettingsState = {
localGatewayDefaults: StudioGatewaySettings | null;
localGatewayDefaultsHasToken: boolean;
hasStoredToken: boolean;
gatewayCredentialScope: string;
hasUnsavedChanges: boolean;
installContext: StudioInstallContext;
domainApiModeEnabled: boolean;
@@ -183,6 +186,7 @@ export const useStudioGatewaySettings = (
);
const [localGatewayDefaultsHasToken, setLocalGatewayDefaultsHasToken] = useState(false);
const [hasStoredToken, setHasStoredToken] = useState(false);
const [gatewayCredentialScope, setGatewayCredentialScope] = useState("");
const [installContext, setInstallContext] = useState<StudioInstallContext>(
defaultStudioInstallContext()
);
@@ -275,6 +279,7 @@ export const useStudioGatewaySettings = (
const nextUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
setGatewayUrlState(nextUrl);
setHasStoredToken(Boolean(envelope.gatewayMeta?.hasStoredToken));
setGatewayCredentialScope(readString(envelope.gatewayMeta?.credentialScope));
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
setLocalGatewayDefaultsHasToken(Boolean(envelope.localGatewayDefaultsMeta?.hasToken));
setInstallContext(envelope.installContext ?? defaultStudioInstallContext());
@@ -321,7 +326,12 @@ export const useStudioGatewaySettings = (
}
const trimmedGatewayUrl = draftGatewayUrl.trim();
const trimmedToken = token.trim();
const canUseExistingToken = hasStoredToken || localGatewayDefaultsHasToken;
const canUseLocalGatewayToken =
localGatewayDefaultsHasToken &&
canUseLocalGatewayDefaultsForUrl(trimmedGatewayUrl, localGatewayDefaults?.url);
const canUseStoredGatewayToken =
hasStoredToken && normalizeGatewayUrl(trimmedGatewayUrl) === normalizeGatewayUrl(gatewayUrl);
const canUseExistingToken = canUseStoredGatewayToken || canUseLocalGatewayToken;
if (!trimmedGatewayUrl) {
setActionError("Gateway URL is required.");
setTestResult(null);
@@ -371,7 +381,9 @@ export const useStudioGatewaySettings = (
applySettingsEnvelope,
disconnecting,
draftGatewayUrl,
gatewayUrl,
hasStoredToken,
localGatewayDefaults,
localGatewayDefaultsHasToken,
refreshRuntimeStatus,
settingsCoordinator,
@@ -508,6 +520,7 @@ export const useStudioGatewaySettings = (
localGatewayDefaults,
localGatewayDefaultsHasToken,
hasStoredToken,
gatewayCredentialScope,
hasUnsavedChanges,
installContext,
domainApiModeEnabled,
@@ -533,6 +546,7 @@ export const useStudioGatewaySettings = (
domainApiModeEnabled,
error,
gatewayUrl,
gatewayCredentialScope,
hasStoredToken,
hasUnsavedChanges,
installContext,
+3 -1
View File
@@ -24,7 +24,7 @@ test("persists_gateway_fields_to_studio_settings", async ({ page }) => {
await page.getByLabel(/Upstream (gateway )?URL/i).fill("ws://gateway.example:18789");
await page.getByLabel("Upstream token").fill("token-123");
const request = await page.waitForRequest((req) => {
const requestPromise = page.waitForRequest((req) => {
if (!req.url().includes("/api/studio") || req.method() !== "PUT") {
return false;
}
@@ -32,6 +32,8 @@ test("persists_gateway_fields_to_studio_settings", async ({ page }) => {
const gateway = (payload.gateway ?? {}) as { url?: string; token?: string };
return gateway.url === "ws://gateway.example:18789" && gateway.token === "token-123";
});
await page.getByRole("button", { name: "Save settings" }).click();
const request = await requestPromise;
const payload = JSON.parse(request.postData() ?? "{}") as Record<string, unknown>;
const gateway = (payload.gateway ?? {}) as { url?: string; token?: string };
expect(gateway.url).toBe("ws://gateway.example:18789");
+1 -1
View File
@@ -11,7 +11,7 @@ type StudioSettingsFixture = {
type StudioRouteEnvelopeFixture = {
localGatewayDefaults?: { url: string; token: string } | null;
localGatewayDefaultsMeta?: { hasToken: boolean };
gatewayMeta?: { hasStoredToken: boolean };
gatewayMeta?: { hasStoredToken: boolean; credentialScope?: string };
installContext?: StudioInstallContext;
domainApiModeEnabled?: boolean;
};
+93
View File
@@ -38,6 +38,46 @@ describe("createAccessGate", () => {
expect(ended).toBe(true);
});
it("rejects app shell requests without cookie when enabled", async () => {
const { createAccessGate } = await import("../../server/access-gate");
const gate = createAccessGate({ token: "abc" });
const headers: Record<string, string> = {};
let body = "";
const res = {
statusCode: 0,
setHeader: (name: string, value: string) => {
headers[name] = value;
},
end: (value?: string) => {
body = value ?? "";
},
};
const handled = gate.handleHttp(
{ url: "/", headers: { host: "example.test" } },
res
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
expect(headers["Content-Type"]).toBe("application/json");
expect(JSON.parse(body)).toEqual({
error: "Studio access token required. Open /?access_token=... once to set a cookie.",
});
});
it("allows app shell requests when token cookie matches", async () => {
const { createAccessGate } = await import("../../server/access-gate");
const gate = createAccessGate({ token: "abc" });
expect(
gate.handleHttp(
{ url: "/", headers: { host: "example.test", cookie: "studio_access=abc" } },
{ setHeader: () => {}, end: () => {}, statusCode: 0 }
)
).toBe(false);
});
it("allows upgrades when cookie matches", async () => {
const { createAccessGate } = await import("../../server/access-gate");
const gate = createAccessGate({ token: "abc" });
@@ -45,4 +85,57 @@ describe("createAccessGate", () => {
gate.allowUpgrade({ headers: { cookie: "studio_access=abc" } })
).toBe(true);
});
it("rejects upgrades when token cookie is missing", async () => {
const { createAccessGate } = await import("../../server/access-gate");
const gate = createAccessGate({ token: "abc" });
expect(gate.allowUpgrade({ headers: {} })).toBe(false);
});
it("encodes access-token cookie values before redirecting", async () => {
const { createAccessGate } = await import("../../server/access-gate");
const gate = createAccessGate({ token: "abc;def" });
const headers: Record<string, string> = {};
const res = {
statusCode: 0,
setHeader: (name: string, value: string) => {
headers[name] = value;
},
end: () => {},
};
const handled = gate.handleHttp(
{ url: "/?access_token=abc%3Bdef", headers: { host: "example.test" } },
res
);
expect(handled).toBe(true);
expect(res.statusCode).toBe(302);
expect(headers["Set-Cookie"]).toContain("studio_access=abc%3Bdef;");
expect(gate.allowUpgrade({ headers: { cookie: "studio_access=abc%3Bdef" } })).toBe(true);
});
it("uses a relative redirect after accepting an access token", async () => {
const { createAccessGate } = await import("../../server/access-gate");
const gate = createAccessGate({ token: "abc" });
const headers: Record<string, string> = {};
const res = {
statusCode: 0,
setHeader: (name: string, value: string) => {
headers[name] = value;
},
end: () => {},
};
const handled = gate.handleHttp(
{
url: "/agents/agent-1/settings?access_token=abc&tab=tools",
headers: { host: "attacker.example", "x-forwarded-proto": "https" },
},
res
);
expect(handled).toBe(true);
expect(headers.Location).toBe("/agents/agent-1/settings?tab=tools");
});
});
+76
View File
@@ -263,6 +263,82 @@ describe("hydrateAgentFleetFromGateway", () => {
expect(result.sessionSettingsSyncedAgentIds).toHaveLength(agentCount);
});
it("filters malformed gateway agent ids before listing previews", async () => {
const call = vi.fn(async (method: string, params: unknown) => {
if (method === "agents.list") {
return {
defaultId: "../agent-1",
mainKey: "main",
agents: [
{ id: "../agent-1", name: "Bad" },
{ id: " agent-1 ", name: "One" },
{ id: "AGENT-1", name: "Duplicate" },
],
};
}
if (method === "sessions.list") {
expect(params).toEqual({
includeGlobal: false,
includeUnknown: false,
search: ":main",
});
return {
sessions: [
{ key: "agent:../agent-1:main", modelProvider: "openai", model: "bad" },
{ key: "agent:agent-1:main", modelProvider: "openai", model: "gpt-5" },
],
};
}
if (method === "exec.approvals.get") {
return { file: { agents: {} } };
}
if (method === "status") {
return { sessions: { recent: [], byAgent: [] } };
}
if (method === "sessions.preview") {
expect(params).toEqual({
keys: ["agent:agent-1:main"],
limit: 8,
maxChars: 240,
});
return {
ts: 1,
previews: [
{
key: "agent:agent-1:main",
status: "ok",
items: [{ role: "assistant", text: "ok", timestamp: 1 }],
},
],
};
}
if (method === "config.get") {
return {
hash: "hash-safe",
config: { agents: { defaults: { model: "openai/gpt-5" }, list: [] } },
};
}
throw new Error(`Unhandled method: ${method}`);
});
const result = await hydrateAgentFleetFromGateway({
client: { call },
gatewayUrl: "ws://127.0.0.1:18789",
cachedConfigSnapshot: null,
loadStudioSettings: async () => ({
version: 1,
gateway: null,
gatewayAutoStart: true,
focused: {},
avatars: {},
}),
isDisconnectLikeError: () => false,
});
expect(result.seeds.map((seed) => seed.agentId)).toEqual(["agent-1"]);
expect(result.sessionCreatedAgentIds).toEqual(["agent-1"]);
});
it("returns safely when batched sessions.list fails", async () => {
const call = vi.fn(async (method: string) => {
if (method === "agents.list") {
@@ -163,4 +163,44 @@ describe("deriveHydrateAgentFleetResult", () => {
expect(result.summaryPatches.length).toBeGreaterThan(0);
expect(result.suggestedSelectedAgentId).toBe("agent-2");
});
it("drops malformed and duplicate gateway agent ids before deriving seeds", () => {
const result = deriveHydrateAgentFleetResult({
gatewayUrl: "ws://127.0.0.1:18789",
configSnapshot: null,
settings: null,
execApprovalsSnapshot: {
file: {
agents: {
"../agent-1": { security: "full", ask: "always" },
" agent-1 ": { security: "allowlist", ask: "on-miss" },
},
},
},
agentsResult: {
defaultId: "../agent-1",
mainKey: "main",
agents: [
{ id: "../agent-1", name: "Bad", identity: {} },
{ id: " agent-1 ", name: "One", identity: {} },
{ id: "AGENT-1", name: "Duplicate", identity: {} },
],
},
mainSessionByAgentId: new Map([["agent-1", { key: "agent:agent-1:main" }]]),
statusSummary: null,
previewResult: null,
});
expect(result.seeds).toEqual([
expect.objectContaining({
agentId: "agent-1",
name: "One",
sessionKey: "agent:agent-1:main",
sessionExecHost: "gateway",
sessionExecSecurity: "allowlist",
sessionExecAsk: "on-miss",
}),
]);
expect(result.sessionCreatedAgentIds).toEqual(["agent-1"]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
isSafeAgentId,
normalizeOpenClawAgentId,
resolveCreatableOpenClawAgentId,
resolveSafeAgentId,
} from "@/lib/agents/agentIds";
describe("agent id validation", () => {
it("accepts OpenClaw path-safe agent ids", () => {
expect(isSafeAgentId("main")).toBe(true);
expect(isSafeAgentId("agent-1")).toBe(true);
expect(isSafeAgentId("Agent_1")).toBe(true);
});
it("rejects ids that the gateway would normalize to another agent", () => {
expect(isSafeAgentId("../agent-1")).toBe(false);
expect(isSafeAgentId("agent.1")).toBe(false);
expect(isSafeAgentId("-agent")).toBe(false);
expect(isSafeAgentId("a".repeat(65))).toBe(false);
});
it("resolves unknown input to a safe trimmed id or null", () => {
expect(resolveSafeAgentId(" agent-1 ")).toBe("agent-1");
expect(resolveSafeAgentId("../agent-1")).toBeNull();
expect(resolveSafeAgentId(null)).toBeNull();
});
it("mirrors OpenClaw agent id normalization for create names", () => {
expect(normalizeOpenClawAgentId("Agent_One")).toBe("agent_one");
expect(normalizeOpenClawAgentId("Agent One")).toBe("agent-one");
expect(normalizeOpenClawAgentId("../Agent.One")).toBe("agent-one");
expect(normalizeOpenClawAgentId("!!!")).toBe("main");
});
it("rejects create names that normalize to the reserved main agent id", () => {
expect(() => resolveCreatableOpenClawAgentId("main")).toThrow(
'Agent name resolves to reserved agent id "main".'
);
expect(() => resolveCreatableOpenClawAgentId("!!!")).toThrow(
'Agent name resolves to reserved agent id "main".'
);
});
});
+9 -14
View File
@@ -9,12 +9,7 @@ import {
describe("agentReconcileOperation", () => {
it("reconciles terminal runs and requests history refresh", async () => {
const call = vi.fn(async (method: string) => {
if (method === "agent.wait") {
return { status: "ok" };
}
throw new Error(`unexpected method ${method}`);
});
const waitForAgentRun = vi.fn(async () => ({ status: "ok" }));
const agent = {
agentId: "a1",
@@ -24,7 +19,7 @@ describe("agentReconcileOperation", () => {
} as unknown as AgentState;
const commands = await runAgentReconcileOperation({
client: { call },
waitForAgentRun,
agents: [agent],
getLatestAgent: () => agent,
claimRunId: () => true,
@@ -32,7 +27,7 @@ describe("agentReconcileOperation", () => {
isDisconnectLikeError: () => false,
});
expect(call).toHaveBeenCalledWith("agent.wait", { runId: "run-1", timeoutMs: 1 });
expect(waitForAgentRun).toHaveBeenCalledWith({ runId: "run-1", timeoutMs: 1 });
expect(commands).toEqual(
expect.arrayContaining([
@@ -48,7 +43,7 @@ describe("agentReconcileOperation", () => {
});
it("skips when agent is not eligible", async () => {
const call = vi.fn();
const waitForAgentRun = vi.fn();
const agent = {
agentId: "a1",
status: "idle",
@@ -57,7 +52,7 @@ describe("agentReconcileOperation", () => {
} as unknown as AgentState;
const commands = await runAgentReconcileOperation({
client: { call },
waitForAgentRun,
agents: [agent],
getLatestAgent: () => agent,
claimRunId: () => true,
@@ -65,12 +60,12 @@ describe("agentReconcileOperation", () => {
isDisconnectLikeError: () => false,
});
expect(call).not.toHaveBeenCalled();
expect(waitForAgentRun).not.toHaveBeenCalled();
expect(commands).toEqual([]);
});
it("reconciles shared run only once and triggers one history refresh", async () => {
const call = vi.fn(async () => ({ status: "ok" }));
const waitForAgentRun = vi.fn(async () => ({ status: "ok" }));
const agentOne = {
agentId: "a1",
status: "running",
@@ -86,7 +81,7 @@ describe("agentReconcileOperation", () => {
let claimed = false;
const commands = await runAgentReconcileOperation({
client: { call },
waitForAgentRun,
agents: [agentOne, agentTwo],
getLatestAgent: (agentId) => (agentId === "a1" ? agentOne : agentTwo),
claimRunId: () => {
@@ -99,7 +94,7 @@ describe("agentReconcileOperation", () => {
});
const historyRefreshes = commands.filter((entry) => entry.kind === "requestHistoryRefresh");
expect(call).toHaveBeenCalledTimes(1);
expect(waitForAgentRun).toHaveBeenCalledTimes(1);
expect(historyRefreshes).toEqual([{ kind: "requestHistoryRefresh", agentId: "a1" }]);
const dispatch = vi.fn();
+11
View File
@@ -34,6 +34,8 @@ describe("agent state ssh executor", () => {
);
const call = mockedRunSshJson.mock.calls[0]?.[0];
expect(call?.input).toContain("workspace-{agent_id}");
expect(call?.input).toContain("rollback_moves");
expect(call?.input).toContain("Rollback also failed");
});
it("restores agent state via ssh", () => {
@@ -55,5 +57,14 @@ describe("agent state ssh executor", () => {
input: expect.stringContaining('python3 - "$1" "$2"'),
})
);
const call = mockedRunSshJson.mock.calls[0]?.[0];
expect(call?.input).toContain("Refusing to restore source outside trashDir");
expect(call?.input).toContain("Refusing to restore symlink outside stateDir");
expect(call?.input).toContain("resolve_restored_symlink_target(dest, os.readlink(src))");
expect(call?.input).toContain('trash_root = base / "trash" / "studio-delete-agent"');
expect(call?.input).toContain("trashDir is not under {trash_root}");
expect(call?.input).not.toMatch(/^\t/m);
expect(call?.input).toContain("rollback_moves");
expect(call?.input).toContain("Rollback also failed");
});
});
+240 -3
View File
@@ -2,11 +2,16 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { restoreAgentStateLocally, trashAgentStateLocally } from "@/lib/agent-state/local";
const mkTmpStateDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-test-"));
const tmpDirs: string[] = [];
const mkTmpStateDir = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-test-"));
tmpDirs.push(dir);
return dir;
};
describe("agent state local", () => {
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
@@ -14,6 +19,9 @@ describe("agent state local", () => {
afterEach(() => {
if (originalStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
else process.env.OPENCLAW_STATE_DIR = originalStateDir;
for (const dir of tmpDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("trashes and restores agent workspace + state", () => {
@@ -39,5 +47,234 @@ describe("agent state local", () => {
expect(fs.existsSync(agentDir)).toBe(true);
expect(fs.readFileSync(path.join(workspace, "hello.txt"), "utf8")).toBe("hi");
});
});
it("trashes broken symlink state entries instead of leaving them behind", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const workspace = path.join(stateDir, `workspace-${agentId}`);
const missingTarget = path.join(stateDir, "missing-workspace-target");
fs.symlinkSync(missingTarget, workspace, "dir");
const trashed = trashAgentStateLocally({ agentId });
const trashedWorkspace = path.join(
trashed.trashDir,
"workspaces",
`workspace-${agentId}`
);
expect(fs.lstatSync(trashedWorkspace).isSymbolicLink()).toBe(true);
expect(() => fs.lstatSync(workspace)).toThrow();
});
it("restores trashed broken symlink state entries when the restored target stays in state", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const workspace = path.join(stateDir, `workspace-${agentId}`);
const missingTarget = path.join(stateDir, "missing-workspace-target");
fs.symlinkSync(missingTarget, workspace, "dir");
const trashed = trashAgentStateLocally({ agentId });
const restored = restoreAgentStateLocally({ agentId, trashDir: trashed.trashDir });
expect(restored.restored.map((move) => move.to)).toContain(workspace);
expect(fs.lstatSync(workspace).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(workspace)).toBe(missingTarget);
});
it("restores symlink sources when the restored target stays in state", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "trash", "studio-delete-agent", "restore-test");
const workspaceSource = path.join(trashDir, "workspaces", `workspace-${agentId}`);
const workspaceDest = path.join(stateDir, `workspace-${agentId}`);
fs.mkdirSync(path.dirname(workspaceSource), { recursive: true });
fs.symlinkSync("workspace-target", workspaceSource, "dir");
const restored = restoreAgentStateLocally({ agentId, trashDir });
expect(restored.restored.map((move) => move.to)).toContain(workspaceDest);
expect(fs.lstatSync(workspaceDest).isSymbolicLink()).toBe(true);
expect(fs.readlinkSync(workspaceDest)).toBe("workspace-target");
});
it("refuses to restore a symlink source that would escape state after restore", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "trash", "studio-delete-agent", "restore-test");
const workspaceSource = path.join(trashDir, "workspaces", `workspace-${agentId}`);
const outsideWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-outside-"));
tmpDirs.push(outsideWorkspace);
fs.mkdirSync(path.dirname(workspaceSource), { recursive: true });
fs.mkdirSync(path.join(trashDir, "agents"), { recursive: true });
fs.symlinkSync(outsideWorkspace, workspaceSource, "dir");
expect(() => restoreAgentStateLocally({ agentId, trashDir })).toThrow(
"Refusing to restore symlink outside stateDir"
);
expect(fs.existsSync(path.join(stateDir, `workspace-${agentId}`))).toBe(false);
expect(fs.existsSync(outsideWorkspace)).toBe(true);
});
it("refuses to restore broken symlink sources that would point outside state", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "trash", "studio-delete-agent", "restore-test");
const workspaceSource = path.join(trashDir, "workspaces", `workspace-${agentId}`);
fs.mkdirSync(path.dirname(workspaceSource), { recursive: true });
fs.symlinkSync(path.join(os.tmpdir(), "missing-openclaw-studio-target"), workspaceSource, "dir");
expect(() => restoreAgentStateLocally({ agentId, trashDir })).toThrow(
"Refusing to restore symlink outside stateDir"
);
expect(fs.existsSync(path.join(stateDir, `workspace-${agentId}`))).toBe(false);
});
it("refuses relative symlinks whose restored target would escape state", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "trash", "studio-delete-agent", "restore-test");
const workspaceSource = path.join(trashDir, "workspaces", `workspace-${agentId}`);
fs.mkdirSync(path.dirname(workspaceSource), { recursive: true });
fs.symlinkSync("../outside-state", workspaceSource, "dir");
expect(() => restoreAgentStateLocally({ agentId, trashDir })).toThrow(
"Refusing to restore symlink outside stateDir"
);
expect(fs.existsSync(path.join(stateDir, `workspace-${agentId}`))).toBe(false);
});
it("refuses symlink restore targets that resolve outside state through another symlink", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "trash", "studio-delete-agent", "restore-test");
const workspaceSource = path.join(trashDir, "workspaces", `workspace-${agentId}`);
const outsideWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-outside-"));
tmpDirs.push(outsideWorkspace);
fs.mkdirSync(path.dirname(workspaceSource), { recursive: true });
fs.symlinkSync(outsideWorkspace, path.join(stateDir, "redirect"), "dir");
fs.symlinkSync("redirect/restored-workspace", workspaceSource, "dir");
expect(() => restoreAgentStateLocally({ agentId, trashDir })).toThrow(
"Refusing to restore symlink outside stateDir"
);
expect(fs.existsSync(path.join(stateDir, `workspace-${agentId}`))).toBe(false);
expect(fs.existsSync(outsideWorkspace)).toBe(true);
});
it("refuses to restore from non-Studio trash directories under the state dir", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "agents", "restore-test");
fs.mkdirSync(path.join(trashDir, "workspaces", `workspace-${agentId}`), { recursive: true });
expect(() => restoreAgentStateLocally({ agentId, trashDir })).toThrow(
"trashDir is not under"
);
expect(fs.existsSync(path.join(stateDir, `workspace-${agentId}`))).toBe(false);
});
it("refuses to restore over an existing broken symlink destination", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "trash", "studio-delete-agent", "restore-test");
const workspaceSource = path.join(trashDir, "workspaces", `workspace-${agentId}`);
const workspaceDest = path.join(stateDir, `workspace-${agentId}`);
fs.mkdirSync(workspaceSource, { recursive: true });
fs.symlinkSync(path.join(stateDir, "missing-destination-target"), workspaceDest, "dir");
expect(() => restoreAgentStateLocally({ agentId, trashDir })).toThrow(
"Refusing to restore over existing path"
);
expect(fs.existsSync(workspaceSource)).toBe(true);
expect(fs.lstatSync(workspaceDest).isSymbolicLink()).toBe(true);
});
it("rolls back already-moved state when trashing fails partway through", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const workspace = path.join(stateDir, `workspace-${agentId}`);
const agentDir = path.join(stateDir, "agents", agentId);
fs.mkdirSync(workspace, { recursive: true });
fs.mkdirSync(agentDir, { recursive: true });
fs.writeFileSync(path.join(workspace, "hello.txt"), "hi", "utf8");
fs.writeFileSync(path.join(agentDir, "state.json"), "{}", "utf8");
const originalRenameSync = fs.renameSync.bind(fs);
let renameCount = 0;
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
renameCount += 1;
if (renameCount === 2) {
throw new Error("simulated second move failure");
}
return originalRenameSync(from, to);
});
try {
expect(() => trashAgentStateLocally({ agentId })).toThrow("simulated second move failure");
} finally {
renameSpy.mockRestore();
}
expect(fs.existsSync(workspace)).toBe(true);
expect(fs.existsSync(agentDir)).toBe(true);
expect(fs.readFileSync(path.join(workspace, "hello.txt"), "utf8")).toBe("hi");
});
it("rolls back already-restored state when restore fails partway through", () => {
const stateDir = mkTmpStateDir();
process.env.OPENCLAW_STATE_DIR = stateDir;
const agentId = "test-agent";
const trashDir = path.join(stateDir, "trash", "studio-delete-agent", "restore-test");
const workspaceSource = path.join(trashDir, "workspaces", `workspace-${agentId}`);
const agentSource = path.join(trashDir, "agents", agentId);
const workspaceDest = path.join(stateDir, `workspace-${agentId}`);
const agentDest = path.join(stateDir, "agents", agentId);
fs.mkdirSync(workspaceSource, { recursive: true });
fs.mkdirSync(agentSource, { recursive: true });
fs.writeFileSync(path.join(workspaceSource, "hello.txt"), "hi", "utf8");
fs.writeFileSync(path.join(agentSource, "state.json"), "{}", "utf8");
const originalRenameSync = fs.renameSync.bind(fs);
let renameCount = 0;
const renameSpy = vi.spyOn(fs, "renameSync").mockImplementation((from, to) => {
renameCount += 1;
if (renameCount === 2) {
throw new Error("simulated restore failure");
}
return originalRenameSync(from, to);
});
try {
expect(() => restoreAgentStateLocally({ agentId, trashDir })).toThrow("simulated restore failure");
} finally {
renameSpy.mockRestore();
}
expect(fs.existsSync(workspaceSource)).toBe(true);
expect(fs.existsSync(agentSource)).toBe(true);
expect(fs.existsSync(workspaceDest)).toBe(false);
expect(fs.existsSync(agentDest)).toBe(false);
expect(fs.readFileSync(path.join(workspaceSource, "hello.txt"), "utf8")).toBe("hi");
});
});
+30
View File
@@ -65,6 +65,21 @@ describe("agent state route", () => {
expect(response.status).toBe(400);
});
it("rejects malformed trash JSON without running mutations", async () => {
const response = await POST(
new Request("http://localhost/api/runtime/agent-state", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{",
})
);
const body = (await response.json()) as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toBe("Invalid JSON payload.");
expect(mockedSpawnSync).not.toHaveBeenCalled();
});
it("rejects unsafe agentId", async () => {
const response = await POST(
new Request("http://localhost/api/runtime/agent-state", {
@@ -181,4 +196,19 @@ describe("agent state route", () => {
expect(cmd).toBe("ssh");
expect(args).toEqual(expect.arrayContaining(["me@host.test"]));
});
it("rejects malformed restore JSON without running mutations", async () => {
const response = await PUT(
new Request("http://localhost/api/runtime/agent-state", {
method: "PUT",
headers: { "content-type": "application/json" },
body: "{",
})
);
const body = (await response.json()) as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toBe("Invalid JSON payload.");
expect(mockedSpawnSync).not.toHaveBeenCalled();
});
});
+27
View File
@@ -228,6 +228,33 @@ describe("agent store", () => {
expect(afterDraftUpdate.transcriptSequenceCounter).toBe(beforeDraftUpdate.transcriptSequenceCounter);
});
it("ignores_empty_appended_output_to_keep_transcript_projection_consistent", () => {
const seed: AgentStoreSeed = {
agentId: "agent-1",
name: "Agent One",
sessionKey: "agent:agent-1:main",
};
let state = agentStoreReducer(initialAgentStoreState, {
type: "hydrateAgents",
agents: [seed],
});
state = agentStoreReducer(state, {
type: "appendOutput",
agentId: "agent-1",
line: "response",
});
const before = state.agents[0];
state = agentStoreReducer(state, {
type: "appendOutput",
agentId: "agent-1",
line: "",
});
expect(state.agents[0]).toBe(before);
});
it("tracks_unseen_activity_for_non_selected_agents", () => {
const seeds: AgentStoreSeed[] = [
{
+1
View File
@@ -8,6 +8,7 @@ const buildProps = () => ({
draftGatewayUrl: "ws://127.0.0.1:18789",
token: "token",
hasStoredToken: true,
localGatewayDefaults: null,
localGatewayDefaultsHasToken: false,
hasUnsavedChanges: false,
status: "disconnected" as const,
@@ -5,6 +5,44 @@ import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime";
describe("control-plane exec approvals policy upsert", () => {
it("rejects unsafe agent ids before reading approval policy", async () => {
const runtime = {
callGateway: vi.fn(),
} as unknown as ControlPlaneRuntime;
await expect(
upsertAgentExecApprovalsPolicyViaRuntime({
runtime,
agentId: "../agent-1",
role: "autonomous",
})
).rejects.toThrow("Invalid agentId: ../agent-1");
expect(runtime.callGateway).not.toHaveBeenCalled();
});
it("requires a base hash unless the exec approvals file is known missing", async () => {
const runtime = {
callGateway: vi.fn(async (method: string) => {
if (method === "exec.approvals.get") {
return {
path: "/tmp/approvals.json",
file: { version: 1, agents: {} },
};
}
throw new Error(`unexpected method: ${method}`);
}),
} as unknown as ControlPlaneRuntime;
await expect(
upsertAgentExecApprovalsPolicyViaRuntime({
runtime,
agentId: "agent-1",
role: "autonomous",
})
).rejects.toThrow("Exec approvals hash unavailable; re-run exec.approvals.get.");
});
it("rebuilds retry payload from latest snapshot on stale base-hash conflicts", async () => {
let getCount = 0;
let setCount = 0;
@@ -172,6 +172,35 @@ describe("SQLiteControlPlaneProjectionStore", () => {
store.close();
});
it("does not index malformed agent ids from gateway event payloads", () => {
const store = new SQLiteControlPlaneProjectionStore(makeDbPath());
store.applyDomainEvent({
type: "gateway.event",
event: "runtime.delta",
seq: 1,
payload: { sessionKey: "agent:../alpha:main", text: "bad session" },
asOf: "2026-02-28T02:01:01.000Z",
});
store.applyDomainEvent({
type: "gateway.event",
event: "runtime.delta",
seq: 2,
payload: { agentId: "../alpha", text: "bad direct" },
asOf: "2026-02-28T02:01:02.000Z",
});
store.applyDomainEvent({
type: "gateway.event",
event: "runtime.delta",
seq: 3,
payload: { sessionKey: "agent:alpha:main", text: "good" },
asOf: "2026-02-28T02:01:03.000Z",
});
expect(store.readAgentOutboxBefore("alpha", 4, 10).map((entry) => entry.id)).toEqual([3]);
store.close();
});
it("backfills legacy outbox rows into agent index and marks non-agent rows", () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
+180
View File
@@ -16,11 +16,13 @@ const createListedJob = (params: {
id: string;
name: string;
agentId?: string;
sessionKey?: string;
updatedAtMs?: number;
}): CronJobSummary => ({
id: params.id,
name: params.name,
agentId: params.agentId,
sessionKey: params.sessionKey,
enabled: true,
updatedAtMs: params.updatedAtMs ?? 1_700_000_000_000,
schedule: { kind: "every", everyMs: 60_000 },
@@ -159,6 +161,117 @@ describe("cron gateway client", () => {
]);
});
it("removes_case_normalized_agent_jobs_with_backup", async () => {
const client = {
call: vi.fn(async (method: string, payload: { id?: string }) => {
if (method === "cron.list") {
return {
jobs: [
createListedJob({ id: "job-1", name: "Job 1", agentId: "Agent-1" }),
createListedJob({ id: "job-2", name: "Job 2", agentId: "agent-2" }),
],
};
}
if (method === "cron.remove") {
return { ok: true, removed: payload.id === "job-1" };
}
throw new Error(`Unexpected method: ${method}`);
}),
} as unknown as GatewayClient;
await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).resolves.toEqual([
expect.objectContaining({
name: "Job 1",
agentId: "agent-1",
}),
]);
expect(client.call).toHaveBeenCalledWith("cron.remove", { id: "job-1" });
expect(client.call).not.toHaveBeenCalledWith("cron.remove", { id: "job-2" });
});
it("validates_all_restore_payloads_before_deleting_backup_jobs", async () => {
const call = vi.fn(async (method: string) => {
if (method === "cron.list") {
return {
jobs: [
createListedJob({ id: "job-1", name: "Job 1", agentId: "agent-1" }),
createListedJob({ id: "job-2", name: " ", agentId: "agent-1" }),
],
};
}
if (method === "cron.remove") {
return { ok: true, removed: true };
}
throw new Error(`Unexpected method: ${method}`);
});
const client = { call } as unknown as GatewayClient;
await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toThrow(
"Cron job job-2 is missing name."
);
expect(call.mock.calls.map(([method]) => method)).toEqual(["cron.list"]);
});
it("validates_backup_session_keys_before_deleting_jobs", async () => {
const call = vi.fn(async (method: string) => {
if (method === "cron.list") {
return {
jobs: [
createListedJob({
id: "job-1",
name: "Job 1",
agentId: "agent-1",
sessionKey: "agent:agent-2:main",
}),
],
};
}
if (method === "cron.remove") {
return { ok: true, removed: true };
}
throw new Error(`Unexpected method: ${method}`);
});
const client = { call } as unknown as GatewayClient;
await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).rejects.toThrow(
"Cron job job-1 sessionKey does not match agentId."
);
expect(call.mock.calls.map(([method]) => method)).toEqual(["cron.list"]);
});
it("preserves_shorthand_backup_session_keys_for_agent_jobs", async () => {
const client = {
call: vi.fn(async (method: string, payload: { id?: string }) => {
if (method === "cron.list") {
return {
jobs: [
createListedJob({
id: "job-1",
name: "Job 1",
agentId: "agent-1",
sessionKey: "project-alpha-monitor",
}),
],
};
}
if (method === "cron.remove") {
return { ok: true, removed: payload.id === "job-1" };
}
throw new Error(`Unexpected method: ${method}`);
}),
} as unknown as GatewayClient;
await expect(removeCronJobsForAgentWithBackup(client, "agent-1")).resolves.toEqual([
expect.objectContaining({
name: "Job 1",
agentId: "agent-1",
sessionKey: "project-alpha-monitor",
}),
]);
});
it("restores_removed_jobs_when_backup_remove_fails_midway", async () => {
const client = {
call: vi.fn(async (method: string, payload: { id?: string; name?: string }) => {
@@ -295,6 +408,73 @@ describe("cron gateway client", () => {
expect(client.call).toHaveBeenCalledWith("cron.add", input);
});
it("trims_owned_session_key_when_creating_job", async () => {
const client = {
call: vi.fn(async () => ({ id: "job-1", name: "Morning brief" })),
} as unknown as GatewayClient;
const input = {
name: "Morning brief",
agentId: "agent-1",
sessionKey: " agent:agent-1:main ",
enabled: true,
schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "America/Chicago" },
sessionTarget: "isolated" as const,
wakeMode: "now" as const,
payload: { kind: "agentTurn" as const, message: "Summarize overnight updates." },
};
await createCronJob(client, input);
expect(client.call).toHaveBeenCalledWith("cron.add", {
...input,
sessionKey: "agent:agent-1:main",
});
});
it("allows_shorthand_session_key_when_creating_job", async () => {
const client = {
call: vi.fn(async () => ({ id: "job-1", name: "Morning brief" })),
} as unknown as GatewayClient;
const input = {
name: "Morning brief",
agentId: "agent-1",
sessionKey: " project-alpha-monitor ",
enabled: true,
schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "America/Chicago" },
sessionTarget: "isolated" as const,
wakeMode: "now" as const,
payload: { kind: "agentTurn" as const, message: "Summarize overnight updates." },
};
await createCronJob(client, input);
expect(client.call).toHaveBeenCalledWith("cron.add", {
...input,
sessionKey: "project-alpha-monitor",
});
});
it("rejects_foreign_session_key_when_creating_job", async () => {
const client = {
call: vi.fn(async () => ({ id: "job-1" })),
} as unknown as GatewayClient;
await expect(
createCronJob(client, {
name: "Morning brief",
agentId: "agent-1",
sessionKey: "agent:agent-2:main",
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Run checks." },
})
).rejects.toThrow("sessionKey does not match agentId.");
expect(client.call).not.toHaveBeenCalled();
});
it("throws_when_create_payload_missing_required_name", async () => {
const client = {
call: vi.fn(async () => ({ id: "job-1" })),
+10
View File
@@ -60,6 +60,16 @@ describe("cron selectors", () => {
expect(filterCronJobsForAgent(jobs, " agent-1 ").map((job) => job.id)).toEqual(["trimmed"]);
expect(resolveLatestCronJobForAgent(jobs, " agent-1 ")?.id).toBe("trimmed");
});
it("matches_agent_ids_case_insensitively_after_gateway_normalization", () => {
const jobs = [
buildJob({ id: "owned", agentId: "Agent-1", updatedAtMs: 20 }),
buildJob({ id: "other", agentId: "agent-2", updatedAtMs: 30 }),
];
expect(filterCronJobsForAgent(jobs, "agent-1").map((job) => job.id)).toEqual(["owned"]);
expect(resolveLatestCronJobForAgent(jobs, "AGENT-1")?.id).toBe("owned");
});
});
describe("cron formatting", () => {
+41 -2
View File
@@ -96,6 +96,45 @@ describe("execApprovalEvents", () => {
expect(parseExecApprovalRequested(event)).toBeNull();
});
it("drops unsafe agent ids and malformed agent session keys from requested payloads", () => {
const event: EventFrame = {
type: "event",
event: "exec.approval.requested",
payload: {
id: "approval-1",
request: {
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
agentId: "../agent-1",
resolvedPath: "/bin/npm",
sessionKey: "agent:../agent-1:main",
},
createdAtMs: 123,
expiresAtMs: 456,
},
};
expect(parseExecApprovalRequested(event)).toEqual({
id: "approval-1",
request: {
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
agentId: null,
resolvedPath: "/bin/npm",
sessionKey: null,
},
createdAtMs: 123,
expiresAtMs: 456,
});
});
it("parses exec.approval.resolved payload", () => {
const event: EventFrame = {
type: "event",
@@ -151,7 +190,7 @@ describe("execApprovalEvents", () => {
expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-2");
});
it("trusts explicit agent id even when the local agent list has not hydrated it yet", () => {
it("does not resolve explicit agent id until the local agent list has hydrated it", () => {
const requested = {
id: "approval-1",
request: {
@@ -168,7 +207,7 @@ describe("execApprovalEvents", () => {
expiresAtMs: 2,
};
const agents = [createAgent("agent-1", "agent:agent-1:main")];
expect(resolveExecApprovalAgentId({ requested, agents })).toBe("agent-prehydration");
expect(resolveExecApprovalAgentId({ requested, agents })).toBeNull();
});
it("falls back to session key when agent id missing", () => {
@@ -120,6 +120,64 @@ describe("execApprovalLifecycleWorkflow", () => {
expect(unscopedEffects?.markActivityAgentIds).toEqual([]);
});
it("does not scope requested approvals to unsafe or unknown agent ids", () => {
const agents = [createAgent("agent-1", "agent:agent-1:main")];
const unsafeEvent: EventFrame = {
type: "event",
event: "exec.approval.requested",
payload: {
id: "approval-unsafe",
request: {
command: "npm run test",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
agentId: "../agent-1",
resolvedPath: "/usr/bin/npm",
sessionKey: "agent:../agent-1:main",
},
createdAtMs: 123,
expiresAtMs: 456,
},
};
const unknownWithMatchingSessionEvent: EventFrame = {
type: "event",
event: "exec.approval.requested",
payload: {
id: "approval-session",
request: {
command: "npm run lint",
cwd: "/repo",
host: "gateway",
security: "allowlist",
ask: "always",
agentId: "missing",
resolvedPath: "/usr/bin/npm",
sessionKey: "agent:agent-1:main",
},
createdAtMs: 223,
expiresAtMs: 456,
},
};
const unsafeEffects = resolveExecApprovalEventEffects({
event: unsafeEvent,
agents,
});
expect(unsafeEffects?.scopedUpserts).toEqual([]);
expect(unsafeEffects?.unscopedUpserts).toEqual([
expect.objectContaining({ id: "approval-unsafe", agentId: null, sessionKey: null }),
]);
const sessionEffects = resolveExecApprovalEventEffects({
event: unknownWithMatchingSessionEvent,
agents,
});
expect(sessionEffects?.scopedUpserts.map((entry) => entry.agentId)).toEqual(["agent-1"]);
expect(sessionEffects?.markActivityAgentIds).toEqual(["agent-1"]);
});
it("maps resolved approval event into remove effects", () => {
const event: EventFrame = {
type: "event",
@@ -178,6 +236,44 @@ describe("execApprovalLifecycleWorkflow", () => {
});
});
it("does not send follow-up intents to unsafe or unknown scoped agent ids", () => {
const agents = [createAgent("agent-1", "agent:agent-1:main")];
expect(
resolveExecApprovalFollowUpIntent({
decision: "allow-once",
approval: createApproval({
agentId: "../agent-1",
sessionKey: "agent:../agent-1:main",
}),
agents,
followUpMessage: "approval granted",
})
).toEqual({
shouldSend: false,
agentId: null,
sessionKey: null,
message: null,
});
expect(
resolveExecApprovalFollowUpIntent({
decision: "allow-once",
approval: createApproval({
agentId: "missing",
sessionKey: "agent:agent-1:main",
}),
agents,
followUpMessage: "approval granted",
})
).toEqual({
shouldSend: true,
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
message: "approval granted",
});
});
it("maps unknown approval id gateway error to local removal intent", () => {
expect(
shouldTreatExecApprovalResolveErrorAsUnknownId(
@@ -223,6 +223,69 @@ describe("execApprovalResolveOperation", () => {
expect(onAllowed).not.toHaveBeenCalled();
});
it("falls back to a matching safe session when approval agent id is unsafe", async () => {
const call = vi.fn(async (method: string) => {
if (method === "exec.approval.resolve") {
return { ok: true };
}
if (method === "agent.wait") {
return { status: "ok" };
}
throw new Error(`unexpected method ${method}`);
});
const approval: PendingExecApproval = {
id: "appr-1",
agentId: "../a1",
sessionKey: "agent:a1:main",
command: "echo hi",
cwd: null,
host: null,
security: null,
ask: null,
resolvedPath: null,
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 60_000,
resolving: false,
error: null,
};
const agent = {
agentId: "a1",
sessionKey: "agent:a1:main",
sessionCreated: true,
status: "running",
runId: "run-1",
} as unknown as AgentState;
const approvalsByAgentId = createState<Record<string, PendingExecApproval[]>>({
"../a1": [approval],
});
const unscopedApprovals = createState<PendingExecApproval[]>([]);
const requestHistoryRefresh = vi.fn();
await resolveExecApprovalViaStudio({
runtimeWriteTransport: createRuntimeWriteTransport({
client: { call } as never,
useDomainIntents: false,
}),
approvalId: "appr-1",
decision: "allow-once",
getAgents: () => [agent],
getLatestAgent: () => agent,
getPendingState: () => ({
approvalsByAgentId: approvalsByAgentId.get(),
unscopedApprovals: unscopedApprovals.get(),
}),
setPendingExecApprovalsByAgentId: approvalsByAgentId.set,
setUnscopedPendingExecApprovals: unscopedApprovals.set,
requestHistoryRefresh,
isDisconnectLikeError: () => false,
});
expect(requestHistoryRefresh).toHaveBeenCalledWith("a1");
});
it("uses exec-approval-resolve intent in domain mode", async () => {
const call = vi.fn(async (method: string) => {
if (method === "exec.approval.resolve") {
+254 -15
View File
@@ -65,6 +65,33 @@ describe("gateway agent helpers", () => {
expect(entry.name).toBe("My Project");
});
it("derives create workspaces from the same normalized id OpenClaw returns", async () => {
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
exists: true,
hash: "hash-create-underscore-1",
path: "/Users/test/.openclaw/openclaw.json",
config: { agents: { list: [] } },
};
}
if (method === "agents.create") {
expect(params).toEqual({
name: "My_Agent",
workspace: "/Users/test/.openclaw/workspace-my_agent",
});
return { ok: true, agentId: "my_agent", name: "My_Agent", workspace: "ignored" };
}
throw new Error("unexpected method");
}),
} as unknown as GatewayClient;
const entry = await createGatewayAgent({ client, name: "My_Agent" });
expect(entry.id).toBe("my_agent");
expect(entry.name).toBe("My_Agent");
});
it("returns no-op on deleting a missing agent", async () => {
const client = {
call: vi.fn(async (method: string) => {
@@ -99,28 +126,40 @@ describe("gateway agent helpers", () => {
expect(client.call).not.toHaveBeenCalled();
});
it("fails when create name produces an empty id slug", async () => {
it("fails when create name resolves to the reserved main agent id", async () => {
const client = {
call: vi.fn(async (method: string) => {
if (method === "config.get") {
return {
exists: true,
hash: "hash-create-empty-slug-1",
path: "/Users/test/.openclaw/openclaw.json",
config: {
agents: { list: [] },
},
};
}
call: vi.fn(async () => {
throw new Error("unexpected method");
}),
} as unknown as GatewayClient;
await expect(createGatewayAgent({ client, name: "!!!" })).rejects.toThrow(
"Name produced an empty folder name."
'Agent name resolves to reserved agent id "main".'
);
expect(client.call).not.toHaveBeenCalled();
});
it("rejects unsafe agent ids returned by agents.create", async () => {
const client = {
call: vi.fn(async (method: string) => {
if (method === "config.get") {
return {
exists: true,
hash: "hash-create-unsafe-id-1",
path: "/Users/test/.openclaw/openclaw.json",
config: { agents: { list: [] } },
};
}
if (method === "agents.create") {
return { ok: true, agentId: "../new-agent", name: "New Agent" };
}
throw new Error("unexpected method");
}),
} as unknown as GatewayClient;
await expect(createGatewayAgent({ client, name: "New Agent" })).rejects.toThrow(
"Invalid agentId: ../new-agent"
);
expect(client.call).toHaveBeenCalledTimes(1);
expect((client.call as ReturnType<typeof vi.fn>).mock.calls[0]?.[0]).toBe("config.get");
});
it("returns current settings when no heartbeat override exists to remove", async () => {
@@ -264,4 +303,204 @@ describe("gateway agent helpers", () => {
expect(result.heartbeat.includeReasoning).toBe(true);
expect(result.hasOverride).toBe(true);
});
it("normalizes heartbeat agent ids before writing overrides", async () => {
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
exists: true,
hash: "hash-trim-update-1",
config: {
agents: {
list: [{ id: "agent-1" }],
},
},
};
}
if (method === "config.patch") {
const raw = (params as { raw?: string }).raw ?? "";
const parsed = JSON.parse(raw) as {
agents?: { list?: Array<{ id?: string; heartbeat?: unknown }> };
};
expect(parsed.agents?.list?.map((entry) => entry.id)).toEqual(["agent-1"]);
expect(parsed.agents?.list?.[0]?.heartbeat).toEqual({
every: "15m",
target: "last",
includeReasoning: false,
});
return { ok: true };
}
throw new Error("unexpected method");
}),
} as unknown as GatewayClient;
const result = await updateGatewayHeartbeat({
client,
agentId: " agent-1 ",
payload: {
override: true,
heartbeat: {
every: "15m",
target: "last",
includeReasoning: false,
},
},
});
expect(result.hasOverride).toBe(true);
expect(client.call).toHaveBeenCalledTimes(2);
});
it("rejects blank heartbeat update agent ids before touching the gateway", async () => {
const client = {
call: vi.fn(),
} as unknown as GatewayClient;
await expect(
updateGatewayHeartbeat({
client,
agentId: " ",
payload: {
override: true,
heartbeat: {
every: "15m",
target: "last",
includeReasoning: false,
},
},
})
).rejects.toThrow("Agent id is required.");
expect(client.call).not.toHaveBeenCalled();
});
it("normalizes heartbeat agent ids before removing overrides", async () => {
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
return {
exists: true,
hash: "hash-trim-remove-1",
config: {
agents: {
list: [
{
id: "agent-1",
heartbeat: { every: "15m", target: "last", includeReasoning: false },
},
],
},
},
};
}
if (method === "config.patch") {
const raw = (params as { raw?: string }).raw ?? "";
const parsed = JSON.parse(raw) as {
agents?: { list?: Array<{ id?: string; heartbeat?: unknown }> };
};
expect(parsed.agents?.list).toEqual([{ id: "agent-1" }]);
return { ok: true };
}
throw new Error("unexpected method");
}),
} as unknown as GatewayClient;
const result = await removeGatewayHeartbeatOverride({
client,
agentId: " agent-1 ",
});
expect(result.hasOverride).toBe(false);
expect(client.call).toHaveBeenCalledTimes(2);
});
it("rejects blank heartbeat removal agent ids before touching the gateway", async () => {
const client = {
call: vi.fn(),
} as unknown as GatewayClient;
await expect(
removeGatewayHeartbeatOverride({
client,
agentId: " ",
})
).rejects.toThrow("Agent id is required.");
expect(client.call).not.toHaveBeenCalled();
});
it("rebuilds heartbeat retry patches from the latest agent list", async () => {
let getCount = 0;
let patchCount = 0;
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
if (method === "config.get") {
getCount += 1;
if (getCount === 1) {
return {
exists: true,
hash: "hash-old",
config: {
agents: {
list: [{ id: "agent-1" }],
},
},
};
}
return {
exists: true,
hash: "hash-fresh",
config: {
agents: {
list: [
{ id: "agent-1" },
{ id: "agent-2", heartbeat: { every: "5m", target: "last", includeReasoning: false } },
],
},
},
};
}
if (method === "config.patch") {
patchCount += 1;
const payload = params as { raw?: string; baseHash?: string };
if (patchCount === 1) {
expect(payload.baseHash).toBe("hash-old");
throw new GatewayResponseError({
code: "INVALID_REQUEST",
message: "config changed since last load; re-run config.get and retry",
});
}
expect(payload.baseHash).toBe("hash-fresh");
const parsed = JSON.parse(payload.raw ?? "{}") as {
agents?: { list?: Array<{ id?: string; heartbeat?: unknown }> };
};
expect(parsed.agents?.list?.find((entry) => entry.id === "agent-2")).toEqual({
id: "agent-2",
heartbeat: { every: "5m", target: "last", includeReasoning: false },
});
expect(parsed.agents?.list?.find((entry) => entry.id === "agent-1")?.heartbeat).toEqual({
every: "15m",
target: "none",
includeReasoning: true,
});
return { ok: true };
}
throw new Error("unexpected method");
}),
} as unknown as GatewayClient;
await updateGatewayHeartbeat({
client,
agentId: "agent-1",
payload: {
override: true,
heartbeat: {
every: "15m",
target: "none",
includeReasoning: true,
},
},
});
expect(patchCount).toBe(2);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { buildGatewayConnectProfile } from "@/lib/controlplane/gateway-connect-profile";
describe("gateway connect profile", () => {
it("normalizes loopback IPv6 origins for legacy control-ui fallback", () => {
const profile = buildGatewayConnectProfile({
profileId: "legacy-control-ui",
upstreamUrl: "ws://[::1]:18789",
token: "token",
protocol: 3,
capabilities: ["tool-events"],
});
expect(profile.socketOptions.origin).toBe("http://localhost:18789");
});
it("preserves brackets for non-loopback IPv6 origins", () => {
const profile = buildGatewayConnectProfile({
profileId: "legacy-control-ui",
upstreamUrl: "wss://[2001:db8::1]:18789",
token: "token",
protocol: 3,
capabilities: ["tool-events"],
});
expect(profile.socketOptions.origin).toBe("https://[2001:db8::1]:18789");
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { isGatewayDisconnectLikeError } from "@/lib/gateway/gateway-disconnect";
describe("isGatewayDisconnectLikeError", () => {
it("recognizes gateway close code 1012 as disconnect-like", () => {
expect(isGatewayDisconnectLikeError(new Error("gateway closed (1012): service restart"))).toBe(
true
);
});
it("does not classify unexpected close codes as disconnect-like", () => {
expect(isGatewayDisconnectLikeError(new Error("gateway closed (1006): abnormal closure"))).toBe(
false
);
});
it("keeps existing stopped-client messages disconnect-like", () => {
expect(isGatewayDisconnectLikeError(new Error("gateway client stopped"))).toBe(true);
});
});
+124 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
import { upsertGatewayAgentExecApprovals } from "@/lib/gateway/execApprovals";
import {
readGatewayAgentExecApprovals,
upsertGatewayAgentExecApprovals,
} from "@/lib/gateway/execApprovals";
describe("upsertGatewayAgentExecApprovals", () => {
it("writes per-agent policy with base hash", async () => {
@@ -52,6 +55,33 @@ describe("upsertGatewayAgentExecApprovals", () => {
});
});
it("rejects unsafe agent ids before reading or writing approval policy", async () => {
const client = {
call: vi.fn(),
} as unknown as GatewayClient;
await expect(
upsertGatewayAgentExecApprovals({
client,
agentId: "../agent-1",
policy: {
security: "allowlist",
ask: "always",
allowlist: [],
},
})
).rejects.toThrow("Invalid agentId: ../agent-1");
await expect(
readGatewayAgentExecApprovals({
client,
agentId: "agent.1",
})
).rejects.toThrow("Invalid agentId: agent.1");
expect(client.call).not.toHaveBeenCalled();
});
it("removes per-agent policy when policy is null", async () => {
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
@@ -132,4 +162,97 @@ describe("upsertGatewayAgentExecApprovals", () => {
expect(setAttempts).toBe(2);
});
it("rebuilds retry payload from the latest exec approvals snapshot", async () => {
let getCount = 0;
let setCount = 0;
const client = {
call: vi.fn(async (method: string, params?: unknown) => {
if (method === "exec.approvals.get") {
getCount += 1;
if (getCount === 1) {
return {
exists: true,
hash: "hash-1",
file: {
version: 1,
agents: {
"agent-1": {
security: "allowlist",
ask: "always",
allowlist: [{ pattern: "/bin/old" }],
},
"agent-2": {
security: "allowlist",
ask: "always",
allowlist: [{ pattern: "/bin/shared" }],
},
},
},
};
}
return {
exists: true,
hash: "hash-2",
file: {
version: 1,
agents: {
"agent-1": {
security: "allowlist",
ask: "always",
allowlist: [{ pattern: "/bin/new" }],
},
"agent-2": {
security: "full",
ask: "off",
allowlist: [{ pattern: "/bin/shared" }, { pattern: "/bin/extra" }],
},
"agent-3": {
security: "allowlist",
ask: "always",
allowlist: [{ pattern: "/bin/third" }],
},
},
},
};
}
if (method === "exec.approvals.set") {
setCount += 1;
const payload = params as {
baseHash?: string;
file?: { agents?: Record<string, unknown> };
};
if (setCount === 1) {
expect(payload.baseHash).toBe("hash-1");
throw new GatewayResponseError({
code: "INVALID_REQUEST",
message: "exec approvals changed since last load; re-run exec.approvals.get and retry",
});
}
expect(payload.baseHash).toBe("hash-2");
expect(payload.file?.agents?.["agent-1"]).toBeUndefined();
expect(payload.file?.agents?.["agent-2"]).toEqual({
security: "full",
ask: "off",
allowlist: [{ pattern: "/bin/shared" }, { pattern: "/bin/extra" }],
});
expect(payload.file?.agents?.["agent-3"]).toEqual({
security: "allowlist",
ask: "always",
allowlist: [{ pattern: "/bin/third" }],
});
return { ok: true };
}
throw new Error(`unexpected method: ${method}`);
}),
} as unknown as GatewayClient;
await upsertGatewayAgentExecApprovals({
client,
agentId: "agent-1",
policy: null,
});
expect(setCount).toBe(2);
});
});
+138
View File
@@ -123,4 +123,142 @@ describe("/api/runtime/media route", () => {
expect(typeof options.maxBuffer).toBe("number");
expect(options.maxBuffer).toBeGreaterThan(payloadBytes.length);
});
it("uses configured ssh target for media even when gateway url is loopback", async () => {
tempDir = makeTempDir("gateway-media-route-tunnel");
process.env.OPENCLAW_STATE_DIR = tempDir;
process.env.OPENCLAW_GATEWAY_SSH_TARGET = "me@tunnel-host.test";
writeStudioSettings(tempDir, "ws://localhost:18789");
const payloadBytes = Buffer.from("fake", "utf8");
mockedSpawnSync.mockReturnValueOnce({
status: 0,
stdout: JSON.stringify({
ok: true,
mime: "image/png",
size: payloadBytes.length,
data: payloadBytes.toString("base64"),
}),
stderr: "",
error: undefined,
} as never);
const remotePath = "/home/ubuntu/.openclaw/images/tunnel.png";
const response = await GET(
new Request(`http://localhost/api/runtime/media?path=${encodeURIComponent(remotePath)}`)
);
expect(response.status).toBe(200);
expect(mockedSpawnSync).toHaveBeenCalledTimes(1);
const [, args] = mockedSpawnSync.mock.calls[0] as [string, string[]];
expect(args).toEqual(expect.arrayContaining(["me@tunnel-host.test", "bash", "-s", "--", remotePath]));
});
it("rejects remote media when decoded payload exceeds the route limit", async () => {
tempDir = makeTempDir("gateway-media-route-remote-size");
process.env.OPENCLAW_STATE_DIR = tempDir;
process.env.OPENCLAW_GATEWAY_SSH_TARGET = "me@host.test";
writeStudioSettings(tempDir, "ws://example.test:18789");
const payloadBytes = Buffer.alloc(25 * 1024 * 1024 + 1, 1);
mockedSpawnSync.mockReturnValueOnce({
status: 0,
stdout: JSON.stringify({
ok: true,
mime: "image/png",
size: 1,
data: payloadBytes.toString("base64"),
}),
stderr: "",
error: undefined,
} as never);
const response = await GET(
new Request(
`http://localhost/api/runtime/media?path=${encodeURIComponent("/home/ubuntu/.openclaw/images/too-large.png")}`
)
);
const body = (await response.json()) as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toContain("media file too large");
});
it("falls back to extension MIME when remote media returns an unsupported MIME", async () => {
tempDir = makeTempDir("gateway-media-route-remote-mime");
process.env.OPENCLAW_STATE_DIR = tempDir;
process.env.OPENCLAW_GATEWAY_SSH_TARGET = "me@host.test";
writeStudioSettings(tempDir, "ws://example.test:18789");
const payloadBytes = Buffer.from("fake", "utf8");
mockedSpawnSync.mockReturnValueOnce({
status: 0,
stdout: JSON.stringify({
ok: true,
mime: "text/html",
size: payloadBytes.length,
data: payloadBytes.toString("base64"),
}),
stderr: "",
error: undefined,
} as never);
const response = await GET(
new Request(
`http://localhost/api/runtime/media?path=${encodeURIComponent("/home/ubuntu/.openclaw/images/pic.png")}`
)
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
});
it("returns local media from the configured OpenClaw state directory", async () => {
tempDir = makeTempDir("gateway-media-route-local");
const stateDir = path.join(tempDir, "state");
process.env.OPENCLAW_STATE_DIR = stateDir;
writeStudioSettings(stateDir, "ws://localhost:18789");
const mediaDir = path.join(stateDir, "agents", "agent-1");
fs.mkdirSync(mediaDir, { recursive: true });
const mediaPath = path.join(mediaDir, "screenshot.png");
const payloadBytes = Buffer.from("fake-png", "utf8");
fs.writeFileSync(mediaPath, payloadBytes);
const response = await GET(
new Request(`http://localhost/api/runtime/media?path=${encodeURIComponent(mediaPath)}`)
);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/png");
expect(response.headers.get("Content-Length")).toBe(String(payloadBytes.length));
const buf = Buffer.from(await response.arrayBuffer());
expect(buf.equals(payloadBytes)).toBe(true);
expect(mockedSpawnSync).not.toHaveBeenCalled();
});
it("rejects local media paths that symlink outside the configured state directory", async () => {
tempDir = makeTempDir("gateway-media-route-symlink");
const stateDir = path.join(tempDir, "state");
const outsideDir = path.join(tempDir, "outside");
process.env.OPENCLAW_STATE_DIR = stateDir;
writeStudioSettings(stateDir, "ws://localhost:18789");
fs.mkdirSync(stateDir, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
const outsidePath = path.join(outsideDir, "secret.png");
const linkPath = path.join(stateDir, "linked-secret.png");
fs.writeFileSync(outsidePath, Buffer.from("outside", "utf8"));
fs.symlinkSync(outsidePath, linkPath);
const response = await GET(
new Request(`http://localhost/api/runtime/media?path=${encodeURIComponent(linkPath)}`)
);
const body = (await response.json()) as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toContain("Refusing to read media outside");
expect(mockedSpawnSync).not.toHaveBeenCalled();
});
});
@@ -761,7 +761,7 @@ describe("gateway runtime event handler (chat)", () => {
expect(requestHistoryRefresh).not.toHaveBeenCalled();
});
it("applies aborted terminal cleanup even when runId mismatches active run", () => {
it("ignores stale aborted terminal events for non-active runIds", () => {
const agents = [
createAgent({
status: "running",
@@ -772,10 +772,11 @@ describe("gateway runtime event handler (chat)", () => {
}),
];
const dispatch = vi.fn();
const queueLivePatch = vi.fn();
const handler = createGatewayRuntimeEventHandler({
getAgents: () => agents,
dispatch,
queueLivePatch: vi.fn(),
queueLivePatch,
clearPendingLivePatch: vi.fn(),
now: () => 1000,
requestHistoryRefresh: vi.fn(async () => {}),
@@ -796,16 +797,36 @@ describe("gateway runtime event handler (chat)", () => {
},
});
expect(dispatch).toHaveBeenCalledWith(
expect(dispatch).not.toHaveBeenCalledWith(
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." })
);
expect(dispatch).toHaveBeenCalledWith(
expect(dispatch).not.toHaveBeenCalledWith(
expect.objectContaining({
type: "updateAgent",
agentId: "agent-1",
patch: expect.objectContaining({ status: "idle", runId: null }),
})
);
handler.handleEvent({
type: "event",
event: "chat",
payload: {
runId: "run-active",
sessionKey: agents[0]!.sessionKey,
state: "delta",
message: { role: "assistant", content: "still active" },
},
});
expect(queueLivePatch).toHaveBeenCalledWith(
"agent-1",
expect.objectContaining({
runId: "run-active",
streamText: "still active",
status: "running",
})
);
});
it("handles aborted/error by appending output and clearing stream fields", () => {
+9
View File
@@ -32,6 +32,15 @@ describe("gateway ssh target resolution", () => {
).toBe("ubuntu@example.test");
});
it("strips brackets from ipv6 gateway urls when deriving ssh target", () => {
expect(
resolveGatewaySshTargetFromGatewayUrl(
"wss://[fd7a:115c:a1e0::1]:18789",
{} as unknown as NodeJS.ProcessEnv
)
).toBe("ubuntu@fd7a:115c:a1e0::1");
});
it("throws_on_missing_gateway_url_when_no_env_override", () => {
expect(() =>
resolveGatewaySshTargetFromGatewayUrl("", {} as unknown as NodeJS.ProcessEnv)
+732 -1
View File
@@ -41,6 +41,154 @@ describe("intent routes", () => {
});
});
it("agent-file-set route rejects unsupported file names before gateway writes", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const mod = await import("@/app/api/intents/agent-file-set/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/agent-file-set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentId: "agent-1",
name: "../profile.json",
content: "{}",
}),
})
);
const body = await response.json() as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toContain("Unsupported agent file name");
expect(callGateway).not.toHaveBeenCalled();
});
it("agent mutation routes reject malformed agent ids before gateway normalization", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const deleteRoute = await import("@/app/api/intents/agent-delete/route");
const renameRoute = await import("@/app/api/intents/agent-rename/route");
const fileSetRoute = await import("@/app/api/intents/agent-file-set/route");
const cronAddRoute = await import("@/app/api/intents/cron-add/route");
const invalidAgentId = "../agent-1";
const deleteResponse = await deleteRoute.POST(
new Request("http://localhost/api/intents/agent-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: invalidAgentId }),
})
);
const renameResponse = await renameRoute.POST(
new Request("http://localhost/api/intents/agent-rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: invalidAgentId, name: "Agent One" }),
})
);
const fileSetResponse = await fileSetRoute.POST(
new Request("http://localhost/api/intents/agent-file-set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentId: invalidAgentId,
name: "AGENTS.md",
content: "hello",
}),
})
);
const cronAddResponse = await cronAddRoute.POST(
new Request("http://localhost/api/intents/cron-add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: invalidAgentId, name: "Job" }),
})
);
expect(deleteResponse.status).toBe(400);
expect(renameResponse.status).toBe(400);
expect(fileSetResponse.status).toBe(400);
expect(cronAddResponse.status).toBe(400);
expect(await deleteResponse.json()).toMatchObject({ error: `Invalid agentId: ${invalidAgentId}` });
expect(callGateway).not.toHaveBeenCalled();
});
it("cron-add rejects session keys that do not belong to the agent", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const cronAddRoute = await import("@/app/api/intents/cron-add/route");
const response = await cronAddRoute.POST(
new Request("http://localhost/api/intents/cron-add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Morning brief",
agentId: "agent-1",
sessionKey: "agent:agent-2:main",
}),
})
);
const body = await response.json() as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toBe("sessionKey does not match agentId.");
expect(callGateway).not.toHaveBeenCalled();
});
it("cron-add forwards shorthand session keys for the selected agent", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const cronAddRoute = await import("@/app/api/intents/cron-add/route");
const response = await cronAddRoute.POST(
new Request("http://localhost/api/intents/cron-add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Morning brief",
agentId: "agent-1",
sessionKey: " project-alpha-monitor ",
}),
})
);
expect(response.status).toBe(200);
expect(callGateway).toHaveBeenCalledWith(
"cron.add",
expect.objectContaining({
name: "Morning brief",
agentId: "agent-1",
sessionKey: "project-alpha-monitor",
})
);
});
it("sessions-reset, session-settings-sync, and agent-wait routes forward expected payloads", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
@@ -100,7 +248,7 @@ describe("intent routes", () => {
expect(callGateway).toHaveBeenCalledWith(
"agent.wait",
{ runId: "run-1", timeoutMs: 3000 },
{ timeoutMs: 3000 }
{ timeoutMs: 8000 }
);
expect(callGateway).toHaveBeenCalledWith(
"cron.run",
@@ -109,6 +257,88 @@ describe("intent routes", () => {
);
});
it("agent-wait keeps transport timeout above short poll timeout", async () => {
const callGateway = vi.fn(async () => ({ status: "ok" }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const waitRoute = await import("@/app/api/intents/agent-wait/route");
const response = await waitRoute.POST(
new Request("http://localhost/api/intents/agent-wait", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ runId: "run-1", timeoutMs: 1 }),
})
);
expect(response.status).toBe(200);
expect(callGateway).toHaveBeenCalledWith(
"agent.wait",
{ runId: "run-1", timeoutMs: 1 },
{ timeoutMs: 5001 }
);
});
it("session mutation routes reject malformed agent-prefixed session keys before gateway normalization", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const chatSendRoute = await import("@/app/api/intents/chat-send/route");
const chatAbortRoute = await import("@/app/api/intents/chat-abort/route");
const resetRoute = await import("@/app/api/intents/sessions-reset/route");
const sessionSettingsRoute = await import("@/app/api/intents/session-settings-sync/route");
const invalidSessionKey = "agent:../agent-1:main";
const chatSendResponse = await chatSendRoute.POST(
new Request("http://localhost/api/intents/chat-send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionKey: invalidSessionKey,
message: "hello",
idempotencyKey: "msg-1",
}),
})
);
const chatAbortResponse = await chatAbortRoute.POST(
new Request("http://localhost/api/intents/chat-abort", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionKey: invalidSessionKey }),
})
);
const resetResponse = await resetRoute.POST(
new Request("http://localhost/api/intents/sessions-reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: invalidSessionKey }),
})
);
const sessionSettingsResponse = await sessionSettingsRoute.POST(
new Request("http://localhost/api/intents/session-settings-sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionKey: invalidSessionKey, model: "openai/gpt-5" }),
})
);
expect(chatSendResponse.status).toBe(400);
expect(chatAbortResponse.status).toBe(400);
expect(resetResponse.status).toBe(400);
expect(sessionSettingsResponse.status).toBe(400);
expect(callGateway).not.toHaveBeenCalled();
});
it("agent-create route composes workspace from config path and forwards to agents.create", async () => {
const callGateway = vi.fn(async (method: string) => {
if (method === "config.get") {
@@ -149,6 +379,112 @@ describe("intent routes", () => {
});
});
it("agent-create route derives workspace from the gateway-normalized agent id", async () => {
const callGateway = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
path: "/tmp/.openclaw/openclaw.json",
};
}
if (method === "agents.create") {
return {
ok: true,
agentId: "agent_two",
name: "Agent_Two",
workspace: "/tmp/.openclaw/workspace-agent_two",
};
}
return { ok: true };
});
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const mod = await import("@/app/api/intents/agent-create/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/agent-create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Agent_Two" }),
})
);
expect(response.status).toBe(200);
expect(callGateway).toHaveBeenCalledWith("agents.create", {
name: "Agent_Two",
workspace: "/tmp/.openclaw/workspace-agent_two",
});
});
it("agent-create route rejects names that normalize to the reserved main id", async () => {
const callGateway = vi.fn();
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const mod = await import("@/app/api/intents/agent-create/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/agent-create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "!!!" }),
})
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({
error: 'Agent name resolves to reserved agent id "main".',
});
expect(callGateway).not.toHaveBeenCalled();
});
it("agent-create route rejects unsafe gateway-created agent ids", async () => {
const callGateway = vi.fn(async (method: string) => {
if (method === "config.get") {
return {
path: "/tmp/.openclaw/openclaw.json",
};
}
if (method === "agents.create") {
return {
ok: true,
agentId: "../agent-two",
name: "Agent Two",
};
}
return { ok: true };
});
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const mod = await import("@/app/api/intents/agent-create/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/agent-create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Agent Two" }),
})
);
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({
error: "Gateway returned an invalid agents.create response (missing or invalid agentId).",
});
});
it("agent-permissions-update route performs config/session updates server-side", async () => {
const upsert = vi.fn(async () => undefined);
const callGateway = vi.fn(async (method: string) => {
@@ -219,6 +555,90 @@ describe("intent routes", () => {
});
});
it("agent-permissions-update rejects session keys for another agent before writes", async () => {
const upsert = vi.fn(async () => undefined);
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
vi.doMock("@/lib/controlplane/exec-approvals", async () => {
const actual = await vi.importActual<typeof import("@/lib/controlplane/exec-approvals")>(
"@/lib/controlplane/exec-approvals"
);
return {
...actual,
upsertAgentExecApprovalsPolicyViaRuntime: upsert,
};
});
const mod = await import("@/app/api/intents/agent-permissions-update/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/agent-permissions-update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentId: "agent-1",
sessionKey: "agent:agent-2:main",
commandMode: "ask",
webAccess: true,
fileTools: true,
}),
})
);
const body = await response.json() as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toBe("sessionKey does not match agentId.");
expect(callGateway).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
});
it("agent-permissions-update rejects malformed explicit session keys before writes", async () => {
const upsert = vi.fn(async () => undefined);
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
vi.doMock("@/lib/controlplane/exec-approvals", async () => {
const actual = await vi.importActual<typeof import("@/lib/controlplane/exec-approvals")>(
"@/lib/controlplane/exec-approvals"
);
return {
...actual,
upsertAgentExecApprovalsPolicyViaRuntime: upsert,
};
});
const mod = await import("@/app/api/intents/agent-permissions-update/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/agent-permissions-update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentId: "agent-1",
sessionKey: "main",
commandMode: "ask",
webAccess: true,
fileTools: true,
}),
})
);
const body = await response.json() as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toBe("sessionKey does not match agentId.");
expect(callGateway).not.toHaveBeenCalled();
expect(upsert).not.toHaveBeenCalled();
});
it("agent-permissions-update returns conflict without mutating approvals", async () => {
const upsert = vi.fn(async () => undefined);
const { ControlPlaneGatewayError } = await import("@/lib/controlplane/openclaw-adapter");
@@ -287,6 +707,118 @@ describe("intent routes", () => {
expect(upsert).not.toHaveBeenCalled();
});
it("agent-permissions-update retries config conflicts against the fresh tool config", async () => {
const upsert = vi.fn(async () => undefined);
const { ControlPlaneGatewayError } = await import("@/lib/controlplane/openclaw-adapter");
let configGetCount = 0;
let configSetCount = 0;
const configSetPayloads: Array<{ raw?: string; baseHash?: string }> = [];
const callGateway = vi.fn(async (method: string, payload?: { raw?: string; baseHash?: string }) => {
if (method === "config.get") {
configGetCount += 1;
if (configGetCount === 1) {
return {
hash: "cfg-hash-old",
exists: true,
config: {
agents: {
list: [
{
id: "agent-1",
sandbox: { mode: "normal" },
tools: { alsoAllow: ["custom"], deny: [] },
},
],
},
},
};
}
return {
hash: "cfg-hash-fresh",
exists: true,
config: {
agents: {
list: [
{
id: "agent-1",
sandbox: { mode: "all" },
tools: { alsoAllow: ["custom", "group:extra"], deny: ["group:web"] },
},
],
},
},
};
}
if (method === "config.set") {
configSetCount += 1;
configSetPayloads.push(payload ?? {});
if (configSetCount === 1) {
throw new ControlPlaneGatewayError({
code: "INVALID_REQUEST",
message: "config baseHash changed since last load; re-run config.get",
});
}
return { ok: true };
}
return { ok: true };
});
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
vi.doMock("@/lib/controlplane/exec-approvals", async () => {
const actual = await vi.importActual<typeof import("@/lib/controlplane/exec-approvals")>(
"@/lib/controlplane/exec-approvals"
);
return {
...actual,
upsertAgentExecApprovalsPolicyViaRuntime: upsert,
};
});
const mod = await import("@/app/api/intents/agent-permissions-update/route");
const response = await mod.POST(
new Request("http://localhost/api/intents/agent-permissions-update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentId: "agent-1",
sessionKey: "agent:agent-1:main",
commandMode: "ask",
webAccess: false,
fileTools: true,
}),
})
);
expect(response.status).toBe(200);
expect(configSetPayloads).toHaveLength(2);
expect(configSetPayloads[1]?.baseHash).toBe("cfg-hash-fresh");
const finalConfig = JSON.parse(configSetPayloads[1]?.raw ?? "{}") as {
agents?: { list?: Array<{ id?: string; tools?: { alsoAllow?: string[]; deny?: string[] } }> };
};
const finalTools = finalConfig.agents?.list?.find((entry) => entry.id === "agent-1")?.tools;
expect(finalTools?.alsoAllow).toEqual([
"custom",
"group:extra",
"group:runtime",
"group:fs",
]);
expect(finalTools?.deny).toEqual(["group:web"]);
expect(upsert).toHaveBeenCalledWith(
expect.objectContaining({ agentId: "agent-1", role: "collaborative" })
);
expect(callGateway).toHaveBeenCalledWith("sessions.patch", {
key: "agent:agent-1:main",
execHost: "sandbox",
execSecurity: "allowlist",
execAsk: "always",
});
});
it("chat-send returns deterministic gateway_unavailable response when runtime cannot start", async () => {
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
@@ -467,6 +999,205 @@ describe("intent routes", () => {
expect(callGateway).not.toHaveBeenCalledWith("cron.remove", { id: "job-2" });
});
it("cron-remove-agent removes jobs whose agent id differs only by gateway casing", async () => {
const callGateway = vi.fn(async (method: string) => {
if (method === "cron.list") {
return {
jobs: [
{
id: "job-1",
name: "Job One",
agentId: "Agent-1",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Do work" },
},
{
id: "job-2",
name: "Job Two",
agentId: "agent-2",
enabled: true,
schedule: { kind: "every", everyMs: 120_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Other" },
},
],
};
}
if (method === "cron.remove") {
return { ok: true, removed: true };
}
return { ok: true };
});
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const removeRoute = await import("@/app/api/intents/cron-remove-agent/route");
const response = await removeRoute.POST(
new Request("http://localhost/api/intents/cron-remove-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: "agent-1" }),
})
);
const body = (await response.json()) as { payload?: { removedJobs?: Array<{ agentId?: string }> } };
expect(response.status).toBe(200);
expect(callGateway).toHaveBeenCalledWith("cron.remove", { id: "job-1" });
expect(callGateway).not.toHaveBeenCalledWith("cron.remove", { id: "job-2" });
expect(body.payload?.removedJobs?.[0]?.agentId).toBe("agent-1");
});
it("cron-restore rejects session keys that do not belong to the job agent", async () => {
const callGateway = vi.fn(async () => ({ ok: true }));
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const restoreRoute = await import("@/app/api/intents/cron-restore/route");
const response = await restoreRoute.POST(
new Request("http://localhost/api/intents/cron-restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jobs: [
{
name: "Job One",
agentId: "agent-1",
sessionKey: "agent:agent-2:main",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Do work" },
},
],
}),
})
);
const body = await response.json() as { error?: string };
expect(response.status).toBe(400);
expect(body.error).toBe("jobs[0].sessionKey does not match agentId.");
expect(callGateway).not.toHaveBeenCalled();
});
it("cron-remove-agent validates all restore payloads before deleting any job", async () => {
const callGateway = vi.fn(async (method: string) => {
if (method === "cron.list") {
return {
jobs: [
{
id: "job-1",
name: "Job One",
agentId: "agent-1",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Do work" },
},
{
id: "job-2",
name: "Broken Job",
agentId: "agent-1",
enabled: true,
schedule: { kind: "every", everyMs: 120_000 },
sessionTarget: "isolated",
wakeMode: "now",
},
],
};
}
if (method === "cron.remove") {
return { ok: true, removed: true };
}
return { ok: true };
});
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const removeRoute = await import("@/app/api/intents/cron-remove-agent/route");
const response = await removeRoute.POST(
new Request("http://localhost/api/intents/cron-remove-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: "agent-1" }),
})
);
const body = (await response.json()) as { error?: string };
expect(response.status).toBe(500);
expect(body.error).toContain("Cron job job-2 is missing payload.");
expect(callGateway).toHaveBeenCalledWith("cron.list", { includeDisabled: true });
expect(callGateway).not.toHaveBeenCalledWith("cron.remove", expect.anything());
});
it("cron-remove-agent validates backup session keys before deleting any job", async () => {
const callGateway = vi.fn(async (method: string) => {
if (method === "cron.list") {
return {
jobs: [
{
id: "job-1",
name: "Job One",
agentId: "agent-1",
sessionKey: "agent:agent-2:main",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "now",
payload: { kind: "agentTurn", message: "Do work" },
},
],
};
}
if (method === "cron.remove") {
return { ok: true, removed: true };
}
return { ok: true };
});
vi.doMock("@/lib/controlplane/runtime", () => ({
isStudioDomainApiModeEnabled: () => true,
getControlPlaneRuntime: () => ({
ensureStarted: async () => {},
callGateway,
}),
}));
const removeRoute = await import("@/app/api/intents/cron-remove-agent/route");
const response = await removeRoute.POST(
new Request("http://localhost/api/intents/cron-remove-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId: "agent-1" }),
})
);
const body = await response.json() as { error?: string };
expect(response.status).toBe(500);
expect(body.error).toContain("Cron job job-1 sessionKey does not match agentId.");
expect(callGateway).toHaveBeenCalledWith("cron.list", { includeDisabled: true });
expect(callGateway).not.toHaveBeenCalledWith("cron.remove", expect.anything());
});
it("cron-remove-agent returns gateway_unavailable when runtime gateway is unavailable", async () => {
const { ControlPlaneGatewayError } = await import("@/lib/controlplane/openclaw-adapter");
const callGateway = vi.fn(async () => {
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
describe("isLocalGatewayUrl", () => {
it("classifies bracketed IPv6 loopback gateway URLs as local", () => {
expect(isLocalGatewayUrl("ws://[::1]:18789")).toBe(true);
});
it("does not classify non-loopback gateway hosts as local", () => {
expect(isLocalGatewayUrl("wss://gateway.example.test")).toBe(false);
});
});
+289
View File
@@ -101,6 +101,152 @@ describe("OpenClawGatewayAdapter", () => {
await adapter.stop();
});
it("can stop a connection attempt without wedging the next start", async () => {
class ManualConnectSocket extends EventEmitter {
readyState: number;
constructor(readyState: number) {
super();
this.readyState = readyState;
}
close() {
if (this.readyState === WebSocket.CLOSED) return;
this.readyState = WebSocket.CLOSED;
this.emit("close");
}
terminate() {
this.close();
}
send(raw: string, callback?: (err?: Error) => void) {
const parsed = JSON.parse(raw) as { id?: string; method?: string };
callback?.();
if (parsed.method !== "connect" || !parsed.id) {
return;
}
queueMicrotask(() => {
this.emit(
"message",
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
})
);
});
}
}
const sockets: ManualConnectSocket[] = [];
const createWebSocket = vi.fn(() => {
const socket = new ManualConnectSocket(
sockets.length === 0 ? WebSocket.CONNECTING : WebSocket.OPEN
);
sockets.push(socket);
if (sockets.length > 1) {
queueMicrotask(() => {
socket.emit(
"message",
JSON.stringify({ type: "event", event: "connect.challenge", payload: {} })
);
});
}
return socket as unknown as WebSocket;
});
const adapter = new OpenClawGatewayAdapter({
loadSettings: () => ({ url: "ws://127.0.0.1:9", token: "tkn" }),
createWebSocket,
});
const firstStart = adapter.start().then(
() => null,
(error: unknown) => error
);
await Promise.resolve();
expect(createWebSocket).toHaveBeenCalledTimes(1);
await adapter.stop();
const firstError = await firstStart;
expect(firstError).toBeInstanceOf(Error);
expect((firstError as Error).message).toBe("Control-plane adapter stopped.");
expect(adapter.getStatus()).toBe("stopped");
await adapter.start();
expect(createWebSocket).toHaveBeenCalledTimes(2);
expect(adapter.getStatus()).toBe("connected");
await adapter.stop();
});
it("forces an open socket closed when graceful stop does not finish", async () => {
vi.useFakeTimers();
class HangingCloseSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
terminated = false;
close() {
this.readyState = WebSocket.CLOSING;
}
terminate() {
this.terminated = true;
this.readyState = WebSocket.CLOSED;
}
send(raw: string, callback?: (err?: Error) => void) {
const parsed = JSON.parse(raw) as { id?: string; method?: string };
callback?.();
if (parsed.method !== "connect" || !parsed.id) {
return;
}
queueMicrotask(() => {
this.emit(
"message",
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
})
);
});
}
}
const socket = new HangingCloseSocket();
const adapter = new OpenClawGatewayAdapter({
loadSettings: () => ({ url: "ws://127.0.0.1:9", token: "tkn" }),
createWebSocket: () => socket as unknown as WebSocket,
});
queueMicrotask(() => {
socket.emit("message", JSON.stringify({ type: "event", event: "connect.challenge", payload: {} }));
});
await adapter.start();
let stopped = false;
const stopPromise = adapter.stop().then(() => {
stopped = true;
});
await Promise.resolve();
expect(stopped).toBe(false);
expect(socket.terminated).toBe(false);
await vi.advanceTimersByTimeAsync(1_000);
await stopPromise;
expect(stopped).toBe(true);
expect(socket.terminated).toBe(true);
expect(adapter.getStatus()).toBe("stopped");
});
it("rejects in-flight requests immediately when the socket closes", async () => {
upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
@@ -167,6 +313,61 @@ describe("OpenClawGatewayAdapter", () => {
await adapter.stop();
});
it("cleans up request state when gateway send throws synchronously", async () => {
class ThrowingRequestSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
close() {
if (this.readyState === WebSocket.CLOSED) return;
this.readyState = WebSocket.CLOSED;
this.emit("close");
}
terminate() {
this.close();
}
send(raw: string, callback?: (err?: Error) => void) {
const parsed = JSON.parse(raw) as { id?: string; method?: string };
if (parsed.method === "status") {
throw new Error("socket send failed");
}
callback?.();
if (parsed.method !== "connect" || !parsed.id) {
return;
}
queueMicrotask(() => {
this.emit(
"message",
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
})
);
});
}
}
const socket = new ThrowingRequestSocket();
const adapter = new OpenClawGatewayAdapter({
loadSettings: () => ({ url: "ws://127.0.0.1:9", token: "tkn" }),
createWebSocket: () => socket as unknown as WebSocket,
});
queueMicrotask(() => {
socket.emit("message", JSON.stringify({ type: "event", event: "connect.challenge", payload: {} }));
});
await adapter.start();
await expect(adapter.request("status", {})).rejects.toThrow(
"Failed to send gateway request for method: status"
);
await adapter.stop();
});
it("fails connect gracefully when sending the connect request throws", async () => {
class ThrowingConnectSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
@@ -271,6 +472,94 @@ describe("OpenClawGatewayAdapter", () => {
await adapter.stop();
});
it("ignores late close events from a timed-out stale socket after reconnect succeeds", async () => {
vi.useFakeTimers();
class ManualReconnectSocket extends EventEmitter {
readyState: number = WebSocket.OPEN;
close() {
if (this.readyState === WebSocket.CLOSED) return;
this.readyState = WebSocket.CLOSING;
}
terminate() {
if (this.readyState === WebSocket.CLOSED) return;
this.readyState = WebSocket.CLOSED;
this.emit("close");
}
send(raw: string, callback?: (err?: Error) => void) {
const parsed = JSON.parse(raw) as { id?: string; method?: string };
callback?.();
if (parsed.method !== "connect" || !parsed.id) {
return;
}
queueMicrotask(() => {
this.emit(
"message",
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3 },
})
);
});
}
}
const sockets: ManualReconnectSocket[] = [];
const createWebSocket = vi.fn(() => {
const socket = new ManualReconnectSocket();
sockets.push(socket);
if (sockets.length > 1) {
queueMicrotask(() => {
socket.emit(
"message",
JSON.stringify({ type: "event", event: "connect.challenge", payload: {} })
);
});
}
return socket as unknown as WebSocket;
});
const adapter = new OpenClawGatewayAdapter({
loadSettings: () => ({ url: "ws://127.0.0.1:9", token: "tkn" }),
createWebSocket,
});
const firstStart = adapter.start().then(
() => null,
(error: unknown) => error
);
await vi.advanceTimersByTimeAsync(8_000);
const firstError = await firstStart;
expect(firstError).toBeInstanceOf(Error);
expect((firstError as Error).message).toBe(
"Control-plane connect timed out waiting for connect response."
);
expect(adapter.getStatus()).toBe("error");
expect(createWebSocket).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1_000);
await Promise.resolve();
await Promise.resolve();
expect(createWebSocket).toHaveBeenCalledTimes(2);
expect(adapter.getStatus()).toBe("connected");
sockets[0].emit("close");
expect(adapter.getStatus()).toBe("connected");
await vi.advanceTimersByTimeAsync(15_000);
expect(createWebSocket).toHaveBeenCalledTimes(2);
const stopPromise = adapter.stop();
await vi.advanceTimersByTimeAsync(1_000);
await stopPromise;
});
it("emits gateway events with unique connection epochs across reconnect cycles", async () => {
upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
+42 -1
View File
@@ -74,6 +74,13 @@ describe("probe-agent-history-latency", () => {
});
});
it("fails fast when cli option values are missing", () => {
expect(() => parseProbeArgs(["--base-url", "--json"])).toThrow(
"Missing value for --base-url"
);
expect(() => parseProbeArgs(["--samples", "0"])).toThrow("Invalid --samples: 0");
});
it("resolves probe log directory from default and custom values", () => {
expect(resolveProbeLogDir(null)).toBe(
path.resolve(process.cwd(), ".agent/local/latency-probes")
@@ -146,10 +153,21 @@ describe("probe-agent-history-latency", () => {
}).map((entry) => entry.path)
).toEqual([
"/api/runtime/summary",
"/api/runtime/agents/main/history?limit=50&view=semantic&turnLimit=50&scanLimit=800",
"/api/runtime/agents/main/history?sessionKey=agent%3Amain%3Amain&limit=50&view=semantic&turnLimit=50&scanLimit=800",
]);
});
it("passes the resolved session key into the semantic history probe", () => {
expect(
buildProbePaths({
agentId: "alpha",
sessionKey: "agent:alpha:work item",
}).find((entry) => entry.name === "semantic-history")?.path
).toBe(
"/api/runtime/agents/alpha/history?sessionKey=agent%3Aalpha%3Awork+item&limit=50&view=semantic&turnLimit=50&scanLimit=800"
);
});
it("computes percentile and stats summaries", () => {
const durations = [100, 200, 300, 400, 500];
expect(percentile(durations, 50)).toBe(300);
@@ -358,6 +376,29 @@ describe("probe-agent-history-latency", () => {
});
});
it("fails runtime preflight on malformed summary payloads", () => {
expect(
assessRuntimePreflight({
response: { ok: true, body: null },
allowDisconnected: true,
}).message
).toContain("invalid /api/runtime/summary payload");
expect(
assessRuntimePreflight({
response: { ok: true, body: {} },
allowDisconnected: true,
}).message
).toContain("missing summary");
expect(
assessRuntimePreflight({
response: { ok: true, body: { summary: {} } },
allowDisconnected: true,
}).message
).toContain("summary.status missing");
});
it("persists run logs as jsonl history and latest snapshot", () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-studio-probe-"));
const persisted = persistProbeRunLog({
+42 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
@@ -20,6 +20,10 @@ import { runSshJson } from "@/lib/ssh/gateway-host";
const mockedSpawnSync = vi.mocked(spawnSync);
describe("runSshJson", () => {
beforeEach(() => {
mockedSpawnSync.mockReset();
});
it("forwards maxBuffer to spawnSync when provided", () => {
mockedSpawnSync.mockReturnValueOnce({
status: 0,
@@ -44,4 +48,41 @@ describe("runSshJson", () => {
];
expect(options.maxBuffer).toBe(12345);
});
it("separates the ssh target from options", () => {
mockedSpawnSync.mockReturnValueOnce({
status: 0,
stdout: JSON.stringify({ ok: true }),
stderr: "",
error: undefined,
} as never);
runSshJson({
sshTarget: "-oProxyCommand=bad",
argv: ["bash", "-s"],
label: "ssh-target-test",
});
const [, args] = mockedSpawnSync.mock.calls[0] as [string, string[]];
expect(args).toEqual([
"-o",
"BatchMode=yes",
"--",
"-oProxyCommand=bad",
"bash",
"-s",
]);
});
it("rejects empty ssh targets before spawning", () => {
expect(() =>
runSshJson({
sshTarget: " ",
argv: ["bash", "-s"],
label: "ssh-target-test",
})
).toThrow("SSH target is required.");
expect(mockedSpawnSync).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More