diff --git a/docs/resources/(resources)/openclaw.mdx b/docs/resources/(resources)/openclaw.mdx new file mode 100644 index 00000000..a2d1e1d5 --- /dev/null +++ b/docs/resources/(resources)/openclaw.mdx @@ -0,0 +1,67 @@ +--- +title: openclaw +description: A reference page for the openclaw resource +--- + +The openclaw resource installs [OpenClaw](https://docs.openclaw.ai/) — a self-hosted gateway that connects chat channels (Discord, Slack, Telegram, WhatsApp, Signal, iMessage, Matrix, Teams, and more) to AI agents — and manages its configuration. It handles installation via the official installer script and gives you declarative control over the gateway, agents, models, channels, tools, and every other section of the OpenClaw config. + +## Parameters + +- **settings**: *(object, optional)* Top-level keys to merge into `~/.openclaw/openclaw.json`. On apply, the declared keys are written; on destroy, only the declared keys are removed. Common sections include: + - `gateway` — `mode` (`"local"` | `"remote"`), `port` (default `18789`), `bind` (`"loopback"` | `"lan"` | `"tailnet"` | `"auto"` | `"custom"`), `auth`, `tls`, `controlUi` + - `agents` — `defaults.{workspace,model,thinking,heartbeat,memory,media,skills}`, `list[]` for per-agent overrides + - `models` — `pricing.enabled`, `mode` (`"merge"` | `"replace"`), `providers` (custom/local model providers such as Ollama or LM Studio) + - `channels` — per-provider sections under `channels.` (e.g. `discord`, `slack`, `telegram`, `whatsapp`, `signal`, `imessage`, `matrix`, `teams`). A channel starts automatically once its config section exists (unless `enabled: false`). Common fields: `dmPolicy`, `groupPolicy`, `allowFrom`, `mediaMaxMb`, `historyLimit`, plus provider-specific credentials (`token`, `botToken`, `appToken`, etc.) + - `tools` — `policy.{allow,deny}` lists controlling which tools (`exec`, `read`, `write`, `browser`, `web_search`, `cron`, etc.) agents can call + - `skills` — `allowBundled`, `load.extraDirs`, `install.nodeManager` + - `plugins` — `enabled`, `allow`/`deny`, `entries.*` + - `mcp` — `servers`, `sessionIdleTtlMs` + - `browser`, `logging`, `cron`, `hooks`, `ui`, `env`, `secrets`, `auth`, `discovery`, `acp` — see the [configuration reference](https://docs.openclaw.ai/gateway/configuration-reference) for the full list of fields + +## Example usage + +### Install OpenClaw with gateway and agent defaults + +```json title="codify.jsonc" +[ + { + "type": "openclaw", + "settings": { + "gateway": { "port": 18789, "bind": "loopback" }, + "agents": { "defaults": { "model": "anthropic/claude-sonnet-4-6" } } + } + } +] +``` + +### OpenClaw with a Telegram channel and restricted tools + +```json title="codify.jsonc" +[ + { + "type": "openclaw", + "settings": { + "channels": { + "telegram": { + "botToken": "", + "dmPolicy": "allowlist", + "allowFrom": ["123456789"] + } + }, + "tools": { + "policy": { "allow": ["exec", "read", "write", "web_search"] } + } + } + } +] +``` + +## Notes + +- OpenClaw is installed via the official installer (`curl -fsSL https://openclaw.ai/install.sh | bash`) on both macOS and Linux. +- The configuration file lives at `~/.openclaw/openclaw.json` and uses JSON5 (Codify reads/writes it as plain JSON, so any comments in a hand-edited file will not be preserved). +- The `settings` parameter merges only the declared top-level keys. Existing sections not in your Codify config are left untouched. +- After applying settings changes, Codify runs `openclaw gateway restart` so the running gateway picks up the new configuration. +- On destroy, the declared `settings` keys are removed and the OpenClaw binary, config, and state directory (`~/.openclaw`) are removed. +- Model provider authentication (API keys) and full guided onboarding are not managed by this resource — configure credentials under `settings.models` / `settings.auth`, or run `openclaw onboard` manually for an interactive setup. +- See the [OpenClaw configuration reference](https://docs.openclaw.ai/gateway/configuration-reference) for the complete list of configuration sections and fields. diff --git a/package.json b/package.json index c8248f16..508574f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.7.1", + "version": "1.8.3", "description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux", "main": "dist/index.js", "scripts": { diff --git a/src/index.ts b/src/index.ts index b0145bc8..e3ee3418 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ import { Pnpm } from './resources/javascript/pnpm/pnpm.js'; import { MacosSettingsResource } from './resources/macos/macos-settings/macos-settings-resource.js'; import { MacportsResource } from './resources/macports/macports.js'; import { ClaudeCodeResource } from './resources/claude-code/claude-code.js'; +import { OpenClawResource } from './resources/openclaw/openclaw.js'; import { ClaudeCodeProjectResource } from './resources/claude-code/claude-code-project.js'; import { OllamaResource } from './resources/ollama/ollama.js'; import { PgcliResource } from './resources/pgcli/pgcli.js'; @@ -127,6 +128,7 @@ runPlugin(Plugin.create( new SyncthingDeviceResource(), new SyncthingFolderResource(), new RbenvResource(), + new OpenClawResource(), ], { minSupportedCliVersion: MIN_SUPPORTED_CLI_VERSION } )) diff --git a/src/resources/javascript/nvm/nvm.ts b/src/resources/javascript/nvm/nvm.ts index 41822cb5..a77b5474 100644 --- a/src/resources/javascript/nvm/nvm.ts +++ b/src/resources/javascript/nvm/nvm.ts @@ -1,4 +1,4 @@ -import { ExampleConfig, getPty, Resource, ResourceSettings, SpawnStatus, Utils } from '@codifycli/plugin-core'; +import { ApplyNotes, CodifyCliSender, ExampleConfig, getPty, Resource, ResourceSettings, SpawnStatus, Utils } from '@codifycli/plugin-core'; import { OS, ResourceConfig } from '@codifycli/schemas'; import * as os from 'node:os'; @@ -87,6 +87,8 @@ export class NvmResource extends Resource { '[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"' ]) } + + CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED, 'nvm'); } override async destroy(): Promise { diff --git a/src/resources/openclaw/openclaw.test.ts b/src/resources/openclaw/openclaw.test.ts new file mode 100644 index 00000000..79e62450 --- /dev/null +++ b/src/resources/openclaw/openclaw.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { settingsSchema } from './openclaw.js'; +import { OpenClawSettingsParameter } from './settings-parameter.js'; + +describe('openclaw settings schema', () => { + it('rejects tools.policy — must use tools.allow / tools.deny directly', () => { + const result = settingsSchema.safeParse({ + tools: { policy: { allow: ['exec', 'read'] } }, + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('policy'); + }); + + it('rejects skills.workspace and skills.autoLoad — must use skills.load.extraDirs', () => { + const result = settingsSchema.safeParse({ + skills: { workspace: '$HOME/openclaw-skills', autoLoad: true }, + }); + expect(result.success).toBe(false); + const issues = JSON.stringify(result.error?.issues); + expect(issues).toContain('workspace'); + expect(issues).toContain('autoLoad'); + }); + + it('rejects cron.jobs — jobs are stored in ~/.openclaw/cron/jobs.json, not in openclaw.json', () => { + const result = settingsSchema.safeParse({ + cron: { jobs: [{ name: 'morning-briefing', schedule: '0 7 * * *' }] }, + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('jobs'); + }); + + it('accepts valid tools config', () => { + const result = settingsSchema.safeParse({ + tools: { allow: ['exec', 'read', 'write', 'web_search'] }, + }); + expect(result.success).toBe(true); + }); + + it('accepts valid skills config with load.extraDirs', () => { + const result = settingsSchema.safeParse({ + skills: { load: { extraDirs: ['$HOME/openclaw-skills'] } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts valid cron config without jobs', () => { + const result = settingsSchema.safeParse({ + cron: { enabled: true, maxConcurrentRuns: 8 }, + }); + expect(result.success).toBe(true); + }); + + it('accepts the full example config shape', () => { + const result = settingsSchema.safeParse({ + gateway: { mode: 'local', port: 18789, bind: 'loopback' }, + agents: { + defaults: { + model: 'anthropic/claude-sonnet-4-6', + workspace: '$HOME/openclaw-workspace', + maxConcurrent: 4, + }, + }, + channels: { + telegram: { + botToken: 'token', + dmPolicy: 'allowlist', + allowFrom: ['123456789'], + }, + }, + tools: { + allow: ['exec', 'read', 'write', 'web_search', 'browser', 'skills'], + }, + skills: { + load: { extraDirs: ['$HOME/openclaw-skills'] }, + }, + browser: { enabled: true, headless: true }, + cron: { enabled: true, maxConcurrentRuns: 8 }, + }); + expect(result.success).toBe(true); + }); + + it('passes through unknown top-level keys (hooks, session, memory, etc.)', () => { + const result = settingsSchema.safeParse({ + hooks: { enabled: true, token: 'abc', path: '/hooks' }, + session: { dmScope: 'main' }, + memory: { backend: 'default' }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/src/resources/openclaw/openclaw.ts b/src/resources/openclaw/openclaw.ts new file mode 100644 index 00000000..6aa08fbe --- /dev/null +++ b/src/resources/openclaw/openclaw.ts @@ -0,0 +1,500 @@ +import { + ApplyNotes, + CodifyCliSender, + CreatePlan, + DestroyPlan, + ExampleConfig, + Resource, + ResourceSettings, + SpawnStatus, + getPty, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { OpenClawSettingsParameter } from './settings-parameter.js'; + +// ── Per-section schemas matching docs.openclaw.ai/gateway/configuration-reference ── + +// tools: strict so tools.policy (invalid) is caught at validation time +const toolsSchema = z + .strictObject({ + profile: z.enum(['minimal', 'coding', 'messaging', 'full']).optional() + .describe('Sets a base allowlist of tools before allow/deny rules are applied.'), + allow: z.array(z.string()).optional() + .describe('Tool IDs or group: references to allow (e.g. "exec", "group:fs"). Case-insensitive, supports * wildcards.'), + deny: z.array(z.string()).optional() + .describe('Tool IDs or group: references to block. Deny rules take precedence over allow rules.'), + byProvider: z.record(z.string(), z.looseObject({ + profile: z.enum(['minimal', 'coding', 'messaging', 'full']).optional() + .describe('Base tool allowlist profile for this provider.'), + allow: z.array(z.string()).optional() + .describe('Tools to allow for this provider.'), + deny: z.array(z.string()).optional() + .describe('Tools to deny for this provider.'), + })).optional() + .describe('Applies different tool restrictions per provider or model (keyed by provider/model ID).'), + toolsBySender: z.record(z.string(), z.looseObject({ + alsoAllow: z.array(z.string()).optional() + .describe('Additional tools to allow for this sender on top of the global policy.'), + allow: z.array(z.string()).optional() + .describe('Explicit tool allowlist for this sender.'), + deny: z.array(z.string()).optional() + .describe('Tools to deny for this sender.'), + })).optional() + .describe('Restricts tools based on the requester\'s identity (keyed by channel, user ID, or * for default).'), + web: z.looseObject({ + fetch: z.looseObject({ enabled: z.boolean().optional() }).optional() + .describe('Configures webpage retrieval capabilities, including provider selection and character limits.'), + search: z.looseObject({ enabled: z.boolean().optional() }).optional() + .describe('Enables web search functionality with API key configuration and result limits.'), + }).optional() + .describe('Web fetch and search tool settings.'), + codeMode: z.boolean().optional() + .describe('Activates a generic code-execution surface where models interact through exec and wait rather than traditional tools.'), + elevated: z.looseObject({ allowFrom: z.array(z.string()).optional() }).optional() + .describe('Controls whether agents can execute commands outside the sandbox; allowFrom restricts which senders can trigger elevated execution.'), + exec: z.looseObject({ timeout: z.number().optional() }).optional() + .describe('Manages command execution behaviour, including timeouts, cleanup intervals, and patch application settings.'), + loopDetection: z.looseObject({ enabled: z.boolean().optional() }).optional() + .describe('Detects and prevents tool-call loops through pattern recognition and configurable thresholds.'), + sandbox: z.looseObject({}).optional() + .describe('Filters which tools remain accessible within sandboxed sessions, including MCP servers and plugins.'), + experimental: z.looseObject({}).optional() + .describe('Enables beta features such as the structured update_plan tool for multi-step work tracking.'), + media: z.looseObject({}).optional() + .describe('Configures audio, image, and video understanding capabilities with model selection and size limits.'), + agentToAgent: z.looseObject({}).optional() + .describe('Controls whether agents can invoke other configured agents as tools.'), + sessions: z.looseObject({}).optional() + .describe('Defines visibility scope for session tools — whether agents can access current, spawned, agent-wide, or all sessions.'), + sessions_spawn: z.looseObject({}).optional() + .describe('Permits inline file attachments when spawning subagent sessions with configurable file and size limits.'), + subagents: z.looseObject({}).optional() + .describe('Sets defaults for spawned subagents, including model selection, concurrency limits, and timeout behaviour.'), + }) + .describe('Tool visibility and policy. Use allow/deny at the top level — there is no tools.policy key.'); + +// skills: strict so skills.workspace and skills.autoLoad (invalid) are caught +const skillsSchema = z + .strictObject({ + allowBundled: z.array(z.string()).optional() + .describe('Optional allowlist restricting which bundled skills are active; managed and workspace skills are unaffected.'), + load: z.looseObject({ + extraDirs: z.array(z.string()).optional() + .describe('Shared skill root directories with lowest search precedence. Use this instead of the non-existent skills.workspace key.'), + allowSymlinkTargets: z.array(z.string()).optional() + .describe('Trusted real target roots that skill symlinks may resolve into outside their configured source.'), + watch: z.boolean().optional() + .describe('When enabled, watches skill directories for SKILL.md changes and reloads automatically.'), + watchDebounceMs: z.number().optional() + .describe('Debounce delay in milliseconds for skill directory watch events (default: 250).'), + }).optional() + .describe('Skill discovery and loading configuration.'), + install: z.looseObject({ + preferBrew: z.boolean().optional() + .describe('When enabled, prioritises Homebrew installers before falling back to other methods.'), + nodeManager: z.enum(['npm', 'pnpm', 'yarn', 'bun']).optional() + .describe('Selects the Node.js package manager used for skill metadata install specs.'), + allowUploadedArchives: z.boolean().optional() + .describe('Permits gateway admin clients to install private zip archives staged through skills.upload.'), + }).optional() + .describe('Skill installation preferences.'), + workshop: z.looseObject({ + allowSymlinkTargetWrites: z.boolean().optional() + .describe('Controls whether Skill Workshop apply can write through already-trusted symlink targets.'), + }).optional() + .describe('Skill Workshop settings for developing and editing skills.'), + entries: z.record(z.string(), z.looseObject({ + enabled: z.boolean().optional() + .describe('Disables a skill even if it is bundled or installed.'), + apiKey: z.union([z.string(), z.object({ source: z.string(), provider: z.string(), id: z.string() })]).optional() + .describe('Convenience field for skills declaring a primary environment variable; accepts a plaintext string or a SecretRef object.'), + env: z.record(z.string(), z.string()).optional() + .describe('Skill-scoped environment variables injected at runtime.'), + config: z.record(z.string(), z.unknown()).optional() + .describe('Plugin-defined configuration object validated against the skill\'s own schema.'), + })).optional() + .describe('Per-skill overrides keyed by skill ID.'), + }) + .describe('Skill management. Use skills.load.extraDirs for extra skill paths — there is no skills.workspace or skills.autoLoad key.'); + +// cron: strict so cron.jobs (invalid — jobs live in ~/.openclaw/cron/jobs.json) is caught +const cronSchema = z + .strictObject({ + enabled: z.boolean().optional() + .describe('Master toggle for cron job functionality.'), + maxConcurrentRuns: z.number().optional() + .describe('Maximum number of concurrently active cron sessions including dispatch and isolated execution.'), + sessionRetention: z.union([z.string(), z.literal(false)]).optional() + .describe('Duration to keep completed isolated cron run sessions (e.g. "24h"); set false to disable retention.'), + webhookToken: z.string().optional() + .describe('Optional bearer token for authenticating outbound cron webhook POST deliveries.'), + store: z.string().optional() + .describe('Path to the jobs store file (default: ~/.openclaw/cron/jobs.json). Note: jobs are defined here, not in openclaw.json.'), + retry: z.looseObject({ + maxAttempts: z.number().min(0).max(10).optional() + .describe('Maximum number of retries for cron jobs on transient errors (range 0–10).'), + backoffMs: z.array(z.number()).optional() + .describe('Array of delay intervals in milliseconds applied sequentially for each retry attempt.'), + retryOn: z.array(z.enum(['rate_limit', 'overloaded', 'network', 'timeout', 'server_error'])).optional() + .describe('Error types that trigger a retry attempt.'), + }).optional() + .describe('Retry policy for failed cron job runs.'), + runLog: z.looseObject({ + maxBytes: z.string().optional() + .describe('Maximum size of cron run log files before rotation (e.g. "2mb").'), + keepLines: z.number().optional() + .describe('Number of newest run-history rows retained per job.'), + }).optional() + .describe('Cron run log retention settings.'), + failureAlert: z.looseObject({ + enabled: z.boolean().optional() + .describe('Activates automatic failure notifications for cron jobs.'), + after: z.number().min(1).optional() + .describe('Number of consecutive failures required before an alert fires.'), + cooldownMs: z.number().optional() + .describe('Minimum milliseconds between repeated alerts for the same job.'), + }).optional() + .describe('Alerting behaviour when cron jobs fail repeatedly.'), + failureDestination: z.looseObject({ + mode: z.enum(['announce', 'webhook']).optional() + .describe('Delivery method for failure alerts: announce sends to a channel, webhook POSTs to a URL.'), + channel: z.string().optional() + .describe('Target channel for announce-mode failure alerts.'), + to: z.string().optional() + .describe('Target user or channel identifier for failure alert delivery.'), + }).optional() + .describe('Global default destination for cron failure notifications across all jobs.'), + }) + .describe('Cron scheduling settings. Jobs are stored separately in ~/.openclaw/cron/jobs.json — there is no cron.jobs key in openclaw.json.'); + +const gatewaySchema = z + .looseObject({ + mode: z.enum(['local', 'remote']) + .describe('Whether the gateway runs locally or connects to a remote instance.'), + port: z.number().optional() + .describe('Single multiplexed port for WebSocket and HTTP communication (default: 18789).'), + bind: z.enum(['auto', 'loopback', 'lan', 'tailnet', 'custom']).optional() + .describe('Network interface the gateway listens on: loopback (localhost only), lan (all interfaces), tailnet (Tailscale), or custom.'), + auth: z.looseObject({ + mode: z.enum(['none', 'token', 'password', 'trusted-proxy']).optional() + .describe('Authentication strategy for gateway connections.'), + token: z.string().optional() + .describe('Shared secret for token-based gateway authentication.'), + password: z.string().optional() + .describe('Shared secret for password-based gateway authentication.'), + allowTailscale: z.boolean().optional() + .describe('Allows Tailscale Serve identity headers to satisfy Control UI authentication.'), + }).optional() + .describe('Gateway authentication settings.'), + tls: z.looseObject({ + enabled: z.boolean().optional() + .describe('Activates TLS termination at the gateway listener for HTTPS/WSS connections.'), + certPath: z.string().optional() + .describe('Filesystem path to the TLS certificate file.'), + keyPath: z.string().optional() + .describe('Filesystem path to the TLS private key file.'), + }).optional() + .describe('TLS termination settings for the gateway listener.'), + reload: z.looseObject({ + mode: z.enum(['off', 'restart', 'hot', 'hybrid']).optional() + .describe('How configuration changes are applied at runtime: off (manual restart), restart, hot (no downtime), or hybrid.'), + }).optional() + .describe('Runtime configuration reload behaviour.'), + }) + .describe('Gateway server settings. gateway.mode is required for the gateway to start.'); + +const agentsSchema = z + .looseObject({ + defaults: z.looseObject({ + workspace: z.string().optional() + .describe('Default workspace directory for agent operations.'), + model: z.union([z.string(), z.object({ + primary: z.string(), + fallbacks: z.array(z.string()).optional(), + })]).optional() + .describe('Default LLM model for agent runs, as "provider/model" or an object with primary and fallbacks.'), + thinkingDefault: z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'adaptive', 'max']).optional() + .describe('Default extended thinking intensity for agents.'), + skills: z.array(z.string()).optional() + .describe('Skills available to agents by default; omit to allow all skills.'), + sandbox: z.looseObject({ + mode: z.enum(['off', 'non-main', 'all']).optional() + .describe('Sandbox scope: off disables sandboxing, non-main sandboxes all agents except the main session, all sandboxes everything.'), + scope: z.enum(['session', 'agent', 'shared']).optional() + .describe('Whether the sandbox is shared across sessions, scoped per agent, or per session.'), + }).optional() + .describe('Sandbox constraints for agent execution environments.'), + heartbeat: z.looseObject({ every: z.string().optional() }).optional() + .describe('Timing and behaviour for periodic agent check-ins (e.g. every: "30m").'), + maxConcurrent: z.number().optional() + .describe('Maximum number of simultaneously active agent sessions.'), + }).optional() + .describe('Default settings inherited by all agents unless overridden in agents.list.'), + list: z.array(z.looseObject({ + id: z.string() + .describe('Stable identifier for this agent, used in bindings and references.'), + name: z.string().optional() + .describe('Display name for this agent shown in the UI.'), + workspace: z.string().optional() + .describe('Workspace directory for this agent, overrides defaults.workspace.'), + model: z.string().optional() + .describe('LLM model for this agent, overrides defaults.model.'), + skills: z.array(z.string()).optional() + .describe('Skills available to this agent; overrides defaults.skills entirely when set.'), + })).optional() + .describe('Named agent definitions, each with its own identity, model, and capabilities.'), + }) + .describe('Agent defaults and named agent list.'); + +const browserSchema = z + .looseObject({ + enabled: z.boolean().optional() + .describe('Toggles browser automation functionality on or off.'), + headless: z.boolean().optional() + .describe('Controls whether browser windows are shown during automation (default: false).'), + executablePath: z.string().optional() + .describe('Path to a custom Chromium-based browser binary; auto-detected if omitted.'), + defaultProfile: z.string().optional() + .describe('Browser profile to load by default for agent automation.'), + noSandbox: z.boolean().optional() + .describe('Disables browser sandbox isolation — only use when the OS sandbox is unavailable.'), + tabCleanup: z.looseObject({ + enabled: z.boolean().optional() + .describe('Enables automatic cleanup of idle browser tabs (default: true).'), + idleMinutes: z.number().optional() + .describe('Minutes a tab must be idle before it is eligible for cleanup (default: 120).'), + maxTabsPerSession: z.number().optional() + .describe('Maximum open tabs per session before forced cleanup (default: 8).'), + }).optional() + .describe('Automatic cleanup of idle or excess browser tabs per session.'), + ssrfPolicy: z.looseObject({ + dangerouslyAllowPrivateNetwork: z.boolean().optional() + .describe('Allows browser navigation to private/internal network addresses — dangerous, off by default.'), + hostnameAllowlist: z.array(z.string()).optional() + .describe('Hostnames or patterns (e.g. "*.example.com") that are always permitted regardless of SSRF policy.'), + }).optional() + .describe('Private network access restrictions and hostname allowlists for browser navigation.'), + profiles: z.record(z.string(), z.looseObject({})).optional() + .describe('Named browser profile configurations, each with distinct CDP ports, colours, and executables.'), + }) + .describe('Chromium browser control settings.'); + +const mcpSchema = z + .looseObject({ + servers: z.record(z.string(), z.looseObject({ + enabled: z.boolean().optional() + .describe('Whether this MCP server is active (default: true).'), + command: z.string().optional() + .describe('Executable to launch for stdio-transport servers (e.g. "npx").'), + args: z.array(z.string()).optional() + .describe('Arguments passed to the stdio command.'), + env: z.record(z.string(), z.string()).optional() + .describe('Extra environment variables injected into the stdio server process.'), + url: z.string().optional() + .describe('URL for HTTP/SSE-transport servers.'), + transport: z.enum(['streamable-http', 'sse']).optional() + .describe('Transport protocol for remote servers (default: sse).'), + headers: z.record(z.string(), z.string()).optional() + .describe('HTTP headers sent with every request to remote servers (e.g. Authorization).'), + toolFilter: z.object({ + include: z.array(z.string()).optional() + .describe('MCP tool names or glob patterns to expose from this server.'), + exclude: z.array(z.string()).optional() + .describe('MCP tool names or glob patterns to hide from this server.'), + }).optional() + .describe('Allowlist/denylist filter for which tools this server exposes to agents.'), + })).optional() + .describe('Named MCP server definitions, keyed by server name.'), + sessionIdleTtlMs: z.number().optional() + .describe('Idle time-to-live in milliseconds for session-scoped MCP runtimes before cleanup (default: 600000).'), + }) + .describe('Model Context Protocol server definitions.'); + +// Top-level settings: loose so undocumented sections (hooks, session, memory, etc.) pass through +export const settingsSchema = z + .looseObject({ + gateway: gatewaySchema.optional() + .describe('Gateway server settings (port, bind address, auth, TLS, reload behaviour).'), + agents: agentsSchema.optional() + .describe('Agent defaults and named agent list.'), + channels: z.record(z.string(), z.looseObject({})).optional() + .describe('Per-channel configuration keyed by provider name (telegram, slack, discord, whatsapp, etc.).'), + tools: toolsSchema.optional() + .describe('Tool visibility and policy (allow/deny lists, per-provider and per-sender overrides).'), + skills: skillsSchema.optional() + .describe('Skill discovery, installation, and per-skill overrides.'), + cron: cronSchema.optional() + .describe('Cron scheduling settings. Individual jobs are defined in ~/.openclaw/cron/jobs.json, not here.'), + browser: browserSchema.optional() + .describe('Chromium browser automation settings.'), + mcp: mcpSchema.optional() + .describe('Model Context Protocol server definitions.'), + models: z.looseObject({ + mode: z.enum(['merge', 'replace']).optional() + .describe('How custom provider models combine with the built-in catalog: merge adds them, replace removes built-ins.'), + providers: z.record(z.string(), z.looseObject({})).optional() + .describe('Custom model provider definitions keyed by provider ID, each with a baseUrl, apiKey, and models list.'), + }).optional() + .describe('Custom LLM provider and model catalog configuration.'), + plugins: z.looseObject({ + enabled: z.boolean().optional() + .describe('Master switch for all plugin functionality; false disables discovery entirely.'), + allow: z.array(z.string()).optional() + .describe('Exclusive allowlist — when set, only listed plugins load.'), + deny: z.array(z.string()).optional() + .describe('Blocklist — deny overrides both allow and per-plugin enablement.'), + load: z.looseObject({ paths: z.array(z.string()).optional() }).optional() + .describe('Additional filesystem directories where standalone plugins are discovered.'), + entries: z.record(z.string(), z.looseObject({ + enabled: z.boolean().optional() + .describe('Enables or disables this plugin.'), + config: z.record(z.string(), z.unknown()).optional() + .describe('Plugin-defined configuration object.'), + })).optional() + .describe('Per-plugin configuration keyed by plugin ID.'), + }).optional() + .describe('Plugin system configuration.'), + env: z.looseObject({ + vars: z.record(z.string(), z.string()).optional() + .describe('Inline environment variables applied when not already present in the process.'), + shellEnv: z.looseObject({ enabled: z.boolean().optional() }).optional() + .describe('When enabled, imports missing environment variables from the system login shell profile.'), + }).optional() + .describe('Environment variable injection for the gateway process.'), + }) + .describe( + 'Top-level keys to merge into ~/.openclaw/openclaw.json. ' + + 'Known sections (gateway, agents, channels, tools, skills, cron, browser, mcp, models, plugins, env) ' + + 'are validated against the OpenClaw config schema. ' + + 'Unknown top-level keys (hooks, session, memory, etc.) pass through as-is.' + ); + +const schema = z + .object({ + settings: settingsSchema.optional(), + }) + .meta({ $comment: 'https://codifycli.com/docs/resources/openclaw/openclaw' }) + .describe('OpenClaw installation and gateway configuration management'); + +export type OpenClawConfig = z.infer; + +const defaultConfig: Partial = { + settings: { + gateway: { mode: 'local' }, + }, +}; + +const exampleBasic: ExampleConfig = { + title: 'Install OpenClaw with gateway and agent defaults', + description: + 'Install OpenClaw and configure the local gateway port/bind address along with the ' + + 'default agent model.', + configs: [ + { + type: 'openclaw', + settings: { + gateway: { mode: 'local', port: 18789, bind: 'loopback' }, + agents: { defaults: { model: 'anthropic/claude-sonnet-4-6' } }, + }, + }, + ], +}; + +const exampleWithChannels: ExampleConfig = { + title: 'OpenClaw with a Telegram channel and restricted tools', + description: + 'Install OpenClaw, connect a Telegram bot channel restricted to an allowlist, and limit ' + + 'the agent tool allowlist to a safe subset.', + configs: [ + { + type: 'openclaw', + settings: { + gateway: { mode: 'local' }, + channels: { + telegram: { + botToken: '', + dmPolicy: 'allowlist', + allowFrom: ['123456789'], + }, + }, + tools: { + allow: ['exec', 'read', 'write', 'web_search'], + }, + skills: { + load: { extraDirs: ['$HOME/openclaw-skills'] }, + }, + cron: { + enabled: true, + maxConcurrentRuns: 8, + }, + }, + }, + ], +}; + +export class OpenClawResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'openclaw', + defaultConfig, + exampleConfigs: { + example1: exampleBasic, + example2: exampleWithChannels, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + settings: { type: 'stateful', definition: new OpenClawSettingsParameter(), order: 1 }, + }, + }; + } + + async refresh(_parameters: Partial): Promise | null> { + const $ = getPty(); + + const { status } = await $.spawnSafe('which openclaw'); + if (status !== SpawnStatus.SUCCESS) { + return null; + } + + return {}; + } + + async create(_plan: CreatePlan): Promise { + const $ = getPty(); + + await $.spawn( + 'bash -c "curl -fsSL https://openclaw.ai/install.sh | bash -s -- --no-onboard --no-prompt"', + { interactive: true }, + ); + + // Ensure PATH is updated so subsequent lifecycle methods can call `openclaw` + const localBin = path.join(os.homedir(), '.local', 'bin'); + process.env['PATH'] = `${localBin}:${process.env['PATH'] ?? ''}`; + + // Register and start the gateway as a managed background service + // (launchd on macOS, systemd user unit on Linux). Config is written by + // the settings StatefulParameter after this returns, then the parameter + // triggers `openclaw gateway restart` to pick it up. + await $.spawn('openclaw gateway install', { interactive: true }); + await $.spawn('openclaw gateway start', { interactive: true }); + + CodifyCliSender.sendApplyNote(ApplyNotes.NEW_SHELL_REQUIRED, 'openclaw'); + } + + async destroy(_plan: DestroyPlan): Promise { + const $ = getPty(); + + await $.spawnSafe('openclaw gateway stop', { interactive: true }); + await $.spawnSafe('openclaw gateway uninstall', { interactive: true }); + await $.spawnSafe('npm uninstall -g openclaw', { interactive: true }); + await $.spawnSafe('rm -f ~/.local/bin/openclaw', { interactive: true }); + + await fs.rm(path.join(os.homedir(), '.openclaw'), { recursive: true, force: true }); + } +} diff --git a/src/resources/openclaw/settings-parameter.ts b/src/resources/openclaw/settings-parameter.ts new file mode 100644 index 00000000..3b3cb674 --- /dev/null +++ b/src/resources/openclaw/settings-parameter.ts @@ -0,0 +1,88 @@ +import { ParameterSetting, Plan, StatefulParameter, getPty } from '@codifycli/plugin-core'; +import { StringIndexedObject } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +type Settings = Record; + +export const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), '.openclaw', 'openclaw.json'); + +export class OpenClawSettingsParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { type: 'object' }; + } + + override async refresh(desired: Settings | null): Promise { + try { + const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8'); + const full = JSON.parse(content) as Settings; + + // Only surface the keys the user declared. OpenClaw writes its own keys + // (meta, wizard, etc.) that Codify must never diff or remove. + if (desired == null) { + return full; + } + const filtered: Settings = {}; + for (const key of Object.keys(desired)) { + if (key in full) { + filtered[key] = full[key]; + } + } + return filtered; + } catch { + return null; + } + } + + async add(valueToAdd: Settings, plan: Plan): Promise { + await this.mergeIntoFile(valueToAdd); + await this.restartGateway(); + } + + async modify(newValue: Settings, previousValue: Settings, plan: Plan): Promise { + let existing: Settings = {}; + try { + existing = JSON.parse(await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8')); + } catch { /* file may not exist */ } + + for (const key of Object.keys(previousValue)) { + if (!(key in newValue)) { + delete existing[key]; + } + } + + Object.assign(existing, newValue); + + await fs.mkdir(path.dirname(OPENCLAW_CONFIG_PATH), { recursive: true }); + await fs.writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify(existing, null, 2)); + await this.restartGateway(); + } + + async remove(valueToRemove: Settings, plan: Plan): Promise { + try { + const existing = JSON.parse(await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8')) as Settings; + for (const key of Object.keys(valueToRemove)) { + delete existing[key]; + } + await fs.writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify(existing, null, 2)); + } catch { /* nothing to do if file doesn't exist */ } + + await this.restartGateway(); + } + + private async mergeIntoFile(settings: Settings): Promise { + let existing: Settings = {}; + try { + existing = JSON.parse(await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8')); + } catch { /* file may not exist yet */ } + + await fs.mkdir(path.dirname(OPENCLAW_CONFIG_PATH), { recursive: true }); + await fs.writeFile(OPENCLAW_CONFIG_PATH, JSON.stringify({ ...existing, ...settings }, null, 2)); + } + + private async restartGateway(): Promise { + const $ = getPty(); + await $.spawnSafe('openclaw gateway restart', { interactive: true }); + } +} diff --git a/src/resources/scripting/action-schema.json b/src/resources/scripting/action-schema.json index f38fa43e..44df1b39 100644 --- a/src/resources/scripting/action-schema.json +++ b/src/resources/scripting/action-schema.json @@ -7,7 +7,7 @@ "properties": { "condition": { "type": "string", - "description": "A condition (in bash) that decides if the action is triggered. Return 0 to trigger and any non-zero exit code to skip." + "description": "A bash condition that decides whether the action needs to run. Exit code 0 means the action is already done — skip it. Any non-zero exit code means the action should run. Think of it as a check for the desired end-state: if the check passes (exit 0), the work is done; if it fails (non-zero), run the action. Example: 'command -v mytool' exits 0 when mytool is already installed (skip), and non-zero when it is absent (run the install action)." }, "action": { "type": "string", @@ -16,6 +16,14 @@ "cwd": { "type": "string", "description": "The directory that the action should be ran in." + }, + "requiresRoot": { + "type": "boolean", + "description": "When true, the action is executed with root privileges (sudo). Codify handles privilege escalation — do not add sudo to the action command directly." + }, + "requiresStdin": { + "type": "boolean", + "description": "When true, the action is run in interactive mode so it can read from stdin (e.g. for prompts or password input)." } }, "required": ["action"], diff --git a/src/resources/scripting/action.ts b/src/resources/scripting/action.ts index e3cd3cb3..d8b7ffd0 100644 --- a/src/resources/scripting/action.ts +++ b/src/resources/scripting/action.ts @@ -8,6 +8,8 @@ export interface ActionConfig extends StringIndexedObject { condition?: string; action: string; cwd?: string; + requiresRoot?: boolean; + requiresStdin?: boolean; } const defaultConfig: Partial = { @@ -16,10 +18,10 @@ const defaultConfig: Partial = { const exampleConditional: ExampleConfig = { title: 'Run a script only when a condition is met', - description: 'Execute a setup command only when the target directory does not already exist, making the action idempotent.', + description: 'Execute a setup command only when the target directory does not already exist, making the action idempotent. The condition checks for the desired end-state: exit 0 = already done (skip), non-zero = not done (run). Here, "[ -d ~/.config/myapp ]" exits 0 when the directory exists (skip), and non-zero when it is missing (run the mkdir action).', configs: [{ type: 'action', - condition: '[ ! -d ~/.config/myapp ]', + condition: '[ -d ~/.config/myapp ]', action: 'mkdir -p ~/.config/myapp && cp /etc/myapp/defaults.conf ~/.config/myapp/config.conf', }] } @@ -64,7 +66,7 @@ export class ActionResource extends Resource { return context.commandType === 'validationPlan' ? parameters : null; } - const { condition, action, cwd } = parameters; + const { condition, action, cwd, requiresRoot, requiresStdin } = parameters; const { status } = await $.spawnSafe(condition, { cwd: cwd ?? undefined }); return status === SpawnStatus.ERROR @@ -73,13 +75,21 @@ export class ActionResource extends Resource { ...(condition ? { condition } : undefined), ...(action ? { action } : undefined), ...(cwd ? { cwd } : undefined), + ...(requiresRoot ? { requiresRoot } : undefined), + ...(requiresStdin ? { requiresStdin } : undefined), }; } - + async create(plan: CreatePlan): Promise { const $ = getPty(); - await $.spawn(plan.desiredConfig.action, { cwd: plan.desiredConfig.cwd ?? undefined, interactive: true }); + const { action, cwd, requiresRoot, requiresStdin } = plan.desiredConfig; + await $.spawn(action, { + cwd: cwd ?? undefined, + interactive: true, + stdin: requiresStdin ?? false, + requiresRoot: requiresRoot ?? false, + }); } - async destroy(plan: DestroyPlan): Promise {} + async destroy(_plan: DestroyPlan): Promise {} } diff --git a/test/openclaw/openclaw.test.ts b/test/openclaw/openclaw.test.ts new file mode 100644 index 00000000..1387dc48 --- /dev/null +++ b/test/openclaw/openclaw.test.ts @@ -0,0 +1,75 @@ +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +const OPENCLAW_CONFIG_PATH = path.join(os.homedir(), '.openclaw', 'openclaw.json'); + +describe('openclaw resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can install openclaw', { timeout: 300_000 }, async () => { + await PluginTester.fullTest( + pluginPath, + [{ type: 'openclaw' }], + { + skipUninstall: true, + validateApply: async () => { + const { data } = await testSpawn('which openclaw'); + expect(data.trim().length).toBeGreaterThan(0); + }, + }, + ); + }); + + it('Can manage settings', { timeout: 300_000 }, async () => { + const initialSettings = { + gateway: { port: 18789, bind: 'loopback' }, + logging: { level: 'debug' }, + }; + + const modifiedSettings = { + gateway: { port: 18790, bind: 'loopback' }, + logging: { level: 'debug' }, + }; + + await PluginTester.fullTest( + pluginPath, + [{ type: 'openclaw', settings: initialSettings }], + { + validateApply: async () => { + const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.gateway.port).toBe(18789); + expect(parsed.logging.level).toBe('debug'); + }, + testModify: { + modifiedConfigs: [{ type: 'openclaw', settings: modifiedSettings }], + validateModify: async () => { + const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.gateway.port).toBe(18790); + }, + }, + validateDestroy: async () => { + try { + const content = await fs.readFile(OPENCLAW_CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(content); + expect(parsed.gateway).toBeUndefined(); + } catch { + // file removed entirely is also acceptable + } + }, + }, + ); + }); + + afterAll(async () => { + // Best-effort cleanup in case tests left openclaw installed + await testSpawn('openclaw gateway stop'); + await testSpawn('npm uninstall -g openclaw'); + await testSpawn('rm -f ~/.local/bin/openclaw'); + await testSpawn('rm -rf ~/.openclaw'); + }, 60_000); +}); diff --git a/test/scripting/action.test.ts b/test/scripting/action.test.ts index 8f1abc36..fde30080 100644 --- a/test/scripting/action.test.ts +++ b/test/scripting/action.test.ts @@ -66,4 +66,40 @@ describe('Action tests', () => { } }) }) + + it('Can run an action with requiresRoot', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'action', + condition: '[ -d /tmp/codify-root-test ]', + action: 'mkdir -p /tmp/codify-root-test', + requiresRoot: true, + } + ], { + skipUninstall: true, + skipImport: true, + validateApply: (plans) => { + expect(plans[0]).toMatchObject({ operation: ResourceOperation.CREATE }); + expect(fs.existsSync('/tmp/codify-root-test')).to.be.true; + } + }) + }) + + it('Can run an action with requiresStdin disabled', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'action', + condition: '[ -f ~/codify-stdin-test.txt ]', + action: 'echo hello > ~/codify-stdin-test.txt', + requiresStdin: false, + } + ], { + skipUninstall: true, + skipImport: true, + validateApply: (plans) => { + expect(plans[0]).toMatchObject({ operation: ResourceOperation.CREATE }); + expect(fs.existsSync(path.resolve(os.homedir(), 'codify-stdin-test.txt'))).to.be.true; + } + }) + }) })