First Release of Claw3D (#11)
Co-authored-by: iamlukethedev <iamlukethedev@users.noreply.github.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user