From d0f565ef2c20478d58eed34f89f66a896b1dee52 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 12:56:37 +0100 Subject: [PATCH 01/11] chore: add task for pluggable capability config Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TASK.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tasks/2026-03-17_1256_pluggable-capability-config/TASK.md 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..56ca032 --- /dev/null +++ b/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md @@ -0,0 +1,55 @@ +# Pluggable Capability Config + +## Status: In Progress + +## 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 + +- [ ] Add config definition types to `packages/types/src/capability.ts` +- [ ] Add Zod schema derivation utility +- [ ] Migrate tailscale capability to declare `configDef` +- [ ] Migrate one-password capability to declare `configDef` +- [ ] Add `ALL_CAPABILITIES` list export +- [ ] Wire capability schema validation into `validateConfig` +- [ ] Add config normalization (legacy bridge) +- [ ] Update provisioning to pass full capability config +- [ ] Update capability secret sanitization +- [ ] Create `DynamicCapabilitySection` component +- [ ] Refactor ConfigBuilder for dynamic capability sections +- [ ] Update sidebar for dynamic capability help +- [ ] Update config-review for dynamic capability rows +- [ ] Create host hook registry +- [ ] Refactor headless.ts to use host hooks +- [ ] Dynamic HeadlessStage in provision-monitor + +## Notes + +## Outcome From 043b2e3325e3c654c74fad0002d4751b5658d61e Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 13:45:05 +0100 Subject: [PATCH 02/11] feat: add unified CapabilityConfigDef with typed paths and Zod derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capabilities can now declare a `configDef` that unifies config schema, TUI form fields, sidebar help, and secret marking in one definition. A `defineCapabilityConfig()` helper ensures field paths type-check against the config interface. Zod schemas are derived from field definitions — no hand-written Zod per capability. - Add CapabilityConfigField, CapabilityConfigDef, ConfigPath, JsonPointer types - Add defineCapabilityConfig() helper for type-safe definitions - Add schema derivation utilities (deriveConfigSchema, buildCapabilitiesSchema) - Add path resolution utilities (getByPath, setByPath) for JSON Pointer support - Migrate tailscale and one-password capabilities to declare configDef - Add ALL_CAPABILITIES list export from @clawctl/capabilities Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 1 + .../src/capabilities/one-password/index.ts | 40 +++- .../src/capabilities/tailscale.ts | 59 +++++ packages/capabilities/src/index.ts | 20 ++ packages/host-core/src/schema-derive.ts | 201 ++++++++++++++++++ packages/types/src/capability.ts | 110 +++++++++- packages/types/src/index.ts | 7 + 7 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 packages/host-core/src/schema-derive.ts diff --git a/bun.lock b/bun.lock index ac2f2a1..d3acfe7 100644 --- a/bun.lock +++ b/bun.lock @@ -39,6 +39,7 @@ "dependencies": { "@clawctl/types": "workspace:*", "dedent": "^1.7.2", + "zod": "^4.3.6", }, }, "packages/cli": { diff --git a/packages/capabilities/src/capabilities/one-password/index.ts b/packages/capabilities/src/capabilities/one-password/index.ts index df90f36..280d5d8 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,6 +12,10 @@ import { secretManagementSkillContent } from "./skill.js"; const SKILLS_DIR = join(PROJECT_MOUNT_POINT, "data", "workspace", "skills"); +interface OnePasswordConfig { + serviceAccountToken: string; +} + export const onePassword: CapabilityDef = { name: "one-password", label: "1Password", @@ -20,6 +24,40 @@ export const onePassword: CapabilityDef = { dependsOn: ["homebrew"], enabled: (config) => config.capabilities?.["one-password"] !== undefined || config.onePassword === true, + 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..9f8ac84 100644 --- a/packages/capabilities/src/capabilities/tailscale.ts +++ b/packages/capabilities/src/capabilities/tailscale.ts @@ -1,7 +1,13 @@ +import { defineCapabilityConfig } from "@clawctl/types"; import type { CapabilityDef } from "@clawctl/types"; const TAILSCALE_INSTALL_URL = "https://tailscale.com/install.sh"; +interface TailscaleConfig { + authKey: string; + mode?: "off" | "serve" | "funnel"; +} + export const tailscale: CapabilityDef = { name: "tailscale", label: "Tailscale", @@ -10,6 +16,59 @@ export const tailscale: CapabilityDef = { dependsOn: ["system-base"], enabled: (config) => config.capabilities?.["tailscale"] !== undefined || config.tailscale === true, + 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/host-core/src/schema-derive.ts b/packages/host-core/src/schema-derive.ts new file mode 100644 index 0000000..cefef77 --- /dev/null +++ b/packages/host-core/src/schema-derive.ts @@ -0,0 +1,201 @@ +/** + * 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) => 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; + } + } + + 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/types/src/capability.ts b/packages/types/src/capability.ts index 853b0b0..7e63280 100644 --- a/packages/types/src/capability.ts +++ b/packages/types/src/capability.ts @@ -149,11 +149,110 @@ 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. + * Infers TConfig so field paths are validated at compile time. + */ +export function defineCapabilityConfig>( + def: CapabilityConfigDef, +): CapabilityConfigDef { + return def; +} + +// --------------------------------------------------------------------------- +// 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 +287,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. */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3aaa056..7064be2 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -16,7 +16,14 @@ export type { CapabilityDef, CapabilityState, ProvisionConfig, + ConfigFieldType, + ConfigPath, + JsonPointer, + CapabilityConfigField, + CapabilityConfigDef, + HostSetupResult, } from "./capability.js"; +export { defineCapabilityConfig } from "./capability.js"; // Schemas export { From 60ee9448812a71bf299455ff8b3f79f4ad0147ff Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 13:48:32 +0100 Subject: [PATCH 03/11] feat: wire capability config validation and normalization - validateConfig accepts optional capabilitySchema for strict capability config validation (derived from configDef field definitions) - Add normalizeConfig to bridge legacy paths (services.onePassword, network.tailscale) with the capabilities map bidirectionally - provisionVM accepts full capabilities map, passes config objects (not just true) through to provision.json - headless.ts normalizes config on entry and passes capabilities map - sanitizeConfig strips secrets from capability configs using configDef field.secret markers - Update op:// cross-validation to also check capabilities["one-password"] Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/host-core/src/config.test.ts | 2 +- packages/host-core/src/config.ts | 123 ++++++++++++++++++++++++-- packages/host-core/src/headless.ts | 11 +-- packages/host-core/src/index.ts | 17 +++- packages/host-core/src/provision.ts | 7 +- packages/types/src/config.ts | 32 +++++-- packages/types/src/index.ts | 1 + 7 files changed, 170 insertions(+), 23 deletions(-) diff --git a/packages/host-core/src/config.test.ts b/packages/host-core/src/config.test.ts index b370aee..9611e97 100644 --- a/packages/host-core/src/config.test.ts +++ b/packages/host-core/src/config.test.ts @@ -507,7 +507,7 @@ describe("validateConfig", () => { project: "/tmp", provider: { type: "anthropic", apiKey: "op://Vault/Anthropic/api-key" }, }), - ).toThrow("services.onePassword is not configured"); + ).toThrow("one-password"); // neither services.onePassword nor capabilities["one-password"] }); }); diff --git a/packages/host-core/src/config.ts b/packages/host-core/src/config.ts index ab514a8..2e82398 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, getByPath } 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 @@ -51,14 +60,109 @@ 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 { +/** + * Normalize config by bridging legacy paths and capabilities. + * + * - If `services.onePassword` exists but `capabilities["one-password"]` doesn't → create it + * - If `network.tailscale` exists but `capabilities.tailscale` doesn't → create it + * - If capabilities exist but legacy paths don't → populate legacy paths for backwards compat + */ +export function normalizeConfig(config: InstanceConfig): InstanceConfig { + const result = { ...config }; + + // Initialize capabilities map + if (!result.capabilities) { + result.capabilities = {}; + } + + // Legacy services.onePassword → capabilities["one-password"] + if (result.services?.onePassword && !result.capabilities["one-password"]) { + result.capabilities["one-password"] = { + serviceAccountToken: result.services.onePassword.serviceAccountToken, + }; + } + // Reverse: capabilities["one-password"] → services.onePassword + if (result.capabilities["one-password"] && !result.services?.onePassword) { + const capConfig = result.capabilities["one-password"]; + if (typeof capConfig === "object" && "serviceAccountToken" in capConfig) { + result.services = { + ...result.services, + onePassword: { serviceAccountToken: capConfig.serviceAccountToken as string }, + }; + } + } + + // Legacy network.tailscale → capabilities.tailscale + if (result.network?.tailscale && !result.capabilities.tailscale) { + result.capabilities.tailscale = { + authKey: result.network.tailscale.authKey, + ...(result.network.tailscale.mode && { mode: result.network.tailscale.mode }), + }; + } + // Reverse: capabilities.tailscale → network.tailscale + if (result.capabilities.tailscale && !result.network?.tailscale) { + const capConfig = result.capabilities.tailscale; + if (typeof capConfig === "object" && "authKey" in capConfig) { + const mode = + "mode" in capConfig && typeof capConfig.mode === "string" + ? (capConfig.mode as "off" | "serve" | "funnel") + : undefined; + result.network = { + ...result.network, + tailscale: { + authKey: capConfig.authKey as string, + ...(mode && { mode }), + }, + }; + } + } + + return result; +} + +/** + * Read and validate a JSON config file. + * + * @param capabilities - When provided, validates capability config sections + * against their configDef-derived Zod schemas and normalizes legacy paths. + */ +export async function loadConfig( + path: string, + capabilities?: CapabilityDef[], +): Promise { let raw: string; try { raw = await readFile(path, "utf-8"); @@ -78,5 +182,12 @@ export async function loadConfig(path: string): Promise { parsed = resolveEnvRefs(parsed as Record); } - return validateConfig(parsed); + const capabilitySchema = capabilities ? buildCapabilitiesSchema(capabilities) : undefined; + let config = validateConfig(parsed, { capabilitySchema }); + + if (capabilities) { + config = normalizeConfig(config); + } + + return config; } diff --git a/packages/host-core/src/headless.ts b/packages/host-core/src/headless.ts index 2d04ca0..cf4d0d0 100644 --- a/packages/host-core/src/headless.ts +++ b/packages/host-core/src/headless.ts @@ -1,6 +1,6 @@ import { writeFile } from "fs/promises"; import { join } from "path"; -import { loadConfig, configToVMConfig, sanitizeConfig } from "./config.js"; +import { loadConfig, configToVMConfig, sanitizeConfig, normalizeConfig } from "./config.js"; import { checkPrereqs } from "./prereqs.js"; import { provisionVM } from "./provision.js"; import { verifyProvisioning } from "./verify.js"; @@ -73,7 +73,7 @@ export async function runHeadlessFromConfig( inputConfig: InstanceConfig, callbacks?: HeadlessCallbacks, ): Promise { - let config = inputConfig; + let config = normalizeConfig(inputConfig); const cb: Required = { ...defaultCallbacks(), ...callbacks, @@ -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,8 @@ export async function runHeadlessFromConfig( extraMounts: vmConfig.extraMounts, }, undefined, - provisionFeatures, + undefined, + config.capabilities, ); cb.onStage("provision", "done", "VM provisioned"); diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index f369722..8a6c1e1 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -9,7 +9,22 @@ export type { VMDriver, VMCreateOptions, ExecResult, OnLine } from "./drivers/ty export { exec, execStream, execWithLogs, commandExists } from "./exec.js"; // Config -export { loadConfig, validateConfig, configToVMConfig, sanitizeConfig } from "./config.js"; +export { + loadConfig, + validateConfig, + configToVMConfig, + sanitizeConfig, + normalizeConfig, +} 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 { diff --git a/packages/host-core/src/provision.ts b/packages/host-core/src/provision.ts index 24f44c2..d04c216 100644 --- a/packages/host-core/src/provision.ts +++ b/packages/host-core/src/provision.ts @@ -7,6 +7,7 @@ import type { VMDriver, VMCreateOptions, OnLine } from "./drivers/types.js"; import { initGitRepo } from "./git.js"; import { clawPath } from "./claw-binary.js"; +/** @deprecated Use capabilities map directly via provisionVM's capabilities parameter. */ export interface ProvisionFeatures { onePassword: boolean; tailscale: boolean; @@ -93,6 +94,7 @@ export async function provisionVM( createOptions: VMCreateOptions = {}, clawBinaryPath: string = clawPath, features: ProvisionFeatures = { onePassword: false, tailscale: false }, + capabilities?: Record>, ): Promise { const { onPhase, onStep, onLine } = callbacks; @@ -101,9 +103,10 @@ export async function provisionVM( await mkdir(join(config.projectDir, "data", "state"), { recursive: true }); onStep?.("Created project directory"); - // Write provision config so claw knows which optional capabilities to enable + // Write provision config so claw knows which optional capabilities to enable. + // Prefer the new capabilities map; fall back to legacy ProvisionFeatures. const provisionConfig: ProvisionConfig = { - capabilities: { + capabilities: capabilities ?? { ...(features.onePassword && { "one-password": true }), ...(features.tailscale && { tailscale: true }), }, diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 35e030a..792ea9b 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 services.onePassword or 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.services?.onePassword || + (config.capabilities && "one-password" in config.capabilities); + if (!hasOp) { + throw new Error( + `Config has op:// references (${opRefs[0].path.join(".")}) but neither services.onePassword nor capabilities["one-password"] is configured`, + ); + } } return { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7064be2..0dde11c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -58,6 +58,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) From 2acac1409a3b4e35774ff6b070b183549b89f5a7 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 13:54:21 +0100 Subject: [PATCH 04/11] feat: dynamic TUI form rendering from capability configDef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConfigBuilder now renders capability sections dynamically from configDef declarations — no hardcoded Services or Tailscale sections. - Add CapabilitySection component for generic capability form rendering - Refactor ConfigBuilder: replace hardcoded Services/Tailscale with dynamic sections from ALL_CAPABILITIES.filter(c => !c.core && c.configDef) - Use capValues state (Record>) for capability config, replacing individual useState hooks - Dynamic focus list with cap: and cap:: patterns - buildConfig assembles capabilities map from capValues + normalizeConfig - Sidebar help falls through to configDef.fields[].help and sectionHelp - ConfigReview renders dynamic capability rows from configDef - defineCapabilityConfig returns type-erased CapabilityConfigDef for storage (generic validates at definition site, erased for CapabilityDef[]) Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 2 +- .../src/capabilities/one-password/index.ts | 4 +- .../src/capabilities/tailscale.ts | 4 +- packages/cli/package.json | 1 + .../cli/src/components/capability-section.tsx | 118 ++++++ packages/cli/src/components/config-review.tsx | 59 +-- packages/cli/src/components/sidebar.tsx | 45 +- packages/cli/src/steps/config-builder.tsx | 384 +++++++++--------- packages/host-core/src/config.ts | 2 +- packages/host-core/src/schema-derive.ts | 2 +- packages/types/src/capability.ts | 11 +- 11 files changed, 372 insertions(+), 260 deletions(-) create mode 100644 packages/cli/src/components/capability-section.tsx diff --git a/bun.lock b/bun.lock index d3acfe7..4c427a9 100644 --- a/bun.lock +++ b/bun.lock @@ -39,13 +39,13 @@ "dependencies": { "@clawctl/types": "workspace:*", "dedent": "^1.7.2", - "zod": "^4.3.6", }, }, "packages/cli": { "name": "@clawctl/cli", "version": "0.7.0", "dependencies": { + "@clawctl/capabilities": "workspace:*", "@clawctl/daemon": "workspace:*", "@clawctl/host-core": "workspace:*", "@clawctl/templates": "workspace:*", diff --git a/packages/capabilities/src/capabilities/one-password/index.ts b/packages/capabilities/src/capabilities/one-password/index.ts index 280d5d8..0ccd462 100644 --- a/packages/capabilities/src/capabilities/one-password/index.ts +++ b/packages/capabilities/src/capabilities/one-password/index.ts @@ -12,9 +12,9 @@ import { secretManagementSkillContent } from "./skill.js"; const SKILLS_DIR = join(PROJECT_MOUNT_POINT, "data", "workspace", "skills"); -interface OnePasswordConfig { +type OnePasswordConfig = { serviceAccountToken: string; -} +}; export const onePassword: CapabilityDef = { name: "one-password", diff --git a/packages/capabilities/src/capabilities/tailscale.ts b/packages/capabilities/src/capabilities/tailscale.ts index 9f8ac84..6421e5c 100644 --- a/packages/capabilities/src/capabilities/tailscale.ts +++ b/packages/capabilities/src/capabilities/tailscale.ts @@ -3,10 +3,10 @@ import type { CapabilityDef } from "@clawctl/types"; const TAILSCALE_INSTALL_URL = "https://tailscale.com/install.sh"; -interface TailscaleConfig { +type TailscaleConfig = { authKey: string; mode?: "off" | "serve" | "funnel"; -} +}; export const tailscale: CapabilityDef = { name: "tailscale", 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/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..9f3f14d 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,31 @@ 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,11 +158,14 @@ 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 + + ), )} {" "} 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..93d750f 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,21 @@ import { DEFAULT_PROJECT_BASE, } from "@clawctl/types"; import type { InstanceConfig } from "@clawctl/types"; +import { ALL_CAPABILITIES } from "@clawctl/capabilities"; +import { normalizeConfig } from "@clawctl/host-core"; 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[] = [ - "resources", - "provider", - "services", - "network", - "bootstrap", - "telegram", -]; - -const SECTION_CHILDREN: Record = { +// --------------------------------------------------------------------------- +// 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", "network", "bootstrap", "telegram"]; + +const CORE_SECTION_CHILDREN: Record = { resources: ["resources.cpus", "resources.memory", "resources.disk"], provider: [ "provider.type", @@ -69,8 +38,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 +48,35 @@ 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: core sections + capability section IDs. */ +function allSectionIds(): string[] { + return [ + ...CORE_SECTIONS, + ...CONFIGURABLE_CAPABILITIES.map((c) => `cap:${c.name}`), + ]; +} + +/** 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 +85,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 +94,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 +108,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 +121,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 +170,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,7 +196,23 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { }; } - return config; + // 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; + } + + // Normalize: bridge capabilities ↔ legacy paths + return normalizeConfig(config); }; // Validate the assembled config @@ -257,22 +255,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"]; + }; - // Section status helpers - const sectionStatus = (id: SectionId): "unconfigured" | "configured" | "error" => { + const sidebarContent = getSidebarContent(); + + // 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 +303,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 +318,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 +330,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 +403,31 @@ 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 +456,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 +529,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Resources */} @@ -529,8 +579,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Provider */} @@ -628,37 +678,11 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { )} - {/* Services */} - - {currentFocus === "services.opToken" && editing ? ( - - - 1P Token - - - - ) : ( - - )} - - - {/* Network */} + {/* Network (gateway port only) */} @@ -677,50 +701,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 +813,8 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { {/* Telegram */} diff --git a/packages/host-core/src/config.ts b/packages/host-core/src/config.ts index 2e82398..fb4087a 100644 --- a/packages/host-core/src/config.ts +++ b/packages/host-core/src/config.ts @@ -1,7 +1,7 @@ import { readFile } from "fs/promises"; import { resolveEnvRefs, validateConfig } from "@clawctl/types"; import type { InstanceConfig, VMConfig, CapabilityDef } from "@clawctl/types"; -import { buildCapabilitiesSchema, getSecretPaths, getByPath } from "./schema-derive.js"; +import { buildCapabilitiesSchema, getSecretPaths } from "./schema-derive.js"; // Re-export validateConfig from types for convenience export { validateConfig } from "@clawctl/types"; diff --git a/packages/host-core/src/schema-derive.ts b/packages/host-core/src/schema-derive.ts index cefef77..ec193bd 100644 --- a/packages/host-core/src/schema-derive.ts +++ b/packages/host-core/src/schema-derive.ts @@ -8,7 +8,7 @@ */ import { z } from "zod"; -import type { CapabilityConfigDef, CapabilityConfigField, CapabilityDef } from "@clawctl/types"; +import type { CapabilityConfigDef, CapabilityDef } from "@clawctl/types"; // --------------------------------------------------------------------------- // Path resolution utilities diff --git a/packages/types/src/capability.ts b/packages/types/src/capability.ts index 7e63280..d83f6f3 100644 --- a/packages/types/src/capability.ts +++ b/packages/types/src/capability.ts @@ -225,12 +225,17 @@ export interface CapabilityConfigDef< /** * Type-safe helper for defining a capability config. - * Infers TConfig so field paths are validated at compile time. + * + * 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; +): CapabilityConfigDef { + return def as CapabilityConfigDef; } // --------------------------------------------------------------------------- From 848162ef8f6042859f05de5f03907a5dfcbd5203 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 13:57:28 +0100 Subject: [PATCH 05/11] feat: data-driven host-side capability setup hooks Replace hardcoded 1Password and Tailscale setup blocks in headless.ts with a hook registry pattern. The headless pipeline iterates over enabled capabilities and runs their registered host hooks. - Add HostCapabilityHook interface and registry in capability-hooks.ts - Wrap setupOnePassword and setupTailscale as registered hooks - getHostHooksForConfig derives active hooks from config - headless.ts loops over hooks instead of hardcoded if-blocks - ProvisionMonitor derives stage labels dynamically from host hooks - HeadlessStage usage relaxed to string for dynamic stage names Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/components/provision-monitor.tsx | 45 +++++--- packages/host-core/src/capability-hooks.ts | 104 ++++++++++++++++++ packages/host-core/src/headless.ts | 44 +++----- packages/host-core/src/index.ts | 4 + packages/host-core/src/schema-derive.ts | 6 +- 5 files changed, 153 insertions(+), 50 deletions(-) create mode 100644 packages/host-core/src/capability-hooks.ts diff --git a/packages/cli/src/components/provision-monitor.tsx b/packages/cli/src/components/provision-monitor.tsx index 6023e6a..a28bc67 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,11 +37,20 @@ 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); }); @@ -51,10 +58,10 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis 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 }); + next.set(stage, { label: stageLabels[stage] ?? stage, status, detail }); return next; }); - }, []); + }, [stageLabels]); const onStep = useCallback((label: string) => { setSteps((prev) => [...prev, label]); @@ -79,15 +86,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 +126,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/host-core/src/capability-hooks.ts b/packages/host-core/src/capability-hooks.ts new file mode 100644 index 0000000..4bf7b07 --- /dev/null +++ b/packages/host-core/src/capability-hooks.ts @@ -0,0 +1,104 @@ +/** + * 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 ?? {}; + // Also check legacy paths + const enabledNames = new Set(Object.keys(caps)); + if (config.services?.onePassword) enabledNames.add("one-password"); + if (config.network?.tailscale) enabledNames.add("tailscale"); + + return HOST_HOOKS.filter((hook) => enabledNames.has(hook.capabilityName)); +} + +/** + * Get the capability-specific config for a host hook from the InstanceConfig. + * Handles both new capabilities map and legacy config paths. + */ +export function getCapabilityConfig( + config: InstanceConfig, + capabilityName: string, +): Record { + // New path: capabilities map + const capConfig = config.capabilities?.[capabilityName]; + if (typeof capConfig === "object") return capConfig; + + // Legacy paths + if (capabilityName === "one-password" && config.services?.onePassword) { + return config.services.onePassword as unknown as Record; + } + if (capabilityName === "tailscale" && config.network?.tailscale) { + return config.network.tailscale as unknown as Record; + } + + return {}; +} diff --git a/packages/host-core/src/headless.ts b/packages/host-core/src/headless.ts index cf4d0d0..e582081 100644 --- a/packages/host-core/src/headless.ts +++ b/packages/host-core/src/headless.ts @@ -4,12 +4,12 @@ import { loadConfig, configToVMConfig, sanitizeConfig, normalizeConfig } from ". 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"; @@ -154,21 +154,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( + // 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, - config.services.onePassword.serviceAccountToken, - (line: string) => cb.onLine("1password", line), + (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}`); } } @@ -204,24 +206,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 8a6c1e1..cd62ee2 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -124,3 +124,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/schema-derive.ts b/packages/host-core/src/schema-derive.ts index ec193bd..3069323 100644 --- a/packages/host-core/src/schema-derive.ts +++ b/packages/host-core/src/schema-derive.ts @@ -8,7 +8,7 @@ */ import { z } from "zod"; -import type { CapabilityConfigDef, CapabilityDef } from "@clawctl/types"; +import type { CapabilityConfigDef, CapabilityConfigField, CapabilityDef } from "@clawctl/types"; // --------------------------------------------------------------------------- // Path resolution utilities @@ -72,7 +72,7 @@ function deriveFieldSchema(field: CapabilityConfigField): z.ZodTypeAny { break; } case "select": { - const values = (field.options ?? []).map((o) => o.value); + 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) { @@ -82,6 +82,8 @@ function deriveFieldSchema(field: CapabilityConfigField): z.ZodTypeAny { } break; } + default: + schema = z.string(); } if (field.defaultValue != null) { From 9b93bb541a0391b3b5783d9a24976f31422d97c4 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 13:57:59 +0100 Subject: [PATCH 06/11] style: auto-format with prettier Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/components/config-review.tsx | 29 +++++++++---------- .../cli/src/components/provision-monitor.tsx | 17 ++++++----- packages/cli/src/steps/config-builder.tsx | 17 ++++++----- packages/host-core/src/config.test.ts | 2 +- packages/host-core/src/headless.ts | 12 ++++---- packages/types/src/capability.ts | 15 +++++----- packages/types/src/config.ts | 4 ++- .../TASK.md | 2 ++ 8 files changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/components/config-review.tsx b/packages/cli/src/components/config-review.tsx index 9f3f14d..f89f14c 100644 --- a/packages/cli/src/components/config-review.tsx +++ b/packages/cli/src/components/config-review.tsx @@ -91,13 +91,12 @@ export function ConfigReview({ config, validationErrors, validationWarnings }: C {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; + const summary = + isConfigured && cap.configDef?.summary + ? cap.configDef.summary( + typeof capConfig === "object" ? (capConfig as Record) : {}, + ) + : null; return ( {isConfigured ? ( @@ -159,14 +158,14 @@ export function ConfigReview({ config, validationErrors, validationWarnings }: C ))} {/* 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 - - ), - )} + {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 a28bc67..e7f276f 100644 --- a/packages/cli/src/components/provision-monitor.tsx +++ b/packages/cli/src/components/provision-monitor.tsx @@ -55,13 +55,16 @@ export function ProvisionMonitor({ driver, config, onComplete, onError }: Provis 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: stageLabels[stage] ?? stage, status, detail }); - return next; - }); - }, [stageLabels]); + 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]); diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index 93d750f..19e0007 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -27,7 +27,13 @@ type Phase = "form" | "review"; /** Core sections that are hardcoded in the wizard. */ type CoreSectionId = "resources" | "provider" | "network" | "bootstrap" | "telegram"; -const CORE_SECTIONS: CoreSectionId[] = ["resources", "provider", "network", "bootstrap", "telegram"]; +const CORE_SECTIONS: CoreSectionId[] = [ + "resources", + "provider", + "network", + "bootstrap", + "telegram", +]; const CORE_SECTION_CHILDREN: Record = { resources: ["resources.cpus", "resources.memory", "resources.disk"], @@ -53,10 +59,7 @@ const CONFIGURABLE_CAPABILITIES = ALL_CAPABILITIES.filter((c) => !c.core && c.co /** All section IDs: core sections + capability section IDs. */ function allSectionIds(): string[] { - return [ - ...CORE_SECTIONS, - ...CONFIGURABLE_CAPABILITIES.map((c) => `cap:${c.name}`), - ]; + return [...CORE_SECTIONS, ...CONFIGURABLE_CAPABILITIES.map((c) => `cap:${c.name}`)]; } /** Children focus IDs for a section (core or capability). */ @@ -423,9 +426,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { const parent = findParentSection(currentFocus); if (parent) { toggleSection(parent); - const newList = buildFocusList( - new Set([...expanded].filter((s) => s !== parent)), - ); + const newList = buildFocusList(new Set([...expanded].filter((s) => s !== parent))); const sectionIdx = newList.indexOf(parent); if (sectionIdx >= 0) setFocusIdx(sectionIdx); } diff --git a/packages/host-core/src/config.test.ts b/packages/host-core/src/config.test.ts index 9611e97..2a88e20 100644 --- a/packages/host-core/src/config.test.ts +++ b/packages/host-core/src/config.test.ts @@ -507,7 +507,7 @@ describe("validateConfig", () => { project: "/tmp", provider: { type: "anthropic", apiKey: "op://Vault/Anthropic/api-key" }, }), - ).toThrow("one-password"); // neither services.onePassword nor capabilities["one-password"] + ).toThrow("one-password"); // neither services.onePassword nor capabilities["one-password"] }); }); diff --git a/packages/host-core/src/headless.ts b/packages/host-core/src/headless.ts index e582081..12e0b0a 100644 --- a/packages/host-core/src/headless.ts +++ b/packages/host-core/src/headless.ts @@ -159,17 +159,17 @@ export async function runHeadlessFromConfig( 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), + const hookResult = await hook.run(capConfig, driver, vmConfig.vmName, (line: string) => + cb.onLine(hook.stageName, line), ); if (hookResult.success) { cb.onStage(hook.stageName as HeadlessStage, "done", hookResult.detail); } else { cb.onStage(hook.stageName as HeadlessStage, "error", hookResult.error); - cb.onError(hook.stageName as HeadlessStage, hookResult.error ?? `${hook.stageLabel} failed`); + cb.onError( + hook.stageName as HeadlessStage, + hookResult.error ?? `${hook.stageLabel} failed`, + ); throw new Error(`${hook.stageLabel} failed: ${hookResult.error}`); } } diff --git a/packages/types/src/capability.ts b/packages/types/src/capability.ts index d83f6f3..e41389d 100644 --- a/packages/types/src/capability.ts +++ b/packages/types/src/capability.ts @@ -160,13 +160,14 @@ 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; +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. diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 792ea9b..f63372d 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -46,7 +46,9 @@ export function validateConfig(raw: unknown, opts?: ValidateConfigOptions): Inst 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}`); + throw new Error( + issue.message.startsWith("'") ? issue.message : `'${path}': ${issue.message}`, + ); } } diff --git a/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md b/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md index 56ca032..d13a504 100644 --- a/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md +++ b/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md @@ -10,6 +10,7 @@ sidebar help, secret marking — all declared in a single `configDef` on 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 @@ -19,6 +20,7 @@ module and one export line. - 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) From 1b01803c8f4d457f840f068af9b407bb7f14e88a Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 13:58:30 +0100 Subject: [PATCH 07/11] docs: mark pluggable capability config task as resolved Co-Authored-By: Claude Opus 4.6 (1M context) --- .../TASK.md | 64 ++++++++++++++----- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md b/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md index d13a504..58758ff 100644 --- a/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md +++ b/tasks/2026-03-17_1256_pluggable-capability-config/TASK.md @@ -1,6 +1,6 @@ # Pluggable Capability Config -## Status: In Progress +## Status: Resolved ## Scope @@ -35,23 +35,53 @@ See plan file: `~/.claude/plans/mellow-tinkering-diffie.md` ## Steps -- [ ] Add config definition types to `packages/types/src/capability.ts` -- [ ] Add Zod schema derivation utility -- [ ] Migrate tailscale capability to declare `configDef` -- [ ] Migrate one-password capability to declare `configDef` -- [ ] Add `ALL_CAPABILITIES` list export -- [ ] Wire capability schema validation into `validateConfig` -- [ ] Add config normalization (legacy bridge) -- [ ] Update provisioning to pass full capability config -- [ ] Update capability secret sanitization -- [ ] Create `DynamicCapabilitySection` component -- [ ] Refactor ConfigBuilder for dynamic capability sections -- [ ] Update sidebar for dynamic capability help -- [ ] Update config-review for dynamic capability rows -- [ ] Create host hook registry -- [ ] Refactor headless.ts to use host hooks -- [ ] Dynamic HeadlessStage in provision-monitor +- [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). From e5e03c944ad4bd4ed77ecac34a86fdce545c2b21 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 14:03:32 +0100 Subject: [PATCH 08/11] fix: match focus list order to visual render order in ConfigBuilder Capability sections are rendered between Network and Agent Identity in the JSX, but the focus list had them appended after all core sections. This caused arrow-down navigation to jump out of sequence. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/steps/config-builder.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index 19e0007..24e224f 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -57,9 +57,19 @@ const CORE_SECTION_CHILDREN: Record = { /** Non-core capabilities that have a configDef (rendered dynamically). */ const CONFIGURABLE_CAPABILITIES = ALL_CAPABILITIES.filter((c) => !c.core && c.configDef); -/** All section IDs: core sections + capability section IDs. */ +/** + * All section IDs in visual render order. + * Capability sections appear after network, before bootstrap/telegram. + */ function allSectionIds(): string[] { - return [...CORE_SECTIONS, ...CONFIGURABLE_CAPABILITIES.map((c) => `cap:${c.name}`)]; + return [ + "resources", + "provider", + "network", + ...CONFIGURABLE_CAPABILITIES.map((c) => `cap:${c.name}`), + "bootstrap", + "telegram", + ]; } /** Children focus IDs for a section (core or capability). */ From dc3ef08ae7ddc281fe92eddf654e1035955cad1d Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 14:19:03 +0100 Subject: [PATCH 09/11] fix: exit cleanly on ctrl-c before provisioning starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user presses Ctrl-C during the config form (before provisioning), provisionConfig is null so neither the success nor cleanup path ran — the process hung because Ink's tty stream kept the event loop alive. Now explicitly process.exit(130) in that case. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/create.ts | 3 +++ 1 file changed, 3 insertions(+) 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); } } From 492dd5190f26e600155847c362351c495a3c38b8 Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 14:46:24 +0100 Subject: [PATCH 10/11] refactor: remove legacy config paths (services, network.tailscale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No backwards compatibility needed — capabilities map is the single source of truth for optional feature config. Removed: - services.onePassword from InstanceConfig type and servicesSchema - network.tailscale from InstanceConfig type and networkSchema - ProvisionConfig.onePassword and .tailscale deprecated fields - ProvisionFeatures interface - normalizeConfig function and all call sites - Legacy fallbacks in capability enabled() functions - Legacy path handling in getHostHooksForConfig/getCapabilityConfig - Backwards-compat boolean translation in VM provision-config reader Updated: - bootstrap.ts reads tailscale mode from capabilities.tailscale - All tests updated to use capabilities map format - Docs (config-reference, 1password-setup, headless-mode) updated with new capabilities-based examples Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/1password-setup.md | 10 +- docs/config-reference.md | 42 +++--- docs/headless-mode.md | 6 +- .../src/capabilities/one-password/index.ts | 3 +- .../src/capabilities/tailscale.ts | 3 +- packages/cli/src/steps/config-builder.tsx | 4 +- packages/host-core/src/bootstrap.ts | 6 +- packages/host-core/src/capability-hooks.ts | 18 +-- packages/host-core/src/config.test.ts | 125 ++++-------------- packages/host-core/src/config.ts | 86 +----------- packages/host-core/src/headless.ts | 5 +- packages/host-core/src/index.ts | 10 +- packages/host-core/src/provision.ts | 19 +-- packages/host-core/src/secrets-sync.test.ts | 4 +- packages/host-core/src/secrets.test.ts | 21 +-- packages/types/src/capability.ts | 13 +- packages/types/src/config.ts | 8 +- packages/types/src/index.ts | 1 - packages/types/src/schemas/base.ts | 18 +-- packages/types/src/schemas/index.ts | 12 +- packages/types/src/types.ts | 10 -- .../vm-cli/src/capabilities/registry.test.ts | 11 +- packages/vm-cli/src/tools/provision-config.ts | 10 +- 23 files changed, 103 insertions(+), 342 deletions(-) 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/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 0ccd462..467e52c 100644 --- a/packages/capabilities/src/capabilities/one-password/index.ts +++ b/packages/capabilities/src/capabilities/one-password/index.ts @@ -22,8 +22,7 @@ export const onePassword: CapabilityDef = { 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: { diff --git a/packages/capabilities/src/capabilities/tailscale.ts b/packages/capabilities/src/capabilities/tailscale.ts index 6421e5c..98c8b93 100644 --- a/packages/capabilities/src/capabilities/tailscale.ts +++ b/packages/capabilities/src/capabilities/tailscale.ts @@ -14,8 +14,7 @@ export const tailscale: CapabilityDef = { 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: { diff --git a/packages/cli/src/steps/config-builder.tsx b/packages/cli/src/steps/config-builder.tsx index 24e224f..bde70bb 100644 --- a/packages/cli/src/steps/config-builder.tsx +++ b/packages/cli/src/steps/config-builder.tsx @@ -16,7 +16,6 @@ import { } from "@clawctl/types"; import type { InstanceConfig } from "@clawctl/types"; import { ALL_CAPABILITIES } from "@clawctl/capabilities"; -import { normalizeConfig } from "@clawctl/host-core"; type Phase = "form" | "review"; @@ -224,8 +223,7 @@ export function ConfigBuilder({ onComplete, onSaveOnly }: ConfigBuilderProps) { config.capabilities[cap.name] = capConfig; } - // Normalize: bridge capabilities ↔ legacy paths - return normalizeConfig(config); + return config; }; // Validate the assembled config 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 index 4bf7b07..7168182 100644 --- a/packages/host-core/src/capability-hooks.ts +++ b/packages/host-core/src/capability-hooks.ts @@ -72,33 +72,17 @@ const HOST_HOOKS: HostCapabilityHook[] = [ */ export function getHostHooksForConfig(config: InstanceConfig): HostCapabilityHook[] { const caps = config.capabilities ?? {}; - // Also check legacy paths - const enabledNames = new Set(Object.keys(caps)); - if (config.services?.onePassword) enabledNames.add("one-password"); - if (config.network?.tailscale) enabledNames.add("tailscale"); - - return HOST_HOOKS.filter((hook) => enabledNames.has(hook.capabilityName)); + return HOST_HOOKS.filter((hook) => hook.capabilityName in caps); } /** * Get the capability-specific config for a host hook from the InstanceConfig. - * Handles both new capabilities map and legacy config paths. */ export function getCapabilityConfig( config: InstanceConfig, capabilityName: string, ): Record { - // New path: capabilities map const capConfig = config.capabilities?.[capabilityName]; if (typeof capConfig === "object") return capConfig; - - // Legacy paths - if (capabilityName === "one-password" && config.services?.onePassword) { - return config.services.onePassword as unknown as Record; - } - if (capabilityName === "tailscale" && config.network?.tailscale) { - return config.network.tailscale as unknown as Record; - } - return {}; } diff --git a/packages/host-core/src/config.test.ts b/packages/host-core/src/config.test.ts index 2a88e20..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("one-password"); // neither services.onePassword nor capabilities["one-password"] + ).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 fb4087a..c429392 100644 --- a/packages/host-core/src/config.ts +++ b/packages/host-core/src/config.ts @@ -38,21 +38,9 @@ export function sanitizeConfig( 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 @@ -93,71 +81,11 @@ export function sanitizeConfig( return clone; } -/** - * Normalize config by bridging legacy paths and capabilities. - * - * - If `services.onePassword` exists but `capabilities["one-password"]` doesn't → create it - * - If `network.tailscale` exists but `capabilities.tailscale` doesn't → create it - * - If capabilities exist but legacy paths don't → populate legacy paths for backwards compat - */ -export function normalizeConfig(config: InstanceConfig): InstanceConfig { - const result = { ...config }; - - // Initialize capabilities map - if (!result.capabilities) { - result.capabilities = {}; - } - - // Legacy services.onePassword → capabilities["one-password"] - if (result.services?.onePassword && !result.capabilities["one-password"]) { - result.capabilities["one-password"] = { - serviceAccountToken: result.services.onePassword.serviceAccountToken, - }; - } - // Reverse: capabilities["one-password"] → services.onePassword - if (result.capabilities["one-password"] && !result.services?.onePassword) { - const capConfig = result.capabilities["one-password"]; - if (typeof capConfig === "object" && "serviceAccountToken" in capConfig) { - result.services = { - ...result.services, - onePassword: { serviceAccountToken: capConfig.serviceAccountToken as string }, - }; - } - } - - // Legacy network.tailscale → capabilities.tailscale - if (result.network?.tailscale && !result.capabilities.tailscale) { - result.capabilities.tailscale = { - authKey: result.network.tailscale.authKey, - ...(result.network.tailscale.mode && { mode: result.network.tailscale.mode }), - }; - } - // Reverse: capabilities.tailscale → network.tailscale - if (result.capabilities.tailscale && !result.network?.tailscale) { - const capConfig = result.capabilities.tailscale; - if (typeof capConfig === "object" && "authKey" in capConfig) { - const mode = - "mode" in capConfig && typeof capConfig.mode === "string" - ? (capConfig.mode as "off" | "serve" | "funnel") - : undefined; - result.network = { - ...result.network, - tailscale: { - authKey: capConfig.authKey as string, - ...(mode && { mode }), - }, - }; - } - } - - return result; -} - /** * Read and validate a JSON config file. * * @param capabilities - When provided, validates capability config sections - * against their configDef-derived Zod schemas and normalizes legacy paths. + * against their configDef-derived Zod schemas. */ export async function loadConfig( path: string, @@ -183,11 +111,5 @@ export async function loadConfig( } const capabilitySchema = capabilities ? buildCapabilitiesSchema(capabilities) : undefined; - let config = validateConfig(parsed, { capabilitySchema }); - - if (capabilities) { - config = normalizeConfig(config); - } - - return config; + return validateConfig(parsed, { capabilitySchema }); } diff --git a/packages/host-core/src/headless.ts b/packages/host-core/src/headless.ts index 12e0b0a..0df295a 100644 --- a/packages/host-core/src/headless.ts +++ b/packages/host-core/src/headless.ts @@ -1,6 +1,6 @@ import { writeFile } from "fs/promises"; import { join } from "path"; -import { loadConfig, configToVMConfig, sanitizeConfig, normalizeConfig } from "./config.js"; +import { loadConfig, configToVMConfig, sanitizeConfig } from "./config.js"; import { checkPrereqs } from "./prereqs.js"; import { provisionVM } from "./provision.js"; import { verifyProvisioning } from "./verify.js"; @@ -73,7 +73,7 @@ export async function runHeadlessFromConfig( inputConfig: InstanceConfig, callbacks?: HeadlessCallbacks, ): Promise { - let config = normalizeConfig(inputConfig); + let config = inputConfig; const cb: Required = { ...defaultCallbacks(), ...callbacks, @@ -123,7 +123,6 @@ export async function runHeadlessFromConfig( extraMounts: vmConfig.extraMounts, }, undefined, - undefined, config.capabilities, ); cb.onStage("provision", "done", "VM provisioned"); diff --git a/packages/host-core/src/index.ts b/packages/host-core/src/index.ts index cd62ee2..f67b420 100644 --- a/packages/host-core/src/index.ts +++ b/packages/host-core/src/index.ts @@ -9,13 +9,7 @@ export type { VMDriver, VMCreateOptions, ExecResult, OnLine } from "./drivers/ty export { exec, execStream, execWithLogs, commandExists } from "./exec.js"; // Config -export { - loadConfig, - validateConfig, - configToVMConfig, - sanitizeConfig, - normalizeConfig, -} from "./config.js"; +export { loadConfig, validateConfig, configToVMConfig, sanitizeConfig } from "./config.js"; // Schema derivation export { @@ -39,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"; diff --git a/packages/host-core/src/provision.ts b/packages/host-core/src/provision.ts index d04c216..7203800 100644 --- a/packages/host-core/src/provision.ts +++ b/packages/host-core/src/provision.ts @@ -7,12 +7,6 @@ import type { VMDriver, VMCreateOptions, OnLine } from "./drivers/types.js"; import { initGitRepo } from "./git.js"; import { clawPath } from "./claw-binary.js"; -/** @deprecated Use capabilities map directly via provisionVM's capabilities parameter. */ -export interface ProvisionFeatures { - onePassword: boolean; - tailscale: boolean; -} - export interface ProvisionCallbacks { onPhase?: (phase: string) => void; onStep?: (step: string) => void; @@ -93,8 +87,7 @@ export async function provisionVM( callbacks: ProvisionCallbacks = {}, createOptions: VMCreateOptions = {}, clawBinaryPath: string = clawPath, - features: ProvisionFeatures = { onePassword: false, tailscale: false }, - capabilities?: Record>, + capabilities: Record> = {}, ): Promise { const { onPhase, onStep, onLine } = callbacks; @@ -103,14 +96,8 @@ export async function provisionVM( await mkdir(join(config.projectDir, "data", "state"), { recursive: true }); onStep?.("Created project directory"); - // Write provision config so claw knows which optional capabilities to enable. - // Prefer the new capabilities map; fall back to legacy ProvisionFeatures. - const provisionConfig: ProvisionConfig = { - capabilities: capabilities ?? { - ...(features.onePassword && { "one-password": true }), - ...(features.tailscale && { tailscale: true }), - }, - }; + // Write provision config so claw knows which optional capabilities to enable + 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/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 e41389d..2300cb6 100644 --- a/packages/types/src/capability.ts +++ b/packages/types/src/capability.ts @@ -306,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 f63372d..54b147e 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -52,15 +52,13 @@ export function validateConfig(raw: unknown, opts?: ValidateConfigOptions): Inst } } - // Cross-validate: op:// references require services.onePassword or capabilities["one-password"] + // Cross-validate: op:// references require capabilities["one-password"] const opRefs = findSecretRefs(raw as Record).filter((r) => r.scheme === "op"); if (opRefs.length > 0) { - const hasOp = - config.services?.onePassword || - (config.capabilities && "one-password" in config.capabilities); + const hasOp = config.capabilities && "one-password" in config.capabilities; if (!hasOp) { throw new Error( - `Config has op:// references (${opRefs[0].path.join(".")}) but neither services.onePassword nor capabilities["one-password"] is configured`, + `Config has op:// references (${opRefs[0].path.join(".")}) but capabilities["one-password"] is not configured`, ); } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0dde11c..167d983 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -30,7 +30,6 @@ export { instanceConfigSchema, resourcesSchema, networkSchema, - servicesSchema, agentSchema, toolsSchema, capabilitiesSchema, 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; From 81cce7f4507d613deec7606a425054a32765554b Mon Sep 17 00:00:00 2001 From: Tim Beyer Date: Tue, 17 Mar 2026 15:39:58 +0100 Subject: [PATCH 11/11] docs: update capabilities.md with configDef pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old configSchema example with the new unified configDef approach: defineCapabilityConfig(), typed field paths, TUI form definition, and secret marking — all in one declaration. Update registration instructions: capabilities only need an export in index.ts + optional host hook entry. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/capabilities.md | 73 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 13 deletions(-) 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