From 4ccb018b03668e8b404824bd7cfa44d1ba6def0f Mon Sep 17 00:00:00 2001 From: Titus Date: Thu, 26 Feb 2026 23:40:46 +0000 Subject: [PATCH 1/5] feat: add hooks field to PluginManifestSchema (AGE-204) - Add PluginHooksSchema with resolve, postProvision, preStart hooks - Add validation: resolve hook keys must match autoResolvable secrets - Add resolve hook for linearUserUuid in openclaw-linear manifest - Add comprehensive tests for hooks validation - Export PluginHooksSchema from barrel --- .../src/__tests__/plugin-manifest.test.ts | 191 ++++++++++++++++++ packages/core/src/plugin-registry.ts | 5 + packages/core/src/schemas/index.ts | 1 + packages/core/src/schemas/plugin-manifest.ts | 34 ++++ 4 files changed, 231 insertions(+) diff --git a/packages/core/src/__tests__/plugin-manifest.test.ts b/packages/core/src/__tests__/plugin-manifest.test.ts index 92c1eeea..073ddb4b 100644 --- a/packages/core/src/__tests__/plugin-manifest.test.ts +++ b/packages/core/src/__tests__/plugin-manifest.test.ts @@ -220,3 +220,194 @@ describe("PluginManifestSchema", () => { }); }); }); + +// --------------------------------------------------------------------------- +// PluginHooksSchema and hooks validation +// --------------------------------------------------------------------------- + +describe("PluginManifestSchema hooks field", () => { + it("accepts manifest with resolve hook matching autoResolvable secret", () => { + const result = PluginManifestSchema.safeParse({ + name: "test-hooks", + displayName: "Test Hooks", + installable: true, + configPath: "plugins.entries", + secrets: { + mySecret: { + envVar: "MY_SECRET", + scope: "agent", + isSecret: false, + autoResolvable: true, + }, + }, + hooks: { + resolve: { + mySecret: "echo test-value", + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("accepts manifest without hooks (backward compatibility)", () => { + const result = PluginManifestSchema.safeParse({ + name: "test-no-hooks", + displayName: "Test No Hooks", + installable: true, + configPath: "plugins.entries", + secrets: {}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hooks).toBeUndefined(); + } + }); + + it("rejects resolve hook key referencing nonexistent secret", () => { + const result = PluginManifestSchema.safeParse({ + name: "test-bad-resolve", + displayName: "Test Bad Resolve", + installable: true, + configPath: "plugins.entries", + secrets: { + existingSecret: { + envVar: "EXISTING", + scope: "agent", + isSecret: false, + autoResolvable: true, + }, + }, + hooks: { + resolve: { + nonExistentSecret: "echo value", + }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes("does not correspond to any secret") + ); + expect(issue).toBeDefined(); + expect(issue?.message).toContain("nonExistentSecret"); + } + }); + + it("rejects resolve hook for secret that is not autoResolvable", () => { + const result = PluginManifestSchema.safeParse({ + name: "test-not-auto", + displayName: "Test Not Auto", + installable: true, + configPath: "plugins.entries", + secrets: { + manualSecret: { + envVar: "MANUAL_SECRET", + scope: "agent", + isSecret: true, + autoResolvable: false, + }, + }, + hooks: { + resolve: { + manualSecret: "echo value", + }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes("not autoResolvable") + ); + expect(issue).toBeDefined(); + expect(issue?.message).toContain("manualSecret"); + } + }); + + it("rejects empty resolve hook script", () => { + const result = PluginManifestSchema.safeParse({ + name: "test-empty-script", + displayName: "Test Empty Script", + installable: true, + configPath: "plugins.entries", + secrets: { + mySecret: { + envVar: "MY_SECRET", + scope: "agent", + isSecret: false, + autoResolvable: true, + }, + }, + hooks: { + resolve: { + mySecret: "", + }, + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes("cannot be empty") + ); + expect(issue).toBeDefined(); + } + }); + + it("rejects empty postProvision hook script", () => { + const result = PluginManifestSchema.safeParse({ + name: "test-empty-post", + displayName: "Test Empty Post", + installable: true, + configPath: "plugins.entries", + secrets: {}, + hooks: { + postProvision: "", + }, + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => + i.message.includes("cannot be empty") + ); + expect(issue).toBeDefined(); + } + }); + + it("accepts manifest with all hook types", () => { + const result = PluginManifestSchema.safeParse({ + name: "test-all-hooks", + displayName: "Test All Hooks", + installable: true, + configPath: "plugins.entries", + secrets: { + uuid: { + envVar: "UUID", + scope: "agent", + isSecret: false, + autoResolvable: true, + }, + }, + hooks: { + resolve: { + uuid: "echo abc-123", + }, + postProvision: "npm install -g some-tool", + preStart: "echo ready", + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hooks?.resolve?.uuid).toBe("echo abc-123"); + expect(result.data.hooks?.postProvision).toBe("npm install -g some-tool"); + expect(result.data.hooks?.preStart).toBe("echo ready"); + } + }); + + it("validates openclaw-linear has resolve hook for linearUserUuid", () => { + const linearManifest = PLUGIN_MANIFEST_REGISTRY["openclaw-linear"]; + expect(linearManifest.hooks).toBeDefined(); + expect(linearManifest.hooks?.resolve).toBeDefined(); + expect(linearManifest.hooks?.resolve?.linearUserUuid).toBeDefined(); + expect(linearManifest.hooks?.resolve?.linearUserUuid).toContain("LINEAR_API_KEY"); + expect(linearManifest.hooks?.resolve?.linearUserUuid).toContain("viewer"); + }); +}); diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index 8273ebe7..f5d44872 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -96,6 +96,11 @@ export const PLUGIN_MANIFEST_REGISTRY: Record = { ], configJsonPath: "plugins.entries.openclaw-linear.config.webhookSecret", }, + hooks: { + resolve: { + linearUserUuid: 'curl -s -X POST https://api.linear.app/graphql -H "Authorization: $LINEAR_API_KEY" -H "Content-Type: application/json" -d \'{"query":"{ viewer { id } }"}\' | jq -r ".data.viewer.id"', + }, + }, }, slack: { name: "slack", diff --git a/packages/core/src/schemas/index.ts b/packages/core/src/schemas/index.ts index bec6996b..31e83976 100644 --- a/packages/core/src/schemas/index.ts +++ b/packages/core/src/schemas/index.ts @@ -17,4 +17,5 @@ export { PluginSecretSchema, WebhookSetupSchema, ConfigTransformSchema, + PluginHooksSchema, } from "./plugin-manifest"; diff --git a/packages/core/src/schemas/plugin-manifest.ts b/packages/core/src/schemas/plugin-manifest.ts index c4d9eedd..0f620f72 100644 --- a/packages/core/src/schemas/plugin-manifest.ts +++ b/packages/core/src/schemas/plugin-manifest.ts @@ -57,6 +57,18 @@ export const ConfigTransformSchema = z.object({ removeSource: z.boolean().default(true), }); +/** + * Lifecycle hooks for plugin provisioning and setup. + */ +export const PluginHooksSchema = z.object({ + /** Per-secret resolution scripts: secretKey -> shell script that outputs the resolved value */ + resolve: z.record(z.string().min(1, "Resolve hook script cannot be empty")).optional(), + /** Script to run after base provisioning, before workspace injection */ + postProvision: z.string().min(1, "postProvision hook script cannot be empty").optional(), + /** Script to run after workspace files are in place, before gateway start */ + preStart: z.string().min(1, "preStart hook script cannot be empty").optional(), +}); + /** * The enriched plugin manifest — consolidates ALL plugin metadata. */ @@ -81,6 +93,8 @@ export const PluginManifestSchema = z.object({ configTransforms: z.array(ConfigTransformSchema).default([]), /** Webhook setup configuration (for plugins that need incoming webhooks) */ webhookSetup: WebhookSetupSchema.optional(), + /** Lifecycle hooks for provisioning and secret resolution */ + hooks: PluginHooksSchema.optional(), }).superRefine((data, ctx) => { // Validate that webhookSetup.secretKey references an existing secret if (data.webhookSetup && !(data.webhookSetup.secretKey in data.secrets)) { @@ -90,4 +104,24 @@ export const PluginManifestSchema = z.object({ message: `webhookSetup.secretKey "${data.webhookSetup.secretKey}" does not exist in secrets`, }); } + + // Validate that resolve hook keys correspond to autoResolvable secrets + if (data.hooks?.resolve) { + for (const resolveKey of Object.keys(data.hooks.resolve)) { + const secret = data.secrets[resolveKey]; + if (!secret) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hooks", "resolve", resolveKey], + message: `Resolve hook key "${resolveKey}" does not correspond to any secret in secrets`, + }); + } else if (!secret.autoResolvable) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hooks", "resolve", resolveKey], + message: `Resolve hook key "${resolveKey}" references secret that is not autoResolvable`, + }); + } + } + } }); From 0d4dc4dedf7de05a1f0b3e34e96d783b66453535 Mon Sep 17 00:00:00 2001 From: Titus Date: Thu, 26 Feb 2026 23:55:39 +0000 Subject: [PATCH 2/5] feat: implement manifest hook execution engine (AGE-205) - Add runResolveHook(): executes shell script, captures stdout as resolved value - Add runLifecycleHook(): executes postProvision/preStart scripts with streaming output - Add resolvePluginSecrets(): orchestrates all resolve hooks for a manifest - Timeout enforcement via AbortSignal, error handling for non-zero exits - 12 tests covering happy paths, timeouts, errors, env inheritance - Export all functions and types from @clawup/core --- .../core/src/__tests__/manifest-hooks.test.ts | 190 +++++++++++++++ packages/core/src/index.ts | 12 + packages/core/src/manifest-hooks.ts | 221 ++++++++++++++++++ 3 files changed, 423 insertions(+) create mode 100644 packages/core/src/__tests__/manifest-hooks.test.ts create mode 100644 packages/core/src/manifest-hooks.ts diff --git a/packages/core/src/__tests__/manifest-hooks.test.ts b/packages/core/src/__tests__/manifest-hooks.test.ts new file mode 100644 index 00000000..d4057530 --- /dev/null +++ b/packages/core/src/__tests__/manifest-hooks.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { runResolveHook, runLifecycleHook, resolvePluginSecrets } from "../manifest-hooks"; +import type { PluginManifest } from "../plugin-registry"; + +describe("runResolveHook", () => { + it("captures stdout as the resolved value", async () => { + const result = await runResolveHook({ + script: 'echo "test-uuid"', + env: {}, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe("test-uuid"); + } + }); + + it("trims whitespace from resolved value", async () => { + const result = await runResolveHook({ + script: 'echo " value "', + env: {}, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe("value"); + } + }); + + it("returns error on timeout", async () => { + const result = await runResolveHook({ + script: "sleep 60", + env: {}, + timeoutMs: 500, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("timed out"); + } + }); + + it("returns error on non-zero exit code", async () => { + const result = await runResolveHook({ + script: 'echo "oops" >&2; exit 1', + env: {}, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("exited with code 1"); + expect(result.error).toContain("oops"); + } + }); + + it("returns error on empty stdout", async () => { + const result = await runResolveHook({ + script: "echo -n ''", + env: {}, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("empty output"); + } + }); + + it("inherits provided env vars", async () => { + const result = await runResolveHook({ + script: 'echo "$MY_TEST_VAR"', + env: { MY_TEST_VAR: "hello-from-env" }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value).toBe("hello-from-env"); + } + }); +}); + +describe("runLifecycleHook", () => { + it("returns success for a passing script", async () => { + const result = await runLifecycleHook({ + script: "echo installing...", + label: "postProvision", + }); + expect(result.ok).toBe(true); + }); + + it("returns error on non-zero exit", async () => { + const result = await runLifecycleHook({ + script: "exit 1", + label: "preStart", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("preStart"); + expect(result.error).toContain("exited with code 1"); + } + }); + + it("returns error on timeout", async () => { + const result = await runLifecycleHook({ + script: "sleep 60", + label: "postProvision", + timeoutMs: 500, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("timed out"); + } + }); +}); + +describe("resolvePluginSecrets", () => { + const baseManifest: PluginManifest = { + name: "test-plugin", + displayName: "Test Plugin", + installable: true, + configPath: "plugins.entries", + needsFunnel: false, + internalKeys: [], + configTransforms: [], + secrets: { + myUuid: { + envVar: "MY_UUID", + scope: "agent", + isSecret: false, + required: false, + autoResolvable: true, + }, + myToken: { + envVar: "MY_TOKEN", + scope: "agent", + isSecret: true, + required: true, + autoResolvable: true, + }, + }, + hooks: { + resolve: { + myUuid: 'echo "uuid-123"', + myToken: 'echo "token-abc"', + }, + }, + }; + + it("resolves all autoResolvable secrets", async () => { + const result = await resolvePluginSecrets({ + manifest: baseManifest, + env: {}, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.values).toEqual({ + MY_UUID: "uuid-123", + MY_TOKEN: "token-abc", + }); + } + }); + + it("returns empty values when no resolve hooks exist", async () => { + const noHooksManifest: PluginManifest = { + ...baseManifest, + hooks: undefined, + }; + const result = await resolvePluginSecrets({ + manifest: noHooksManifest, + env: {}, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.values).toEqual({}); + } + }); + + it("fails fast on first resolve hook error", async () => { + const failManifest: PluginManifest = { + ...baseManifest, + hooks: { + resolve: { + myUuid: 'echo "uuid-123"', + myToken: "exit 1", + }, + }, + }; + const result = await resolvePluginSecrets({ + manifest: failManifest, + env: {}, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("myToken"); + } + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 808b989d..2025e40e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,6 +55,17 @@ export { isSecretCoveredByPlugin, } from "./plugin-loader"; +// Manifest hooks +export { + runResolveHook, + runLifecycleHook, + resolvePluginSecrets, + type ResolveHookResult, + type HookResult, + type HookSuccess, + type HookError, +} from "./manifest-hooks"; + // Coding agent registry export type { CodingAgentEntry, CodingAgentSecret } from "./coding-agent-registry"; export { CODING_AGENT_REGISTRY } from "./coding-agent-registry"; @@ -82,6 +93,7 @@ export { IdentityManifestSchema, PluginManifestSchema, PluginSecretSchema, + PluginHooksSchema, WebhookSetupSchema, ConfigTransformSchema, } from "./schemas"; diff --git a/packages/core/src/manifest-hooks.ts b/packages/core/src/manifest-hooks.ts new file mode 100644 index 00000000..6745ba0f --- /dev/null +++ b/packages/core/src/manifest-hooks.ts @@ -0,0 +1,221 @@ +/** + * Manifest hook execution engine for plugin provisioning and secret resolution. + */ + +import { spawn } from "child_process"; +import type { PluginManifest } from "./plugin-registry"; + +/** Result of a successful resolve hook execution */ +export interface ResolveHookSuccess { + ok: true; + value: string; +} + +/** Result of a successful lifecycle hook execution */ +export interface HookSuccess { + ok: true; +} + +/** Result of a failed hook execution */ +export interface HookError { + ok: false; + error: string; +} + +export type ResolveHookResult = ResolveHookSuccess | HookError; +export type HookResult = HookSuccess | HookError; + +/** + * Execute a resolve hook script and capture stdout as the resolved secret value. + * + * @param script - Shell script to execute + * @param env - Environment variables (includes secrets like $LINEAR_API_KEY) + * @param timeoutMs - Timeout in milliseconds (default: 30000) + * @returns The resolved value (trimmed stdout) or an error + */ +export function runResolveHook(params: { + script: string; + env: Record; + timeoutMs?: number; +}): Promise { + const { script, env, timeoutMs = 30000 } = params; + + return new Promise((resolve) => { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + resolve({ + ok: false, + error: `Resolve hook timed out after ${timeoutMs}ms`, + }); + }, timeoutMs); + + let stdout = ""; + let stderr = ""; + + const child = spawn("/bin/sh", ["-c", script], { + env: { ...process.env, ...env }, + signal: controller.signal, + }); + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + child.on("error", (err) => { + clearTimeout(timeout); + if ((err as NodeJS.ErrnoException).code === "ABORT_ERR") { + // Timeout already handled + return; + } + resolve({ + ok: false, + error: `Failed to spawn process: ${err.message}`, + }); + }); + + child.on("close", (code) => { + clearTimeout(timeout); + + if (code !== 0) { + resolve({ + ok: false, + error: `Resolve hook exited with code ${code}. stderr: ${stderr.trim()}`, + }); + return; + } + + const trimmed = stdout.trim(); + if (!trimmed) { + resolve({ + ok: false, + error: "Resolve hook produced empty output (resolved value cannot be empty)", + }); + return; + } + + resolve({ ok: true, value: trimmed }); + }); + }); +} + +/** + * Execute a lifecycle hook (postProvision or preStart). + * Streams stdout/stderr to logger and returns success/failure. + * + * @param script - Shell script to execute + * @param env - Environment variables (optional) + * @param timeoutMs - Timeout in milliseconds (default: 120000 for postProvision, 60000 for preStart) + * @param label - Label for logging (e.g., "postProvision", "preStart") + * @returns Success or error result + */ +export function runLifecycleHook(params: { + script: string; + env?: Record; + timeoutMs?: number; + label: string; +}): Promise { + const { script, env = {}, timeoutMs = 120000, label } = params; + + return new Promise((resolve) => { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + resolve({ + ok: false, + error: `${label} hook timed out after ${timeoutMs}ms`, + }); + }, timeoutMs); + + let stderr = ""; + + const child = spawn("/bin/sh", ["-c", script], { + env: { ...process.env, ...env }, + signal: controller.signal, + }); + + // Stream stdout/stderr to console for visibility + child.stdout.on("data", (chunk) => { + process.stdout.write(`[${label}] ${chunk.toString()}`); + }); + + child.stderr.on("data", (chunk) => { + const msg = chunk.toString(); + stderr += msg; + process.stderr.write(`[${label}] ${msg}`); + }); + + child.on("error", (err) => { + clearTimeout(timeout); + if ((err as NodeJS.ErrnoException).code === "ABORT_ERR") { + // Timeout already handled + return; + } + resolve({ + ok: false, + error: `Failed to spawn ${label} process: ${err.message}`, + }); + }); + + child.on("close", (code) => { + clearTimeout(timeout); + + if (code !== 0) { + resolve({ + ok: false, + error: `${label} hook exited with code ${code}. stderr: ${stderr.trim()}`, + }); + return; + } + + resolve({ ok: true }); + }); + }); +} + +/** + * Resolve all autoResolvable secrets for a plugin manifest. + * Runs each resolve hook in sequence and collects the results. + * + * @param manifest - Plugin manifest with hooks and secrets + * @param env - Environment variables (must include any secrets needed by resolve hooks) + * @returns Map of envVar -> resolved value, or an error + */ +export async function resolvePluginSecrets(params: { + manifest: PluginManifest; + env: Record; +}): Promise<{ ok: true; values: Record } | { ok: false; error: string }> { + const { manifest, env } = params; + + if (!manifest.hooks?.resolve) { + return { ok: true, values: {} }; + } + + const resolvedValues: Record = {}; + + for (const [secretKey, script] of Object.entries(manifest.hooks.resolve)) { + const secret = manifest.secrets[secretKey]; + if (!secret) { + return { + ok: false, + error: `Resolve hook key "${secretKey}" does not correspond to any secret (validation should have caught this)`, + }; + } + + const result = await runResolveHook({ script, env }); + if (!result.ok) { + return { + ok: false, + error: `Failed to resolve secret "${secretKey}" (${secret.envVar}): ${result.error}`, + }; + } + + resolvedValues[secret.envVar] = result.value; + } + + return { ok: true, values: resolvedValues }; +} From 912adfdf2cde104dcafc56e44c02fcc1089b571e Mon Sep 17 00:00:00 2001 From: Titus Date: Thu, 26 Feb 2026 23:59:32 +0000 Subject: [PATCH 3/5] feat: integrate manifest hooks into provisioning pipeline (AGE-206) - Wire postProvision/preStart hooks into cloud-init at correct lifecycle points - Replace hardcoded Linear UUID auto-resolve with generic resolvePluginSecrets() - Add --skip-hooks CLI flag to bypass hook execution during setup - Pass hooks through Pulumi plugin config to cloud-init generation - Move manifest-hooks to @clawup/core/manifest-hooks subpath (avoids child_process in browser) - Full build passing (core, cli, pulumi, web), all 196 tests green --- packages/cli/bin.ts | 1 + packages/cli/commands/setup.ts | 111 ++++++++----------- packages/core/package.json | 6 +- packages/core/src/index.ts | 12 +- packages/pulumi/src/components/cloud-init.ts | 40 +++++++ packages/pulumi/src/index.ts | 1 + 6 files changed, 95 insertions(+), 76 deletions(-) diff --git a/packages/cli/bin.ts b/packages/cli/bin.ts index 11db4b60..547fff48 100644 --- a/packages/cli/bin.ts +++ b/packages/cli/bin.ts @@ -54,6 +54,7 @@ program .option("--env-file ", "Path to .env file (defaults to .env in project root)") .option("--deploy", "Deploy immediately after setup") .option("-y, --yes", "Skip confirmation prompt (for deploy)") + .option("--skip-hooks", "Skip plugin lifecycle hook execution") .action(async (opts) => { await setupCommand(opts); }); diff --git a/packages/cli/commands/setup.ts b/packages/cli/commands/setup.ts index 30a9d245..3e44b544 100644 --- a/packages/cli/commands/setup.ts +++ b/packages/cli/commands/setup.ts @@ -21,6 +21,7 @@ import { resolvePlugins, PLUGIN_MANIFEST_REGISTRY, } from "@clawup/core"; +import { resolvePluginSecrets, runLifecycleHook } from "@clawup/core/manifest-hooks"; import { fetchIdentity } from "@clawup/core/identity"; import { findProjectRoot } from "../lib/project"; import { selectOrCreateStack, setConfig, qualifiedStackName } from "../lib/pulumi"; @@ -40,6 +41,7 @@ interface SetupOptions { envFile?: string; deploy?: boolean; yes?: boolean; + skipHooks?: boolean; } /** Fetched identity data stored alongside the agent definition */ @@ -292,9 +294,8 @@ export async function setupCommand(opts: SetupOptions = {}): Promise { p.log.success("All secrets resolved"); // ------------------------------------------------------------------------- - // 7. Auto-resolve secrets (e.g., Linear UUID fetch) + // 7. Auto-resolve secrets (via manifest hooks or env overrides) // ------------------------------------------------------------------------- - // Generic: for each plugin with auto-resolvable secrets, attempt resolution const autoResolvedSecrets: Record> = {}; for (const fi of fetchedIdentities) { @@ -302,8 +303,8 @@ export async function setupCommand(opts: SetupOptions = {}): Promise { if (!plugins) continue; for (const pluginName of plugins) { - const manifest = resolvePlugin(pluginName, fi.identityResult); - for (const [key, secret] of Object.entries(manifest.secrets)) { + const pluginManifest = resolvePlugin(pluginName, fi.identityResult); + for (const [key, secret] of Object.entries(pluginManifest.secrets)) { if (!secret.autoResolvable) continue; const roleUpper = fi.agent.role.toUpperCase(); @@ -324,73 +325,55 @@ export async function setupCommand(opts: SetupOptions = {}): Promise { autoResolvedSecrets[fi.agent.role][key] = envValue; continue; } - - // Plugin-specific auto-resolution logic - const resolved = await autoResolveSecret(pluginName, key, fi, resolvedSecrets, envDict); - if (resolved) { - if (!autoResolvedSecrets[fi.agent.role]) autoResolvedSecrets[fi.agent.role] = {}; - autoResolvedSecrets[fi.agent.role][key] = resolved; - } } - } - } - /** - * Auto-resolve a secret for a specific plugin. - * Currently supports Linear UUID fetch; future plugins can add their own resolvers here. - */ - async function autoResolveSecret( - pluginName: string, - key: string, - fi: FetchedIdentity, - secrets: ReturnType, - _envDict: Record, - ): Promise { - if (pluginName === "openclaw-linear" && key === "linearUserUuid") { - const linearApiKey = secrets.perAgent[fi.agent.name]?.linearApiKey; - if (!linearApiKey) { - exitWithError( - `Cannot fetch Linear user UUID for ${fi.agent.displayName}: linearApiKey not resolved.` - ); - } - - const roleUpper = fi.agent.role.toUpperCase(); - const s = p.spinner(); - s.start(`Fetching Linear user ID for ${fi.agent.displayName}...`); - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 15_000); - const res = await fetch("https://api.linear.app/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: linearApiKey, - }, - body: JSON.stringify({ query: "{ viewer { id } }" }), - signal: controller.signal, - }); - clearTimeout(timeout); - if (!res.ok) { - throw new Error(`HTTP ${res.status}: ${res.statusText}`); + // Use manifest resolve hooks (if not skipped and hooks exist) + if (!opts.skipHooks && pluginManifest.hooks?.resolve) { + // Build env for resolve hooks — include resolved secrets for this agent + const hookEnv: Record = {}; + const agentSecrets = resolvedSecrets.perAgent[fi.agent.name] ?? {}; + for (const [k, v] of Object.entries(agentSecrets)) { + // Map camelCase key back to env var using plugin secret definitions + for (const [, sec] of Object.entries(pluginManifest.secrets)) { + const envDerivedKey = sec.envVar + .toLowerCase() + .split("_") + .map((part: string, i: number) => i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); + if (envDerivedKey === k) { + hookEnv[sec.envVar] = v; + } + } } - const data = (await res.json()) as { data?: { viewer?: { id?: string } }; errors?: Array<{ message: string }> }; - if (data.errors && data.errors.length > 0) { - throw new Error(`GraphQL error: ${data.errors[0].message}`); + + const s = p.spinner(); + s.start(`Resolving secrets for ${fi.agent.displayName} (${pluginName})...`); + const hookResult = await resolvePluginSecrets({ manifest: pluginManifest, env: hookEnv }); + if (hookResult.ok) { + // Map resolved env vars back to secret keys + for (const [secretKey, secret] of Object.entries(pluginManifest.secrets)) { + if (hookResult.values[secret.envVar]) { + // Skip if already resolved above + if (autoResolvedSecrets[fi.agent.role]?.[secretKey]) continue; + if (!autoResolvedSecrets[fi.agent.role]) autoResolvedSecrets[fi.agent.role] = {}; + autoResolvedSecrets[fi.agent.role][secretKey] = hookResult.values[secret.envVar]; + } + } + s.stop(`Resolved secrets for ${fi.agent.displayName} (${pluginName})`); + } else { + s.stop(`Failed to resolve secrets for ${fi.agent.displayName}`); + const roleUpper = fi.agent.role.toUpperCase(); + exitWithError( + `${hookResult.error}\n` + + `Set the required env vars in your .env file (prefixed with ${roleUpper}_) to bypass hook resolution, then run \`clawup setup\` again.` + ); } - const uuid = data?.data?.viewer?.id; - if (!uuid) throw new Error("No user ID in response"); - s.stop(`${fi.agent.displayName}: ${uuid}`); - return uuid; - } catch (err) { - s.stop(`Could not fetch Linear user ID for ${fi.agent.displayName}`); - exitWithError( - `Failed to fetch Linear user UUID: ${err instanceof Error ? err.message : String(err)}\n` + - `Set ${roleUpper}_LINEAR_USER_UUID in your .env file to bypass the API call, then run \`clawup setup\` again.` - ); } } + } - return undefined; + if (opts.skipHooks) { + p.log.warn("Hooks skipped (--skip-hooks)"); } // ------------------------------------------------------------------------- diff --git a/packages/core/package.json b/packages/core/package.json index 3a6ba33b..82e22975 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,7 +15,8 @@ "./dep-registry": "./dist/dep-registry.js", "./plugin-registry": "./dist/plugin-registry.js", "./coding-agent-registry": "./dist/coding-agent-registry.js", - "./schemas": "./dist/schemas/index.js" + "./schemas": "./dist/schemas/index.js", + "./manifest-hooks": "./dist/manifest-hooks.js" }, "typesVersions": { "*": { @@ -27,7 +28,8 @@ "dep-registry": ["dist/dep-registry.d.ts"], "plugin-registry": ["dist/plugin-registry.d.ts"], "coding-agent-registry": ["dist/coding-agent-registry.d.ts"], - "schemas": ["dist/schemas/index.d.ts"] + "schemas": ["dist/schemas/index.d.ts"], + "manifest-hooks": ["dist/manifest-hooks.d.ts"] } }, "scripts": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2025e40e..e5c69b1e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -55,16 +55,8 @@ export { isSecretCoveredByPlugin, } from "./plugin-loader"; -// Manifest hooks -export { - runResolveHook, - runLifecycleHook, - resolvePluginSecrets, - type ResolveHookResult, - type HookResult, - type HookSuccess, - type HookError, -} from "./manifest-hooks"; +// Manifest hooks — re-exported from "@clawup/core/manifest-hooks" subpath +// to avoid pulling child_process into browser bundles. // Coding agent registry export type { CodingAgentEntry, CodingAgentSecret } from "./coding-agent-registry"; diff --git a/packages/pulumi/src/components/cloud-init.ts b/packages/pulumi/src/components/cloud-init.ts index 2f44b045..cd8966b4 100644 --- a/packages/pulumi/src/components/cloud-init.ts +++ b/packages/pulumi/src/components/cloud-init.ts @@ -32,6 +32,12 @@ export interface PluginInstallConfig { targetKeys: Record; removeSource: boolean; }>; + /** Lifecycle hooks from the plugin manifest */ + hooks?: { + resolve?: Record; + postProvision?: string; + preStart?: string; + }; } export interface CloudInitConfig { @@ -168,6 +174,38 @@ echo "Plugin installation complete" ` : ""; + // Plugin postProvision hooks (run after base provisioning, before workspace injection) + const postProvisionHooksScript = (config.plugins ?? []) + .filter((p) => p.hooks?.postProvision) + .map((p) => ` +# postProvision hook: ${p.name} +echo "Running postProvision hook for ${p.name}..." +sudo -H -u ubuntu bash << 'HOOK_POST_PROVISION_${p.name.toUpperCase().replace(/-/g, "_")}' +export HOME=/home/ubuntu +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" +${p.hooks!.postProvision} +HOOK_POST_PROVISION_${p.name.toUpperCase().replace(/-/g, "_")} +echo "postProvision hook for ${p.name} complete" +`) + .join("\n"); + + // Plugin preStart hooks (run after workspace + config, before gateway start) + const preStartHooksScript = (config.plugins ?? []) + .filter((p) => p.hooks?.preStart) + .map((p) => ` +# preStart hook: ${p.name} +echo "Running preStart hook for ${p.name}..." +sudo -H -u ubuntu bash << 'HOOK_PRE_START_${p.name.toUpperCase().replace(/-/g, "_")}' +export HOME=/home/ubuntu +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" +${p.hooks!.preStart} +HOOK_PRE_START_${p.name.toUpperCase().replace(/-/g, "_")} +echo "preStart hook for ${p.name} complete" +`) + .join("\n"); + // Dynamic clawhub skill install steps const clawhubSkillsScript = (config.clawhubSkills ?? []).length > 0 ? ` @@ -364,6 +402,7 @@ openclaw onboard --non-interactive --accept-risk \\ --skip-daemon \\ --skip-skills || echo "WARNING: OpenClaw onboarding failed. Run openclaw onboard manually." ' +${postProvisionHooksScript} ${workspaceFilesScript} ${pluginInstallScript} ${clawhubSkillsScript} @@ -378,6 +417,7 @@ sudo -H -u ubuntu \\ python3 << 'PYTHON_SCRIPT' ${configPatchScript} PYTHON_SCRIPT +${preStartHooksScript} ${tailscaleProxySection} ${config.foregroundMode ? `# Run openclaw doctor before starting daemon in foreground echo "Running openclaw doctor..." diff --git a/packages/pulumi/src/index.ts b/packages/pulumi/src/index.ts index 6848ec7f..12a0c9ab 100644 --- a/packages/pulumi/src/index.ts +++ b/packages/pulumi/src/index.ts @@ -264,6 +264,7 @@ function buildPluginsForAgent( configPath: manifest.configPath, internalKeys: manifest.internalKeys.length > 0 ? manifest.internalKeys : undefined, configTransforms: manifest.configTransforms.length > 0 ? manifest.configTransforms : undefined, + hooks: manifest.hooks, }); // Collect secret outputs from Pulumi config From c56046c6b6ca4556dc563607c213b840aca7b365 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 27 Feb 2026 00:33:05 +0000 Subject: [PATCH 4/5] merge: main into feat/manifest-hooks-schema - Merge main (includes PR #148 plugin abstraction) - Fix plugin E2E test: skip hooks during setup (fake API keys can't resolve) --- packages/cli/__e2e__/plugins.e2e.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/__e2e__/plugins.e2e.test.ts b/packages/cli/__e2e__/plugins.e2e.test.ts index 76a48592..9599ac0c 100644 --- a/packages/cli/__e2e__/plugins.e2e.test.ts +++ b/packages/cli/__e2e__/plugins.e2e.test.ts @@ -226,7 +226,7 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", ( it("setup validates plugin secrets and creates Pulumi stack", async () => { // Run setup with the .env file - await setupCommand({ envFile: path.join(tempDir, ".env") }); + await setupCommand({ envFile: path.join(tempDir, ".env"), skipHooks: true }); // Verify Pulumi stack exists const result = execSync("pulumi stack ls --json", { From ccb63c26260c18aa6e3afd36060381e924cbe974 Mon Sep 17 00:00:00 2001 From: Scout Date: Fri, 27 Feb 2026 00:43:01 +0000 Subject: [PATCH 5/5] feat: test hooks E2E with echo-based stub manifests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created test-linear and test-slack plugin manifests in fixture identity with echo-based resolve hooks (no real API calls) - test-linear hook: resolves linearUserUuid via echo instead of curl to Linear API - Plugin E2E tests now exercise full hook resolution pipeline - Removed skipHooks workaround — hooks run end-to-end - Verify hook-resolved UUID appears in deployed container's cloud-init script - All 24 E2E tests passing, 196 unit tests passing --- .../fixtures/plugin-identity/identity.yaml | 4 +- .../plugin-identity/plugins/test-linear.yaml | 39 +++++++++++++++++++ .../plugin-identity/plugins/test-slack.yaml | 22 +++++++++++ packages/cli/__e2e__/plugins.e2e.test.ts | 14 ++++--- 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-linear.yaml create mode 100644 packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-slack.yaml diff --git a/packages/cli/__e2e__/helpers/fixtures/plugin-identity/identity.yaml b/packages/cli/__e2e__/helpers/fixtures/plugin-identity/identity.yaml index 9486c281..1773a1f9 100644 --- a/packages/cli/__e2e__/helpers/fixtures/plugin-identity/identity.yaml +++ b/packages/cli/__e2e__/helpers/fixtures/plugin-identity/identity.yaml @@ -7,8 +7,8 @@ volumeSize: 10 model: anthropic/claude-sonnet-4-5 codingAgent: claude-code plugins: - - slack - - openclaw-linear + - test-slack + - test-linear skills: [] templateVars: - OWNER_NAME diff --git a/packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-linear.yaml b/packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-linear.yaml new file mode 100644 index 00000000..fbffd413 --- /dev/null +++ b/packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-linear.yaml @@ -0,0 +1,39 @@ +name: test-linear +displayName: Test Linear +installable: true +needsFunnel: true +configPath: plugins.entries +secrets: + apiKey: + envVar: LINEAR_API_KEY + scope: agent + isSecret: true + required: true + autoResolvable: false + validator: "lin_api_" + webhookSecret: + envVar: LINEAR_WEBHOOK_SECRET + scope: agent + isSecret: true + required: true + autoResolvable: false + linearUserUuid: + envVar: LINEAR_USER_UUID + scope: agent + isSecret: false + required: false + autoResolvable: true +internalKeys: + - agentId + - linearUserUuid +configTransforms: [] +webhookSetup: + urlPath: "/hooks/linear" + secretKey: webhookSecret + instructions: + - "1. Create webhook" + - "2. Paste URL" + configJsonPath: "plugins.entries.test-linear.config.webhookSecret" +hooks: + resolve: + linearUserUuid: 'echo "test-resolved-uuid-$(echo $LINEAR_API_KEY | cut -c1-8)"' diff --git a/packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-slack.yaml b/packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-slack.yaml new file mode 100644 index 00000000..87ddc61e --- /dev/null +++ b/packages/cli/__e2e__/helpers/fixtures/plugin-identity/plugins/test-slack.yaml @@ -0,0 +1,22 @@ +name: test-slack +displayName: Test Slack +installable: false +needsFunnel: false +configPath: channels +secrets: + botToken: + envVar: SLACK_BOT_TOKEN + scope: agent + isSecret: true + required: true + autoResolvable: false + validator: "xoxb-" + appToken: + envVar: SLACK_APP_TOKEN + scope: agent + isSecret: true + required: true + autoResolvable: false + validator: "xapp-" +internalKeys: [] +configTransforms: [] diff --git a/packages/cli/__e2e__/plugins.e2e.test.ts b/packages/cli/__e2e__/plugins.e2e.test.ts index 9599ac0c..4df65853 100644 --- a/packages/cli/__e2e__/plugins.e2e.test.ts +++ b/packages/cli/__e2e__/plugins.e2e.test.ts @@ -201,7 +201,7 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", ( "PLUGINTESTER_SLACK_APP_TOKEN=xapp-fake-app-token-for-e2e", "PLUGINTESTER_LINEAR_API_KEY=lin_api_fake_key_for_e2e", "PLUGINTESTER_LINEAR_WEBHOOK_SECRET=fake-webhook-secret-for-e2e", - "PLUGINTESTER_LINEAR_USER_UUID=fake-uuid-for-e2e", + // LINEAR_USER_UUID intentionally omitted — resolved by test-linear hook ], }); @@ -226,7 +226,7 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", ( it("setup validates plugin secrets and creates Pulumi stack", async () => { // Run setup with the .env file - await setupCommand({ envFile: path.join(tempDir, ".env"), skipHooks: true }); + await setupCommand({ envFile: path.join(tempDir, ".env") }); // Verify Pulumi stack exists const result = execSync("pulumi stack ls --json", { @@ -280,7 +280,9 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", ( // Assert: Linear plugin secrets are in the cloud-init script expect(cloudinitScript).toContain("LINEAR_API_KEY="); expect(cloudinitScript).toContain("LINEAR_WEBHOOK_SECRET="); + // LINEAR_USER_UUID was resolved by the test-linear hook (echo-based stub) expect(cloudinitScript).toContain("LINEAR_USER_UUID="); + expect(cloudinitScript).toContain("test-resolved-uuid-"); } finally { dispose(); } @@ -321,17 +323,17 @@ describe("Plugin Lifecycle: deploy → validate → destroy (Slack + Linear)", ( expect(containerCheck!.detail).toBe("running"); // Assert: Plugin secret checks were executed for Slack - const slackBotCheck = ui.getCheckResult("slack secret (SLACK_BOT_TOKEN)"); + const slackBotCheck = ui.getCheckResult("test-slack secret (SLACK_BOT_TOKEN)"); expect(slackBotCheck).not.toBeNull(); - const slackAppCheck = ui.getCheckResult("slack secret (SLACK_APP_TOKEN)"); + const slackAppCheck = ui.getCheckResult("test-slack secret (SLACK_APP_TOKEN)"); expect(slackAppCheck).not.toBeNull(); // Assert: Plugin secret checks were executed for Linear - const linearApiCheck = ui.getCheckResult("openclaw-linear secret (LINEAR_API_KEY)"); + const linearApiCheck = ui.getCheckResult("test-linear secret (LINEAR_API_KEY)"); expect(linearApiCheck).not.toBeNull(); - const linearWebhookCheck = ui.getCheckResult("openclaw-linear secret (LINEAR_WEBHOOK_SECRET)"); + const linearWebhookCheck = ui.getCheckResult("test-linear secret (LINEAR_WEBHOOK_SECRET)"); expect(linearWebhookCheck).not.toBeNull(); // Assert: Overall validation reports failures (expected with dummy secrets/API key)