Files
openclaw-studio/src/features/agents/approvals/execApprovalEvents.ts
T
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

110 lines
3.7 KiB
TypeScript

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;
request: {
command: string;
cwd: string | null;
host: string | null;
security: string | null;
ask: string | null;
agentId: string | null;
resolvedPath: string | null;
sessionKey: string | null;
};
createdAtMs: number;
expiresAtMs: number;
};
type ResolvedPayload = {
id: string;
decision: ExecApprovalDecision;
resolvedBy: string | null;
ts: number;
};
const asRecord = (value: unknown): Record<string, unknown> | null =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
const asNonEmptyString = (value: unknown): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
};
const asOptionalString = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
const asPositiveTimestamp = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
export const parseExecApprovalRequested = (event: EventFrame): RequestedPayload | null => {
if (event.type !== "event" || event.event !== "exec.approval.requested") return null;
const payload = asRecord(event.payload);
if (!payload) return null;
const id = asNonEmptyString(payload.id);
const request = asRecord(payload.request);
const createdAtMs = asPositiveTimestamp(payload.createdAtMs);
const expiresAtMs = asPositiveTimestamp(payload.expiresAtMs);
if (!id || !request || !createdAtMs || !expiresAtMs) return null;
const command = asNonEmptyString(request.command);
if (!command) return null;
return {
id,
request: {
command,
cwd: asOptionalString(request.cwd),
host: asOptionalString(request.host),
security: asOptionalString(request.security),
ask: asOptionalString(request.ask),
agentId: resolveSafeAgentId(request.agentId),
resolvedPath: asOptionalString(request.resolvedPath),
sessionKey: resolveSafeSessionKey(request.sessionKey),
},
createdAtMs,
expiresAtMs,
};
};
export const parseExecApprovalResolved = (event: EventFrame): ResolvedPayload | null => {
if (event.type !== "event" || event.event !== "exec.approval.resolved") return null;
const payload = asRecord(event.payload);
if (!payload) return null;
const id = asNonEmptyString(payload.id);
const decisionRaw = asNonEmptyString(payload.decision);
const ts = asPositiveTimestamp(payload.ts);
if (!id || !decisionRaw || !ts) return null;
if (decisionRaw !== "allow-once" && decisionRaw !== "allow-always" && decisionRaw !== "deny") {
return null;
}
return {
id,
decision: decisionRaw,
resolvedBy: asOptionalString(payload.resolvedBy),
ts,
};
};
export const resolveExecApprovalAgentId = (params: {
requested: RequestedPayload;
agents: AgentState[];
}): string | null => {
const requestedAgentId = params.requested.request.agentId;
if (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;
const matchedBySession = params.agents.find(
(agent) => agent.sessionKey.trim() === requestedSessionKey
);
return matchedBySession?.agentId ?? null;
};