Files
George Pickett 732b994120 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.
2026-06-22 10:06:22 -07:00

514 lines
16 KiB
JavaScript

const os = require("node:os");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { resolveHosts, isPublicHost } = require("./network-policy");
const { readOpenclawGatewayDefaults } = require("./studio-settings");
const execFileAsync = promisify(execFile);
const OPENCLAW_PROBE_TIMEOUT_MS = 1_500;
const TAILSCALE_PROBE_TIMEOUT_MS = 1_200;
const STUDIO_CLI_PROBE_TIMEOUT_MS = 1_200;
const STUDIO_CLI_LATEST_TIMEOUT_MS = 2_500;
const STUDIO_CLI_LATEST_CACHE_TTL_MS = 1000 * 60 * 60 * 12;
const STUDIO_CLI_LATEST_ERROR_CACHE_TTL_MS = 1000 * 60 * 10;
const STUDIO_CLI_PACKAGE_NAME = "openclaw-studio";
const normalizeErrorCode = (error) => {
if (!error || typeof error !== "object") return "";
if (typeof error.code === "string") return error.code.trim();
return "";
};
const normalizeErrorMessage = (error) => {
if (error instanceof Error) {
return error.message.trim();
}
return "";
};
const normalizeJsonValue = (value) => {
if (!value || typeof value !== "object") return null;
return value;
};
const coerceString = (value) => {
return typeof value === "string" ? value.trim() : "";
};
const parseSemver = (value) => {
const normalized = coerceString(value);
const match = normalized.match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
if (!match) return null;
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) {
return null;
}
return {
major,
minor,
patch,
prerelease: match[4] ? match[4].split(".").filter(Boolean) : [],
};
};
const comparePrereleaseSegment = (left, right) => {
const leftNumeric = /^\d+$/.test(left);
const rightNumeric = /^\d+$/.test(right);
if (leftNumeric && rightNumeric) {
const leftNumber = Number(left);
const rightNumber = Number(right);
if (leftNumber !== rightNumber) return leftNumber > rightNumber ? 1 : -1;
return 0;
}
if (leftNumeric && !rightNumeric) return -1;
if (!leftNumeric && rightNumeric) return 1;
if (left === right) return 0;
return left > right ? 1 : -1;
};
const comparePrerelease = (left, right) => {
if (left.length === 0 && right.length === 0) return 0;
if (left.length === 0) return 1;
if (right.length === 0) return -1;
const maxLength = Math.max(left.length, right.length);
for (let index = 0; index < maxLength; index += 1) {
const leftSegment = left[index];
const rightSegment = right[index];
if (leftSegment === undefined) return -1;
if (rightSegment === undefined) return 1;
const segmentComparison = comparePrereleaseSegment(leftSegment, rightSegment);
if (segmentComparison !== 0) return segmentComparison;
}
return 0;
};
const compareSemverVersions = (currentVersion, latestVersion) => {
const current = parseSemver(currentVersion);
const latest = parseSemver(latestVersion);
if (!current || !latest) return null;
if (current.major !== latest.major) return current.major > latest.major ? 1 : -1;
if (current.minor !== latest.minor) return current.minor > latest.minor ? 1 : -1;
if (current.patch !== latest.patch) return current.patch > latest.patch ? 1 : -1;
return comparePrerelease(current.prerelease, latest.prerelease);
};
const runJsonCommand = async (command, args, timeoutMs, runner = execFileAsync) => {
try {
const { stdout } = await runner(command, args, {
timeout: timeoutMs,
maxBuffer: 1024 * 1024,
windowsHide: true,
encoding: "utf8",
});
const parsed = JSON.parse(String(stdout ?? "").trim());
return {
available: true,
ok: true,
value: normalizeJsonValue(parsed),
error: null,
};
} catch (error) {
const code = normalizeErrorCode(error);
const message = normalizeErrorMessage(error);
const timedOut =
code === "ETIMEDOUT" ||
code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ||
message.toLowerCase().includes("timed out");
if (code === "ENOENT") {
return {
available: false,
ok: false,
value: null,
error: "cli_not_found",
};
}
return {
available: true,
ok: false,
value: null,
error: timedOut ? "probe_timeout" : message || "probe_failed",
};
}
};
const runTextCommand = async (command, args, timeoutMs, runner = execFileAsync) => {
try {
const { stdout } = await runner(command, args, {
timeout: timeoutMs,
maxBuffer: 1024 * 1024,
windowsHide: true,
encoding: "utf8",
});
return {
available: true,
ok: true,
stdout: coerceString(stdout),
error: null,
};
} catch (error) {
const code = normalizeErrorCode(error);
const message = normalizeErrorMessage(error);
const timedOut =
code === "ETIMEDOUT" ||
code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" ||
message.toLowerCase().includes("timed out");
if (code === "ENOENT") {
return {
available: false,
ok: false,
stdout: "",
error: "cli_not_found",
};
}
return {
available: true,
ok: false,
stdout: "",
error: timedOut ? "probe_timeout" : message || "probe_failed",
};
}
};
let studioCliLatestCache = {
latestVersion: null,
checkedAt: null,
checkedAtMs: 0,
error: null,
};
const readStudioCliLatestCache = (nowMs = Date.now()) => {
if (!studioCliLatestCache.checkedAtMs) return null;
const ttlMs = studioCliLatestCache.error
? STUDIO_CLI_LATEST_ERROR_CACHE_TTL_MS
: STUDIO_CLI_LATEST_CACHE_TTL_MS;
if (nowMs - studioCliLatestCache.checkedAtMs > ttlMs) return null;
return {
latestVersion: studioCliLatestCache.latestVersion,
checkedAt: studioCliLatestCache.checkedAt,
error: studioCliLatestCache.error,
};
};
const writeStudioCliLatestCache = (input) => {
const checkedAt = input.checkedAt || new Date().toISOString();
const checkedAtMs = Date.parse(checkedAt);
studioCliLatestCache = {
latestVersion: input.latestVersion || null,
checkedAt,
checkedAtMs: Number.isFinite(checkedAtMs) ? checkedAtMs : Date.now(),
error: input.error || null,
};
return {
latestVersion: studioCliLatestCache.latestVersion,
checkedAt: studioCliLatestCache.checkedAt,
error: studioCliLatestCache.error,
};
};
const fetchLatestStudioCliVersion = async (fetchImpl = fetch) => {
const cached = readStudioCliLatestCache();
if (cached) return cached;
if (typeof fetchImpl !== "function") {
return writeStudioCliLatestCache({
latestVersion: null,
error: "version_check_unavailable",
});
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), STUDIO_CLI_LATEST_TIMEOUT_MS);
try {
const response = await fetchImpl(
`https://registry.npmjs.org/${encodeURIComponent(STUDIO_CLI_PACKAGE_NAME)}/latest`,
{
signal: controller.signal,
headers: { accept: "application/json" },
}
);
if (!response.ok) {
return writeStudioCliLatestCache({
latestVersion: null,
error: `registry_http_${response.status}`,
});
}
const payload = await response.json();
const latestVersion = coerceString(payload && payload.version);
if (!latestVersion) {
return writeStudioCliLatestCache({
latestVersion: null,
error: "invalid_registry_payload",
});
}
return writeStudioCliLatestCache({
latestVersion,
checkedAt: new Date().toISOString(),
error: null,
});
} catch (error) {
const message = normalizeErrorMessage(error).toLowerCase();
const timeoutLike =
message.includes("timed out") ||
message.includes("aborted") ||
message.includes("aborterror");
return writeStudioCliLatestCache({
latestVersion: null,
error: timeoutLike ? "version_check_timeout" : "version_check_failed",
});
} finally {
clearTimeout(timeout);
}
};
const resolveVersionFromOutput = (stdout) => {
const firstLine = coerceString(stdout).split(/\r?\n/, 1)[0] || "";
const match = firstLine.match(/v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/);
return match ? coerceString(match[0]) : "";
};
const probeStudioCliVersion = async (runner = execFileAsync) => {
const probe = await runTextCommand(
"openclaw-studio",
["--version"],
STUDIO_CLI_PROBE_TIMEOUT_MS,
runner
);
if (!probe.available) {
return {
installed: false,
currentVersion: null,
error: null,
};
}
if (!probe.ok) {
return {
installed: true,
currentVersion: null,
error: probe.error || "version_probe_failed",
};
}
const currentVersion = resolveVersionFromOutput(probe.stdout);
if (!currentVersion) {
return {
installed: true,
currentVersion: null,
error: "version_parse_failed",
};
}
return {
installed: true,
currentVersion,
error: null,
};
};
const probeStudioCli = async (env = process.env, runner = execFileAsync, fetchImpl = fetch) => {
const current = await probeStudioCliVersion(runner);
if (!current.installed) {
return {
installed: false,
currentVersion: null,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: null,
};
}
if (env && String(env.NODE_ENV || "").trim() === "test") {
return {
installed: true,
currentVersion: current.currentVersion,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: current.error,
};
}
if (!current.currentVersion) {
return {
installed: true,
currentVersion: null,
latestVersion: null,
updateAvailable: false,
checkedAt: null,
checkError: current.error || "version_probe_failed",
};
}
const latest = await fetchLatestStudioCliVersion(fetchImpl);
const versionComparison = latest.latestVersion
? compareSemverVersions(current.currentVersion, latest.latestVersion)
: null;
return {
installed: true,
currentVersion: current.currentVersion,
latestVersion: latest.latestVersion,
updateAvailable: versionComparison === -1,
checkedAt: latest.checkedAt,
checkError:
current.error ||
latest.error ||
(latest.latestVersion && versionComparison === null ? "version_compare_failed" : null),
};
};
const normalizeDnsName = (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return null;
return trimmed.replace(/\.$/, "");
};
const probeTailscale = async (runner = execFileAsync) => {
const result = await runJsonCommand(
"tailscale",
["status", "--json"],
TAILSCALE_PROBE_TIMEOUT_MS,
runner
);
if (!result.available) {
return {
installed: false,
loggedIn: false,
dnsName: null,
};
}
const parsed = result.value;
const backendState =
parsed && typeof parsed.BackendState === "string" ? parsed.BackendState.trim() : "";
const dnsName = normalizeDnsName(parsed && parsed.Self ? parsed.Self.DNSName : "");
const loggedIn =
result.ok &&
backendState !== "NeedsLogin" &&
backendState !== "NoState" &&
backendState !== "Stopped";
return {
installed: true,
loggedIn,
dnsName: loggedIn ? dnsName : null,
};
};
const probeLocalGateway = async (runner = execFileAsync) => {
const [statusProbe, sessionsProbe] = await Promise.all([
runJsonCommand("openclaw", ["status", "--json"], OPENCLAW_PROBE_TIMEOUT_MS, runner),
runJsonCommand("openclaw", ["sessions", "--json"], OPENCLAW_PROBE_TIMEOUT_MS, runner),
]);
const issues = Array.from(
new Set([statusProbe.error, sessionsProbe.error].filter((value) => typeof value === "string" && value))
);
return {
cliAvailable: statusProbe.available || sessionsProbe.available,
statusProbeOk: statusProbe.ok,
sessionsProbeOk: sessionsProbe.ok,
probeHealthy: statusProbe.ok || sessionsProbe.ok,
issues,
};
};
const resolveRemoteShell = (env = process.env) => {
return Boolean(
String(env.SSH_CONNECTION ?? "").trim() ||
String(env.SSH_CLIENT ?? "").trim() ||
String(env.SSH_TTY ?? "").trim()
);
};
const resolveHostname = () => {
const hostname = String(os.hostname?.() ?? "").trim();
return hostname || null;
};
async function detectInstallContext(env = process.env, options = {}) {
const resolveHostsImpl = options.resolveHosts || resolveHosts;
const isPublicHostImpl = options.isPublicHost || isPublicHost;
const readOpenclawGatewayDefaultsImpl =
options.readOpenclawGatewayDefaults || readOpenclawGatewayDefaults;
const runCommand = options.runCommand || execFileAsync;
const fetchImpl = options.fetch || fetch;
const configuredHosts = Array.from(
new Set(resolveHostsImpl(env).map((value) => String(value ?? "").trim()).filter(Boolean))
);
const publicHosts = configuredHosts.filter((host) => isPublicHostImpl(host));
const localDefaults = readOpenclawGatewayDefaultsImpl(env);
const [localGatewayProbe, tailscale, studioCli] = await Promise.all([
probeLocalGateway(runCommand),
probeTailscale(runCommand),
probeStudioCli(env, runCommand, fetchImpl),
]);
return {
studioHost: {
hostname: resolveHostname(),
configuredHosts,
publicHosts,
loopbackOnly: publicHosts.length === 0,
remoteShell: resolveRemoteShell(env),
studioAccessTokenConfigured: Boolean(String(env.STUDIO_ACCESS_TOKEN ?? "").trim()),
},
localGateway: {
defaultsDetected: Boolean(localDefaults?.url),
url: localDefaults?.url ?? null,
hasToken: Boolean(localDefaults?.token),
cliAvailable: localGatewayProbe.cliAvailable,
statusProbeOk: localGatewayProbe.statusProbeOk,
sessionsProbeOk: localGatewayProbe.sessionsProbeOk,
probeHealthy: localGatewayProbe.probeHealthy,
issues: localGatewayProbe.issues,
},
studioCli,
tailscale,
};
}
function buildStartupGuidance(params) {
const installContext = params.installContext;
const port = Number.isFinite(params.port) && params.port > 0 ? params.port : 3000;
const hostLabel =
installContext.tailscale.dnsName ||
installContext.studioHost.publicHosts[0] ||
"<studio-host>";
const sshTarget = installContext.tailscale.dnsName || hostLabel;
const lines = [];
if (installContext.studioHost.remoteShell && installContext.studioHost.loopbackOnly) {
lines.push(
`Studio is running on a remote host. http://localhost:${port} only opens on that machine.`
);
if (installContext.localGateway.defaultsDetected || installContext.localGateway.probeHealthy) {
lines.push("If OpenClaw is on this same host, keep Studio's upstream at ws://localhost:18789.");
}
if (installContext.tailscale.loggedIn && installContext.tailscale.dnsName) {
lines.push(
`Recommended: tailscale serve --yes --bg --https 443 http://127.0.0.1:${port}`
);
lines.push(`Then open: https://${installContext.tailscale.dnsName}`);
} else {
lines.push("Recommended: install/login to Tailscale, or keep Studio on loopback and use SSH tunneling.");
}
lines.push(`SSH tunnel fallback: ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`);
return lines;
}
if (installContext.studioHost.publicHosts.length > 0) {
lines.push(`Studio is exposed on ${installContext.studioHost.publicHosts.join(", ")}.`);
if (installContext.studioHost.studioAccessTokenConfigured) {
lines.push("Open /?access_token=... once from each new browser to set the Studio access cookie.");
}
if (installContext.localGateway.defaultsDetected || installContext.localGateway.probeHealthy) {
lines.push("If OpenClaw is on this same host, keep Studio's upstream at ws://localhost:18789.");
}
return lines;
}
return lines;
}
module.exports = {
detectInstallContext,
buildStartupGuidance,
};