From 6e2f34e1934a540f2dcaeeae0ecc89df04c0a624 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Sun, 22 Feb 2026 00:38:18 -0800 Subject: [PATCH] agents: add self_update tool for non-owner gateway updates --- src/agents/openclaw-gateway-tool.e2e.test.ts | 35 +++++++++++ src/agents/openclaw-tools.ts | 4 ++ src/agents/system-prompt.e2e.test.ts | 15 ++++- src/agents/system-prompt.ts | 22 +++++-- src/agents/tool-policy.ts | 3 +- src/agents/tools/self-update-tool.ts | 63 ++++++++++++++++++++ 6 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 src/agents/tools/self-update-tool.ts diff --git a/src/agents/openclaw-gateway-tool.e2e.test.ts b/src/agents/openclaw-gateway-tool.e2e.test.ts index ac0e46470bb..225cf5b2e36 100644 --- a/src/agents/openclaw-gateway-tool.e2e.test.ts +++ b/src/agents/openclaw-gateway-tool.e2e.test.ts @@ -164,3 +164,38 @@ describe("gateway tool", () => { } }); }); + +describe("self_update tool", () => { + it("is not owner-only", () => { + const tool = createOpenClawTools().find((candidate) => candidate.name === "self_update"); + expect(tool).toBeDefined(); + expect(tool?.ownerOnly).toBeUndefined(); + }); + + it("calls update.run on the gateway", async () => { + const gateway = await import("./tools/gateway.js"); + const callSpy = gateway.callGatewayTool as ReturnType; + callSpy.mockClear(); + + const tool = createOpenClawTools({ + agentSessionKey: "agent:main:web:session1", + }).find((candidate) => candidate.name === "self_update"); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing self_update tool"); + } + + await tool.execute("call-su-1", { + note: "user requested update", + }); + + expect(callSpy).toHaveBeenCalledWith( + "update.run", + expect.any(Object), + expect.objectContaining({ + note: "user requested update", + sessionKey: "agent:main:web:session1", + }), + ); + }); +}); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 41f059fb6a7..ec8c71a45c0 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -12,6 +12,7 @@ import { createGatewayTool } from "./tools/gateway-tool.js"; import { createImageTool } from "./tools/image-tool.js"; import { createMessageTool } from "./tools/message-tool.js"; import { createNodesTool } from "./tools/nodes-tool.js"; +import { createSelfUpdateTool } from "./tools/self-update-tool.js"; import { createSessionStatusTool } from "./tools/session-status-tool.js"; import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js"; import { createSessionsListTool } from "./tools/sessions-list-tool.js"; @@ -124,6 +125,9 @@ export function createOpenClawTools(options?: { agentSessionKey: options?.agentSessionKey, config: options?.config, }), + createSelfUpdateTool({ + agentSessionKey: options?.agentSessionKey, + }), createAgentsListTool({ agentSessionKey: options?.agentSessionKey, requesterAgentIdOverride: options?.requesterAgentIdOverride, diff --git a/src/agents/system-prompt.e2e.test.ts b/src/agents/system-prompt.e2e.test.ts index b8c27176aef..010c119a902 100644 --- a/src/agents/system-prompt.e2e.test.ts +++ b/src/agents/system-prompt.e2e.test.ts @@ -349,15 +349,24 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("- Opus: anthropic/claude-opus-4-5"); }); - it("adds ClaudeBot self-update guidance when gateway tool is available", () => { + it("adds self-update guidance when gateway tool is available", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", toolNames: ["gateway", "exec"], }); - expect(prompt).toContain("## OpenClaw Self-Update"); + expect(prompt).toContain("## Ironclaw Self-Update"); expect(prompt).toContain("config.apply"); - expect(prompt).toContain("update.run"); + }); + + it("adds self-update guidance when self_update tool is available", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["self_update", "exec"], + }); + + expect(prompt).toContain("## Ironclaw Self-Update"); + expect(prompt).toContain("self_update"); }); it("includes skills guidance when skills prompt is present", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index b8eccd16f59..fe501838b26 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -328,6 +328,7 @@ export function buildAgentSystemPrompt(params: { cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)", message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running OpenClaw process", + self_update: "Update Ironclaw to the latest version and restart the gateway", agents_list: "List agent ids allowed for sessions_spawn", sessions_list: "List other sessions (incl. sub-agents) with filters/last", sessions_history: "Fetch history for another session/sub-agent", @@ -357,6 +358,7 @@ export function buildAgentSystemPrompt(params: { "cron", "message", "gateway", + "self_update", "agents_list", "sessions_list", "sessions_history", @@ -405,6 +407,7 @@ export function buildAgentSystemPrompt(params: { } const hasGateway = availableTools.has("gateway"); + const hasSelfUpdate = availableTools.has("self_update"); const readToolName = resolveToolName("read"); const execToolName = resolveToolName("exec"); const processToolName = resolveToolName("process"); @@ -538,16 +541,23 @@ export function buildAgentSystemPrompt(params: { ...skillsSection, ...memorySection, // Skip self-update for subagent/none modes - hasGateway && !isMinimal ? "## OpenClaw Self-Update" : "", - hasGateway && !isMinimal + (hasGateway || hasSelfUpdate) && !isMinimal ? "## Ironclaw Self-Update" : "", + (hasGateway || hasSelfUpdate) && !isMinimal ? [ - "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", + 'Self-update is ONLY allowed when the user explicitly asks for it (e.g. "update yourself", "upgrade", "get latest version").', + hasSelfUpdate + ? "When the user asks to update, use the `self_update` tool. It updates the Ironclaw package and restarts the gateway automatically." + : "", + hasGateway + ? "The `gateway` tool (owner-only) also supports update.run, config.apply, config.patch, and restart actions." + : "", "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", - "Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).", "After restart, OpenClaw pings the last active session automatically.", - ].join("\n") + ] + .filter(Boolean) + .join("\n") : "", - hasGateway && !isMinimal ? "" : "", + (hasGateway || hasSelfUpdate) && !isMinimal ? "" : "", "", // Skip model aliases for subagent/none modes params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index bd029643a87..6b9cbadfcca 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -45,7 +45,7 @@ export const TOOL_GROUPS: Record = { // UI helpers "group:ui": ["browser", "canvas"], // Automation + infra - "group:automation": ["cron", "gateway"], + "group:automation": ["cron", "gateway", "self_update"], // Messaging surface "group:messaging": ["message"], // Nodes + device tools @@ -58,6 +58,7 @@ export const TOOL_GROUPS: Record = { "cron", "message", "gateway", + "self_update", "agents_list", "sessions_list", "sessions_history", diff --git a/src/agents/tools/self-update-tool.ts b/src/agents/tools/self-update-tool.ts new file mode 100644 index 00000000000..d7f54eb975e --- /dev/null +++ b/src/agents/tools/self-update-tool.ts @@ -0,0 +1,63 @@ +import { Type } from "@sinclair/typebox"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { type AnyAgentTool, jsonResult } from "./common.js"; +import { callGatewayTool, readGatewayCallOptions } from "./gateway.js"; + +const log = createSubsystemLogger("self-update-tool"); + +const DEFAULT_UPDATE_TIMEOUT_MS = 20 * 60_000; + +const SelfUpdateToolSchema = Type.Object({ + gatewayUrl: Type.Optional(Type.String()), + gatewayToken: Type.Optional(Type.String()), + timeoutMs: Type.Optional(Type.Number()), + sessionKey: Type.Optional(Type.String()), + note: Type.Optional(Type.String()), + restartDelayMs: Type.Optional(Type.Number()), +}); + +/** + * A non-owner-only tool that only triggers `update.run` on the gateway. + * Unlike the full `gateway` tool (which exposes config mutation and restart), + * this tool is safe for any authorized sender. + */ +export function createSelfUpdateTool(opts?: { agentSessionKey?: string }): AnyAgentTool { + return { + label: "Self Update", + name: "self_update", + description: + "Update Ironclaw to the latest version and restart the gateway. Use when the user asks to update, upgrade, or get the latest version.", + parameters: SelfUpdateToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const gatewayOpts = readGatewayCallOptions(params); + + const sessionKey = + typeof params.sessionKey === "string" && params.sessionKey.trim() + ? params.sessionKey.trim() + : opts?.agentSessionKey?.trim() || undefined; + const note = + typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined; + const restartDelayMs = + typeof params.restartDelayMs === "number" && Number.isFinite(params.restartDelayMs) + ? Math.floor(params.restartDelayMs) + : undefined; + + const updateTimeoutMs = gatewayOpts.timeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS; + const updateGatewayOpts = { + ...gatewayOpts, + timeoutMs: updateTimeoutMs, + }; + + log.info(`self_update tool: update requested (sessionKey=${sessionKey ?? "none"})`); + + const result = await callGatewayTool("update.run", updateGatewayOpts, { + sessionKey, + note, + restartDelayMs, + timeoutMs: updateTimeoutMs, + }); + return jsonResult({ ok: true, result }); + }, + }; +}