mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
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:
@@ -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/**",
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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());
|
||||
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 [];
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type StudioSettingsResponse = {
|
||||
};
|
||||
gatewayMeta?: {
|
||||
hasStoredToken: boolean;
|
||||
credentialScope?: string;
|
||||
};
|
||||
installContext?: StudioInstallContext;
|
||||
domainApiModeEnabled?: boolean;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user