diff --git a/bun.lock b/bun.lock index ac2f2a1..4c427a9 100644 --- a/bun.lock +++ b/bun.lock @@ -45,6 +45,7 @@ "name": "@clawctl/cli", "version": "0.7.0", "dependencies": { + "@clawctl/capabilities": "workspace:*", "@clawctl/daemon": "workspace:*", "@clawctl/host-core": "workspace:*", "@clawctl/templates": "workspace:*", diff --git a/docs/1password-setup.md b/docs/1password-setup.md index 377edce..3ebb52d 100644 --- a/docs/1password-setup.md +++ b/docs/1password-setup.md @@ -138,8 +138,8 @@ OP_SERVICE_ACCOUNT_TOKEN=ops_your_token_here ```json { - "services": { - "onePassword": { + "capabilities": { + "one-password": { "serviceAccountToken": "env://OP_SERVICE_ACCOUNT_TOKEN" } } @@ -154,8 +154,8 @@ in memory only -- never written to disk. ```json { - "services": { - "onePassword": { + "capabilities": { + "one-password": { "serviceAccountToken": "env://OP_SERVICE_ACCOUNT_TOKEN" } }, @@ -169,7 +169,7 @@ in memory only -- never written to disk. } ``` -Config files with `op://` references require `services.onePassword` to be +Config files with `op://` references require `capabilities["one-password"]` to be configured (validation will reject configs with `op://` refs but no 1Password service account). diff --git a/docs/capabilities.md b/docs/capabilities.md index d913aa1..00be4fc 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -73,25 +73,62 @@ The split is intentional: ## Writing a capability -A capability is a `CapabilityDef` constant. Here's the structure: +A capability is a `CapabilityDef` constant. Optional capabilities declare a +`configDef` that drives config validation (Zod is derived automatically), +TUI form rendering, sidebar help, and secret sanitization — all from one +definition. ```typescript +import { defineCapabilityConfig } from "@clawctl/types"; import type { CapabilityDef } from "@clawctl/types"; +// 1. Define the config type — this is the contract +type MyToolConfig = { + apiToken: string; + region?: "us" | "eu"; +}; + export const myTool: CapabilityDef = { name: "my-tool", label: "My Tool", version: "1.0.0", core: false, // true = always enabled dependsOn: ["homebrew"], // runs after homebrew in same phase - enabled: ( - config, // when to activate (non-core only) - ) => "my-tool" in (config.capabilities ?? {}), - + enabled: (config) => config.capabilities?.["my-tool"] !== undefined, + + // 2. Unified config definition — replaces configSchema, formDef, secretFields + // defineCapabilityConfig() validates field paths at compile time + configDef: defineCapabilityConfig({ + sectionLabel: "My Tool", + sectionHelp: { title: "My Tool", lines: ["Integration with My Tool service."] }, + fields: [ + { + path: "apiToken", // typed: must be keyof MyToolConfig + label: "API Token", + type: "password", + required: true, + secret: true, // stripped from clawctl.json, masked in TUI + placeholder: "mt-...", + help: { title: "API Token", lines: ["Your My Tool API token."] }, + }, + { + path: "region", + label: "Region", + type: "select", + defaultValue: "us", + options: [ + { label: "US", value: "us" }, + { label: "EU", value: "eu" }, + ], + }, + ], + summary: (v) => (v.apiToken ? `My Tool (${v.region ?? "us"})` : ""), + }), + + // 3. Lifecycle hooks (VM-side provisioning) hooks: { "provision-tools": { - // hook key = phase or pre:/post: phase - execContext: "user", // "root" or "user" + execContext: "user", steps: [ { name: "my-tool-install", @@ -103,7 +140,6 @@ export const myTool: CapabilityDef = { }, ], doctorChecks: [ - // optional health checks { name: "path-my-tool", availableAfter: "provision-tools", @@ -114,7 +150,6 @@ export const myTool: CapabilityDef = { ], }, bootstrap: { - // post-onboard AGENTS.md section execContext: "user", steps: [ { @@ -131,15 +166,27 @@ export const myTool: CapabilityDef = { }; ``` +The user's config file uses the capabilities map: + +```json +{ + "capabilities": { + "my-tool": { "apiToken": "mt-abc123", "region": "eu" } + } +} +``` + ### Registration After writing the module: -1. Export it from `packages/capabilities/src/index.ts` -2. Import it in `packages/vm-cli/src/capabilities/registry.ts` and add it - to `ALL_CAPABILITIES` +1. Export it from `packages/capabilities/src/index.ts` and add it to + `ALL_CAPABILITIES` +2. If the capability needs host-side setup (token validation, auth flows), + add a hook entry in `packages/host-core/src/capability-hooks.ts` -The runner discovers hooks automatically from the registry. +The TUI wizard, config validation, sidebar help, and secret sanitization +all derive from the `configDef` automatically — no other files to edit. ### Step functions diff --git a/docs/config-reference.md b/docs/config-reference.md index 6e88479..97de6f8 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -41,17 +41,17 @@ and `project` are required. Everything else is optional and has sensible default "network": { "forwardGateway": true, "gatewayPort": 18789, - "gatewayToken": "my-secret-token", + "gatewayToken": "my-secret-token" + }, + "capabilities": { + "one-password": { + "serviceAccountToken": "ops_..." + }, "tailscale": { "authKey": "tskey-auth-...", "mode": "serve" } }, - "services": { - "onePassword": { - "serviceAccountToken": "ops_..." - } - }, "provider": { "type": "anthropic", "apiKey": "sk-ant-...", @@ -107,23 +107,23 @@ VM resource allocation. Omit the entire section to use defaults. Network and connectivity settings. -| Field | Type | Default | Description | -| ------------------- | ------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `forwardGateway` | boolean | `true` | Forward the gateway port from guest to host. Set `false` if using Tailscale only — the gateway is reachable over the tailnet. | -| `gatewayPort` | number | `18789` | Host-side port for the gateway forward. Must be 1024–65535. | -| `gatewayToken` | string | — | Gateway auth token. Auto-generated if not set. | -| `tailscale` | object | — | If present, connects the VM to your Tailscale network non-interactively. | -| `tailscale.authKey` | string | — | Tailscale auth key (`tskey-auth-...`). Generate one at [Tailscale Admin → Keys](https://login.tailscale.com/admin/settings/keys). | -| `tailscale.mode` | string | `"serve"` | Gateway mode: `"serve"` (HTTPS on tailnet), `"funnel"` (public HTTPS), or `"off"`. See [Tailscale Setup](tailscale-setup.md#gateway-integration). | +| Field | Type | Default | Description | +| ---------------- | ------- | ------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `forwardGateway` | boolean | `true` | Forward the gateway port from guest to host. Set `false` if using Tailscale only — the gateway is reachable over the tailnet. | +| `gatewayPort` | number | `18789` | Host-side port for the gateway forward. Must be 1024–65535. | +| `gatewayToken` | string | — | Gateway auth token. Auto-generated if not set. | -## `services` +## `capabilities` -External service integrations. Presence of a section means "configure this service." +Capability integrations. Presence of a capability section means "configure this capability." -| Field | Type | Description | -| --------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `onePassword` | object | If present, validates and persists a 1Password service account token in the VM. Enables `op://` reference resolution. | -| `onePassword.serviceAccountToken` | string | 1Password service account token (`ops_...`) or `env://VAR_NAME` reference. Stored at `~/.openclaw/credentials/op-token` inside the VM. | +| Field | Type | Default | Description | +| ---------------------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `one-password` | object | — | If present, validates and persists a 1Password service account token in the VM. Enables `op://` reference resolution. | +| `one-password.serviceAccountToken` | string | — | 1Password service account token (`ops_...`) or `env://VAR_NAME` reference. Stored at `~/.openclaw/credentials/op-token` inside the VM. | +| `tailscale` | object | — | If present, connects the VM to your Tailscale network non-interactively. | +| `tailscale.authKey` | string | — | Tailscale auth key (`tskey-auth-...`). Generate one at [Tailscale Admin → Keys](https://login.tailscale.com/admin/settings/keys). | +| `tailscale.mode` | string | `"serve"` | Gateway mode: `"serve"` (HTTPS on tailnet), `"funnel"` (public HTTPS), or `"off"`. See [Tailscale Setup](tailscale-setup.md#gateway-integration). | ## `tools` @@ -206,7 +206,7 @@ Telegram channel configuration (optional). Applied during bootstrap after onboar String values in the config can use URI references instead of plaintext secrets: - **`env://VAR_NAME`** — resolved from the host environment at config-load time. Bun auto-loads `.env` files. -- **`op://vault/item/field`** — resolved inside the VM via `op read` after 1Password setup. Requires `services.onePassword`. +- **`op://vault/item/field`** — resolved inside the VM via `op read` after 1Password setup. Requires `capabilities["one-password"]`. This means configs with `op://` and `env://` references contain zero plaintext secrets and can be safely committed to git. See diff --git a/docs/headless-mode.md b/docs/headless-mode.md index 7063d7d..8c43bbd 100644 --- a/docs/headless-mode.md +++ b/docs/headless-mode.md @@ -33,9 +33,9 @@ All three modes run the same provisioning stages: 2. **Install Lima** — via Homebrew, if not already present 3. **Create and provision VM** — generate lima.yaml, boot Ubuntu 24.04, run provisioning 4. **Verify installed tools** — Node.js 22, Tailscale, Homebrew, 1Password CLI -5. **Set up 1Password** — if `services.onePassword` is configured +5. **Set up 1Password** — if `capabilities["one-password"]` is configured 6. **Resolve secrets** — if `op://` references are present in the config -7. **Connect Tailscale** — if `network.tailscale` is configured +7. **Connect Tailscale** — if `capabilities.tailscale` is configured 8. **Bootstrap gateway** — if `provider` is configured (runs `openclaw onboard --non-interactive`) 9. **Register instance** — write `clawctl.json` and update the instance registry @@ -91,7 +91,7 @@ from the environment at load time: "type": "anthropic", "apiKey": "env://ANTHROPIC_API_KEY" }, - "network": { + "capabilities": { "tailscale": { "authKey": "env://TAILSCALE_AUTH_KEY" } diff --git a/packages/capabilities/src/capabilities/one-password/index.ts b/packages/capabilities/src/capabilities/one-password/index.ts index df90f36..467e52c 100644 --- a/packages/capabilities/src/capabilities/one-password/index.ts +++ b/packages/capabilities/src/capabilities/one-password/index.ts @@ -1,6 +1,6 @@ import dedent from "dedent"; import { join } from "path"; -import { PROJECT_MOUNT_POINT } from "@clawctl/types"; +import { PROJECT_MOUNT_POINT, defineCapabilityConfig } from "@clawctl/types"; import type { CapabilityDef } from "@clawctl/types"; import { provisionOpCli, @@ -12,14 +12,51 @@ import { secretManagementSkillContent } from "./skill.js"; const SKILLS_DIR = join(PROJECT_MOUNT_POINT, "data", "workspace", "skills"); +type OnePasswordConfig = { + serviceAccountToken: string; +}; + export const onePassword: CapabilityDef = { name: "one-password", label: "1Password", version: "1.0.0", core: false, dependsOn: ["homebrew"], - enabled: (config) => - config.capabilities?.["one-password"] !== undefined || config.onePassword === true, + enabled: (config) => config.capabilities?.["one-password"] !== undefined, + configDef: defineCapabilityConfig({ + sectionLabel: "1Password", + sectionHelp: { + title: "1Password", + lines: [ + "Inject secrets via op:// refs.", + "Installs the op CLI and skills.", + "", + "Requires a service account token.", + ], + }, + fields: [ + { + path: "serviceAccountToken", + label: "SA Token", + type: "password", + required: true, + secret: true, + placeholder: "1Password service account token", + help: { + title: "1Password Token", + lines: [ + "Service account token for the", + "1Password CLI.", + "", + "Create one at:", + " my.1password.com/developer", + " /service-accounts", + ], + }, + }, + ], + summary: (v) => (v.serviceAccountToken ? "1Password" : ""), + }), hooks: { // Phase 1: Install the op CLI binary "provision-tools": { diff --git a/packages/capabilities/src/capabilities/tailscale.ts b/packages/capabilities/src/capabilities/tailscale.ts index b041665..98c8b93 100644 --- a/packages/capabilities/src/capabilities/tailscale.ts +++ b/packages/capabilities/src/capabilities/tailscale.ts @@ -1,15 +1,73 @@ +import { defineCapabilityConfig } from "@clawctl/types"; import type { CapabilityDef } from "@clawctl/types"; const TAILSCALE_INSTALL_URL = "https://tailscale.com/install.sh"; +type TailscaleConfig = { + authKey: string; + mode?: "off" | "serve" | "funnel"; +}; + export const tailscale: CapabilityDef = { name: "tailscale", label: "Tailscale", version: "1.0.0", core: false, dependsOn: ["system-base"], - enabled: (config) => - config.capabilities?.["tailscale"] !== undefined || config.tailscale === true, + enabled: (config) => config.capabilities?.["tailscale"] !== undefined, + configDef: defineCapabilityConfig({ + sectionLabel: "Tailscale", + sectionHelp: { + title: "Tailscale", + lines: [ + "Connect the VM to a Tailscale", + "network for secure remote access.", + "", + "Requires a pre-authenticated key.", + ], + }, + fields: [ + { + path: "authKey", + label: "Auth Key", + type: "password", + required: true, + secret: true, + placeholder: "tskey-auth-...", + help: { + title: "Tailscale Auth Key", + lines: [ + "Pre-authenticated key from your", + "Tailscale admin panel.", + "", + "Generate at:", + " login.tailscale.com/admin", + " /settings/keys", + ], + }, + }, + { + path: "mode", + label: "Mode", + type: "select", + defaultValue: "serve", + options: [ + { label: "serve", value: "serve" }, + { label: "funnel", value: "funnel" }, + { label: "off", value: "off" }, + ], + help: { + title: "Tailscale Mode", + lines: [ + "serve — HTTPS on your tailnet", + "funnel — public access via Tailscale", + "off — install but don't expose", + ], + }, + }, + ], + summary: (v) => (v.authKey ? `Tailscale (${v.mode ?? "serve"})` : ""), + }), hooks: { "provision-system": { execContext: "root", diff --git a/packages/capabilities/src/index.ts b/packages/capabilities/src/index.ts index 0583a37..cb8c4f9 100644 --- a/packages/capabilities/src/index.ts +++ b/packages/capabilities/src/index.ts @@ -1,3 +1,5 @@ +import type { CapabilityDef } from "@clawctl/types"; + // Individual capability definitions export { systemBase } from "./capabilities/system-base/index.js"; export { homebrew } from "./capabilities/homebrew/index.js"; @@ -5,3 +7,21 @@ export { openclaw } from "./capabilities/openclaw/index.js"; export { checkpoint } from "./capabilities/checkpoint.js"; export { tailscale } from "./capabilities/tailscale.js"; export { onePassword } from "./capabilities/one-password/index.js"; + +// Re-import for the list (tree-shaking preserves individual exports) +import { systemBase } from "./capabilities/system-base/index.js"; +import { homebrew } from "./capabilities/homebrew/index.js"; +import { openclaw } from "./capabilities/openclaw/index.js"; +import { checkpoint } from "./capabilities/checkpoint.js"; +import { tailscale } from "./capabilities/tailscale.js"; +import { onePassword } from "./capabilities/one-password/index.js"; + +/** All registered capabilities, in dependency order. */ +export const ALL_CAPABILITIES: CapabilityDef[] = [ + systemBase, + homebrew, + openclaw, + checkpoint, + tailscale, + onePassword, +]; diff --git a/packages/cli/package.json b/packages/cli/package.json index dd03a6a..feabdef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -9,6 +9,7 @@ "dependencies": { "@clawctl/types": "workspace:*", "@clawctl/host-core": "workspace:*", + "@clawctl/capabilities": "workspace:*", "@clawctl/daemon": "workspace:*", "@clawctl/templates": "workspace:*", "commander": "^14.0.3", diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index 75e1434..5efdd92 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -152,6 +152,9 @@ export async function runCreateWizard(driver: VMDriver): Promise { const vmConfig = configToVMConfig(provisionConfig); await cleanupVM(driver, vmConfig.vmName, vmConfig.projectDir); process.exit(130); + } else { + // Ctrl-C before provisioning started — nothing to clean up, just exit. + process.exit(130); } } diff --git a/packages/cli/src/components/capability-section.tsx b/packages/cli/src/components/capability-section.tsx new file mode 100644 index 0000000..30ba75b --- /dev/null +++ b/packages/cli/src/components/capability-section.tsx @@ -0,0 +1,118 @@ +/** + * Generic capability form section — renders any capability's configDef + * as a FormSection with FormField children. + * + * This component is data-driven: it receives a CapabilityConfigDef and + * renders the appropriate input widgets (text, password, select) based + * on each field's type. No capability-specific logic lives here. + */ + +import React from "react"; +import { Text, Box } from "ink"; +import TextInput from "ink-text-input"; +import SelectInput from "ink-select-input"; +import { FormField } from "./form-field.js"; +import { FormSection } from "./form-section.js"; +import type { CapabilityConfigDef } from "@clawctl/types"; + +export interface CapabilitySectionProps { + /** The capability's config definition (from configDef on CapabilityDef). */ + configDef: CapabilityConfigDef; + /** Current field values, keyed by field path. */ + values: Record; + /** Called when a field value changes. */ + onChange: (path: string, value: string) => void; + /** Whether this section header is focused. */ + focused: boolean; + /** Whether this section is expanded (showing fields). */ + expanded: boolean; + /** The currently focused field path within this section (null if section header is focused). */ + focusedField: string | null; + /** Whether the focused field is in editing mode. */ + editing: boolean; + /** The field path currently showing a select dropdown (null if none). */ + selectingField: string | null; + /** Called when a select dropdown completes. */ + onSelectDone: () => void; +} + +export function CapabilitySection({ + configDef, + values, + onChange, + focused, + expanded, + focusedField, + editing, + selectingField, + onSelectDone, +}: CapabilitySectionProps) { + const summary = configDef.summary ? configDef.summary(values) : ""; + const hasAnyValue = configDef.fields.some((f) => values[f.path as string]); + const status = hasAnyValue ? ("configured" as const) : ("unconfigured" as const); + + return ( + + {configDef.fields.map((field) => { + const path = field.path as string; + const isFocused = focusedField === path; + const isEditing = isFocused && editing; + const isSelecting = selectingField === path; + + if (field.type === "select" && isSelecting) { + return ( + + {field.label} + ({ label: o.label, value: o.value }))} + initialIndex={Math.max( + 0, + (field.options ?? []).findIndex( + (o) => o.value === (values[path] || field.defaultValue), + ), + )} + onSelect={(item) => { + onChange(path, item.value); + onSelectDone(); + }} + /> + + ); + } + + if ((field.type === "text" || field.type === "password") && isEditing) { + return ( + + + {field.label} + + onChange(path, v)} + mask={field.type === "password" ? "*" : undefined} + placeholder={field.placeholder} + /> + + ); + } + + return ( + + ); + })} + + ); +} diff --git a/packages/cli/src/components/config-review.tsx b/packages/cli/src/components/config-review.tsx index 7a00e0c..f89f14c 100644 --- a/packages/cli/src/components/config-review.tsx +++ b/packages/cli/src/components/config-review.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Text, Box } from "ink"; import type { InstanceConfig } from "@clawctl/types"; +import { ALL_CAPABILITIES } from "@clawctl/capabilities"; interface ConfigReviewProps { config: InstanceConfig; @@ -86,26 +87,30 @@ export function ConfigReview({ config, validationErrors, validationWarnings }: C - - {config.services?.onePassword ? ( - {"\u2713"} configured - ) : ( - - {"\u2500\u2500"} not configured {"\u2500\u2500"} - - )} - - - {config.network?.tailscale ? ( - - {"\u2713"} {config.network.tailscale.mode ?? "serve"} (auth key provided) - - ) : ( - - {"\u2500\u2500"} not configured {"\u2500\u2500"} - - )} - + {/* Dynamic capability rows */} + {ALL_CAPABILITIES.filter((c) => !c.core && c.configDef).map((cap) => { + const capConfig = config.capabilities?.[cap.name]; + const isConfigured = capConfig !== undefined; + const summary = + isConfigured && cap.configDef?.summary + ? cap.configDef.summary( + typeof capConfig === "object" ? (capConfig as Record) : {}, + ) + : null; + return ( + + {isConfigured ? ( + + {"\u2713"} {summary || "configured"} + + ) : ( + + {"\u2500\u2500"} not configured {"\u2500\u2500"} + + )} + + ); + })} @@ -152,12 +157,15 @@ export function ConfigReview({ config, validationErrors, validationWarnings }: C {"\u2139"} {warn} ))} - {config.services?.onePassword && ( - {"\u2139"} 1Password token will be validated during provisioning - )} - {config.network?.tailscale && ( - {"\u2139"} Tailscale auth key will be used (no interactive login) - )} + {/* Capability provisioning hints */} + {ALL_CAPABILITIES.filter( + (c) => !c.core && c.configDef && config.capabilities?.[c.name], + ).map((cap) => ( + + {" "} + {"\u2139"} {cap.configDef!.sectionLabel} will be configured during provisioning + + ))} {" "} {"\u2139"} Config will be saved to {config.project}/clawctl.json diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index 6023e6a..e7f276f 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -1,10 +1,10 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Text, Box, useInput } from "ink"; import { Spinner } from "./spinner.js"; import { LogOutput } from "./log-output.js"; import { useTerminalSize } from "../hooks/use-terminal-size.js"; import type { VMDriver } from "@clawctl/host-core"; -import { runHeadlessFromConfig } from "@clawctl/host-core"; +import { runHeadlessFromConfig, getHostHooksForConfig } from "@clawctl/host-core"; import type { HeadlessResult, HeadlessCallbacks, @@ -19,13 +19,11 @@ interface StageInfo { detail?: string; } -const STAGE_LABELS: Record = { +const CORE_STAGE_LABELS: Record = { prereqs: "Prerequisites", provision: "Provisioning VM", verify: "Verifying installation", - onepassword: "Setting up 1Password", secrets: "Resolving secrets", - tailscale: "Connecting Tailscale", bootstrap: "Bootstrapping OpenClaw", done: "Complete", }; @@ -39,22 +37,34 @@ interface ProvisionMonitorProps { export function ProvisionMonitor({ driver, config, onComplete, onError }: ProvisionMonitorProps) { const { rows } = useTerminalSize(); - const [stages, setStages] = useState>(() => new Map()); + const [stages, setStages] = useState>(() => new Map()); const [steps, setSteps] = useState([]); const [logs, setLogs] = useState([]); const [showLogs, setShowLogs] = useState(true); + // Build stage label lookup from core labels + dynamic host hooks + const stageLabels = useMemo(() => { + const labels = { ...CORE_STAGE_LABELS }; + for (const hook of getHostHooksForConfig(config)) { + labels[hook.stageName] = hook.stageLabel; + } + return labels; + }, [config]); + useInput((input) => { if (input === "v") setShowLogs((s) => !s); }); - const onStage = useCallback((stage: HeadlessStage, status: StageStatus, detail?: string) => { - setStages((prev) => { - const next = new Map(prev); - next.set(stage, { label: STAGE_LABELS[stage], status, detail }); - return next; - }); - }, []); + const onStage = useCallback( + (stage: HeadlessStage, status: StageStatus, detail?: string) => { + setStages((prev) => { + const next = new Map(prev); + next.set(stage, { label: stageLabels[stage] ?? stage, status, detail }); + return next; + }); + }, + [stageLabels], + ); const onStep = useCallback((label: string) => { setSteps((prev) => [...prev, label]); @@ -79,15 +89,17 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis runHeadlessFromConfig(driver, config, callbacks).then(onComplete).catch(onError); }, []); - // Determine which stages are relevant for this config - const activeStages: HeadlessStage[] = [ - "prereqs", - "provision", - "verify", - ...(config.services?.onePassword ? ["onepassword" as HeadlessStage] : []), - ...(config.network?.tailscale ? ["tailscale" as HeadlessStage] : []), - ...(config.provider ? ["bootstrap" as HeadlessStage] : []), - ]; + // Determine which stages are relevant for this config (derived from host hooks) + const activeStages = useMemo(() => { + const hostHookStages = getHostHooksForConfig(config).map((h) => h.stageName); + return [ + "prereqs", + "provision", + "verify", + ...hostHookStages, + ...(config.provider ? ["bootstrap"] : []), + ]; + }, [config]); // The status panel: header(1) + stages + 2 extra rows for steps overflow. const statusHeight = 1 + activeStages.length + 2; @@ -117,7 +129,7 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis {activeStages.map((stageId) => { const info = stages.get(stageId); const status = info?.status ?? "pending"; - const label = STAGE_LABELS[stageId]; + const label = stageLabels[stageId] ?? stageId; const detail = info?.detail; return ( diff --git a/packages/cli/src/components/sidebar.tsx b/packages/cli/src/components/sidebar.tsx index ac8d1d5..5a8d2cf 100644 --- a/packages/cli/src/components/sidebar.tsx +++ b/packages/cli/src/components/sidebar.tsx @@ -114,52 +114,15 @@ export const SIDEBAR_HELP: Record = { "(secrets are stripped).", ], }, - services: { - title: "Services", - lines: [ - "External service integrations.", - "", - "1Password: inject secrets via", - "op:// references in your config.", - "Requires a service account token.", - ], - }, - "services.opToken": { - title: "1Password Token", - lines: [ - "Service account token for", - "1Password secret injection.", - "", - "Get one at:", - " my.1password.com/developer", - "", - "Enables op:// references in", - "all config fields.", - ], - }, network: { title: "Network", lines: [ - "Gateway port forwarding and", - "Tailscale connectivity.", - "", - "Defaults:", - " Port 18789 forwarded", - " No Tailscale", - ], - }, - "network.tailscale": { - title: "Tailscale Auth Key", - lines: [ - "Pre-authenticated key for", - "Tailscale network join.", + "Gateway port forwarding.", "", - "Generate at:", - " login.tailscale.com/admin", - " /settings/keys", + "Default port: 18789", "", - "Enables remote access to", - "your agent's dashboard.", + "Change only if the default", + "port conflicts on your host.", ], }, bootstrap: { diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index 3f097d1..bde70bb 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -4,7 +4,9 @@ import TextInput from "ink-text-input"; import SelectInput from "ink-select-input"; import { FormField } from "../components/form-field.js"; import { FormSection } from "../components/form-section.js"; +import { CapabilitySection } from "../components/capability-section.js"; import { Sidebar, SIDEBAR_HELP } from "../components/sidebar.js"; +import type { SidebarContent } from "../components/sidebar.js"; import { ConfigReview } from "../components/config-review.js"; import { instanceConfigSchema, @@ -13,54 +15,26 @@ import { DEFAULT_PROJECT_BASE, } from "@clawctl/types"; import type { InstanceConfig } from "@clawctl/types"; +import { ALL_CAPABILITIES } from "@clawctl/capabilities"; type Phase = "form" | "review"; -/** - * Flat list of all navigable items. Sections are headers; fields within - * expanded sections are sub-items. - */ -type FocusId = - | "name" - | "project" - | "resources" - | "resources.cpus" - | "resources.memory" - | "resources.disk" - | "provider" - | "provider.type" - | "provider.apiKey" - | "provider.model" - | "provider.baseUrl" - | "provider.modelId" - | "services" - | "services.opToken" - | "network" - | "network.port" - | "network.tailscaleKey" - | "network.tailscaleMode" - | "bootstrap" - | "bootstrap.agentName" - | "bootstrap.agentContext" - | "bootstrap.userName" - | "bootstrap.userContext" - | "telegram" - | "telegram.botToken" - | "telegram.allowFrom" - | "action"; // "Review & Create" button - -type SectionId = "resources" | "provider" | "services" | "network" | "bootstrap" | "telegram"; - -const SECTIONS: SectionId[] = [ +// --------------------------------------------------------------------------- +// Focus list: hardcoded core sections + dynamic capability sections +// --------------------------------------------------------------------------- + +/** Core sections that are hardcoded in the wizard. */ +type CoreSectionId = "resources" | "provider" | "network" | "bootstrap" | "telegram"; + +const CORE_SECTIONS: CoreSectionId[] = [ "resources", "provider", - "services", "network", "bootstrap", "telegram", ]; -const SECTION_CHILDREN: Record = { +const CORE_SECTION_CHILDREN: Record = { resources: ["resources.cpus", "resources.memory", "resources.disk"], provider: [ "provider.type", @@ -69,8 +43,7 @@ const SECTION_CHILDREN: Record = { "provider.baseUrl", "provider.modelId", ], - services: ["services.opToken"], - network: ["network.port", "network.tailscaleKey", "network.tailscaleMode"], + network: ["network.port"], bootstrap: [ "bootstrap.agentName", "bootstrap.agentContext", @@ -80,12 +53,42 @@ const SECTION_CHILDREN: Record = { telegram: ["telegram.botToken", "telegram.allowFrom"], }; -function buildFocusList(expanded: Set): FocusId[] { - const list: FocusId[] = ["name", "project"]; - for (const section of SECTIONS) { - list.push(section as FocusId); +/** Non-core capabilities that have a configDef (rendered dynamically). */ +const CONFIGURABLE_CAPABILITIES = ALL_CAPABILITIES.filter((c) => !c.core && c.configDef); + +/** + * All section IDs in visual render order. + * Capability sections appear after network, before bootstrap/telegram. + */ +function allSectionIds(): string[] { + return [ + "resources", + "provider", + "network", + ...CONFIGURABLE_CAPABILITIES.map((c) => `cap:${c.name}`), + "bootstrap", + "telegram", + ]; +} + +/** Children focus IDs for a section (core or capability). */ +function sectionChildren(sectionId: string): string[] { + if (sectionId in CORE_SECTION_CHILDREN) { + return CORE_SECTION_CHILDREN[sectionId as CoreSectionId]; + } + // Dynamic capability section: cap: → cap:: + const capName = sectionId.replace("cap:", ""); + const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === capName); + if (!cap?.configDef) return []; + return cap.configDef.fields.map((f) => `cap:${capName}:${f.path as string}`); +} + +function buildFocusList(expanded: Set): string[] { + const list: string[] = ["name", "project"]; + for (const section of allSectionIds()) { + list.push(section); if (expanded.has(section)) { - list.push(...SECTION_CHILDREN[section]); + list.push(...sectionChildren(section)); } } list.push("action"); @@ -94,11 +97,6 @@ function buildFocusList(expanded: Set): FocusId[] { const MEMORY_OPTIONS = ["4GiB", "8GiB", "16GiB", "32GiB"]; const DISK_OPTIONS = ["30GiB", "50GiB", "100GiB", "200GiB"]; -const TS_MODE_OPTIONS = [ - { label: "serve", value: "serve" }, - { label: "funnel", value: "funnel" }, - { label: "off", value: "off" }, -]; interface ConfigBuilderProps { onComplete: (config: InstanceConfig) => void; @@ -108,7 +106,7 @@ interface ConfigBuilderProps { export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const [phase, setPhase] = useState("form"); - // Config state + // Core config state const [name, setName] = useState(""); const [project, setProject] = useState(""); const [cpus, setCpus] = useState("4"); @@ -122,13 +120,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const [providerBaseUrl, setProviderBaseUrl] = useState(""); const [providerModelId, setProviderModelId] = useState(""); - // Services - const [opToken, setOpToken] = useState(""); - - // Network + // Network (gateway port only — tailscale moved to capability) const [gatewayPort, setGatewayPort] = useState("18789"); - const [tailscaleKey, setTailscaleKey] = useState(""); - const [tailscaleMode, setTailscaleMode] = useState("serve"); // Bootstrap const [agentName, setAgentName] = useState(""); @@ -140,18 +133,30 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const [botToken, setBotToken] = useState(""); const [allowFrom, setAllowFrom] = useState(""); + // Capability config values: { "tailscale": { "authKey": "...", "mode": "serve" }, ... } + const [capValues, setCapValues] = useState>>({}); + // Navigation - const [expanded, setExpanded] = useState>(new Set()); + const [expanded, setExpanded] = useState>(new Set()); const [focusIdx, setFocusIdx] = useState(0); const [editing, setEditing] = useState(false); const [selectingProviderType, setSelectingProviderType] = useState(false); - const [selectingTsMode, setSelectingTsMode] = useState(false); const [selectingMemory, setSelectingMemory] = useState(false); const [selectingDisk, setSelectingDisk] = useState(false); + // For capability select fields: "cap:tailscale:mode" or null + const [selectingCapField, setSelectingCapField] = useState(null); const focusList = useMemo(() => buildFocusList(expanded), [expanded]); const currentFocus = focusList[focusIdx] ?? "name"; + // Helper to update a single capability field value + const setCapValue = (capName: string, fieldPath: string, value: string) => { + setCapValues((prev) => ({ + ...prev, + [capName]: { ...(prev[capName] ?? {}), [fieldPath]: value }, + })); + }; + // Build the InstanceConfig from current state const buildConfig = (): InstanceConfig => { const config: InstanceConfig = { @@ -177,20 +182,9 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } } - if (opToken) { - config.services = { onePassword: { serviceAccountToken: opToken } }; - } - const port = parseInt(gatewayPort, 10); - if (tailscaleKey || (port && port !== 18789)) { - config.network = {}; - if (port && port !== 18789) config.network.gatewayPort = port; - if (tailscaleKey) { - config.network.tailscale = { - authKey: tailscaleKey, - mode: tailscaleMode as "off" | "serve" | "funnel", - }; - } + if (port && port !== 18789) { + config.network = { ...config.network, gatewayPort: port }; } if (agentName) { @@ -214,6 +208,21 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { }; } + // Capability config from dynamic sections + for (const cap of CONFIGURABLE_CAPABILITIES) { + const vals = capValues[cap.name]; + if (!vals) continue; + const hasAnyValue = Object.values(vals).some((v) => v); + if (!hasAnyValue) continue; + if (!config.capabilities) config.capabilities = {}; + // Build config object from field values, filtering empty strings + const capConfig: Record = {}; + for (const [k, v] of Object.entries(vals)) { + if (v) capConfig[k] = v; + } + config.capabilities[cap.name] = capConfig; + } + return config; }; @@ -257,22 +266,47 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { return { errors, warnings }; }; - // Sidebar content based on current focus - const sidebarFocusKey = phase === "review" ? "review" : currentFocus.split(".")[0]; - const sidebarContent = - SIDEBAR_HELP[currentFocus] ?? SIDEBAR_HELP[sidebarFocusKey] ?? SIDEBAR_HELP["name"]; + // Sidebar content: check hardcoded help first, then capability configDef help + const getSidebarContent = (): SidebarContent => { + if (phase === "review") return SIDEBAR_HELP["review"]; + + // Direct match in hardcoded help + if (SIDEBAR_HELP[currentFocus]) return SIDEBAR_HELP[currentFocus]; + + // Capability field help: cap:: + if (currentFocus.startsWith("cap:")) { + const parts = currentFocus.split(":"); + if (parts.length === 3) { + const [, capName, fieldPath] = parts; + const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === capName); + const field = cap?.configDef?.fields.find((f) => (f.path as string) === fieldPath); + if (field?.help) return field.help; + } + // Capability section help: cap: + if (parts.length === 2) { + const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === parts[1]); + if (cap?.configDef?.sectionHelp) return cap.configDef.sectionHelp; + } + } + + // Fall back to section-level help + const sectionKey = currentFocus.split(".")[0]; + return SIDEBAR_HELP[sectionKey] ?? SIDEBAR_HELP["name"]; + }; + + const sidebarContent = getSidebarContent(); - // Section status helpers - const sectionStatus = (id: SectionId): "unconfigured" | "configured" | "error" => { + // Section status helpers (core sections only) + const coreSectionStatus = (id: CoreSectionId): "unconfigured" | "configured" | "error" => { switch (id) { case "resources": return "configured"; // always has defaults case "provider": return providerType ? "configured" : "unconfigured"; - case "services": - return opToken ? "configured" : "unconfigured"; - case "network": - return tailscaleKey ? "configured" : "unconfigured"; + case "network": { + const port = parseInt(gatewayPort, 10); + return port && port !== 18789 ? "configured" : "unconfigured"; + } case "bootstrap": return agentName ? "configured" : "unconfigured"; case "telegram": @@ -280,16 +314,14 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } }; - const sectionSummary = (id: SectionId): string => { + const coreSectionSummary = (id: CoreSectionId): string => { switch (id) { case "resources": return `${cpus} cpu \u00b7 ${memory} \u00b7 ${disk}`; case "provider": return providerType || ""; - case "services": - return opToken ? "1Password" : ""; case "network": - return tailscaleKey ? `Tailscale (${tailscaleMode})` : "defaults"; + return gatewayPort !== "18789" ? `port ${gatewayPort}` : "defaults"; case "bootstrap": return agentName || ""; case "telegram": @@ -297,7 +329,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } }; - const toggleSection = (id: SectionId) => { + const toggleSection = (id: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) { @@ -309,7 +341,37 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { }); }; - const isSelectMode = selectingProviderType || selectingTsMode || selectingMemory || selectingDisk; + const isSelectMode = + selectingProviderType || selectingMemory || selectingDisk || selectingCapField !== null; + + /** Check if a focus ID is a section header (core or capability). */ + const isSection = (id: string): boolean => { + return ( + (CORE_SECTIONS as string[]).includes(id) || + (id.startsWith("cap:") && id.split(":").length === 2) + ); + }; + + /** Check if a capability select field is active for a given focus ID. */ + const isCapSelectField = (focusId: string): boolean => { + if (!focusId.startsWith("cap:")) return false; + const parts = focusId.split(":"); + if (parts.length !== 3) return false; + const [, capName, fieldPath] = parts; + const cap = CONFIGURABLE_CAPABILITIES.find((c) => c.name === capName); + const field = cap?.configDef?.fields.find((f) => (f.path as string) === fieldPath); + return field?.type === "select"; + }; + + /** Find the parent section for a focus ID. */ + const findParentSection = (focusId: string): string | null => { + for (const sectionId of allSectionIds()) { + if (sectionChildren(sectionId).includes(focusId)) { + return sectionId; + } + } + return null; + }; // Handle input useInput( @@ -352,32 +414,29 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } else if (key.downArrow || key.tab) { setFocusIdx((i) => Math.min(focusList.length - 1, i + 1)); } else if (key.return) { - if (SECTIONS.includes(currentFocus as SectionId)) { - toggleSection(currentFocus as SectionId); + if (isSection(currentFocus)) { + toggleSection(currentFocus); } else if (currentFocus === "action") { setPhase("review"); } else if (currentFocus === "provider.type") { setSelectingProviderType(true); - } else if (currentFocus === "network.tailscaleMode") { - setSelectingTsMode(true); } else if (currentFocus === "resources.memory") { setSelectingMemory(true); } else if (currentFocus === "resources.disk") { setSelectingDisk(true); + } else if (isCapSelectField(currentFocus)) { + setSelectingCapField(currentFocus); } else { setEditing(true); } } else if (key.escape) { // Collapse parent section - for (const section of SECTIONS) { - if (SECTION_CHILDREN[section].includes(currentFocus)) { - toggleSection(section); - // Move focus to the section header - const newList = buildFocusList(new Set([...expanded].filter((s) => s !== section))); - const sectionIdx = newList.indexOf(section as FocusId); - if (sectionIdx >= 0) setFocusIdx(sectionIdx); - break; - } + const parent = findParentSection(currentFocus); + if (parent) { + toggleSection(parent); + const newList = buildFocusList(new Set([...expanded].filter((s) => s !== parent))); + const sectionIdx = newList.indexOf(parent); + if (sectionIdx >= 0) setFocusIdx(sectionIdx); } } else if (input.toLowerCase() === "r") { setPhase("review"); @@ -406,7 +465,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { } // Determine field status - const fieldStatus = (id: FocusId) => { + const fieldStatus = (id: string) => { if (currentFocus === id && editing) return "editing" as const; if (currentFocus === id) return "focused" as const; return "idle" as const; @@ -479,8 +538,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Resources */} @@ -529,8 +588,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Provider */} @@ -628,37 +687,11 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { )} - {/* Services */} - - {currentFocus === "services.opToken" && editing ? ( - - - 1P Token - - - - ) : ( - - )} - - - {/* Network */} + {/* Network (gateway port only) */} @@ -677,50 +710,42 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { placeholder="18789" /> )} - {currentFocus === "network.tailscaleKey" && editing ? ( - - - TS Auth Key - - - - ) : ( - - )} - {selectingTsMode ? ( - - TS Mode - o.value === tailscaleMode)} - onSelect={(item) => { - setTailscaleMode(item.value); - setSelectingTsMode(false); - }} - /> - - ) : ( - - )} + {/* Dynamic capability sections */} + {CONFIGURABLE_CAPABILITIES.map((cap) => { + const sectionId = `cap:${cap.name}`; + const capVals = capValues[cap.name] ?? {}; + // Determine which field within this capability is focused + let focusedField: string | null = null; + if (currentFocus.startsWith(`cap:${cap.name}:`)) { + focusedField = currentFocus.split(":").slice(2).join(":"); + } + return ( + setCapValue(cap.name, path, value)} + focused={currentFocus === sectionId} + expanded={expanded.has(sectionId)} + focusedField={focusedField} + editing={editing} + selectingField={ + selectingCapField?.startsWith(`cap:${cap.name}:`) + ? selectingCapField.split(":").slice(2).join(":") + : null + } + onSelectDone={() => setSelectingCapField(null)} + /> + ); + })} + {/* Bootstrap / Agent Identity */} @@ -797,8 +822,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Telegram */} diff --git a/packages/host-core/src/bootstrap.ts b/packages/host-core/src/bootstrap.ts index e3461d7..25dbd21 100644 --- a/packages/host-core/src/bootstrap.ts +++ b/packages/host-core/src/bootstrap.ts @@ -84,7 +84,11 @@ export async function bootstrapOpenclaw( // Tailscale gateway mode (serve/funnel/off) — defaults to "serve" when // Tailscale is configured, so the user gets HTTPS on the tailnet automatically - const tsMode = config.network?.tailscale ? (config.network.tailscale.mode ?? "serve") : undefined; + const tsCap = config.capabilities?.tailscale; + const tsMode = + tsCap && typeof tsCap === "object" && "authKey" in tsCap + ? ((tsCap.mode as string) ?? "serve") + : undefined; if (tsMode && tsMode !== "off") { configCmds.push(`openclaw config set gateway.tailscale.mode ${tsMode}`); diff --git a/packages/host-core/src/capability-hooks.ts b/packages/host-core/src/capability-hooks.ts new file mode 100644 index 0000000..7168182 --- /dev/null +++ b/packages/host-core/src/capability-hooks.ts @@ -0,0 +1,88 @@ +/** + * Host-side setup hooks for capabilities. + * + * Unlike CapabilityDef hooks (which run inside the VM via claw), these run + * on the host after VM provisioning. They handle things like token validation, + * authentication flows, and service connections that require host-side access. + * + * Each hook is registered by capability name. The headless pipeline iterates + * over enabled capabilities and runs their host hooks in registration order. + */ + +import type { HostSetupResult } from "@clawctl/types"; +import type { VMDriver } from "./drivers/types.js"; +import { setupOnePassword, setupTailscale } from "./credentials.js"; +import type { InstanceConfig } from "@clawctl/types"; + +export interface HostCapabilityHook { + /** Capability name this hook belongs to. */ + capabilityName: string; + /** Stage name for the headless progress UI. */ + stageName: string; + /** Human-readable label shown during provisioning. */ + stageLabel: string; + /** The setup function. */ + run: ( + config: Record, + driver: VMDriver, + vmName: string, + onLine?: (message: string) => void, + ) => Promise; +} + +/** Registry of host-side capability setup hooks. */ +const HOST_HOOKS: HostCapabilityHook[] = [ + { + capabilityName: "one-password", + stageName: "onepassword", + stageLabel: "Setting up 1Password", + run: async (config, driver, vmName, onLine) => { + const token = config.serviceAccountToken as string | undefined; + if (!token) { + return { success: false, error: "No service account token provided" }; + } + const result = await setupOnePassword(driver, vmName, token, onLine); + if (result.valid) { + return { success: true, detail: `Token validated (${result.account})` }; + } + return { success: false, error: result.error }; + }, + }, + { + capabilityName: "tailscale", + stageName: "tailscale", + stageLabel: "Connecting to Tailscale", + run: async (config, driver, vmName, onLine) => { + const authKey = config.authKey as string | undefined; + if (!authKey) { + return { success: false, error: "No auth key provided" }; + } + const result = await setupTailscale(driver, vmName, authKey, onLine); + if (result.connected) { + return { success: true, detail: `Connected as ${result.hostname}` }; + } + return { success: false, error: result.error }; + }, + }, +]; + +/** + * Get host hooks for capabilities that are enabled in the config. + * Returns hooks in registration order, filtered to enabled capabilities. + */ +export function getHostHooksForConfig(config: InstanceConfig): HostCapabilityHook[] { + const caps = config.capabilities ?? {}; + return HOST_HOOKS.filter((hook) => hook.capabilityName in caps); +} + +/** + * Get the capability-specific config for a host hook from the InstanceConfig. + */ +export function getCapabilityConfig( + config: InstanceConfig, + capabilityName: string, +): Record { + const capConfig = config.capabilities?.[capabilityName]; + if (typeof capConfig === "object") return capConfig; + return {}; +} diff --git a/packages/host-core/src/config.test.ts b/packages/host-core/src/config.test.ts index b370aee..da0412a 100644 --- a/packages/host-core/src/config.test.ts +++ b/packages/host-core/src/config.test.ts @@ -26,9 +26,11 @@ describe("validateConfig", () => { forwardGateway: false, gatewayPort: 9000, gatewayToken: "my-token", + }, + capabilities: { tailscale: { authKey: "tskey-auth-abc" }, + "one-password": { serviceAccountToken: "ops_abc" }, }, - services: { onePassword: { serviceAccountToken: "ops_abc" } }, tools: { docker: true, python: true }, mounts: [ { location: "~/.ssh", mountPoint: "/mnt/ssh" }, @@ -48,8 +50,8 @@ describe("validateConfig", () => { expect(config.network?.forwardGateway).toBe(false); expect(config.network?.gatewayPort).toBe(9000); expect(config.network?.gatewayToken).toBe("my-token"); - expect(config.network?.tailscale?.authKey).toBe("tskey-auth-abc"); - expect(config.services?.onePassword?.serviceAccountToken).toBe("ops_abc"); + expect(config.capabilities?.tailscale).toEqual({ authKey: "tskey-auth-abc" }); + expect(config.capabilities?.["one-password"]).toEqual({ serviceAccountToken: "ops_abc" }); expect(config.tools?.docker).toBe(true); expect(config.mounts).toEqual([ { location: "~/.ssh", mountPoint: "/mnt/ssh" }, @@ -170,76 +172,26 @@ describe("validateConfig", () => { ).toThrow(); }); - test("throws on bad network.tailscale", () => { - expect(() => - validateConfig({ name: "t", project: "/tmp", network: { tailscale: { authKey: "" } } }), - ).toThrow("tailscale.authKey"); - - expect(() => - validateConfig({ name: "t", project: "/tmp", network: { tailscale: "key" } }), - ).toThrow("tailscale"); - }); - - // -- network.tailscale.mode -------------------------------------------------- - - test("accepts tailscale.mode 'off'", () => { - const config = validateConfig({ - name: "t", - project: "/tmp", - network: { tailscale: { authKey: "tskey-auth-abc", mode: "off" } }, - }); - expect(config.network?.tailscale?.mode).toBe("off"); - }); - - test("accepts tailscale.mode 'serve'", () => { + test("accepts capabilities config", () => { const config = validateConfig({ name: "t", project: "/tmp", - network: { tailscale: { authKey: "tskey-auth-abc", mode: "serve" } }, - }); - expect(config.network?.tailscale?.mode).toBe("serve"); - }); - - test("accepts tailscale.mode 'funnel'", () => { - const config = validateConfig({ - name: "t", - project: "/tmp", - network: { tailscale: { authKey: "tskey-auth-abc", mode: "funnel" } }, + capabilities: { + tailscale: { authKey: "tskey-auth-abc", mode: "serve" }, + "one-password": { serviceAccountToken: "ops_abc" }, + }, }); - expect(config.network?.tailscale?.mode).toBe("funnel"); + expect(config.capabilities?.tailscale).toEqual({ authKey: "tskey-auth-abc", mode: "serve" }); + expect(config.capabilities?.["one-password"]).toEqual({ serviceAccountToken: "ops_abc" }); }); - test("accepts tailscale without mode (optional)", () => { + test("accepts capability enabled with true", () => { const config = validateConfig({ name: "t", project: "/tmp", - network: { tailscale: { authKey: "tskey-auth-abc" } }, + capabilities: { tailscale: true }, }); - expect(config.network?.tailscale?.mode).toBeUndefined(); - }); - - test("throws on invalid tailscale.mode", () => { - expect(() => - validateConfig({ - name: "t", - project: "/tmp", - network: { tailscale: { authKey: "tskey-auth-abc", mode: "invalid" } }, - }), - ).toThrow(); - }); - - test("throws on bad services.onePassword", () => { - expect(() => - validateConfig({ - name: "t", - project: "/tmp", - services: { onePassword: { serviceAccountToken: "" } }, - }), - ).toThrow("serviceAccountToken"); - - expect(() => - validateConfig({ name: "t", project: "/tmp", services: { onePassword: true } }), - ).toThrow("onePassword"); + expect(config.capabilities?.tailscale).toBe(true); }); test("throws on non-array mounts", () => { @@ -490,24 +442,24 @@ describe("validateConfig", () => { // -- op:// cross-validation ------------------------------------------------- - test("accepts op:// references when onePassword is configured", () => { + test("accepts op:// references when one-password capability is configured", () => { const config = validateConfig({ name: "t", project: "/tmp", - services: { onePassword: { serviceAccountToken: "ops_abc" } }, + capabilities: { "one-password": { serviceAccountToken: "ops_abc" } }, provider: { type: "anthropic", apiKey: "op://Vault/Anthropic/api-key" }, }); expect(config.provider?.apiKey).toBe("op://Vault/Anthropic/api-key"); }); - test("throws on op:// references without onePassword configured", () => { + test("throws on op:// references without one-password capability", () => { expect(() => validateConfig({ name: "t", project: "/tmp", provider: { type: "anthropic", apiKey: "op://Vault/Anthropic/api-key" }, }), - ).toThrow("services.onePassword is not configured"); + ).toThrow("one-password"); }); }); @@ -588,44 +540,18 @@ describe("sanitizeConfig", () => { expect((result.provider as Record).apiKey).toBeUndefined(); }); - test("strips network.gatewayToken and network.tailscale.authKey", () => { + test("strips network.gatewayToken", () => { const result = sanitizeConfig({ name: "t", project: "/tmp", network: { gatewayToken: "secret-token", gatewayPort: 9000, - tailscale: { authKey: "tskey-secret" }, }, }); const net = result.network as Record; expect(net.gatewayToken).toBeUndefined(); expect(net.gatewayPort).toBe(9000); - expect((net.tailscale as Record).authKey).toBeUndefined(); - }); - - test("strips tailscale.authKey but preserves tailscale.mode", () => { - const result = sanitizeConfig({ - name: "t", - project: "/tmp", - network: { - tailscale: { authKey: "tskey-secret", mode: "serve" }, - }, - }); - const net = result.network as Record; - const ts = net.tailscale as Record; - expect(ts.authKey).toBeUndefined(); - expect(ts.mode).toBe("serve"); - }); - - test("strips services.onePassword.serviceAccountToken", () => { - const result = sanitizeConfig({ - name: "t", - project: "/tmp", - services: { onePassword: { serviceAccountToken: "ops_secret" } }, - }); - const op = (result.services as Record).onePassword as Record; - expect(op.serviceAccountToken).toBeUndefined(); }); test("strips telegram.botToken", () => { @@ -711,11 +637,14 @@ describe("loadConfig", () => { JSON.stringify({ name: "test-vm", project: "/tmp/test-vm", - services: { onePassword: { serviceAccountToken: "env://CLAWCTL_TEST_TOKEN" } }, + capabilities: { + "one-password": { serviceAccountToken: "env://CLAWCTL_TEST_TOKEN" }, + }, }), ); const config = await loadConfig(tmp); - expect(config.services?.onePassword?.serviceAccountToken).toBe("ops_resolved_token"); + const opCap = config.capabilities?.["one-password"] as Record; + expect(opCap?.serviceAccountToken).toBe("ops_resolved_token"); }); test("throws on unset env:// reference", async () => { @@ -725,7 +654,9 @@ describe("loadConfig", () => { JSON.stringify({ name: "test-vm", project: "/tmp/test-vm", - services: { onePassword: { serviceAccountToken: "env://NONEXISTENT_VAR_99" } }, + capabilities: { + "one-password": { serviceAccountToken: "env://NONEXISTENT_VAR_99" }, + }, }), ); await expect(loadConfig(tmp)).rejects.toThrow("NONEXISTENT_VAR_99"); diff --git a/packages/host-core/src/config.ts b/packages/host-core/src/config.ts index ab514a8..c429392 100644 --- a/packages/host-core/src/config.ts +++ b/packages/host-core/src/config.ts @@ -1,6 +1,7 @@ import { readFile } from "fs/promises"; import { resolveEnvRefs, validateConfig } from "@clawctl/types"; -import type { InstanceConfig, VMConfig } from "@clawctl/types"; +import type { InstanceConfig, VMConfig, CapabilityDef } from "@clawctl/types"; +import { buildCapabilitiesSchema, getSecretPaths } from "./schema-derive.js"; // Re-export validateConfig from types for convenience export { validateConfig } from "@clawctl/types"; @@ -20,8 +21,16 @@ export function configToVMConfig(config: InstanceConfig): VMConfig { return vm; } -/** Strip secrets and one-time fields from config for persistence as clawctl.json. */ -export function sanitizeConfig(config: InstanceConfig): Record { +/** + * Strip secrets and one-time fields from config for persistence as clawctl.json. + * + * @param capabilities - When provided, also strips fields marked `secret: true` + * in each capability's configDef from the capabilities section. + */ +export function sanitizeConfig( + config: InstanceConfig, + capabilities?: CapabilityDef[], +): Record { const clone = JSON.parse(JSON.stringify(config)) as Record; // provider.apiKey @@ -29,21 +38,9 @@ export function sanitizeConfig(config: InstanceConfig): Record delete (clone.provider as Record).apiKey; } - // network.gatewayToken, network.tailscale.authKey + // network.gatewayToken if (clone.network && typeof clone.network === "object") { - const net = clone.network as Record; - delete net.gatewayToken; - if (net.tailscale && typeof net.tailscale === "object") { - delete (net.tailscale as Record).authKey; - } - } - - // services.onePassword.serviceAccountToken - if (clone.services && typeof clone.services === "object") { - const svc = clone.services as Record; - if (svc.onePassword && typeof svc.onePassword === "object") { - delete (svc.onePassword as Record).serviceAccountToken; - } + delete (clone.network as Record).gatewayToken; } // telegram.botToken @@ -51,14 +48,49 @@ export function sanitizeConfig(config: InstanceConfig): Record delete (clone.telegram as Record).botToken; } + // Capability secrets (from configDef fields marked secret: true) + if (capabilities && clone.capabilities && typeof clone.capabilities === "object") { + const caps = clone.capabilities as Record; + for (const cap of capabilities) { + if (!cap.configDef) continue; + const capConfig = caps[cap.name]; + if (!capConfig || typeof capConfig !== "object") continue; + const secretPaths = getSecretPaths(cap.configDef); + for (const path of secretPaths) { + // For simple paths (top-level keys), delete directly + if (!path.startsWith("/")) { + delete (capConfig as Record)[path]; + } else { + // For JSON Pointer paths, need to traverse + const parts = path.slice(1).split("/"); + let target = capConfig as Record; + for (let i = 0; i < parts.length - 1; i++) { + const next = target[parts[i]]; + if (!next || typeof next !== "object") break; + target = next as Record; + } + delete target[parts[parts.length - 1]]; + } + } + } + } + // bootstrap (one-time action) delete clone.bootstrap; return clone; } -/** Read and validate a JSON config file. */ -export async function loadConfig(path: string): Promise { +/** + * Read and validate a JSON config file. + * + * @param capabilities - When provided, validates capability config sections + * against their configDef-derived Zod schemas. + */ +export async function loadConfig( + path: string, + capabilities?: CapabilityDef[], +): Promise { let raw: string; try { raw = await readFile(path, "utf-8"); @@ -78,5 +110,6 @@ export async function loadConfig(path: string): Promise { parsed = resolveEnvRefs(parsed as Record); } - return validateConfig(parsed); + const capabilitySchema = capabilities ? buildCapabilitiesSchema(capabilities) : undefined; + return validateConfig(parsed, { capabilitySchema }); } diff --git a/packages/host-core/src/headless.ts b/packages/host-core/src/headless.ts index 2d04ca0..0df295a 100644 --- a/packages/host-core/src/headless.ts +++ b/packages/host-core/src/headless.ts @@ -4,12 +4,12 @@ import { loadConfig, configToVMConfig, sanitizeConfig } from "./config.js"; import { checkPrereqs } from "./prereqs.js"; import { provisionVM } from "./provision.js"; import { verifyProvisioning } from "./verify.js"; -import { setupOnePassword, setupTailscale } from "./credentials.js"; import { findSecretRefs, hasOpRefs, resolveOpRefs, getNestedValue } from "./secrets.js"; import type { ResolvedSecretRef } from "./secrets.js"; import { syncSecretsToVM, writeEnvSecrets } from "./secrets-sync.js"; import { bootstrapOpenclaw } from "./bootstrap.js"; import { cleanupVM, onSignalCleanup } from "./cleanup.js"; +import { getHostHooksForConfig, getCapabilityConfig } from "./capability-hooks.js"; import { GATEWAY_PORT } from "@clawctl/types"; import type { InstanceConfig } from "@clawctl/types"; import { BIN_NAME } from "./bin-name.js"; @@ -109,10 +109,6 @@ export async function runHeadlessFromConfig( try { // 2. Provision VM cb.onStage("provision", "running", "Starting VM provisioning..."); - const provisionFeatures = { - onePassword: !!config.services?.onePassword || !!config.capabilities?.["one-password"], - tailscale: !!config.network?.tailscale || !!config.capabilities?.tailscale, - }; await provisionVM( driver, vmConfig, @@ -127,7 +123,7 @@ export async function runHeadlessFromConfig( extraMounts: vmConfig.extraMounts, }, undefined, - provisionFeatures, + config.capabilities, ); cb.onStage("provision", "done", "VM provisioned"); @@ -157,21 +153,23 @@ export async function runHeadlessFromConfig( } cb.onStage("verify", "done", "All tools verified"); - // 4. Services: 1Password - if (config.services?.onePassword) { - cb.onStage("onepassword", "running", "Setting up 1Password..."); - const opResult = await setupOnePassword( - driver, - vmConfig.vmName, - config.services.onePassword.serviceAccountToken, - (line: string) => cb.onLine("1password", line), + // 4. Host-side capability setup hooks + const hostHooks = getHostHooksForConfig(config); + for (const hook of hostHooks) { + cb.onStage(hook.stageName as HeadlessStage, "running", `${hook.stageLabel}...`); + const capConfig = getCapabilityConfig(config, hook.capabilityName); + const hookResult = await hook.run(capConfig, driver, vmConfig.vmName, (line: string) => + cb.onLine(hook.stageName, line), ); - if (opResult.valid) { - cb.onStage("onepassword", "done", `Token validated (${opResult.account})`); + if (hookResult.success) { + cb.onStage(hook.stageName as HeadlessStage, "done", hookResult.detail); } else { - cb.onStage("onepassword", "error", opResult.error); - cb.onError("onepassword", opResult.error ?? "Token validation failed"); - throw new Error("1Password setup failed"); + cb.onStage(hook.stageName as HeadlessStage, "error", hookResult.error); + cb.onError( + hook.stageName as HeadlessStage, + hookResult.error ?? `${hook.stageLabel} failed`, + ); + throw new Error(`${hook.stageLabel} failed: ${hookResult.error}`); } } @@ -207,24 +205,6 @@ export async function runHeadlessFromConfig( ); } - // 5. Network: Tailscale - if (config.network?.tailscale) { - cb.onStage("tailscale", "running", "Connecting to Tailscale..."); - const tsResult = await setupTailscale( - driver, - vmConfig.vmName, - config.network.tailscale.authKey, - (line: string) => cb.onLine("tailscale", line), - ); - if (tsResult.connected) { - cb.onStage("tailscale", "done", `Connected as ${tsResult.hostname}`); - } else { - cb.onStage("tailscale", "error", tsResult.error); - cb.onError("tailscale", tsResult.error ?? "Connection failed"); - throw new Error("Tailscale connection failed"); - } - } - // 6. Bootstrap openclaw (if provider configured) const hostPort = config.network?.gatewayPort ?? GATEWAY_PORT; let gatewayToken: string | undefined; diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index f369722..f67b420 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -11,6 +11,15 @@ export { exec, execStream, execWithLogs, commandExists } from "./exec.js"; // Config export { loadConfig, validateConfig, configToVMConfig, sanitizeConfig } from "./config.js"; +// Schema derivation +export { + deriveConfigSchema, + buildCapabilitiesSchema, + getSecretPaths, + getByPath, + setByPath, +} from "./schema-derive.js"; + // Secrets (I/O + re-exports of pure functions from @clawctl/types) export { resolveOpRefs, @@ -24,7 +33,7 @@ export type { SecretRef, ResolvedSecretRef } from "./secrets.js"; // Provision export { provisionVM } from "./provision.js"; -export type { ProvisionCallbacks, ProvisionFeatures } from "./provision.js"; +export type { ProvisionCallbacks } from "./provision.js"; // Claw binary (embedded asset in compiled mode, direct path in dev mode) export { clawPath } from "./claw-binary.js"; @@ -109,3 +118,7 @@ export type { CleanupTarget } from "./cleanup.js"; // Headless export { runHeadless, runHeadlessFromConfig } from "./headless.js"; export type { HeadlessResult, HeadlessCallbacks, HeadlessStage, StageStatus } from "./headless.js"; + +// Capability host hooks +export { getHostHooksForConfig, getCapabilityConfig } from "./capability-hooks.js"; +export type { HostCapabilityHook } from "./capability-hooks.js"; diff --git a/packages/host-core/src/provision.ts b/packages/host-core/src/provision.ts index 24f44c2..7203800 100644 --- a/packages/host-core/src/provision.ts +++ b/packages/host-core/src/provision.ts @@ -7,11 +7,6 @@ import type { VMDriver, VMCreateOptions, OnLine } from "./drivers/types.js"; import { initGitRepo } from "./git.js"; import { clawPath } from "./claw-binary.js"; -export interface ProvisionFeatures { - onePassword: boolean; - tailscale: boolean; -} - export interface ProvisionCallbacks { onPhase?: (phase: string) => void; onStep?: (step: string) => void; @@ -92,7 +87,7 @@ export async function provisionVM( callbacks: ProvisionCallbacks = {}, createOptions: VMCreateOptions = {}, clawBinaryPath: string = clawPath, - features: ProvisionFeatures = { onePassword: false, tailscale: false }, + capabilities: Record> = {}, ): Promise { const { onPhase, onStep, onLine } = callbacks; @@ -102,12 +97,7 @@ export async function provisionVM( onStep?.("Created project directory"); // Write provision config so claw knows which optional capabilities to enable - const provisionConfig: ProvisionConfig = { - capabilities: { - ...(features.onePassword && { "one-password": true }), - ...(features.tailscale && { tailscale: true }), - }, - }; + const provisionConfig: ProvisionConfig = { capabilities }; await writeFile( join(config.projectDir, "data", PROVISION_CONFIG_FILE), JSON.stringify(provisionConfig, null, 2) + "\n", diff --git a/packages/host-core/src/schema-derive.ts b/packages/host-core/src/schema-derive.ts new file mode 100644 index 0000000..3069323 --- /dev/null +++ b/packages/host-core/src/schema-derive.ts @@ -0,0 +1,203 @@ +/** + * Zod schema derivation from CapabilityConfigDef field definitions. + * + * Capabilities declare their config via `configDef` — field definitions + * that describe type, required, options, etc. This module derives Zod + * schemas from those definitions so validation works without hand-written + * Zod code in each capability. + */ + +import { z } from "zod"; +import type { CapabilityConfigDef, CapabilityConfigField, CapabilityDef } from "@clawctl/types"; + +// --------------------------------------------------------------------------- +// Path resolution utilities +// --------------------------------------------------------------------------- + +/** Split a config path into key parts. */ +function pathToParts(path: string): string[] { + if (path.startsWith("/")) { + // JSON Pointer: "/auth/key" → ["auth", "key"] + return path.slice(1).split("/"); + } + // Plain key: "authKey" → ["authKey"] + return [path]; +} + +/** + * Resolve a config path (plain key or JSON Pointer) against an object. + * - "authKey" → obj.authKey + * - "/auth/key" → obj.auth.key + */ +export function getByPath(obj: Record, path: string): unknown { + const parts = pathToParts(path); + let current: unknown = obj; + for (const part of parts) { + if (current == null || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** + * Set a value at a config path in an object, creating intermediaries. + * - "authKey" → obj.authKey = value + * - "/auth/key" → obj.auth.key = value (creates obj.auth if needed) + */ +export function setByPath(obj: Record, path: string, value: unknown): void { + const parts = pathToParts(path); + let current = obj; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!(part in current) || typeof current[part] !== "object" || current[part] == null) { + current[part] = {}; + } + current = current[part] as Record; + } + current[parts[parts.length - 1]] = value; +} + +// --------------------------------------------------------------------------- +// Schema derivation +// --------------------------------------------------------------------------- + +/** Derive a Zod schema for a single field. */ +function deriveFieldSchema(field: CapabilityConfigField): z.ZodTypeAny { + let schema: z.ZodTypeAny; + + switch (field.type) { + case "text": + case "password": { + schema = field.required ? z.string().min(1) : z.string(); + break; + } + case "select": { + const values = (field.options ?? []).map((o: { label: string; value: string }) => o.value); + if (values.length >= 2) { + schema = z.enum(values as [string, string, ...string[]]); + } else if (values.length === 1) { + schema = z.literal(values[0]); + } else { + schema = z.string(); + } + break; + } + default: + schema = z.string(); + } + + if (field.defaultValue != null) { + schema = schema.default(field.defaultValue); + } + + if (!field.required) { + schema = schema.optional(); + } + + return schema; +} + +interface SchemaTree { + [key: string]: z.ZodTypeAny | SchemaTree; +} + +/** Recursively convert a tree of schemas/sub-trees into a Zod shape. */ +function treeToZodShape(tree: SchemaTree): Record { + const shape: Record = {}; + for (const [key, value] of Object.entries(tree)) { + if (value instanceof z.ZodType) { + shape[key] = value; + } else { + // Sub-tree → nested z.object + shape[key] = z.object(treeToZodShape(value)); + } + } + return shape; +} + +/** + * Derive a Zod object schema from a CapabilityConfigDef's field definitions. + * + * Flat fields produce a flat z.object. Nested JSON Pointer paths produce + * nested z.object structures. + */ +export function deriveConfigSchema(configDef: CapabilityConfigDef): z.ZodTypeAny { + // Build a tree of schemas from field paths + const tree: SchemaTree = {}; + + for (const field of configDef.fields) { + const parts = pathToParts(field.path as string); + const fieldSchema = deriveFieldSchema(field); + + if (parts.length === 1) { + tree[parts[0]] = fieldSchema; + } else { + // Nested: build intermediate objects + let current: SchemaTree = tree; + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in current) || current[parts[i]] instanceof z.ZodType) { + current[parts[i]] = {}; + } + current = current[parts[i]] as SchemaTree; + } + current[parts[parts.length - 1]] = fieldSchema; + } + } + + // Convert the tree to nested z.object schemas + const zodShape = treeToZodShape(tree); + let schema: z.ZodTypeAny = z.object(zodShape); + + if (configDef.refine) { + const refineFn = configDef.refine; + schema = (schema as z.ZodTypeAny).refine( + (val: unknown) => refineFn(val as Record) === null, + { + message: "Cross-field validation failed", + }, + ); + } + + return schema; +} + +/** + * Build a composed Zod schema for the entire `capabilities` config section. + * + * For each capability with a configDef, the schema is derived and keyed by + * capability name. Each capability value can be either `true` (enabled with + * defaults) or a config object matching its derived schema. + * Unknown capability keys are allowed with a permissive schema. + */ +export function buildCapabilitiesSchema(capabilities: CapabilityDef[]): z.ZodTypeAny { + const knownShapes: Record = {}; + + for (const cap of capabilities) { + if (cap.configDef) { + const objSchema = deriveConfigSchema(cap.configDef); + // Allow true (enabled with defaults) or a config object + knownShapes[cap.name] = z.union([z.literal(true), objSchema]).optional(); + } + } + + if (Object.keys(knownShapes).length === 0) { + // No capabilities with configDef — use permissive schema + return z + .record(z.string(), z.union([z.literal(true), z.record(z.string(), z.unknown())])) + .optional(); + } + + // Known capabilities get strict validation; unknown keys are allowed permissively + return z + .object(knownShapes) + .catchall(z.union([z.literal(true), z.record(z.string(), z.unknown())])) + .optional(); +} + +/** + * Extract secret field paths from a capability's configDef. + * Returns an array of paths (plain keys or JSON Pointers) marked as secret. + */ +export function getSecretPaths(configDef: CapabilityConfigDef): string[] { + return configDef.fields.filter((f) => f.secret).map((f) => f.path as string); +} diff --git a/packages/host-core/src/secrets-sync.test.ts b/packages/host-core/src/secrets-sync.test.ts index 0d51c0b..72b0d73 100644 --- a/packages/host-core/src/secrets-sync.test.ts +++ b/packages/host-core/src/secrets-sync.test.ts @@ -16,7 +16,9 @@ describe("sanitizeKey", () => { }); test("lowercases mixed case", () => { - expect(sanitizeKey(["services", "onePassword", "token"])).toBe("services_onepassword_token"); + expect(sanitizeKey(["capabilities", "one-password", "token"])).toBe( + "capabilities_one-password_token", + ); }); }); diff --git a/packages/host-core/src/secrets.test.ts b/packages/host-core/src/secrets.test.ts index bf4cd57..bac271a 100644 --- a/packages/host-core/src/secrets.test.ts +++ b/packages/host-core/src/secrets.test.ts @@ -15,11 +15,13 @@ describe("findSecretRefs", () => { test("finds env:// references", () => { const refs = findSecretRefs({ - services: { onePassword: { serviceAccountToken: "env://OP_SERVICE_ACCOUNT_TOKEN" } }, + capabilities: { + "one-password": { serviceAccountToken: "env://OP_SERVICE_ACCOUNT_TOKEN" }, + }, }); expect(refs).toEqual([ { - path: ["services", "onePassword", "serviceAccountToken"], + path: ["capabilities", "one-password", "serviceAccountToken"], reference: "env://OP_SERVICE_ACCOUNT_TOKEN", scheme: "env", }, @@ -30,7 +32,7 @@ describe("findSecretRefs", () => { const refs = findSecretRefs({ provider: { apiKey: "op://V/I/f" }, telegram: { botToken: "op://V/Bot/token" }, - services: { onePassword: { serviceAccountToken: "env://OP_TOKEN" } }, + capabilities: { "one-password": { serviceAccountToken: "env://OP_TOKEN" } }, }); expect(refs).toHaveLength(3); expect(refs.map((r) => r.scheme)).toEqual( @@ -117,11 +119,10 @@ describe("resolveEnvRefs", () => { test("resolves env:// references from process.env", () => { const result = resolveEnvRefs({ - services: { onePassword: { serviceAccountToken: "env://TEST_API_KEY" } }, + capabilities: { "one-password": { serviceAccountToken: "env://TEST_API_KEY" } }, }); - expect( - (result.services as Record>).onePassword.serviceAccountToken, - ).toBe("resolved-api-key"); + const caps = result.capabilities as Record>; + expect(caps["one-password"].serviceAccountToken).toBe("resolved-api-key"); }); test("resolves multiple env:// references", () => { @@ -174,8 +175,10 @@ describe("resolveEnvRefs", () => { test("includes field path in error message", () => { expect(() => - resolveEnvRefs({ services: { onePassword: { token: "env://MISSING_XYZ" } } }), - ).toThrow("services.onePassword.token"); + resolveEnvRefs({ + capabilities: { "one-password": { token: "env://MISSING_XYZ" } }, + }), + ).toThrow("capabilities.one-password.token"); }); }); diff --git a/packages/types/src/capability.ts b/packages/types/src/capability.ts index 853b0b0..2300cb6 100644 --- a/packages/types/src/capability.ts +++ b/packages/types/src/capability.ts @@ -149,11 +149,116 @@ export interface CapabilityMigration { run: (ctx: CapabilityContext) => Promise; } +// --------------------------------------------------------------------------- +// Capability config definition types +// --------------------------------------------------------------------------- + +/** Field types supported by the config definition / TUI form. */ +export type ConfigFieldType = "text" | "password" | "select"; + +/** + * Recursive JSON Pointer paths for nested config objects. + * Produces union of paths like "/auth" | "/auth/key" | "/mode". + */ +export type JsonPointer = + T extends Record + ? { + [K in keyof T & string]: + | `/${K}` + | (T[K] extends Record ? `/${K}${JsonPointer}` : never); + }[keyof T & string] + : never; + +/** + * Path into a config object. + * - Top-level: plain key, e.g. "authKey" + * - Nested: JSON Pointer (RFC 6901), e.g. "/auth/key" + */ +export type ConfigPath = (keyof T & string) | JsonPointer; + +/** A single field in a capability's config definition. */ +export interface CapabilityConfigField> { + /** + * Path into the capability's config object. + * Plain key for top-level fields, JSON Pointer for nested. + * Typed against TConfig — TypeScript catches invalid paths. + */ + path: ConfigPath; + /** Display label in the TUI form. */ + label: string; + /** Determines the input widget and Zod schema derivation. */ + type: ConfigFieldType; + /** Whether this field must be provided. Zod: required fields are not .optional(). */ + required?: boolean; + /** Whether this field contains a secret (for sanitization + input masking). */ + secret?: boolean; + /** Options for select fields. Values are used to derive z.enum(). */ + options?: { label: string; value: string }[]; + /** Default value for the field. */ + defaultValue?: string; + /** Placeholder text shown in the TUI form input. */ + placeholder?: string; + /** Contextual help shown in the TUI sidebar when this field is focused. */ + help?: { title: string; lines: string[] }; +} + +/** + * Unified config definition for a capability. + * + * Declares both the config schema (via field definitions, Zod is derived) + * and the TUI form layout. The TypeScript config interface is the contract; + * `defineCapabilityConfig()` ensures field paths type-check against it. + */ +export interface CapabilityConfigDef< + TConfig extends Record = Record, +> { + /** Section label in the TUI wizard. */ + sectionLabel: string; + /** Sidebar help shown when the section header is focused. */ + sectionHelp?: { title: string; lines: string[] }; + /** Fields in this section. Each maps to a path in TConfig. */ + fields: CapabilityConfigField[]; + /** Compute a summary string for the collapsed section header. */ + summary?: (values: Partial>) => string; + /** Cross-field validation. Return an error string, or null if valid. */ + refine?: (values: Partial) => string | null; +} + +/** + * Type-safe helper for defining a capability config. + * + * Validates field paths against TConfig at the definition site, then + * returns a type-erased `CapabilityConfigDef` for storage on `CapabilityDef`. + * This is intentional — the generic does its job at compile time, but the + * stored value doesn't carry the parameter since `CapabilityDef[]` arrays + * can't hold mixed generics. + */ +export function defineCapabilityConfig>( + def: CapabilityConfigDef, +): CapabilityConfigDef { + return def as CapabilityConfigDef; +} + +// --------------------------------------------------------------------------- +// Host-side setup hook result +// --------------------------------------------------------------------------- + +/** Result of a host-side capability setup action. */ +export interface HostSetupResult { + success: boolean; + detail?: string; + error?: string; +} + +// --------------------------------------------------------------------------- +// CapabilityDef +// --------------------------------------------------------------------------- + /** * Full capability definition — an atomic, self-contained provisioning module. * * Capabilities are plain constant objects. They declare their metadata, - * lifecycle hooks, doctor checks, migrations, and config schemas in a + * lifecycle hooks, doctor checks, migrations, and config definitions in a * single file (or directory for complex capabilities). */ export interface CapabilityDef { @@ -188,9 +293,12 @@ export interface CapabilityDef { /** Ordered migration chain (like DB migrations). */ migrations?: CapabilityMigration[]; - /** Zod schema for this capability's config section (optional). */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - configSchema?: any; + /** + * Unified config definition: declares the config schema (Zod is derived + * from field definitions) and the TUI form layout. Use + * `defineCapabilityConfig()` for type-safe field paths. + */ + configDef?: CapabilityConfigDef; } /** Tracks installed capability versions inside the VM. */ @@ -198,19 +306,8 @@ export interface CapabilityState { installed: Record; } -/** - * Feature flags / capability config written by the host, read by claw. - * - * The `capabilities` map replaces the old boolean flags. - * Old fields are kept for backwards compatibility. - */ +/** Feature flags / capability config written by the host, read by claw. */ export interface ProvisionConfig { /** Enabled capabilities and their config. true = enabled with defaults. */ capabilities: Record>; - - // --- Backwards compatibility (deprecated, mapped to capabilities internally) --- - /** @deprecated Use capabilities["one-password"] instead. */ - onePassword?: boolean; - /** @deprecated Use capabilities["tailscale"] instead. */ - tailscale?: boolean; } diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 35e030a..54b147e 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -22,8 +22,13 @@ function formatZodError(error: z.ZodError): string { return issue.message; } +export interface ValidateConfigOptions { + /** Strict Zod schema for the capabilities section (built from capability configDefs). */ + capabilitySchema?: z.ZodTypeAny; +} + /** Validate raw JSON and return a typed InstanceConfig. */ -export function validateConfig(raw: unknown): InstanceConfig { +export function validateConfig(raw: unknown, opts?: ValidateConfigOptions): InstanceConfig { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new Error("Config must be a JSON object"); } @@ -35,12 +40,27 @@ export function validateConfig(raw: unknown): InstanceConfig { const config = result.data; - // Cross-validate: op:// references require services.onePassword + // Validate capability-specific config against strict schemas + if (opts?.capabilitySchema && config.capabilities) { + const capResult = opts.capabilitySchema.safeParse(config.capabilities); + if (!capResult.success) { + const issue = (capResult as { error: z.ZodError }).error.issues[0]; + const path = ["capabilities", ...issue.path].join("."); + throw new Error( + issue.message.startsWith("'") ? issue.message : `'${path}': ${issue.message}`, + ); + } + } + + // Cross-validate: op:// references require capabilities["one-password"] const opRefs = findSecretRefs(raw as Record).filter((r) => r.scheme === "op"); - if (opRefs.length > 0 && !config.services?.onePassword) { - throw new Error( - `Config has op:// references (${opRefs[0].path.join(".")}) but services.onePassword is not configured`, - ); + if (opRefs.length > 0) { + const hasOp = config.capabilities && "one-password" in config.capabilities; + if (!hasOp) { + throw new Error( + `Config has op:// references (${opRefs[0].path.join(".")}) but capabilities["one-password"] is not configured`, + ); + } } return { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3aaa056..167d983 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -16,14 +16,20 @@ export type { CapabilityDef, CapabilityState, ProvisionConfig, + ConfigFieldType, + ConfigPath, + JsonPointer, + CapabilityConfigField, + CapabilityConfigDef, + HostSetupResult, } from "./capability.js"; +export { defineCapabilityConfig } from "./capability.js"; // Schemas export { instanceConfigSchema, resourcesSchema, networkSchema, - servicesSchema, agentSchema, toolsSchema, capabilitiesSchema, @@ -51,6 +57,7 @@ export type { ProviderDef, ProviderConfig } from "./providers.js"; export { PROVIDERS, PROVIDER_TYPES, ALL_PROVIDER_TYPES } from "./providers.js"; // Config (pure functions) +export type { ValidateConfigOptions } from "./config.js"; export { validateConfig } from "./config.js"; // Secrets (pure functions) diff --git a/packages/types/src/schemas/base.ts b/packages/types/src/schemas/base.ts index 40c54cd..fe03ccb 100644 --- a/packages/types/src/schemas/base.ts +++ b/packages/types/src/schemas/base.ts @@ -1,5 +1,5 @@ /** - * Zod schemas for stable config sections: resources, network, services, + * Zod schemas for stable config sections: resources, network, * agent, tools, mounts. */ import { z } from "zod"; @@ -14,22 +14,6 @@ export const networkSchema = z.object({ forwardGateway: z.boolean().optional(), gatewayPort: z.number().int().min(1024).max(65535).optional(), gatewayToken: z.string().min(1).optional(), - tailscale: z - .object({ - authKey: z.string().min(1, "'network.tailscale.authKey' must be a non-empty string"), - mode: z.enum(["off", "serve", "funnel"]).optional(), - }) - .optional(), -}); - -export const servicesSchema = z.object({ - onePassword: z - .object({ - serviceAccountToken: z - .string() - .min(1, "'services.onePassword.serviceAccountToken' must be a non-empty string"), - }) - .optional(), }); export const agentSchema = z.object({ diff --git a/packages/types/src/schemas/index.ts b/packages/types/src/schemas/index.ts index b6a3dfb..690f203 100644 --- a/packages/types/src/schemas/index.ts +++ b/packages/types/src/schemas/index.ts @@ -4,14 +4,7 @@ * Each section is defined in its own module and assembled here. */ import { z } from "zod"; -import { - resourcesSchema, - networkSchema, - servicesSchema, - agentSchema, - toolsSchema, - mountsSchema, -} from "./base.js"; +import { resourcesSchema, networkSchema, agentSchema, toolsSchema, mountsSchema } from "./base.js"; import { providerSchema } from "./provider.js"; import { telegramSchema } from "./telegram.js"; import { bootstrapSchema } from "./bootstrap.js"; @@ -26,7 +19,6 @@ export const instanceConfigSchema = z.object({ project: z.string().min(1, "Config requires a non-empty 'project' string"), resources: resourcesSchema.optional(), network: networkSchema.optional(), - services: servicesSchema.optional(), tools: toolsSchema.optional(), capabilities: capabilitiesSchema, mounts: mountsSchema.optional(), @@ -36,7 +28,7 @@ export const instanceConfigSchema = z.object({ telegram: telegramSchema.optional(), }); -export { resourcesSchema, networkSchema, servicesSchema, agentSchema, toolsSchema, mountsSchema }; +export { resourcesSchema, networkSchema, agentSchema, toolsSchema, mountsSchema }; export { providerSchema }; export { bootstrapSchema }; export { telegramSchema }; diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index ea52d35..a1cc5d0 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -39,16 +39,6 @@ export interface InstanceConfig { gatewayPort?: number; /** Gateway auth token. Injected into openclaw config if set. */ gatewayToken?: string; - tailscale?: { - authKey: string; - /** Gateway mode: "serve" (HTTPS on tailnet), "funnel" (public), "off". */ - mode?: "off" | "serve" | "funnel"; - }; - }; - - /** External service integrations. */ - services?: { - onePassword?: { serviceAccountToken: string }; }; /** Additional tools to install (future, deprecated — use capabilities). */ diff --git a/packages/vm-cli/src/capabilities/registry.test.ts b/packages/vm-cli/src/capabilities/registry.test.ts index 57f8c7e..48a2e71 100644 --- a/packages/vm-cli/src/capabilities/registry.test.ts +++ b/packages/vm-cli/src/capabilities/registry.test.ts @@ -48,15 +48,10 @@ describe("registry", () => { expect(isEnabled(ts, makeConfig({ tailscale: true }))).toBe(true); }); - it("one-password enabled via backwards-compat flag", () => { + it("one-password enabled via capabilities map", () => { const op = ALL_CAPABILITIES.find((c) => c.name === "one-password")!; - expect(isEnabled(op, { capabilities: {}, onePassword: true })).toBe(true); - expect(isEnabled(op, { capabilities: {}, onePassword: false })).toBe(false); - }); - - it("tailscale enabled via backwards-compat flag", () => { - const ts = ALL_CAPABILITIES.find((c) => c.name === "tailscale")!; - expect(isEnabled(ts, { capabilities: {}, tailscale: true })).toBe(true); + expect(isEnabled(op, makeConfig({ "one-password": true }))).toBe(true); + expect(isEnabled(op, makeConfig())).toBe(false); }); }); diff --git a/packages/vm-cli/src/tools/provision-config.ts b/packages/vm-cli/src/tools/provision-config.ts index d1e8777..49214c7 100644 --- a/packages/vm-cli/src/tools/provision-config.ts +++ b/packages/vm-cli/src/tools/provision-config.ts @@ -7,19 +7,11 @@ const CONFIG_PATH = join(PROJECT_MOUNT_POINT, "data", PROVISION_CONFIG_FILE); const DEFAULTS: ProvisionConfig = { capabilities: {} }; -/** Read the provision config written by the host. Translates old boolean flags. */ +/** Read the provision config written by the host. */ export async function readProvisionConfig(): Promise { try { const raw = await readFile(CONFIG_PATH, "utf-8"); const parsed = JSON.parse(raw); - - // Backwards compat: translate old boolean flags to capabilities map - if (!parsed.capabilities) { - parsed.capabilities = {}; - if (parsed.onePassword) parsed.capabilities["one-password"] = true; - if (parsed.tailscale) parsed.capabilities["tailscale"] = true; - } - return { ...DEFAULTS, ...parsed }; } catch { return DEFAULTS; diff --git a/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md b/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md new file mode 100644 index 0000000..58758ff --- /dev/null +++ b/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md @@ -0,0 +1,87 @@ +# Pluggable Capability Config + +## Status: Resolved + +## Scope + +Make capabilities fully self-describing: config schema, TUI form fields, +sidebar help, secret marking — all declared in a single `configDef` on +`CapabilityDef`. Adding a new capability should require only the capability +module and one export line. + +**In scope:** + +- Unified `CapabilityConfigDef` type with typed paths, Zod derivation +- Migrate tailscale + one-password to declare `configDef` +- Dynamic schema validation for capability config +- Dynamic TUI form rendering from `configDef` +- Config normalization (legacy bridge) +- Provisioning passthrough of full capability config +- Host-side setup hook registry + +**Out of scope:** + +- Making core config sections (provider, telegram, bootstrap) into capabilities +- Nested config objects (path infrastructure is in place, not exercised yet) + +## Plan + +1. Phase 1: Types + unified config definitions +2. Phase 2: Config validation + flow +3. Phase 3: Dynamic TUI form +4. Phase 4: Host-side setup hooks + +See plan file: `~/.claude/plans/mellow-tinkering-diffie.md` + +## Steps + +- [x] Add config definition types to `packages/types/src/capability.ts` +- [x] Add Zod schema derivation utility +- [x] Migrate tailscale capability to declare `configDef` +- [x] Migrate one-password capability to declare `configDef` +- [x] Add `ALL_CAPABILITIES` list export +- [x] Wire capability schema validation into `validateConfig` +- [x] Add config normalization (legacy bridge) +- [x] Update provisioning to pass full capability config +- [x] Update capability secret sanitization +- [x] Create `DynamicCapabilitySection` component +- [x] Refactor ConfigBuilder for dynamic capability sections +- [x] Update sidebar for dynamic capability help +- [x] Update config-review for dynamic capability rows +- [x] Create host hook registry +- [x] Refactor headless.ts to use host hooks +- [x] Dynamic HeadlessStage in provision-monitor + +## Notes + +- Used `type` aliases (not `interface`) for capability config types because + TypeScript interfaces don't satisfy the `Record` constraint + needed by the `CapabilityConfigDef` generic. +- `defineCapabilityConfig()` returns type-erased `CapabilityConfigDef` for + storage — the generic validates paths at the definition site, then erases + for `CapabilityDef[]` compatibility. +- The `configSchema?: any` field on CapabilityDef was replaced by `configDef`. + Zod schemas are now derived from field definitions, not hand-written. +- Schema derivation placed in `host-core/schema-derive.ts` (not capabilities + package) because capabilities should be pure definitions with no processing logic. +- Host-side hooks live in `host-core/capability-hooks.ts` (not on CapabilityDef) + because they need VMDriver which is a host-only dependency. + +## Outcome + +All four phases implemented: + +1. **Types**: `CapabilityConfigField`, `CapabilityConfigDef`, `ConfigPath`, + `JsonPointer`, `defineCapabilityConfig()`, `HostSetupResult` +2. **Validation**: `validateConfig` accepts optional capability schema, + `normalizeConfig` bridges legacy config paths, `sanitizeConfig` strips + capability secrets generically, `provisionVM` passes full config objects +3. **TUI**: `CapabilitySection` component renders any configDef dynamically, + ConfigBuilder uses `capValues` state for capability fields, sidebar and + config-review derive content from capability definitions +4. **Host hooks**: Registry pattern replaces hardcoded 1Password/Tailscale + blocks in headless.ts, provision-monitor derives stages dynamically + +Adding a new capability now requires: one module file (with configDef) + one +export line in capabilities/index.ts + one hook entry in capability-hooks.ts +(only if the capability needs host-side setup).