openclaw/extensions/openshell/src/config.ts

237 lines
6.9 KiB
TypeScript

import path from "node:path";
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core";
export type OpenShellPluginConfig = {
mode?: string;
command?: string;
gateway?: string;
gatewayEndpoint?: string;
from?: string;
policy?: string;
providers?: string[];
gpu?: boolean;
autoProviders?: boolean;
remoteWorkspaceDir?: string;
remoteAgentWorkspaceDir?: string;
timeoutSeconds?: number;
};
export type ResolvedOpenShellPluginConfig = {
mode: "mirror" | "remote";
command: string;
gateway?: string;
gatewayEndpoint?: string;
from: string;
policy?: string;
providers: string[];
gpu: boolean;
autoProviders: boolean;
remoteWorkspaceDir: string;
remoteAgentWorkspaceDir: string;
timeoutMs: number;
};
const DEFAULT_COMMAND = "openshell";
const DEFAULT_MODE = "mirror";
const DEFAULT_SOURCE = "openclaw";
const DEFAULT_REMOTE_WORKSPACE_DIR = "/sandbox";
const DEFAULT_REMOTE_AGENT_WORKSPACE_DIR = "/agent";
const DEFAULT_TIMEOUT_MS = 120_000;
type ParseSuccess = { success: true; data?: OpenShellPluginConfig };
type ParseFailure = {
success: false;
error: {
issues: Array<{ path: Array<string | number>; message: string }>;
};
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function trimString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeProviders(value: unknown): string[] | null {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
return null;
}
const seen = new Set<string>();
const providers: string[] = [];
for (const entry of value) {
if (typeof entry !== "string" || !entry.trim()) {
return null;
}
const normalized = entry.trim();
if (seen.has(normalized)) {
continue;
}
seen.add(normalized);
providers.push(normalized);
}
return providers;
}
function normalizeRemotePath(value: string | undefined, fallback: string): string {
const candidate = value ?? fallback;
const normalized = path.posix.normalize(candidate.trim() || fallback);
if (!normalized.startsWith("/")) {
throw new Error(`OpenShell remote path must be absolute: ${candidate}`);
}
return normalized;
}
export function createOpenShellPluginConfigSchema(): OpenClawPluginConfigSchema {
const safeParse = (value: unknown): ParseSuccess | ParseFailure => {
if (value === undefined) {
return { success: true, data: undefined };
}
if (!isRecord(value)) {
return {
success: false,
error: { issues: [{ path: [], message: "expected config object" }] },
};
}
const allowedKeys = new Set([
"mode",
"command",
"gateway",
"gatewayEndpoint",
"from",
"policy",
"providers",
"gpu",
"autoProviders",
"remoteWorkspaceDir",
"remoteAgentWorkspaceDir",
"timeoutSeconds",
]);
for (const key of Object.keys(value)) {
if (!allowedKeys.has(key)) {
return {
success: false,
error: { issues: [{ path: [key], message: `unknown config key: ${key}` }] },
};
}
}
const providers = normalizeProviders(value.providers);
if (providers === null) {
return {
success: false,
error: {
issues: [{ path: ["providers"], message: "providers must be an array of strings" }],
},
};
}
const timeoutSeconds = value.timeoutSeconds;
if (
timeoutSeconds !== undefined &&
(typeof timeoutSeconds !== "number" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1)
) {
return {
success: false,
error: {
issues: [{ path: ["timeoutSeconds"], message: "timeoutSeconds must be a number >= 1" }],
},
};
}
for (const key of ["gpu", "autoProviders"] as const) {
const candidate = value[key];
if (candidate !== undefined && typeof candidate !== "boolean") {
return {
success: false,
error: { issues: [{ path: [key], message: `${key} must be a boolean` }] },
};
}
}
return {
success: true,
data: {
mode: trimString(value.mode),
command: trimString(value.command),
gateway: trimString(value.gateway),
gatewayEndpoint: trimString(value.gatewayEndpoint),
from: trimString(value.from),
policy: trimString(value.policy),
providers,
gpu: value.gpu as boolean | undefined,
autoProviders: value.autoProviders as boolean | undefined,
remoteWorkspaceDir: trimString(value.remoteWorkspaceDir),
remoteAgentWorkspaceDir: trimString(value.remoteAgentWorkspaceDir),
timeoutSeconds: timeoutSeconds as number | undefined,
},
};
};
return {
safeParse,
jsonSchema: {
type: "object",
additionalProperties: false,
properties: {
command: { type: "string" },
mode: { type: "string", enum: ["mirror", "remote"] },
gateway: { type: "string" },
gatewayEndpoint: { type: "string" },
from: { type: "string" },
policy: { type: "string" },
providers: { type: "array", items: { type: "string" } },
gpu: { type: "boolean" },
autoProviders: { type: "boolean" },
remoteWorkspaceDir: { type: "string" },
remoteAgentWorkspaceDir: { type: "string" },
timeoutSeconds: { type: "number", minimum: 1 },
},
},
};
}
export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig {
const parsed = createOpenShellPluginConfigSchema().safeParse?.(value);
if (!parsed || !parsed.success) {
const issues = parsed && !parsed.success ? parsed.error?.issues : undefined;
const message =
issues?.map((issue: { message: string }) => issue.message).join(", ") || "invalid config";
throw new Error(`Invalid openshell plugin config: ${message}`);
}
const raw = parsed.data ?? {};
const cfg = (raw ?? {}) as OpenShellPluginConfig;
const mode = cfg.mode ?? DEFAULT_MODE;
if (mode !== "mirror" && mode !== "remote") {
throw new Error(`Invalid openshell plugin config: mode must be one of mirror, remote`);
}
return {
mode,
command: cfg.command ?? DEFAULT_COMMAND,
gateway: cfg.gateway,
gatewayEndpoint: cfg.gatewayEndpoint,
from: cfg.from ?? DEFAULT_SOURCE,
policy: cfg.policy,
providers: cfg.providers ?? [],
gpu: cfg.gpu ?? false,
autoProviders: cfg.autoProviders ?? true,
remoteWorkspaceDir: normalizeRemotePath(cfg.remoteWorkspaceDir, DEFAULT_REMOTE_WORKSPACE_DIR),
remoteAgentWorkspaceDir: normalizeRemotePath(
cfg.remoteAgentWorkspaceDir,
DEFAULT_REMOTE_AGENT_WORKSPACE_DIR,
),
timeoutMs:
typeof cfg.timeoutSeconds === "number"
? Math.floor(cfg.timeoutSeconds * 1000)
: DEFAULT_TIMEOUT_MS,
};
}