First Release of Claw3D (#11)

Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
Luke The Dev
2026-03-19 23:14:04 -05:00
committed by GitHub
parent 5ea96b2650
commit 4fa4f13558
431 changed files with 105438 additions and 14 deletions
+737
View File
@@ -0,0 +1,737 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
GatewayBrowserClient,
clearGatewayBrowserSessionStorage,
type GatewayHelloOk,
} from "./openclaw/GatewayBrowserClient";
import type {
StudioGatewaySettings,
StudioSettings,
StudioSettingsPatch,
StudioSettingsPublic,
} from "@/lib/studio/settings";
import type {
StudioSettingsLoadOptions,
StudioSettingsResponse,
} from "@/lib/studio/coordinator";
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
import { GatewayResponseError } from "@/lib/gateway/errors";
export type ReqFrame = {
type: "req";
id: string;
method: string;
params: unknown;
};
export type ResFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: {
code: string;
message: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
};
export type GatewayStateVersion = {
presence: number;
health: number;
};
export type EventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: GatewayStateVersion;
};
export type GatewayFrame = ReqFrame | ResFrame | EventFrame;
export const parseGatewayFrame = (raw: string): GatewayFrame | null => {
try {
return JSON.parse(raw) as GatewayFrame;
} catch {
return null;
}
};
export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => {
const trimmedAgent = agentId.trim();
const trimmedKey = mainKey.trim() || "main";
return `agent:${trimmedAgent}:${trimmedKey}`;
};
export const parseAgentIdFromSessionKey = (sessionKey: string): string | null => {
const match = sessionKey.match(/^agent:([^:]+):/);
return match ? match[1] : null;
};
export const isSameSessionKey = (a: string, b: string) => {
const left = a.trim();
const right = b.trim();
return left.length > 0 && left === right;
};
const CONNECT_FAILED_CLOSE_CODE = 4008;
const GATEWAY_CONNECT_TIMEOUT_MS = 8_000;
const parseConnectFailedCloseReason = (
reason: string
): { code: string; message: string } | null => {
const trimmed = reason.trim();
if (!trimmed.toLowerCase().startsWith("connect failed:")) return null;
const remainder = trimmed.slice("connect failed:".length).trim();
if (!remainder) return null;
const idx = remainder.indexOf(" ");
const code = (idx === -1 ? remainder : remainder.slice(0, idx)).trim();
if (!code) return null;
const message = (idx === -1 ? "" : remainder.slice(idx + 1)).trim();
return { code, message: message || "connect failed" };
};
const DEFAULT_UPSTREAM_GATEWAY_URL =
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
if (!value || typeof value !== "object") return null;
const raw = value as { url?: unknown; token?: unknown };
const url = typeof raw.url === "string" ? raw.url.trim() : "";
const token = typeof raw.token === "string" ? raw.token.trim() : "";
if (!url || !token) return null;
return { url, token };
};
type StatusHandler = (status: GatewayStatus) => void;
type EventHandler = (event: EventFrame) => void;
export type GatewayGapInfo = { expected: number; received: number };
type GapHandler = (info: GatewayGapInfo) => void;
export type GatewayStatus = "disconnected" | "connecting" | "connected";
export type GatewayConnectOptions = {
gatewayUrl: string;
token?: string;
authScopeKey?: string;
clientName?: string;
disableDeviceAuth?: boolean;
};
export { GatewayResponseError } from "@/lib/gateway/errors";
export type { GatewayErrorPayload } from "@/lib/gateway/errors";
export class GatewayClient {
private client: GatewayBrowserClient | null = null;
private statusHandlers = new Set<StatusHandler>();
private eventHandlers = new Set<EventHandler>();
private gapHandlers = new Set<GapHandler>();
private status: GatewayStatus = "disconnected";
private pendingConnect: Promise<void> | null = null;
private resolveConnect: (() => void) | null = null;
private rejectConnect: ((error: Error) => void) | null = null;
private manualDisconnect = false;
private lastHello: GatewayHelloOk | null = null;
onStatus(handler: StatusHandler) {
this.statusHandlers.add(handler);
handler(this.status);
return () => {
this.statusHandlers.delete(handler);
};
}
onEvent(handler: EventHandler) {
this.eventHandlers.add(handler);
return () => {
this.eventHandlers.delete(handler);
};
}
onGap(handler: GapHandler) {
this.gapHandlers.add(handler);
return () => {
this.gapHandlers.delete(handler);
};
}
async connect(options: GatewayConnectOptions) {
if (!options.gatewayUrl.trim()) {
throw new Error("Gateway URL is required.");
}
if (this.client) {
throw new Error("Gateway is already connected or connecting.");
}
this.manualDisconnect = false;
this.updateStatus("connecting");
this.pendingConnect = new Promise<void>((resolve, reject) => {
this.resolveConnect = resolve;
this.rejectConnect = reject;
});
const nextClient = new GatewayBrowserClient({
url: options.gatewayUrl,
token: options.token,
authScopeKey: options.authScopeKey,
clientName: options.clientName,
disableDeviceAuth: options.disableDeviceAuth,
onHello: (hello) => {
if (this.client !== nextClient) return;
this.lastHello = hello;
this.updateStatus("connected");
this.resolveConnect?.();
this.clearConnectPromise();
},
onEvent: (event) => {
if (this.client !== nextClient) return;
this.eventHandlers.forEach((handler) => handler(event));
},
onClose: ({ code, reason }) => {
if (this.client !== nextClient) return;
const connectFailed =
code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null;
const err = connectFailed
? new GatewayResponseError({
code: connectFailed.code,
message: connectFailed.message,
})
: new Error(`Gateway closed (${code}): ${reason}`);
if (this.rejectConnect) {
this.rejectConnect(err);
this.clearConnectPromise();
}
if (!this.manualDisconnect) {
nextClient.stop();
}
if (this.client === nextClient) {
this.client = null;
}
this.updateStatus("disconnected");
if (this.manualDisconnect) {
console.info("Gateway disconnected.");
}
},
onGap: ({ expected, received }) => {
if (this.client !== nextClient) return;
this.gapHandlers.forEach((handler) => handler({ expected, received }));
},
});
this.client = nextClient;
nextClient.start();
let connectTimeoutId: number | null = null;
try {
await Promise.race([
this.pendingConnect,
new Promise<never>((_, reject) => {
connectTimeoutId = window.setTimeout(() => {
reject(
new Error(
"Timed out connecting to the gateway. Check that it is running, or change the gateway address and try again."
)
);
}, GATEWAY_CONNECT_TIMEOUT_MS);
}),
]);
} catch (err) {
const activeClient = this.client;
this.clearConnectPromise();
activeClient?.stop();
if (this.client === activeClient) {
this.client = null;
}
this.updateStatus("disconnected");
throw err;
} finally {
if (connectTimeoutId !== null) {
window.clearTimeout(connectTimeoutId);
}
}
}
disconnect() {
if (!this.client) {
return;
}
this.manualDisconnect = true;
this.client.stop();
this.client = null;
this.clearConnectPromise();
this.updateStatus("disconnected");
console.info("Gateway disconnected.");
}
async call<T = unknown>(method: string, params: unknown): Promise<T> {
if (!method.trim()) {
throw new Error("Gateway method is required.");
}
if (!this.client || !this.client.connected) {
throw new Error("Gateway is not connected.");
}
const payload = await this.client.request<T>(method, params);
return payload as T;
}
getLastHello() {
return this.lastHello;
}
private updateStatus(status: GatewayStatus) {
this.status = status;
this.statusHandlers.forEach((handler) => handler(status));
}
private clearConnectPromise() {
this.pendingConnect = null;
this.resolveConnect = null;
this.rejectConnect = null;
}
}
export const isGatewayDisconnectLikeError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
if (!msg) return false;
if (
msg.includes("gateway not connected") ||
msg.includes("gateway is not connected") ||
msg.includes("gateway client stopped")
) {
return true;
}
const match = msg.match(/gateway closed \\((\\d+)\\)/);
if (!match) return false;
const code = Number(match[1]);
return Number.isFinite(code) && code === 1012;
};
const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i;
const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i;
export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => {
if (!(error instanceof GatewayResponseError)) return false;
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = error.message.trim();
if (!message) return false;
return (
WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) &&
WEBCHAT_SESSION_MUTATION_HINT_RE.test(message)
);
};
type SessionSettingsPatchPayload = {
key: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export type GatewaySessionsPatchResult = {
ok: true;
key: string;
entry?: {
thinkingLevel?: string;
};
resolved?: {
modelProvider?: string;
model?: string;
};
};
export type SyncGatewaySessionSettingsParams = {
client: GatewayClient;
sessionKey: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export const syncGatewaySessionSettings = async ({
client,
sessionKey,
model,
thinkingLevel,
execHost,
execSecurity,
execAsk,
}: SyncGatewaySessionSettingsParams) => {
const key = sessionKey.trim();
if (!key) {
throw new Error("Session key is required.");
}
const includeModel = model !== undefined;
const includeThinkingLevel = thinkingLevel !== undefined;
const includeExecHost = execHost !== undefined;
const includeExecSecurity = execSecurity !== undefined;
const includeExecAsk = execAsk !== undefined;
if (
!includeModel &&
!includeThinkingLevel &&
!includeExecHost &&
!includeExecSecurity &&
!includeExecAsk
) {
throw new Error("At least one session setting must be provided.");
}
const payload: SessionSettingsPatchPayload = { key };
if (includeModel) {
payload.model = model ?? null;
}
if (includeThinkingLevel) {
payload.thinkingLevel = thinkingLevel ?? null;
}
if (includeExecHost) {
payload.execHost = execHost ?? null;
}
if (includeExecSecurity) {
payload.execSecurity = execSecurity ?? null;
}
if (includeExecAsk) {
payload.execAsk = execAsk ?? null;
}
return await client.call<GatewaySessionsPatchResult>("sessions.patch", payload);
};
const doctorFixHint =
"Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout).";
const formatGatewayError = (error: unknown) => {
if (error instanceof GatewayResponseError) {
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
}
return `Gateway error (${error.code}): ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "Unknown gateway error.";
};
export type GatewayConnectionState = {
client: GatewayClient;
status: GatewayStatus;
gatewayUrl: string;
token: string;
localGatewayDefaults: StudioGatewaySettings | null;
error: string | null;
connectPromptReady: boolean;
shouldPromptForConnect: boolean;
connect: () => Promise<void>;
disconnect: () => void;
useLocalGatewayDefaults: () => void;
setGatewayUrl: (value: string) => void;
setToken: (value: string) => void;
clearError: () => void;
};
type StudioSettingsCoordinatorLike = {
loadSettings: (
options?: StudioSettingsLoadOptions,
) => Promise<StudioSettings | StudioSettingsPublic | null>;
loadSettingsEnvelope?: (
options?: StudioSettingsLoadOptions,
) => Promise<StudioSettingsResponse>;
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
flushPending: () => Promise<void>;
};
const isAuthError = (errorMessage: string | null): boolean => {
if (!errorMessage) return false;
const lower = errorMessage.toLowerCase();
return (
lower.includes("auth") ||
lower.includes("unauthorized") ||
lower.includes("forbidden") ||
lower.includes("invalid token") ||
lower.includes("token required") ||
(lower.includes("token") && lower.includes("not configured")) ||
lower.includes("gateway_token_missing")
);
};
const MAX_AUTO_RETRY_ATTEMPTS = 20;
const INITIAL_RETRY_DELAY_MS = 2_000;
const MAX_RETRY_DELAY_MS = 30_000;
const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
"studio.gateway_url_missing",
"studio.gateway_token_missing",
"studio.gateway_url_invalid",
"studio.settings_load_failed",
]);
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
const normalized = code?.trim().toLowerCase() ?? "";
if (!normalized) return false;
return NON_RETRYABLE_CONNECT_ERROR_CODES.has(normalized);
};
export const resolveGatewayAutoRetryDelayMs = (params: {
status: GatewayStatus;
didAutoConnect: boolean;
hasConnectedOnce: boolean;
wasManualDisconnect: boolean;
gatewayUrl: string;
errorMessage: string | null;
connectErrorCode: string | null;
attempt: number;
}): number | null => {
if (params.status !== "disconnected") return null;
if (!params.didAutoConnect) return null;
if (!params.hasConnectedOnce) return null;
if (params.wasManualDisconnect) return null;
if (!params.gatewayUrl.trim()) return null;
if (params.attempt >= MAX_AUTO_RETRY_ATTEMPTS) return null;
if (isNonRetryableConnectErrorCode(params.connectErrorCode)) return null;
if (params.connectErrorCode === null && isAuthError(params.errorMessage)) return null;
return Math.min(
INITIAL_RETRY_DELAY_MS * Math.pow(1.5, params.attempt),
MAX_RETRY_DELAY_MS
);
};
export const useGatewayConnection = (
settingsCoordinator: StudioSettingsCoordinatorLike
): GatewayConnectionState => {
const [client] = useState(() => new GatewayClient());
const didAutoConnect = useRef(false);
const hasConnectedOnceRef = useRef(false);
const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null);
const retryAttemptRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasManualDisconnectRef = useRef(false);
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
const [token, setToken] = useState("");
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
null
);
const [status, setStatus] = useState<GatewayStatus>("disconnected");
const [error, setError] = useState<string | null>(null);
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const envelope =
typeof settingsCoordinator.loadSettingsEnvelope === "function"
? await settingsCoordinator.loadSettingsEnvelope({ force: true })
: {
settings: await settingsCoordinator.loadSettings({ force: true }),
localGatewayDefaults: null,
};
const settings = envelope.settings ?? null;
const gateway = settings?.gateway ?? null;
if (cancelled) return;
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
const nextToken =
gateway && "token" in gateway && typeof gateway.token === "string"
? gateway.token
: "";
loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl.trim(),
token: nextToken,
};
setGatewayUrl(nextGatewayUrl);
setToken(nextToken);
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
setError(message);
}
} finally {
if (!cancelled) {
if (!loadedGatewaySettings.current) {
loadedGatewaySettings.current = {
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
token: "",
};
}
setSettingsLoaded(true);
}
}
};
void loadSettings();
return () => {
cancelled = true;
};
}, [settingsCoordinator]);
useEffect(() => {
return client.onStatus((nextStatus) => {
setStatus(nextStatus);
if (nextStatus !== "connecting") {
setError(null);
if (nextStatus === "connected") {
setConnectErrorCode(null);
}
}
});
}, [client]);
useEffect(() => {
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
client.disconnect();
};
}, [client]);
const connect = useCallback(async () => {
setError(null);
setConnectErrorCode(null);
wasManualDisconnectRef.current = false;
try {
await settingsCoordinator.flushPending();
await client.connect({
gatewayUrl: resolveStudioProxyGatewayUrl(),
token,
authScopeKey: gatewayUrl,
clientName: "openclaw-control-ui",
});
await ensureGatewayReloadModeHotForLocalStudio({
client,
upstreamGatewayUrl: gatewayUrl,
});
retryAttemptRef.current = 0;
} catch (err) {
setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null);
setError(formatGatewayError(err));
}
}, [client, gatewayUrl, settingsCoordinator, token]);
useEffect(() => {
if (didAutoConnect.current) return;
if (!settingsLoaded) return;
if (!gatewayUrl.trim()) return;
didAutoConnect.current = true;
void connect();
}, [connect, gatewayUrl, settingsLoaded]);
// Auto-retry on disconnect (gateway busy, network blip, etc.)
useEffect(() => {
const attempt = retryAttemptRef.current;
const delay = resolveGatewayAutoRetryDelayMs({
status,
didAutoConnect: didAutoConnect.current,
hasConnectedOnce: hasConnectedOnceRef.current,
wasManualDisconnect: wasManualDisconnectRef.current,
gatewayUrl,
errorMessage: error,
connectErrorCode,
attempt,
});
if (delay === null) return;
retryTimerRef.current = setTimeout(() => {
retryAttemptRef.current = attempt + 1;
void connect();
}, delay);
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
}, [connect, connectErrorCode, error, gatewayUrl, status]);
// Reset retry count on successful connection
useEffect(() => {
if (status === "connected") {
hasConnectedOnceRef.current = true;
retryAttemptRef.current = 0;
}
}, [status]);
useEffect(() => {
if (!settingsLoaded) return;
const baseline = loadedGatewaySettings.current;
if (!baseline) return;
const nextGatewayUrl = gatewayUrl.trim();
if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) {
return;
}
settingsCoordinator.schedulePatch(
{
gateway: {
url: nextGatewayUrl,
token,
},
},
400
);
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
const useLocalGatewayDefaults = useCallback(() => {
if (!localGatewayDefaults) {
return;
}
setGatewayUrl(localGatewayDefaults.url);
setToken(localGatewayDefaults.token);
setError(null);
setConnectErrorCode(null);
}, [localGatewayDefaults]);
const disconnect = useCallback(() => {
setError(null);
setConnectErrorCode(null);
wasManualDisconnectRef.current = true;
client.disconnect();
clearGatewayBrowserSessionStorage();
}, [client]);
const clearError = useCallback(() => {
setError(null);
setConnectErrorCode(null);
}, []);
const connectPromptReady = settingsLoaded;
const shouldPromptForConnect =
settingsLoaded &&
status !== "connected" &&
(!gatewayUrl.trim() || !token.trim() || wasManualDisconnectRef.current || Boolean(error));
return {
client,
status,
gatewayUrl,
token,
localGatewayDefaults,
error,
connectPromptReady,
shouldPromptForConnect,
connect,
disconnect,
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
clearError,
};
};
+761
View File
@@ -0,0 +1,761 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
export type AgentHeartbeatActiveHours = {
start: string;
end: string;
};
export type AgentHeartbeat = {
every: string;
target: string;
includeReasoning: boolean;
ackMaxChars?: number | null;
activeHours?: AgentHeartbeatActiveHours | null;
};
export type AgentHeartbeatResult = {
heartbeat: AgentHeartbeat;
hasOverride: boolean;
};
export type AgentHeartbeatUpdatePayload = {
override: boolean;
heartbeat: AgentHeartbeat;
};
export type AgentHeartbeatSummary = {
id: string;
agentId: string;
source: "override" | "default";
enabled: boolean;
heartbeat: AgentHeartbeat;
};
export type HeartbeatListResult = {
heartbeats: AgentHeartbeatSummary[];
};
export type HeartbeatWakeResult = { ok: true } | { ok: false };
export type GatewayConfigSnapshot = {
config?: Record<string, unknown>;
hash?: string;
exists?: boolean;
path?: string | null;
};
type HeartbeatBlock = Record<string, unknown> | null | undefined;
const DEFAULT_EVERY = "30m";
const DEFAULT_TARGET = "last";
const DEFAULT_ACK_MAX_CHARS = 300;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
export type ConfigAgentEntry = Record<string, unknown> & { id: string };
export type GatewayAgentSandboxOverrides = {
mode?: "off" | "non-main" | "all";
workspaceAccess?: "none" | "ro" | "rw";
};
export type GatewayAgentToolsOverrides = {
profile?: "minimal" | "coding" | "messaging" | "full";
allow?: string[];
alsoAllow?: string[];
deny?: string[];
sandbox?: {
tools?: {
allow?: string[];
deny?: string[];
};
};
};
export type GatewayAgentOverrides = {
sandbox?: GatewayAgentSandboxOverrides;
tools?: GatewayAgentToolsOverrides;
};
const DEFAULT_AGENT_ID = "main";
export const readConfigAgentList = (
config: Record<string, unknown> | undefined
): ConfigAgentEntry[] => {
if (!config) return [];
const agents = isRecord(config.agents) ? config.agents : null;
const list = Array.isArray(agents?.list) ? agents.list : [];
return list.filter((entry): entry is ConfigAgentEntry => {
if (!isRecord(entry)) return false;
if (typeof entry.id !== "string") return false;
return entry.id.trim().length > 0;
});
};
export const resolveDefaultConfigAgentId = (
config: Record<string, unknown> | undefined
): string => {
const list = readConfigAgentList(config);
if (list.length === 0) {
return DEFAULT_AGENT_ID;
}
const defaults = list.filter((entry) => entry.default === true);
const selected = defaults[0] ?? list[0];
const resolved = selected.id.trim();
return resolved || DEFAULT_AGENT_ID;
};
export const writeConfigAgentList = (
config: Record<string, unknown>,
list: ConfigAgentEntry[]
): Record<string, unknown> => {
const agents = isRecord(config.agents) ? { ...config.agents } : {};
return { ...config, agents: { ...agents, list } };
};
export const upsertConfigAgentEntry = (
list: ConfigAgentEntry[],
agentId: string,
updater: (entry: ConfigAgentEntry) => ConfigAgentEntry
): { list: ConfigAgentEntry[]; entry: ConfigAgentEntry } => {
let updatedEntry: ConfigAgentEntry | null = null;
const nextList = list.map((entry) => {
if (entry.id !== agentId) return entry;
const next = updater({ ...entry, id: agentId });
updatedEntry = next;
return next;
});
if (!updatedEntry) {
updatedEntry = updater({ id: agentId });
nextList.push(updatedEntry);
}
return { list: nextList, entry: updatedEntry };
};
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;
};
const coerceString = (value: unknown) => (typeof value === "string" ? value : undefined);
const coerceBoolean = (value: unknown) =>
typeof value === "boolean" ? value : undefined;
const coerceNumber = (value: unknown) =>
typeof value === "number" && Number.isFinite(value) ? value : undefined;
const coerceActiveHours = (value: unknown) => {
if (!isRecord(value)) return undefined;
const start = coerceString(value.start);
const end = coerceString(value.end);
if (!start || !end) return undefined;
return { start, end };
};
const mergeHeartbeat = (defaults: HeartbeatBlock, override: HeartbeatBlock) => {
const merged = {
...(defaults ?? {}),
...(override ?? {}),
} as Record<string, unknown>;
if (override && typeof override === "object" && "activeHours" in override) {
merged.activeHours = (override as Record<string, unknown>).activeHours;
} else if (defaults && typeof defaults === "object" && "activeHours" in defaults) {
merged.activeHours = (defaults as Record<string, unknown>).activeHours;
}
return merged;
};
const normalizeHeartbeat = (
defaults: HeartbeatBlock,
override: HeartbeatBlock
): AgentHeartbeatResult => {
const resolved = mergeHeartbeat(defaults, override);
const every = coerceString(resolved.every) ?? DEFAULT_EVERY;
const target = coerceString(resolved.target) ?? DEFAULT_TARGET;
const includeReasoning = coerceBoolean(resolved.includeReasoning) ?? false;
const ackMaxChars = coerceNumber(resolved.ackMaxChars) ?? DEFAULT_ACK_MAX_CHARS;
const activeHours = coerceActiveHours(resolved.activeHours) ?? null;
return {
heartbeat: {
every,
target,
includeReasoning,
ackMaxChars,
activeHours,
},
hasOverride: Boolean(override && typeof override === "object"),
};
};
const readHeartbeatDefaults = (config: Record<string, unknown>): HeartbeatBlock => {
const agents = isRecord(config.agents) ? config.agents : null;
const defaults = agents && isRecord(agents.defaults) ? agents.defaults : null;
return (defaults?.heartbeat ?? null) as HeartbeatBlock;
};
const buildHeartbeatOverride = (payload: AgentHeartbeat): Record<string, unknown> => {
const nextHeartbeat: Record<string, unknown> = {
every: payload.every,
target: payload.target,
includeReasoning: payload.includeReasoning,
};
if (payload.ackMaxChars !== undefined && payload.ackMaxChars !== null) {
nextHeartbeat.ackMaxChars = payload.ackMaxChars;
}
if (payload.activeHours) {
nextHeartbeat.activeHours = {
start: payload.activeHours.start,
end: payload.activeHours.end,
};
}
return nextHeartbeat;
};
export const resolveHeartbeatSettings = (
config: Record<string, unknown>,
agentId: string
): AgentHeartbeatResult => {
const list = readConfigAgentList(config);
const entry = list.find((item) => item.id === agentId) ?? null;
const defaults = readHeartbeatDefaults(config);
const override =
entry && typeof entry === "object"
? ((entry as Record<string, unknown>).heartbeat as HeartbeatBlock)
: null;
return normalizeHeartbeat(defaults, override);
};
type GatewayStatusHeartbeatAgent = {
agentId?: string;
enabled?: boolean;
every?: string;
everyMs?: number | null;
};
type GatewayStatusSnapshot = {
heartbeat?: {
agents?: GatewayStatusHeartbeatAgent[];
};
};
const resolveHeartbeatAgentId = (agentId: string) => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
return trimmed;
};
const resolveStatusHeartbeatAgent = (
status: GatewayStatusSnapshot,
agentId: string
): GatewayStatusHeartbeatAgent | null => {
const list = Array.isArray(status.heartbeat?.agents) ? status.heartbeat?.agents : [];
for (const entry of list) {
if (!entry || typeof entry.agentId !== "string") continue;
if (entry.agentId.trim() !== agentId) continue;
return entry;
}
return null;
};
export const listHeartbeatsForAgent = async (
client: GatewayClient,
agentId: string
): Promise<HeartbeatListResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
const [snapshot, status] = await Promise.all([
client.call<GatewayConfigSnapshot>("config.get", {}),
client.call<GatewayStatusSnapshot>("status", {}),
]);
const config = isRecord(snapshot.config) ? snapshot.config : {};
const resolved = resolveHeartbeatSettings(config, resolvedAgentId);
const statusHeartbeat = resolveStatusHeartbeatAgent(status, resolvedAgentId);
const enabled = Boolean(statusHeartbeat?.enabled);
const every = typeof statusHeartbeat?.every === "string" ? statusHeartbeat.every.trim() : "";
const heartbeat = every ? { ...resolved.heartbeat, every } : resolved.heartbeat;
if (!enabled && !resolved.hasOverride) {
return { heartbeats: [] };
}
return {
heartbeats: [
{
id: resolvedAgentId,
agentId: resolvedAgentId,
source: resolved.hasOverride ? "override" : "default",
enabled,
heartbeat,
},
],
};
};
export const triggerHeartbeatNow = async (
client: GatewayClient,
agentId: string
): Promise<HeartbeatWakeResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
return client.call<HeartbeatWakeResult>("wake", {
mode: "now",
text: `Claw3D heartbeat trigger (${resolvedAgentId}).`,
});
};
const shouldRetryConfigWrite = (err: unknown) => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run config\.get|config changed since last load/i.test(err.message);
};
const applyGatewayConfigPatch = async (params: {
client: GatewayClient;
patch: Record<string, unknown>;
baseHash?: string | null;
exists?: boolean;
attempt?: number;
}): Promise<void> => {
const attempt = params.attempt ?? 0;
const requiresBaseHash = params.exists !== false;
const baseHash = requiresBaseHash ? params.baseHash?.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),
};
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("config.patch", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
return applyGatewayConfigPatch({
...params,
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
attempt: attempt + 1,
});
}
throw err;
}
};
export const renameGatewayAgent = async (params: {
client: GatewayClient;
agentId: string;
name: string;
}) => {
const trimmed = params.name.trim();
if (!trimmed) {
throw new Error("Agent name is required.");
}
await params.client.call("agents.update", { agentId: params.agentId, name: trimmed });
return { id: params.agentId, name: trimmed };
};
const dirnameLike = (value: string): string => {
const lastSlash = value.lastIndexOf("/");
const lastBackslash = value.lastIndexOf("\\");
const idx = Math.max(lastSlash, lastBackslash);
if (idx < 0) return "";
return value.slice(0, idx);
};
const joinPathLike = (dir: string, leaf: string): string => {
const sep = dir.includes("\\") ? "\\" : "/";
const trimmedDir = dir.endsWith("/") || dir.endsWith("\\") ? dir.slice(0, -1) : dir;
return `${trimmedDir}${sep}${leaf}`;
};
export const createGatewayAgent = async (params: {
client: GatewayClient;
name: string;
}): Promise<ConfigAgentEntry> => {
const trimmed = params.name.trim();
if (!trimmed) {
throw new Error("Agent name is required.");
}
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const configPath = typeof snapshot.path === "string" ? snapshot.path.trim() : "";
if (!configPath) {
throw new Error(
'Gateway did not return a config path; cannot compute a default workspace for "agents.create".',
);
}
const stateDir = dirnameLike(configPath);
if (!stateDir) {
throw new Error(
`Gateway config path "${configPath}" is missing a directory; cannot compute workspace.`,
);
}
const idGuess = slugifyAgentName(trimmed);
const workspace = joinPathLike(stateDir, `workspace-${idGuess}`);
const result = (await params.client.call("agents.create", {
name: trimmed,
workspace,
})) as { ok?: boolean; agentId?: string; name?: string; workspace?: string };
const agentId = typeof result?.agentId === "string" ? result.agentId.trim() : "";
if (!agentId) {
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
}
return { id: agentId, name: trimmed };
};
export const deleteGatewayAgent = async (params: {
client: GatewayClient;
agentId: string;
}) => {
try {
const result = (await params.client.call("agents.delete", {
agentId: params.agentId,
})) as { ok?: boolean; removedBindings?: unknown };
const removedBindings =
typeof result?.removedBindings === "number" && Number.isFinite(result.removedBindings)
? Math.max(0, Math.floor(result.removedBindings))
: 0;
return { removed: true, removedBindings };
} catch (err) {
if (err instanceof GatewayResponseError && /not found/i.test(err.message)) {
return { removed: false, removedBindings: 0 };
}
throw err;
}
};
export const updateGatewayHeartbeat = async (params: {
client: GatewayClient;
agentId: string;
payload: AgentHeartbeatUpdatePayload;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("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({
client: params.client,
patch: { agents: { list: nextList } },
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
});
return resolveHeartbeatSettings(nextConfig, params.agentId);
};
export const removeGatewayHeartbeatOverride = async (params: {
client: GatewayClient;
agentId: string;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("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({
client: params.client,
patch: { agents: { list: nextList } },
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
});
return resolveHeartbeatSettings(nextConfig, params.agentId);
};
export type AgentSkillsAccessMode = "all" | "none" | "allowlist";
const resolveRequiredAgentId = (agentId: string): string => {
const trimmed = agentId.trim();
if (!trimmed) {
throw new Error("Agent id is required.");
}
return trimmed;
};
const normalizeSkillAllowlistInput = (values: ReadonlyArray<unknown>): string[] => {
const next = values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(next)).sort((a, b) => a.localeCompare(b));
};
const normalizeSkillAllowlist = (values: string[]): string[] => {
return normalizeSkillAllowlistInput(values);
};
const areStringArraysEqual = (a: readonly string[], b: readonly string[]): boolean => {
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) return false;
}
return true;
};
const buildAgentSkillsConfig = (params: {
baseConfig: Record<string, unknown>;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Record<string, unknown> => {
const list = readConfigAgentList(params.baseConfig);
const currentEntry = list.find((entry) => entry.id === params.agentId);
const hasEntry = Boolean(currentEntry);
const currentRawSkills = currentEntry?.skills;
if (params.mode === "all") {
if (!hasEntry) {
return params.baseConfig;
}
if (!Object.prototype.hasOwnProperty.call(currentEntry, "skills")) {
return params.baseConfig;
}
}
if (params.mode === "none" && Array.isArray(currentRawSkills) && currentRawSkills.length === 0) {
return params.baseConfig;
}
if (params.mode === "allowlist") {
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const normalizedNext = normalizeSkillAllowlist(rawSkills);
if (Array.isArray(currentRawSkills)) {
const normalizedCurrent = normalizeSkillAllowlistInput(currentRawSkills);
if (areStringArraysEqual(normalizedCurrent, normalizedNext)) {
return params.baseConfig;
}
}
}
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
const next: ConfigAgentEntry = { ...entry, id: params.agentId };
if (params.mode === "all") {
if ("skills" in next) {
delete next.skills;
}
return next;
}
if (params.mode === "none") {
next.skills = [];
return next;
}
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
next.skills = normalizeSkillAllowlist(rawSkills);
return next;
});
return writeConfigAgentList(params.baseConfig, nextList);
};
export const readGatewayAgentSkillsAllowlist = async (params: {
client: GatewayClient;
agentId: string;
}): Promise<string[] | undefined> => {
const agentId = resolveRequiredAgentId(params.agentId);
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const entry = list.find((item) => item.id === agentId);
if (!entry) {
return undefined;
}
const raw = entry.skills;
if (!Array.isArray(raw)) {
return undefined;
}
return normalizeSkillAllowlistInput(raw);
};
export const updateGatewayAgentSkillsAllowlist = async (params: {
client: GatewayClient;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Promise<void> => {
const agentId = resolveRequiredAgentId(params.agentId);
if (params.mode === "allowlist" && !params.skillNames) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildAgentSkillsConfig({
baseConfig,
agentId,
mode: params.mode,
skillNames: params.skillNames,
});
if (nextConfig === baseConfig) {
return;
}
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
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.");
}
if (baseHash) {
payload.baseHash = baseHash;
}
try {
await params.client.call("config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return attemptWrite(attempt + 1);
}
throw err;
}
};
await attemptWrite(0);
};
const normalizeToolList = (values: string[] | undefined): string[] | undefined => {
if (!values) return undefined;
const next = values
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(next));
};
export const updateGatewayAgentOverrides = async (params: {
client: GatewayClient;
agentId: string;
overrides: GatewayAgentOverrides;
}): Promise<void> => {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
if (params.overrides.tools?.allow !== undefined && params.overrides.tools?.alsoAllow !== undefined) {
throw new Error("Agent tools overrides cannot set both allow and alsoAllow.");
}
const hasSandboxOverrides =
Boolean(params.overrides.sandbox?.mode) || Boolean(params.overrides.sandbox?.workspaceAccess);
const hasToolsOverrides =
Boolean(params.overrides.tools?.profile) ||
params.overrides.tools?.allow !== undefined ||
params.overrides.tools?.alsoAllow !== undefined ||
params.overrides.tools?.deny !== undefined ||
params.overrides.tools?.sandbox?.tools?.allow !== undefined ||
params.overrides.tools?.sandbox?.tools?.deny !== undefined;
if (!hasSandboxOverrides && !hasToolsOverrides) {
return;
}
const buildNextConfig = (baseConfig: Record<string, unknown>): Record<string, unknown> => {
const list = readConfigAgentList(baseConfig);
const { list: nextList } = upsertConfigAgentEntry(list, agentId, (entry) => {
const next: ConfigAgentEntry = { ...entry, id: agentId };
if (hasSandboxOverrides) {
const currentSandbox = isRecord(next.sandbox) ? { ...next.sandbox } : {};
if (params.overrides.sandbox?.mode) {
currentSandbox.mode = params.overrides.sandbox.mode;
}
if (params.overrides.sandbox?.workspaceAccess) {
currentSandbox.workspaceAccess = params.overrides.sandbox.workspaceAccess;
}
next.sandbox = currentSandbox;
}
if (hasToolsOverrides) {
const currentTools = isRecord(next.tools) ? { ...next.tools } : {};
if (params.overrides.tools?.profile) {
currentTools.profile = params.overrides.tools.profile;
}
const allow = normalizeToolList(params.overrides.tools?.allow);
if (allow !== undefined) {
currentTools.allow = allow;
delete currentTools.alsoAllow;
}
const alsoAllow = normalizeToolList(params.overrides.tools?.alsoAllow);
if (alsoAllow !== undefined) {
currentTools.alsoAllow = alsoAllow;
delete currentTools.allow;
}
const deny = normalizeToolList(params.overrides.tools?.deny);
if (deny !== undefined) {
currentTools.deny = deny;
}
const sandboxAllow = normalizeToolList(params.overrides.tools?.sandbox?.tools?.allow);
const sandboxDeny = normalizeToolList(params.overrides.tools?.sandbox?.tools?.deny);
if (sandboxAllow !== undefined || sandboxDeny !== undefined) {
const sandboxRaw = (currentTools as Record<string, unknown>).sandbox;
const sandbox = isRecord(sandboxRaw) ? { ...sandboxRaw } : {};
const sandboxToolsRaw = (sandbox as Record<string, unknown>).tools;
const sandboxTools = isRecord(sandboxToolsRaw) ? { ...sandboxToolsRaw } : {};
if (sandboxAllow !== undefined) {
(sandboxTools as Record<string, unknown>).allow = sandboxAllow;
}
if (sandboxDeny !== undefined) {
(sandboxTools as Record<string, unknown>).deny = sandboxDeny;
}
(sandbox as Record<string, unknown>).tools = sandboxTools;
(currentTools as Record<string, unknown>).sandbox = sandbox;
}
next.tools = currentTools;
}
return next;
});
return writeConfigAgentList(baseConfig, nextList);
};
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildNextConfig(baseConfig);
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
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.");
}
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return attemptWrite(attempt + 1);
}
throw err;
}
};
await attemptWrite(0);
};
+64
View File
@@ -0,0 +1,64 @@
import type { AgentFileName } from "@/lib/agents/agentFiles";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
type AgentsFilesGetResponse = {
file?: { missing?: unknown; content?: unknown };
};
const resolveAgentId = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("agentId is required.");
}
return trimmed;
};
export const readGatewayAgentFile = async (params: {
client: GatewayClient;
agentId: string;
name: AgentFileName;
}): Promise<{ exists: boolean; content: string }> => {
const agentId = resolveAgentId(params.agentId);
const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", {
agentId,
name: params.name,
});
const file = response?.file;
const fileRecord = file && typeof file === "object" ? (file as Record<string, unknown>) : null;
const missing = fileRecord?.missing === true;
const content =
fileRecord && typeof fileRecord.content === "string" ? fileRecord.content : "";
return { exists: !missing, content };
};
export const writeGatewayAgentFile = async (params: {
client: GatewayClient;
agentId: string;
name: AgentFileName;
content: string;
}): Promise<void> => {
const agentId = resolveAgentId(params.agentId);
await params.client.call("agents.files.set", {
agentId,
name: params.name,
content: params.content,
});
};
export const writeGatewayAgentFiles = async (params: {
client: GatewayClient;
agentId: string;
files: Partial<Record<AgentFileName, string>>;
}): Promise<void> => {
const agentId = resolveAgentId(params.agentId);
const entries = Object.entries(params.files).filter(
(entry): entry is [AgentFileName, string] => typeof entry[1] === "string"
);
for (const [name, content] of entries) {
await params.client.call("agents.files.set", {
agentId,
name,
content,
});
}
};
+24
View File
@@ -0,0 +1,24 @@
export type GatewayErrorPayload = {
code: string;
message: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
export class GatewayResponseError extends Error {
code: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
constructor(payload: GatewayErrorPayload) {
super(payload.message || "Gateway request failed");
this.name = "GatewayResponseError";
this.code = payload.code;
this.details = payload.details;
this.retryable = payload.retryable;
this.retryAfterMs = payload.retryAfterMs;
}
}
+178
View File
@@ -0,0 +1,178 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
export type GatewayExecApprovalSecurity = "deny" | "allowlist" | "full";
export type GatewayExecApprovalAsk = "off" | "on-miss" | "always";
type ExecAllowlistEntry = {
id?: string;
pattern: string;
lastUsedAt?: number;
lastUsedCommand?: string;
lastResolvedPath?: string;
};
type ExecApprovalsAgent = {
security?: GatewayExecApprovalSecurity;
ask?: GatewayExecApprovalAsk;
askFallback?: string;
autoAllowSkills?: boolean;
allowlist?: ExecAllowlistEntry[];
};
type ExecApprovalsFile = {
version: 1;
socket?: {
path?: string;
token?: string;
};
defaults?: {
security?: string;
ask?: string;
askFallback?: string;
autoAllowSkills?: boolean;
};
agents?: Record<string, ExecApprovalsAgent>;
};
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file?: ExecApprovalsFile;
};
const shouldRetrySet = (err: unknown): boolean => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run exec\.approvals\.get|changed since last load/i.test(err.message);
};
const normalizeAllowlist = (patterns: Array<{ pattern: string }>): Array<{ pattern: string }> => {
const next = patterns
.map((entry) => entry.pattern.trim())
.filter((pattern) => pattern.length > 0);
return Array.from(new Set(next)).map((pattern) => ({ pattern }));
};
const setExecApprovalsWithRetry = async (params: {
client: GatewayClient;
file: ExecApprovalsFile;
baseHash?: string | null;
exists?: boolean;
attempt?: number;
}): Promise<void> => {
const attempt = params.attempt ?? 0;
const requiresBaseHash = params.exists !== false;
const baseHash = requiresBaseHash ? params.baseHash?.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 };
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("exec.approvals.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetrySet(err)) {
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
return setExecApprovalsWithRetry({
...params,
baseHash: snapshot.hash ?? undefined,
exists: snapshot.exists,
attempt: attempt + 1,
});
}
throw err;
}
};
export async function upsertGatewayAgentExecApprovals(params: {
client: GatewayClient;
agentId: string;
policy: {
security: GatewayExecApprovalSecurity;
ask: GatewayExecApprovalAsk;
allowlist: Array<{ pattern: string }>;
} | null;
}): Promise<void> {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const snapshot = await params.client.call<ExecApprovalsSnapshot>("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,
});
}
export async function readGatewayAgentExecApprovals(params: {
client: GatewayClient;
agentId: string;
}): Promise<{
security: GatewayExecApprovalSecurity | null;
ask: GatewayExecApprovalAsk | null;
allowlist: Array<{ pattern: string }>;
} | null> {
const agentId = params.agentId.trim();
if (!agentId) {
throw new Error("Agent id is required.");
}
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
const entry = snapshot.file?.agents?.[agentId];
if (!entry) return null;
const security =
entry.security === "deny" || entry.security === "allowlist" || entry.security === "full"
? entry.security
: null;
const ask = entry.ask === "off" || entry.ask === "on-miss" || entry.ask === "always" ? entry.ask : null;
const allowlist = Array.isArray(entry.allowlist)
? entry.allowlist
.map((item) => (item && typeof item === "object" ? (item as ExecAllowlistEntry).pattern : ""))
.filter((pattern): pattern is string => typeof pattern === "string")
.map((pattern) => pattern.trim())
.filter((pattern) => pattern.length > 0)
.map((pattern) => ({ pattern }))
: [];
return {
security,
ask,
allowlist,
};
}
+107
View File
@@ -0,0 +1,107 @@
import { GatewayResponseError, type GatewayClient } from "@/lib/gateway/GatewayClient";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
type GatewayConfigSnapshot = {
config?: Record<string, unknown>;
hash?: string;
exists?: boolean;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const shouldRetryConfigWrite = (err: unknown) => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run config\.get|config changed since last load/i.test(err.message);
};
const resolveReloadModeFromConfig = (config: unknown): string | null => {
if (!isRecord(config)) return null;
const gateway = isRecord(config.gateway) ? config.gateway : null;
const reload = gateway && isRecord(gateway.reload) ? gateway.reload : null;
if (!reload || typeof reload.mode !== "string") return "hybrid";
const mode = reload.mode.trim().toLowerCase();
return mode.length > 0 ? mode : "hybrid";
};
export const shouldAwaitDisconnectRestartForReloadMode = (mode: string | null): boolean =>
mode !== "hot" && mode !== "off" && mode !== "hybrid";
export async function shouldAwaitDisconnectRestartForRemoteMutation(params: {
client: GatewayClient;
cachedConfigSnapshot: { config?: unknown } | null;
logError?: (message: string, error: unknown) => void;
}): Promise<boolean> {
const cachedMode = resolveReloadModeFromConfig(params.cachedConfigSnapshot?.config);
if (cachedMode) {
return shouldAwaitDisconnectRestartForReloadMode(cachedMode);
}
try {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const mode = resolveReloadModeFromConfig(snapshot.config);
return shouldAwaitDisconnectRestartForReloadMode(mode);
} catch (err) {
params.logError?.(
"Failed to determine gateway reload mode; defaulting to restart wait.",
err
);
return true;
}
}
export async function ensureGatewayReloadModeHotForLocalStudio(params: {
client: GatewayClient;
upstreamGatewayUrl: string;
}): Promise<void> {
if (!isLocalGatewayUrl(params.upstreamGatewayUrl)) {
return;
}
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const exists = snapshot.exists !== false;
const baseHash = exists ? snapshot.hash?.trim() : undefined;
if (exists && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const gateway = isRecord(baseConfig.gateway) ? baseConfig.gateway : {};
const reload = isRecord(gateway.reload) ? gateway.reload : {};
const mode = typeof reload.mode === "string" ? reload.mode.trim() : "";
if (mode === "hot" || mode === "off") {
return;
}
const nextConfig: Record<string, unknown> = {
...baseConfig,
gateway: {
...gateway,
reload: {
...reload,
mode: "hot",
},
},
};
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
if (baseHash) {
payload.baseHash = baseHash;
}
try {
await params.client.call("config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
await attemptWrite(attempt + 1);
return;
}
throw err;
}
};
await attemptWrite(0);
}
+22
View File
@@ -0,0 +1,22 @@
const parseHostname = (gatewayUrl: string): string | null => {
const trimmed = gatewayUrl.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).hostname;
} catch {
return null;
}
};
export const isLocalGatewayUrl = (gatewayUrl: string): boolean => {
const hostname = parseHostname(gatewayUrl);
if (!hostname) return false;
const normalized = hostname.trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "0.0.0.0"
);
};
+92
View File
@@ -0,0 +1,92 @@
export type GatewayModelChoice = {
id: string;
name: string;
provider: string;
contextWindow?: number;
reasoning?: boolean;
};
type GatewayModelAliasEntry = {
alias?: string;
};
type GatewayModelDefaults = {
model?: string | { primary?: string; fallbacks?: string[] };
models?: Record<string, GatewayModelAliasEntry>;
};
export type GatewayModelPolicySnapshot = {
config?: {
agents?: {
defaults?: GatewayModelDefaults;
list?: Array<{
id?: string;
model?: string | { primary?: string; fallbacks?: string[] };
}>;
};
};
};
export const resolveConfiguredModelKey = (
raw: string,
models?: Record<string, GatewayModelAliasEntry>
) => {
const trimmed = raw.trim();
if (!trimmed) return null;
if (trimmed.includes("/")) return trimmed;
if (models) {
const target = Object.entries(models).find(
([, entry]) => entry?.alias?.trim().toLowerCase() === trimmed.toLowerCase()
);
if (target?.[0]) return target[0];
}
return `anthropic/${trimmed}`;
};
export const buildAllowedModelKeys = (snapshot: GatewayModelPolicySnapshot | null) => {
const allowedList: string[] = [];
const allowedSet = new Set<string>();
const defaults = snapshot?.config?.agents?.defaults;
const modelDefaults = defaults?.model;
const modelAliases = defaults?.models;
const pushKey = (raw?: string | null) => {
if (!raw) return;
const resolved = resolveConfiguredModelKey(raw, modelAliases);
if (!resolved) return;
if (allowedSet.has(resolved)) return;
allowedSet.add(resolved);
allowedList.push(resolved);
};
if (typeof modelDefaults === "string") {
pushKey(modelDefaults);
} else if (modelDefaults && typeof modelDefaults === "object") {
pushKey(modelDefaults.primary ?? null);
for (const fallback of modelDefaults.fallbacks ?? []) {
pushKey(fallback);
}
}
if (modelAliases) {
for (const key of Object.keys(modelAliases)) {
pushKey(key);
}
}
return allowedList;
};
export const buildGatewayModelChoices = (
catalog: GatewayModelChoice[],
snapshot: GatewayModelPolicySnapshot | null
) => {
const allowedKeys = buildAllowedModelKeys(snapshot);
if (allowedKeys.length === 0) return catalog;
const filtered = catalog.filter((entry) => allowedKeys.includes(`${entry.provider}/${entry.id}`));
const filteredKeys = new Set(filtered.map((entry) => `${entry.provider}/${entry.id}`));
const extras: GatewayModelChoice[] = [];
for (const key of allowedKeys) {
if (filteredKeys.has(key)) continue;
const [provider, id] = key.split("/");
if (!provider || !id) continue;
extras.push({ provider, id, name: key });
}
return [...filtered, ...extras];
};
@@ -0,0 +1,657 @@
// Adapted from `openclaw/openclaw` `ui/src/ui/gateway.ts`.
// Source license: MIT. Last verified against OpenClaw 2026.2.12
// (`f9e444dd56ccfc2271e8ae1729b7a14a55e1c11e`).
// Update this file via `npm run sync:gateway-client -- /path/to/gateway.ts` and record
// provenance changes in `THIRD_PARTY_CODE.md` whenever the upstream source changes.
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
import { GatewayResponseError } from "@/lib/gateway/errors";
const GATEWAY_CLIENT_NAMES = {
CONTROL_UI: "openclaw-control-ui",
} as const;
const GATEWAY_CLIENT_MODES = {
WEBCHAT: "webchat",
} as const;
type CryptoLike = {
randomUUID?: (() => string) | undefined;
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
};
let warnedWeakCrypto = false;
function uuidFromBytes(bytes: Uint8Array): string {
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
16,
20
)}-${hex.slice(20)}`;
}
function weakRandomBytes(): Uint8Array {
const bytes = new Uint8Array(16);
const now = Date.now();
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
bytes[0] ^= now & 0xff;
bytes[1] ^= (now >>> 8) & 0xff;
bytes[2] ^= (now >>> 16) & 0xff;
bytes[3] ^= (now >>> 24) & 0xff;
return bytes;
}
function warnWeakCryptoOnce() {
if (warnedWeakCrypto) return;
warnedWeakCrypto = true;
console.warn("[uuid] crypto API missing; falling back to weak randomness");
}
function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
const bytes = new Uint8Array(16);
cryptoLike.getRandomValues(bytes);
return uuidFromBytes(bytes);
}
warnWeakCryptoOnce();
return uuidFromBytes(weakRandomBytes());
}
type DeviceAuthPayloadParams = {
deviceId: string;
clientId: string;
clientMode: string;
role: string;
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce?: string | null;
version?: "v1" | "v2";
};
function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const version = params.version ?? (params.nonce ? "v2" : "v1");
const scopes = params.scopes.join(",");
const token = params.token ?? "";
const base = [
version,
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
];
if (version === "v2") {
base.push(params.nonce ?? "");
}
return base.join("|");
}
type DeviceAuthEntry = {
token: string;
role: string;
scopes: string[];
updatedAtMs: number;
};
type DeviceAuthStore = {
version: 1;
deviceId: string;
tokens: Record<string, DeviceAuthEntry>;
};
const DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1";
function normalizeAuthScope(scope: string | undefined): string {
const trimmed = scope?.trim();
if (!trimmed) return "default";
return trimmed.toLowerCase();
}
function buildScopedTokenKey(scope: string, role: string): string {
return `${scope}::${role}`;
}
function normalizeRole(role: string): string {
return role.trim();
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function readDeviceAuthStore(): DeviceAuthStore | null {
try {
const raw = window.localStorage.getItem(DEVICE_AUTH_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (!parsed || parsed.version !== 1) return null;
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
return parsed;
} catch {
return null;
}
}
function writeDeviceAuthStore(store: DeviceAuthStore) {
try {
window.localStorage.setItem(DEVICE_AUTH_STORAGE_KEY, JSON.stringify(store));
} catch {
// best-effort
}
}
function loadDeviceAuthToken(params: { deviceId: string; role: string; scope: string }): DeviceAuthEntry | null {
const store = readDeviceAuthStore();
if (!store || store.deviceId !== params.deviceId) return null;
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const entry = store.tokens[key];
if (!entry || typeof entry.token !== "string") return null;
return entry;
}
function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
scope: string;
token: string;
scopes?: string[];
}): DeviceAuthEntry {
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens: {},
};
const existing = readDeviceAuthStore();
if (existing && existing.deviceId === params.deviceId) {
next.tokens = { ...existing.tokens };
}
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[key] = entry;
writeDeviceAuthStore(next);
return entry;
}
function clearDeviceAuthToken(params: { deviceId: string; role: string; scope: string }) {
const store = readDeviceAuthStore();
if (!store || store.deviceId !== params.deviceId) return;
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const hasScoped = Boolean(store.tokens[key]);
const hasLegacy = Boolean(store.tokens[role]);
if (!hasScoped && !hasLegacy) return;
const next = { ...store, tokens: { ...store.tokens } };
delete next.tokens[key];
delete next.tokens[role];
writeDeviceAuthStore(next);
}
type StoredIdentity = {
version: 1;
deviceId: string;
publicKey: string;
privateKey: string;
createdAtMs: number;
};
type DeviceIdentity = {
deviceId: string;
publicKey: string;
privateKey: string;
};
const DEVICE_IDENTITY_STORAGE_KEY = "openclaw-device-identity-v1";
export function clearGatewayBrowserSessionStorage() {
try {
localStorage.removeItem(DEVICE_AUTH_STORAGE_KEY);
localStorage.removeItem(DEVICE_IDENTITY_STORAGE_KEY);
} catch {
// best-effort
}
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
}
function base64UrlDecode(input: string): Uint8Array {
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(publicKey));
return bytesToHex(new Uint8Array(hash));
}
async function generateIdentity(): Promise<DeviceIdentity> {
const privateKey = utils.randomSecretKey();
const publicKey = await getPublicKeyAsync(privateKey);
const deviceId = await fingerprintPublicKey(publicKey);
return {
deviceId,
publicKey: base64UrlEncode(publicKey),
privateKey: base64UrlEncode(privateKey),
};
}
async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
try {
const raw = localStorage.getItem(DEVICE_IDENTITY_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StoredIdentity;
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKey === "string" &&
typeof parsed.privateKey === "string"
) {
const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey));
if (derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(updated));
return {
deviceId: derivedId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
return {
deviceId: parsed.deviceId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
}
} catch {
// fall through to regenerate
}
const identity = await generateIdentity();
const stored: StoredIdentity = {
version: 1,
deviceId: identity.deviceId,
publicKey: identity.publicKey,
privateKey: identity.privateKey,
createdAtMs: Date.now(),
};
localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored));
return identity;
}
async function signDevicePayload(privateKeyBase64Url: string, payload: string) {
const key = base64UrlDecode(privateKeyBase64Url);
const data = new TextEncoder().encode(payload);
const sig = await signAsync(data, key);
return base64UrlEncode(sig);
}
export type GatewayEventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: { presence: number; health: number };
};
export type GatewayResponseFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: { code: string; message: string; details?: unknown };
};
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
auth?: {
deviceToken?: string;
role?: string;
scopes?: string[];
issuedAtMs?: number;
};
policy?: { tickIntervalMs?: number };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: unknown) => void;
};
export type GatewayBrowserClientOptions = {
url: string;
token?: string;
password?: string;
authScopeKey?: string;
disableDeviceAuth?: boolean;
clientName?: string;
clientVersion?: string;
platform?: string;
mode?: string;
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void;
onEvent?: (evt: GatewayEventFrame) => void;
onClose?: (info: { code: number; reason: string }) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
const CONNECT_FAILED_CLOSE_CODE = 4008;
const WS_CLOSE_REASON_MAX_BYTES = 123;
function truncateWsCloseReason(reason: string, maxBytes = WS_CLOSE_REASON_MAX_BYTES): string {
const trimmed = reason.trim();
if (!trimmed) return "connect failed";
const encoder = new TextEncoder();
if (encoder.encode(trimmed).byteLength <= maxBytes) return trimmed;
let out = "";
for (const char of trimmed) {
const next = out + char;
if (encoder.encode(next).byteLength > maxBytes) break;
out = next;
}
return out.trimEnd() || "connect failed";
}
export class GatewayBrowserClient {
private ws: WebSocket | null = null;
private pending = new Map<string, Pending>();
private closed = false;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectSent = false;
private connectTimer: number | null = null;
private backoffMs = 800;
constructor(private opts: GatewayBrowserClientOptions) {}
start() {
this.closed = false;
this.connect();
}
stop() {
this.closed = true;
this.ws?.close();
this.ws = null;
this.flushPending(new Error("gateway client stopped"));
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
}
private connect() {
if (this.closed) return;
this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.queueConnect();
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
this.ws.onclose = (ev) => {
const reason = String(ev.reason ?? "");
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason });
this.scheduleReconnect();
};
this.ws.onerror = () => {
// ignored; close handler will fire
};
}
private scheduleReconnect() {
if (this.closed) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
window.setTimeout(() => this.connect(), delay);
}
private flushPending(err: Error) {
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
}
private async sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const isSecureContext =
!this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
const authScopeKey = normalizeAuthScope(this.opts.authScopeKey ?? this.opts.url);
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
scope: authScopeKey,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth =
authToken || this.opts.password
? {
token: authToken,
password: this.opts.password,
}
: undefined;
let device:
| {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string | undefined;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? navigator.platform ?? "web",
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
instanceId: this.opts.instanceId,
},
role,
scopes,
device,
caps: [],
auth,
userAgent: navigator.userAgent,
locale: navigator.language,
};
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role,
scope: authScopeKey,
token: hello.auth.deviceToken,
scopes: hello.auth.scopes ?? [],
});
}
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch((err) => {
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey });
}
const rawReason =
err instanceof GatewayResponseError
? `connect failed: ${err.code} ${err.message}`
: "connect failed";
const reason = truncateWsCloseReason(rawReason);
if (reason !== rawReason) {
console.warn("[gateway] connect close reason truncated to 123 UTF-8 bytes");
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, reason);
});
}
private handleMessage(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const frame = parsed as { type?: unknown };
if (frame.type === "event") {
const evt = parsed as GatewayEventFrame;
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
this.connectNonce = nonce;
void this.sendConnect();
}
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
}
this.lastSeq = seq;
}
try {
this.opts.onEvent?.(evt);
} catch (err) {
console.error("[gateway] event handler error:", err);
}
return;
}
if (frame.type === "res") {
const res = parsed as GatewayResponseFrame;
const pending = this.pending.get(res.id);
if (!pending) return;
this.pending.delete(res.id);
if (res.ok) pending.resolve(res.payload);
else {
if (res.error && typeof res.error.code === "string") {
pending.reject(
new GatewayResponseError({
code: res.error.code,
message: res.error.message ?? "request failed",
details: res.error.details,
})
);
return;
}
pending.reject(new Error(res.error?.message ?? "request failed"));
}
return;
}
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway not connected"));
}
const id = generateUUID();
const frame = { type: "req", id, method, params };
const p = new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
});
this.ws.send(JSON.stringify(frame));
return p;
}
private queueConnect() {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
this.connectTimer = window.setTimeout(() => {
void this.sendConnect();
}, 750);
}
}
+6
View File
@@ -0,0 +1,6 @@
export const resolveStudioProxyGatewayUrl = (): string => {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${protocol}://${host}/api/gateway/ws`;
};