diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 77c5cd285..fc044dbcd 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -260,6 +260,15 @@ const entries: SubCliEntry[] = [ mod.registerSecurityCli(program); }, }, + { + name: "secrets", + description: "Secrets runtime reload controls", + hasSubcommands: true, + register: async (program) => { + const mod = await import("../secrets-cli.js"); + mod.registerSecretsCli(program); + }, + }, { name: "skills", description: "List and inspect available skills", diff --git a/src/cli/secrets-cli.test.ts b/src/cli/secrets-cli.test.ts new file mode 100644 index 000000000..f561b589b --- /dev/null +++ b/src/cli/secrets-cli.test.ts @@ -0,0 +1,53 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const callGatewayFromCli = vi.fn(); + +const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = + createCliRuntimeCapture(); + +vi.mock("./gateway-rpc.js", () => ({ + addGatewayClientOptions: (cmd: Command) => cmd, + callGatewayFromCli: (method: string, opts: unknown, params?: unknown, extra?: unknown) => + callGatewayFromCli(method, opts, params, extra), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +const { registerSecretsCli } = await import("./secrets-cli.js"); + +describe("secrets CLI", () => { + const createProgram = () => { + const program = new Command(); + program.exitOverride(); + registerSecretsCli(program); + return program; + }; + + beforeEach(() => { + resetRuntimeCapture(); + callGatewayFromCli.mockReset(); + }); + + it("calls secrets.reload and prints human output", async () => { + callGatewayFromCli.mockResolvedValue({ ok: true, warningCount: 1 }); + await createProgram().parseAsync(["secrets", "reload"], { from: "user" }); + expect(callGatewayFromCli).toHaveBeenCalledWith( + "secrets.reload", + expect.anything(), + undefined, + expect.objectContaining({ expectFinal: false }), + ); + expect(runtimeLogs.at(-1)).toBe("Secrets reloaded with 1 warning(s)."); + expect(runtimeErrors).toHaveLength(0); + }); + + it("prints JSON when requested", async () => { + callGatewayFromCli.mockResolvedValue({ ok: true, warningCount: 0 }); + await createProgram().parseAsync(["secrets", "reload", "--json"], { from: "user" }); + expect(runtimeLogs.at(-1)).toContain('"ok": true'); + }); +}); diff --git a/src/cli/secrets-cli.ts b/src/cli/secrets-cli.ts new file mode 100644 index 000000000..01e88b687 --- /dev/null +++ b/src/cli/secrets-cli.ts @@ -0,0 +1,47 @@ +import type { Command } from "commander"; +import { danger } from "../globals.js"; +import { defaultRuntime } from "../runtime.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { theme } from "../terminal/theme.js"; +import { addGatewayClientOptions, callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; + +type SecretsReloadOptions = GatewayRpcOpts & { json?: boolean }; + +export function registerSecretsCli(program: Command) { + const secrets = program + .command("secrets") + .description("Secrets runtime controls") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink("/gateway/security", "docs.openclaw.ai/gateway/security")}\n`, + ); + + addGatewayClientOptions( + secrets + .command("reload") + .description("Re-resolve secret references and atomically swap runtime snapshot") + .option("--json", "Output JSON", false), + ).action(async (opts: SecretsReloadOptions) => { + try { + const result = await callGatewayFromCli("secrets.reload", opts, undefined, { + expectFinal: false, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + const warningCount = Number( + (result as { warningCount?: unknown } | undefined)?.warningCount ?? 0, + ); + if (Number.isFinite(warningCount) && warningCount > 0) { + defaultRuntime.log(`Secrets reloaded with ${warningCount} warning(s).`); + return; + } + defaultRuntime.log("Secrets reloaded."); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index f52b24de7..8d1dbdb3c 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -101,6 +101,7 @@ const METHOD_SCOPE_GROUPS: Record = { "agents.delete", "skills.install", "skills.update", + "secrets.reload", "cron.add", "cron.update", "cron.remove", diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 4023fdb98..76f400f36 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -50,6 +50,7 @@ const BASE_METHODS = [ "update.run", "voicewake.get", "voicewake.set", + "secrets.reload", "sessions.list", "sessions.preview", "sessions.patch", diff --git a/src/gateway/server-methods/secrets.test.ts b/src/gateway/server-methods/secrets.test.ts new file mode 100644 index 000000000..202e1df8a --- /dev/null +++ b/src/gateway/server-methods/secrets.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; +import { createSecretsHandlers } from "./secrets.js"; + +describe("secrets handlers", () => { + it("responds with warning count on successful reload", async () => { + const handlers = createSecretsHandlers({ + reloadSecrets: vi.fn().mockResolvedValue({ warningCount: 2 }), + }); + const respond = vi.fn(); + await handlers["secrets.reload"]({ + req: { type: "req", id: "1", method: "secrets.reload" }, + params: {}, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + expect(respond).toHaveBeenCalledWith(true, { ok: true, warningCount: 2 }); + }); + + it("returns unavailable when reload fails", async () => { + const handlers = createSecretsHandlers({ + reloadSecrets: vi.fn().mockRejectedValue(new Error("reload failed")), + }); + const respond = vi.fn(); + await handlers["secrets.reload"]({ + req: { type: "req", id: "1", method: "secrets.reload" }, + params: {}, + client: null, + isWebchatConnect: () => false, + respond, + context: {} as never, + }); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "UNAVAILABLE", + message: "Error: reload failed", + }), + ); + }); +}); diff --git a/src/gateway/server-methods/secrets.ts b/src/gateway/server-methods/secrets.ts new file mode 100644 index 000000000..995fb384a --- /dev/null +++ b/src/gateway/server-methods/secrets.ts @@ -0,0 +1,17 @@ +import { ErrorCodes, errorShape } from "../protocol/index.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +export function createSecretsHandlers(params: { + reloadSecrets: () => Promise<{ warningCount: number }>; +}): GatewayRequestHandlers { + return { + "secrets.reload": async ({ respond }) => { + try { + const result = await params.reloadSecrets(); + respond(true, { ok: true, warningCount: result.warningCount }); + } catch (err) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err))); + } + }, + }; +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 721207df2..133c79e2f 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -77,6 +77,7 @@ import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js"; import { coreGatewayHandlers } from "./server-methods.js"; import { createExecApprovalHandlers } from "./server-methods/exec-approval.js"; import { safeParseJson } from "./server-methods/nodes.helpers.js"; +import { createSecretsHandlers } from "./server-methods/secrets.js"; import { hasConnectedMobileNode } from "./server-mobile-nodes.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; import { createNodeSubscriptionManager } from "./server-node-subscriptions.js"; @@ -666,6 +667,19 @@ export async function startGatewayServer( const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, { forwarder: execApprovalForwarder, }); + const secretsHandlers = createSecretsHandlers({ + reloadSecrets: async () => { + const active = getActiveSecretsRuntimeSnapshot(); + if (!active) { + throw new Error("Secrets runtime snapshot is not active."); + } + const prepared = await activateRuntimeSecrets(active.sourceConfig, { + reason: "reload", + activate: true, + }); + return { warningCount: prepared.warnings.length }; + }, + }); const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port; @@ -687,6 +701,7 @@ export async function startGatewayServer( extraHandlers: { ...pluginRegistry.gatewayHandlers, ...execApprovalHandlers, + ...secretsHandlers, }, broadcast, context: {