import path from "node:path"; import { buildPluginConfigSchema, type OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/core"; import { z } from "openclaw/plugin-sdk/zod"; export type OpenShellPluginConfig = { mode?: "mirror" | "remote"; 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; function normalizeProviders(value: string[] | undefined): string[] { const seen = new Set(); const providers: string[] = []; for (const entry of value ?? []) { const normalized = entry.trim(); if (seen.has(normalized)) { continue; } seen.add(normalized); providers.push(normalized); } return providers; } const nonEmptyTrimmedString = (message: string) => z.string({ error: message }).trim().min(1, { error: message }); const OpenShellPluginConfigSchema = z.strictObject({ mode: z.enum(["mirror", "remote"], { error: "mode must be one of mirror, remote" }).optional(), command: nonEmptyTrimmedString("command must be a non-empty string").optional(), gateway: nonEmptyTrimmedString("gateway must be a non-empty string").optional(), gatewayEndpoint: nonEmptyTrimmedString("gatewayEndpoint must be a non-empty string").optional(), from: nonEmptyTrimmedString("from must be a non-empty string").optional(), policy: nonEmptyTrimmedString("policy must be a non-empty string").optional(), providers: z .array( z.string({ error: "providers must be an array of strings" }).trim().min(1, { error: "providers must be an array of strings", }), { error: "providers must be an array of strings", }, ) .optional(), gpu: z.boolean({ error: "gpu must be a boolean" }).optional(), autoProviders: z.boolean({ error: "autoProviders must be a boolean" }).optional(), remoteWorkspaceDir: nonEmptyTrimmedString( "remoteWorkspaceDir must be a non-empty string", ).optional(), remoteAgentWorkspaceDir: nonEmptyTrimmedString( "remoteAgentWorkspaceDir must be a non-empty string", ).optional(), timeoutSeconds: z .number({ error: "timeoutSeconds must be a number >= 1" }) .min(1, { error: "timeoutSeconds must be a number >= 1" }) .optional(), }); function formatOpenShellConfigIssue(issue: z.ZodIssue | undefined): string { if (!issue) { return "invalid config"; } if (issue.code === "unrecognized_keys" && issue.keys.length > 0) { return `unknown config key: ${issue.keys[0]}`; } if (issue.code === "invalid_type" && issue.path.length === 0) { return "expected config object"; } return issue.message; } 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 { return buildPluginConfigSchema(OpenShellPluginConfigSchema, { safeParse(value) { if (value === undefined) { return { success: true, data: undefined }; } const parsed = OpenShellPluginConfigSchema.safeParse(value); if (parsed.success) { return { success: true, data: parsed.data }; } return { success: false, error: { issues: parsed.error.issues.map((issue) => ({ path: issue.path.filter((segment): segment is string | number => { const kind = typeof segment; return kind === "string" || kind === "number"; }), message: formatOpenShellConfigIssue(issue), })), }, }; }, }); } export function resolveOpenShellPluginConfig(value: unknown): ResolvedOpenShellPluginConfig { if (value === undefined) { return { mode: DEFAULT_MODE, command: DEFAULT_COMMAND, gateway: undefined, gatewayEndpoint: undefined, from: DEFAULT_SOURCE, policy: undefined, providers: [], gpu: false, autoProviders: true, remoteWorkspaceDir: DEFAULT_REMOTE_WORKSPACE_DIR, remoteAgentWorkspaceDir: DEFAULT_REMOTE_AGENT_WORKSPACE_DIR, timeoutMs: DEFAULT_TIMEOUT_MS, }; } const parsed = OpenShellPluginConfigSchema.safeParse(value); if (!parsed.success) { const message = formatOpenShellConfigIssue(parsed.error.issues[0]); throw new Error(`Invalid openshell plugin config: ${message}`); } const cfg = parsed.data as OpenShellPluginConfig; const mode = cfg.mode ?? DEFAULT_MODE; return { mode, command: cfg.command ?? DEFAULT_COMMAND, gateway: cfg.gateway, gatewayEndpoint: cfg.gatewayEndpoint, from: cfg.from ?? DEFAULT_SOURCE, policy: cfg.policy, providers: normalizeProviders(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, }; }