diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index c1494675b759..e050c6ef924f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -459,15 +459,10 @@ "gateway": { "emoji": "πŸ”Œ", "title": "Gateway", - "actions": { - "restart": { - "label": "restart", - "detailKeys": [ - "reason", - "delayMs" - ] - } - } + "detailKeys": [ + "action", + "path" + ] }, "exec": { "emoji": "πŸ› οΈ", diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 6b55b0d0bb20..efca43dbf693 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -35,7 +35,7 @@ openclaw gateway run # equivalent, explicit form - `openclaw onboard --mode local` and `openclaw setup` write `gateway.mode=local`. If the config file exists but `gateway.mode` is missing, that is treated as damaged/clobbered config and the Gateway refuses to guess `local` for you β€” re-run onboarding, set the key manually, or pass `--allow-unconfigured`. - Binding beyond loopback without auth is blocked. - `--bind` values `lan`, `tailnet`, and `custom` resolve over IPv4-only paths today; IPv6-only bring-your-own-host setups need an IPv4 sidecar or proxy in front of the Gateway. - - `SIGUSR1` triggers an in-process restart when authorized. `commands.restart` (default: enabled) gates externally-sent `SIGUSR1`; set it to `false` to block manual OS-signal restarts while still allowing restart via the `gateway restart` command, the gateway tool, and config-apply/update. + - `SIGUSR1` triggers an in-process restart when authorized. `commands.restart` (default: enabled) gates externally-sent `SIGUSR1`; set it to `false` to block manual OS-signal restarts. The agent-facing `gateway` tool is read-only; agents request restart through the human-approved `openclaw` delegation tool. - `SIGINT`/`SIGTERM` stop the process but do not restore custom terminal state β€” if you wrap the CLI in a TUI or raw-mode input, restore the terminal yourself before exit. diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 5eec2af4964b..860fdea65ad1 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -925,7 +925,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native mcp: false, // allow /mcp plugins: false, // allow /plugins debug: false, // allow /debug - restart: true, // allow /restart + gateway restart tool + restart: true, // allow /restart + external SIGUSR1 restart requests ownerAllowFrom: ["discord:123456789012345678"], ownerDisplay: "raw", // raw | hash ownerDisplaySecret: "${OWNER_ID_HASH_SECRET}", @@ -954,7 +954,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native - `plugins: true` enables `/plugins` for plugin discovery, install, and enable/disable controls. - `channels..configWrites` gates config mutations per channel (default: true). - For multi-account channels, `channels..accounts..configWrites` also gates writes that target that account (for example `/allowlist --config --account ` or `/config set channels..accounts....`). -- `restart: false` disables `/restart` and gateway restart tool actions. Default: `true`. +- `restart: false` disables `/restart` and external `SIGUSR1` restart requests. Default: `true`. - `ownerAllowFrom` is the explicit owner allowlist for owner-only commands and owner-gated channel actions. It is separate from `allowFrom`. - `ownerDisplay: "hash"` hashes owner ids in the system prompt. Set `ownerDisplaySecret` to control hashing. - `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored). diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 401d4894e2a8..350075238bd3 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -287,12 +287,12 @@ Slash commands and directives are honored only for authorized senders, derived f ## Control plane tools -Two built-in tools can make persistent changes: +Two built-in tools remain control-plane sensitive: -- `gateway` inspects config with `config.schema.lookup` / `config.get`, and mutates with `config.apply`, `config.patch`, and `update.run`. +- `gateway` reads config with `config.schema.lookup` / `config.get`. It cannot write config, update OpenClaw, or restart the Gateway. - `cron` creates scheduled jobs that keep running after the original chat/task ends. -`gateway config.apply`/`config.patch` are fail-closed by default: only a narrow allowlist of low-risk agent runtime tuning (`agents.defaults.model`, `agents.defaults.thinkingDefault`, per-agent model/thinking/reasoning/fast-mode fields), mention-gating (`channels.*.requireMention` at several nesting depths), and visible-reply settings (`messages.visibleReplies`, `messages.groupChat.visibleReplies`, `messages.groupChat.unmentionedInbound`) are agent-tunable. Any other changed config path is rejected. Prompt overlays stay operator-controlled, and new sensitive config trees are protected unless deliberately added to that allowlist. The tool still refuses to rewrite `tools.exec.ask` or `tools.exec.security`; legacy `tools.bash.*` aliases normalize to the equivalent `tools.exec.*` path before the write is checked. +The `gateway` tool stays owner-only because config reads can expose secrets and host topology. Agents request persistent config or lifecycle changes through the `openclaw` delegation tool; OpenClaw maps them to typed operations and requires human approval before applying them. See [OpenClaw setup agent](/cli/openclaw#operations-and-approval). For any agent/surface handling untrusted content, deny these by default: @@ -304,7 +304,7 @@ For any agent/surface handling untrusted content, deny these by default: } ``` -`commands.restart=false` only blocks restart actions - it does not disable `gateway` config/update actions. +`commands.restart=false` disables `/restart` and external `SIGUSR1` restart requests. The `gateway` agent tool has no restart action. ## Node execution (`system.run`) diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index dc7e21fb386d..3ee6ccd2eb67 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -122,7 +122,7 @@ command handling is enabled for the surface. - Enables `/restart` and gateway restart tool actions. + Enables `/restart` and external `SIGUSR1` restart requests. diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 1c79dde1f8f0..1d43eaa6e255 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -200,7 +200,7 @@ describe("createOpenClawCodingTools", () => { resetGlobalHookRunner(); }); - it("exposes gateway config and restart actions to owner sessions", () => { + it("exposes only gateway config reads to owner sessions", () => { const tools = createOpenClawCodingTools({ config: testConfig }); const gateway = requireTool(tools, "gateway"); @@ -213,7 +213,7 @@ describe("createOpenClawCodingTools", () => { const values = new Set(); collectActionValues(action, values); - expectListIncludes([...values], ["restart", "config.get", "config.patch", "config.apply"]); + expect([...values]).toEqual(["config.get", "config.schema.lookup"]); }); it("does not add Tool Search control tools from the shared factory by default", () => { diff --git a/src/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index 7dafd9792f87..90648ef120a5 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -1,15 +1,6 @@ -// Verifies OpenClaw gateway tool schema, restart signaling, and config mutations. -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +// Verifies the read-only OpenClaw gateway tool schema and config reads. import { beforeEach, describe, expect, it, vi } from "vitest"; import { GatewayClientRequestError } from "../gateway/client.js"; -import { readRestartSentinel } from "../infra/restart-sentinel.js"; -import { - resetGatewayRestartStateForInProcessRestart, - setPreRestartDeferralCheck, -} from "../infra/restart.js"; -import { withEnvAsync } from "../test-utils/env.js"; import { createGatewayTool } from "./tools/gateway-tool.js"; import { callGatewayTool } from "./tools/gateway.js"; @@ -23,75 +14,18 @@ vi.mock("./tools/gateway.js", () => ({ readGatewayCallOptions: readGatewayCallOptionsMock, })); -function requireGatewayTool(agentSessionKey?: string) { - // Tests run with restart enabled so schema and execution paths are visible. - return createGatewayTool({ - ...(agentSessionKey ? { agentSessionKey } : {}), - config: { commands: { restart: true } }, - }); -} - -function collectActionValues(schema: unknown, values: Set): void { - // Tool schemas can expose actions through const, enum, or anyOf variants. - if (!schema || typeof schema !== "object") { - return; - } - - const record = schema as Record; - if (typeof record.const === "string") { - values.add(record.const); - } - if (Array.isArray(record.enum)) { - for (const value of record.enum) { - if (typeof value === "string") { - values.add(value); - } - } - } - if (Array.isArray(record.anyOf)) { - for (const variant of record.anyOf) { - collectActionValues(variant, values); - } - } -} - type GatewayCall = [method: string, options: unknown, params?: unknown]; -function gatewayCalls(): GatewayCall[] { - return vi.mocked(callGatewayTool).mock.calls as GatewayCall[]; -} - function gatewayCall(method: string): GatewayCall { - const call = gatewayCalls().find(([candidate]) => candidate === method); + const call = (vi.mocked(callGatewayTool).mock.calls as GatewayCall[]).find( + ([candidate]) => candidate === method, + ); if (!call) { throw new Error(`Expected gateway call for ${method}`); } return call; } -function expectGatewayCallFields( - method: string, - expectedParams: Record, -): Record { - const params = gatewayCall(method)[2]; - if (params === undefined) { - throw new Error(`Expected gateway call params for ${method}`); - } - const record = params as Record; - for (const [key, value] of Object.entries(expectedParams)) { - expect(record[key]).toEqual(value); - } - return record; -} - -function expectGatewayMethodCalled(method: string): void { - expect(gatewayCalls().some(([candidate]) => candidate === method)).toBe(true); -} - -function expectGatewayMethodNotCalled(method: string): void { - expect(gatewayCalls().some(([candidate]) => candidate === method)).toBe(false); -} - function expectRecordFields( record: unknown, expected: Record, @@ -106,31 +40,6 @@ function expectRecordFields( return actual; } -function expectConfigMutationCall(params: { - callGatewayTool: { - mock: { - calls: Array; - }; - }; - action: "config.apply" | "config.patch"; - raw: string; - sessionKey: string; - replacePaths?: string[]; -}) { - // Config writes must include the base hash from a preceding config.get read. - expect(params.callGatewayTool.mock.calls.some(([method]) => method === "config.get")).toBe(true); - const call = params.callGatewayTool.mock.calls.find(([method]) => method === params.action); - if (!call) { - throw new Error(`Expected gateway call for ${params.action}`); - } - expectRecordFields(call[2], { - raw: params.raw.trim(), - baseHash: "hash-1", - sessionKey: params.sessionKey, - ...(params.replacePaths ? { replacePaths: params.replacePaths } : {}), - }); -} - describe("gateway tool", () => { beforeEach(() => { callGatewayToolMock.mockClear(); @@ -152,9 +61,7 @@ describe("gateway tool", () => { if (method === "config.schema.lookup") { return { path: "gateway.auth", - schema: { - type: "object", - }, + schema: { type: "object" }, hint: { label: "Gateway Auth" }, hintPath: "gateway.auth", children: [ @@ -174,23 +81,20 @@ describe("gateway tool", () => { }); }); - it("exposes restart and config actions in the gateway tool schema", () => { - const tool = requireGatewayTool(); + it("exposes only config read actions", () => { + const tool = createGatewayTool(); const parameters = tool.parameters as { - properties?: Record; + properties?: { action?: { enum?: string[] } }; }; - const values = new Set(); - collectActionValues(parameters.properties?.action, values); - for (const action of ["restart", "config.get", "config.patch", "config.apply"]) { - expect(values.has(action)).toBe(true); - } + expect(parameters.properties?.action?.enum).toEqual(["config.get", "config.schema.lookup"]); + expect(tool.description).toBe( + "Read gateway config + schema. Writes/restart: use openclaw tool.", + ); }); it("scopes config.get output to the requested path and keeps metadata compact", async () => { - const tool = requireGatewayTool(); - - const result = await tool.execute("call-config-get", { + const result = await createGatewayTool().execute("call-config-get", { action: "config.get", path: "tools.exec", }); @@ -218,37 +122,17 @@ describe("gateway tool", () => { ]); }); - it("rejects config.get paths that do not exist", async () => { - const tool = requireGatewayTool(); - + it.each([ + ["tools.missing", "config path not found: tools.missing"], + ["...", "config path not found: ..."], + ["constructor.prototype", "config path not found: constructor.prototype"], + ])("rejects invalid config.get path %s", async (path, message) => { await expect( - tool.execute("call-missing-config-path", { + createGatewayTool().execute("call-invalid-config-path", { action: "config.get", - path: "tools.missing", + path, }), - ).rejects.toThrow("config path not found: tools.missing"); - }); - - it("rejects config.get paths with no segments", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-empty-config-path", { - action: "config.get", - path: "...", - }), - ).rejects.toThrow("config path not found: ..."); - }); - - it("rejects config.get paths that resolve through the prototype chain", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-inherited-config-path", { - action: "config.get", - path: "constructor.prototype", - }), - ).rejects.toThrow("config path not found: constructor.prototype"); + ).rejects.toThrow(message); }); it("reads config.get paths with bracketed array indexes", async () => { @@ -259,9 +143,8 @@ describe("gateway tool", () => { }, }, }); - const tool = requireGatewayTool(); - const result = await tool.execute("call-indexed-config-path", { + const result = await createGatewayTool().execute("call-indexed-config-path", { action: "config.get", path: "agents.list[0].id", }); @@ -288,10 +171,9 @@ describe("gateway tool", () => { callGatewayToolMock.mockResolvedValueOnce({ config: { oversized: "x".repeat(100_000) }, }); - const tool = requireGatewayTool(); await expect( - tool.execute("call-large-config", { + createGatewayTool().execute("call-large-config", { action: "config.get", }), ).rejects.toThrow( @@ -299,556 +181,14 @@ describe("gateway tool", () => { ); }); - it("schedules SIGUSR1 restart and writes the routed sentinel", async () => { - resetGatewayRestartStateForInProcessRestart(); - setPreRestartDeferralCheck(() => 0); - const kill = vi.spyOn(process, "kill").mockImplementation(() => true); - const sigusr1Handler = vi.fn(); - process.on("SIGUSR1", sigusr1Handler); - const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-")); - - try { - await withEnvAsync( - { OPENCLAW_STATE_DIR: stateDir, OPENCLAW_PROFILE: "isolated" }, - async () => { - const result = await requireGatewayTool().execute("call1", { - action: "restart", - delayMs: 0, - }); - expectRecordFields(result.details, { - ok: true, - pid: process.pid, - signal: "SIGUSR1", - delayMs: 0, - }); - - await vi.waitFor(() => expect(sigusr1Handler).toHaveBeenCalledTimes(1), { - interval: 1, - timeout: 1_000, - }); - expect(kill).not.toHaveBeenCalled(); - - const sentinel = await readRestartSentinel(); - expect(sentinel?.payload.kind).toBe("restart"); - expect(sentinel?.payload.doctorHint).toBe( - "Recommended follow-up: run openclaw --profile isolated doctor --non-interactive in a terminal or approvals-capable OpenClaw surface.", - ); - }, - ); - } finally { - process.removeListener("SIGUSR1", sigusr1Handler); - kill.mockRestore(); - resetGatewayRestartStateForInProcessRestart(); - setPreRestartDeferralCheck(() => 0); - await fs.rm(stateDir, { recursive: true, force: true }); - } - }); - - it("passes config.apply through gateway call", async () => { - vi.mocked(callGatewayTool).mockImplementation(async (method: string) => { - if (method === "config.get") { - return { - hash: "hash-1", - config: { - tools: { - exec: { - ask: "on-miss", - security: "allowlist", - }, - }, - }, - }; - } - if (method === "config.apply") { - return { - ok: true, - path: "/tmp/openclaw.json", - config: { agents: { defaults: { reasoningDefault: "medium" } } }, - restart: { ok: true, config: "nested field preserved" }, - }; - } - return { ok: true }; - }); - const sessionKey = "agent:main:whatsapp:dm:+15555550123"; - const tool = requireGatewayTool(sessionKey); - - const raw = - '{\n agents: { defaults: { reasoningDefault: "medium" } },\n tools: { exec: { ask: "on-miss", security: "allowlist" } }\n}\n'; - const result = await tool.execute("call2", { - action: "config.apply", - raw, - }); - - expect(result.details).toEqual({ - ok: true, - result: { - ok: true, - path: "/tmp/openclaw.json", - restart: { ok: true, config: "nested field preserved" }, - }, - }); - expectConfigMutationCall({ - callGatewayTool: vi.mocked(callGatewayTool), - action: "config.apply", - raw, - sessionKey, - }); - }); - - it("passes config.patch through gateway call", async () => { - vi.mocked(callGatewayTool).mockImplementation(async (method: string) => { - if (method === "config.get") { - return { - hash: "hash-1", - config: { - tools: { - exec: { - ask: "on-miss", - security: "allowlist", - }, - }, - }, - }; - } - if (method === "config.patch") { - return { - ok: true, - noop: true, - path: "/tmp/openclaw.json", - config: { channels: { telegram: { groups: {} } } }, - }; - } - return { ok: true }; - }); - const sessionKey = "agent:main:whatsapp:dm:+15555550123"; - const tool = requireGatewayTool(sessionKey); - - const raw = '{\n channels: { telegram: { groups: { "*": { requireMention: false } } } }\n}\n'; - const result = await tool.execute("call4", { - action: "config.patch", - raw, - replacePaths: ["channels.telegram.groups"], - }); - - expect(result.details).toEqual({ - ok: true, - result: { - ok: true, - noop: true, - path: "/tmp/openclaw.json", - }, - }); - expectConfigMutationCall({ - callGatewayTool: vi.mocked(callGatewayTool), - action: "config.patch", - raw, - sessionKey, - replacePaths: ["channels.telegram.groups"], - }); - }); - - it("rejects config.patch when it changes exec approval settings", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-protected-patch", { - action: "config.patch", - raw: '{ tools: { exec: { ask: "off" } } }', - }), - ).rejects.toThrow("gateway config.patch cannot change protected config paths: tools.exec.ask"); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("normalizes replacePaths before config.patch policy checks", async () => { - vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { - if (method === "config.get") { - return { - hash: "hash-1", - config: { - agents: { - list: [ - { id: "main", default: true, workspace: "/tmp/main" }, - { id: "work", workspace: "/tmp/work" }, - ], - }, - }, - }; - } - return { ok: true }; - }); - const sessionKey = "agent:main:whatsapp:dm:+15555550123"; - const tool = requireGatewayTool(sessionKey); - - const raw = '{ agents: { list: [{ id: "main", model: "openai/gpt-5.5" }] } }'; - const result = await tool.execute("call-indexed-replace-path", { - action: "config.patch", - raw, - replacePaths: ["agents.list[0]"], - }); - - expect(result.details).toMatchObject({ ok: true }); - expectConfigMutationCall({ - callGatewayTool: vi.mocked(callGatewayTool), - action: "config.patch", - raw, - sessionKey, - replacePaths: ["agents.list[0]"], - }); - }); - - it("rejects config.patch when it changes safe bin approval paths", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-protected-safe-bins-patch", { - action: "config.patch", - raw: '{ tools: { exec: { safeBins: ["bash"], safeBinProfiles: { bash: { allowedValueFlags: ["-c"] } } } } }', - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: tools.exec.safeBinProfiles.bash.allowedValueFlags, tools.exec.safeBins", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("passes config.patch through gateway call when protected exec arrays and objects are unchanged", async () => { - vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { - if (method === "config.get") { - return { - hash: "hash-1", - config: { - tools: { - exec: { - ask: "on-miss", - security: "allowlist", - safeBins: ["bash"], - safeBinProfiles: { - bash: { - allowedValueFlags: ["-c"], - }, - }, - safeBinTrustedDirs: ["/tmp/openclaw-bin"], - strictInlineEval: true, - }, - }, - }, - }; - } - return { ok: true }; - }); - const tool = requireGatewayTool("agent:main:whatsapp:dm:+15555550123"); - - const raw = `{ - tools: { - exec: { - safeBins: ["bash"], - safeBinProfiles: { - bash: { - allowedValueFlags: ["-c"], - }, - }, - safeBinTrustedDirs: ["/tmp/openclaw-bin"], - strictInlineEval: true, - }, - }, - }`; - await tool.execute("call-same-protected-patch", { - action: "config.patch", - raw, - }); - - expectConfigMutationCall({ - callGatewayTool: vi.mocked(callGatewayTool), - action: "config.patch", - raw, - sessionKey: "agent:main:whatsapp:dm:+15555550123", - }); - }); - - it("rejects config.patch when it changes strict inline eval directly", async () => { - vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { - if (method === "config.get") { - return { hash: "hash-1", config: {} }; - } - return { ok: true }; - }); - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-protected-inline-eval-direct", { - action: "config.patch", - raw: "{ tools: { exec: { strictInlineEval: false } } }", - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: tools.exec.strictInlineEval", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch when a legacy tools.bash alias changes strict inline eval", async () => { - vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { - if (method === "config.get") { - return { hash: "hash-1", config: {} }; - } - return { ok: true }; - }); - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-legacy-protected-inline-eval", { - action: "config.patch", - raw: "{ tools: { bash: { strictInlineEval: false } } }", - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: tools.exec.strictInlineEval", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch when a legacy tools.bash alias changes exec security", async () => { - vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { - if (method === "config.get") { - return { hash: "hash-1", config: {} }; - } - return { ok: true }; - }); - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-legacy-protected-patch", { - action: "config.patch", - raw: '{ tools: { bash: { security: "full" } } }', - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: tools.exec.security", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.apply when it changes exec security settings", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-protected-apply", { - action: "config.apply", - raw: '{ tools: { exec: { ask: "on-miss", security: "full" } } }', - }), - ).rejects.toThrow( - "gateway config.apply cannot change protected config paths: tools.exec.security", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.apply"); - }); - - it("rejects config.apply when protected exec settings are omitted", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-missing-protected", { - action: "config.apply", - raw: '{ agents: { defaults: { reasoningDefault: "medium" } } }', - }), - ).rejects.toThrow( - "gateway config.apply cannot change protected config paths: tools.exec.ask, tools.exec.security", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.apply"); - }); - - it("rejects config.apply when it changes safe bin trusted directories", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-protected-safe-bin-trust-apply", { - action: "config.apply", - raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", safeBinTrustedDirs: ["/tmp/openclaw-bin"] } } }', - }), - ).rejects.toThrow( - "gateway config.apply cannot change protected config paths: tools.exec.safeBinTrustedDirs", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.apply"); - }); - - it("rejects config.patch when it rewrites gateway.remote.url", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-remote-redirect", { - action: "config.patch", - raw: '{ gateway: { remote: { url: "wss://attacker.example/collect" } } }', - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: gateway.remote.url", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch when it rewrites global tools policy", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-tools-policy", { - action: "config.patch", - raw: '{ tools: { allow: ["exec"], elevated: { enabled: true } } }', - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: tools.allow, tools.elevated.enabled", - ); - expectGatewayMethodCalled("config.get"); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch that enables dangerouslyDisableDeviceAuth", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-dangerous-device-auth", { - action: "config.patch", - raw: "{ gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }", - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: gateway.controlUi.dangerouslyDisableDeviceAuth", - ); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch that enables allowUnsafeExternalContent on gmail hooks", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-dangerous-gmail", { - action: "config.patch", - raw: "{ hooks: { gmail: { allowUnsafeExternalContent: true } } }", - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: hooks.gmail.allowUnsafeExternalContent", - ); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch that weakens applyPatch.workspaceOnly", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-dangerous-workspace", { - action: "config.patch", - raw: "{ tools: { exec: { applyPatch: { workspaceOnly: false } } } }", - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: tools.exec.applyPatch.workspaceOnly", - ); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch that enables allowInsecureAuth on control UI", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-dangerous-insecure-auth", { - action: "config.patch", - raw: "{ gateway: { controlUi: { allowInsecureAuth: true } } }", - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: gateway.controlUi.allowInsecureAuth", - ); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("rejects config.patch that enables dangerouslyAllowHostHeaderOriginFallback", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-dangerous-origin-fallback", { - action: "config.patch", - raw: "{ gateway: { controlUi: { dangerouslyAllowHostHeaderOriginFallback: true } } }", - }), - ).rejects.toThrow( - "gateway config.patch cannot change protected config paths: gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback", - ); - expectGatewayMethodNotCalled("config.patch"); - }); - - it("allows config.patch that does not enable any dangerous flag", async () => { - const sessionKey = "agent:main:whatsapp:dm:+15555550123"; - const tool = requireGatewayTool(sessionKey); - - const raw = '{ channels: { telegram: { groups: { "*": { requireMention: false } } } } }'; - await tool.execute("call-safe-patch", { - action: "config.patch", - raw, - }); - - expectGatewayCallFields("config.patch", { raw: raw.trim() }); - }); - - it("allows config.patch on allowlisted paths when a dangerous flag is already enabled", async () => { - vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => { - if (method === "config.get") { - return { - hash: "hash-1", - config: { - tools: { exec: { ask: "on-miss", security: "allowlist" } }, - hooks: { gmail: { allowUnsafeExternalContent: true } }, - }, - }; - } - return { ok: true }; - }); - const sessionKey = "agent:main:whatsapp:dm:+15555550123"; - const tool = requireGatewayTool(sessionKey); - - const raw = '{ agents: { defaults: { reasoningDefault: "medium" } } }'; - await tool.execute("call-keep-dangerous", { - action: "config.patch", - raw, - }); - - expectGatewayCallFields("config.patch", { raw: raw.trim() }); - }); - - it("rejects config.apply that introduces a dangerous flag", async () => { - const tool = requireGatewayTool(); - - await expect( - tool.execute("call-dangerous-apply", { - action: "config.apply", - raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", applyPatch: { workspaceOnly: false } } } }', - }), - ).rejects.toThrow( - "gateway config.apply cannot change protected config paths: tools.exec.applyPatch.workspaceOnly", - ); - expectGatewayMethodNotCalled("config.apply"); - }); - - it("does not expose update.run", () => { - const tool = requireGatewayTool(); - const parameters = tool.parameters as { - properties?: { action?: { enum?: string[] } }; - }; - - expect(parameters.properties?.action?.enum).not.toContain("update.run"); - }); - it("returns a path-scoped schema lookup result", async () => { - const tool = requireGatewayTool(); - - const result = await tool.execute("call5", { + const result = await createGatewayTool().execute("call-schema", { action: "config.schema.lookup", path: "gateway.auth", }); - expect(gatewayCall("config.schema.lookup")[2]).toEqual({ - path: "gateway.auth", - }); - const details = expectRecordFields(result.details, { - ok: true, - }); + expect(gatewayCall("config.schema.lookup")[2]).toEqual({ path: "gateway.auth" }); + const details = expectRecordFields(result.details, { ok: true }); const lookupResult = expectRecordFields(details.result, { path: "gateway.auth", hintPath: "gateway.auth", @@ -861,21 +201,17 @@ describe("gateway tool", () => { required: true, hintPath: "gateway.auth.token", }); - const schema = (result.details as { result?: { schema?: { properties?: unknown } } }).result - ?.schema; - expect(schema?.properties).toBeUndefined(); }); it("returns an in-band schema lookup miss for unknown paths", async () => { - vi.mocked(callGatewayTool).mockRejectedValueOnce( + callGatewayToolMock.mockRejectedValueOnce( new GatewayClientRequestError({ code: "INVALID_REQUEST", message: "config schema path not found", }), ); - const tool = requireGatewayTool(); - const result = await tool.execute("call6", { + const result = await createGatewayTool().execute("call-missing-schema", { action: "config.schema.lookup", path: "agents.main.authorizedSenders", }); diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index dbd0b84b334c..60f2a1913a5a 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -503,10 +503,7 @@ export function createOpenClawTools( ...(embedded ? [] : [ - createGatewayTool({ - agentSessionKey: options?.agentSessionKey, - config: options?.config, - }), + createGatewayTool(), ...createOpenClawDelegateToolsForRun({ ...options, sessionAgentId }), ]), createAgentsListTool({ diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 6af6465aa3df..84f798837bdf 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -298,10 +298,8 @@ describe("buildAgentSystemPrompt", () => { }); expect(prompt).toContain("## OpenClaw Control"); - expect(prompt).toContain("prefer `gateway`"); - expect(prompt).toContain("CLI lifecycle only explicit"); - expect(prompt).toContain("openclaw gateway status|restart|start|stop"); - expect(prompt).toContain("`restart`, not stop+start"); + expect(prompt).toContain("Config read: `gateway`"); + expect(prompt).not.toContain("openclaw gateway status|restart|start|stop"); expect(prompt).toContain("Do not invent commands"); }); @@ -733,13 +731,18 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("- Opus: anthropic/claude-opus-4-5"); }); - it("keeps update.run out of gateway guidance", () => { + it("keeps gateway guidance read-only", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", toolNames: ["gateway", "exec"], }); - expect(prompt).toContain("config.schema.lookup"); + expect(prompt).toContain( + "Config read: `gateway` (`config.get|config.schema.lookup`). Write/restart unavailable; ask human.", + ); + expect(prompt).not.toContain("config.patch"); + expect(prompt).not.toContain("config.apply"); + expect(prompt).not.toContain("`config.schema.lookup|get|patch|apply`, `restart`"); expect(prompt).not.toContain("update.run"); expect(prompt).not.toContain("Use config.schema to"); expect(prompt).not.toContain("config.schema, config.apply"); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index dace2446ef00..2dcf51fb0c83 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -793,7 +793,7 @@ export function buildAgentSystemPrompt(params: { cron: "Schedule/wake. Reminder text must read as reminder when fired; mention reminder for delayed gaps; include useful recent context.", message: "Message/channel actions", openclaw: "System setup/config expert; writes need human approval", - gateway: "Gateway restart/config", + gateway: "Read gateway config/schema", agents_list: acpSpawnRuntimeEnabled ? "List allowed OpenClaw subagent ids; not ACP ids" : "List allowed subagent ids", @@ -1161,10 +1161,8 @@ export function buildAgentSystemPrompt(params: { "Config, channels, plugins, new agents, model/provider, updates: ask `openclaw`. Never write own config; OpenClaw is system expert.", ] : [ - "Config/restart: prefer `gateway` (`config.schema.lookup|get|patch|apply`, `restart`).", + "Config read: `gateway` (`config.get|config.schema.lookup`). Write/restart unavailable; ask human.", ]), - "CLI lifecycle only explicit: `openclaw gateway status|restart|start|stop`.", - "`restart`, not stop+start.", "", ...skillsSection, ...skillWorkshopSection, diff --git a/src/agents/test-helpers/fast-openclaw-tools.ts b/src/agents/test-helpers/fast-openclaw-tools.ts index 7d96b9db47af..2615c8c339f3 100644 --- a/src/agents/test-helpers/fast-openclaw-tools.ts +++ b/src/agents/test-helpers/fast-openclaw-tools.ts @@ -28,13 +28,7 @@ const coreTools = [ stubActionTool("cron", ["schedule", "cancel"]), stubActionTool("message", ["send", "reply"]), stubTool("heartbeat_respond"), - stubActionTool("gateway", [ - "restart", - "config.get", - "config.schema.lookup", - "config.apply", - "config.patch", - ]), + stubActionTool("gateway", ["config.get", "config.schema.lookup"]), stubTool("openclaw"), stubActionTool("agents_list", ["list", "show"]), stubActionTool("sessions_list", ["list", "show"]), diff --git a/src/agents/tool-catalog.ts b/src/agents/tool-catalog.ts index 77853206279a..340be5d3d17b 100644 --- a/src/agents/tool-catalog.ts +++ b/src/agents/tool-catalog.ts @@ -281,7 +281,7 @@ const CORE_TOOL_DEFINITIONS: CoreToolDefinition[] = [ { id: "gateway", label: "gateway", - description: "Gateway control", + description: "Read Gateway config and schema", sectionId: "automation", profiles: [], includeInOpenClawGroup: true, diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index 027517b5812d..c141a61fc809 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -313,12 +313,7 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { gateway: { emoji: "πŸ”Œ", title: "Gateway", - actions: { - restart: { - label: "restart", - detailKeys: ["reason", "delayMs"], - }, - }, + detailKeys: ["action", "path"], }, exec: { emoji: "πŸ› οΈ", diff --git a/src/agents/tools/gateway-config-guard.ts b/src/agents/tools/gateway-config-guard.ts deleted file mode 100644 index 25653d2d8530..000000000000 --- a/src/agents/tools/gateway-config-guard.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; -import { isRecord as isPlainObject } from "@openclaw/normalization-core/record-coerce"; -import { parseConfigJson5 } from "../../config/io.js"; -import { applyMergePatch } from "../../config/merge-patch.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { collectEnabledInsecureOrDangerousFlags } from "../../security/dangerous-config-flags.js"; - -// `assertGatewayConfigMutationAllowed` is the explicit model -> operator -// trust-boundary control on `config.apply`/`config.patch`, so the runtime tool -// must fail closed and allow only a narrow set of agent-tunable paths. -const ALLOWED_GATEWAY_CONFIG_PATHS = [ - // Low-risk agent runtime tuning. - // agents.list[].model is allowed below; the defaults-shape spelling of the - // same capability must match or allowlisting depends on config shape. - "agents.defaults.model", - "agents.defaults.thinkingDefault", - "agents.defaults.subagents.thinking", - "agents.defaults.reasoningDefault", - "agents.defaults.fastModeDefault", - "agents.list[].id", - "agents.list[].model", - "agents.list[].thinkingDefault", - "agents.list[].subagents.thinking", - "agents.list[].reasoningDefault", - "agents.list[].fastModeDefault", - // Mention gating is an agent-facing scope knob across channel adapters. - // Depths here must cover the deepest `requireMention` path the channel - // adapters use today β€” Telegram topic overrides live at - // `channels.telegram.groups..topics..requireMention`. - "channels.*.requireMention", - "channels.*.*.requireMention", - "channels.*.*.*.requireMention", - "channels.*.*.*.*.requireMention", - "channels.*.*.*.*.*.requireMention", - // Visible reply delivery mode is a bounded message UX setting, not a secret - // or privilege boundary. Let agents repair silent group/channel rooms. - "messages.visibleReplies", - "messages.groupChat.visibleReplies", - "messages.groupChat.unmentionedInbound", -] as const; - -/** @internal Exposed for regression tests only; do not import from runtime code. */ -export function assertGatewayConfigMutationAllowedForTest(params: { - action: "config.apply" | "config.patch"; - currentConfig: Record; - raw: string; - replacePaths?: string[]; -}): void { - assertGatewayConfigMutationAllowed(params); -} - -function parseGatewayConfigMutationRaw( - raw: string, - action: "config.apply" | "config.patch", -): unknown { - const parsedRes = parseConfigJson5(raw); - if (!parsedRes.ok) { - throw new Error(parsedRes.error); - } - if ( - !parsedRes.parsed || - typeof parsedRes.parsed !== "object" || - Array.isArray(parsedRes.parsed) - ) { - throw new Error(`${action} raw must be an object.`); - } - return parsedRes.parsed; -} - -function normalizeGatewayConfigPath(path: string): string { - return path.startsWith("tools.bash.") ? path.replace(/^tools\.bash\./, "tools.exec.") : path; -} - -function readKeyedArrayEntries(list: unknown): { - duplicateIds: boolean; - entries: Map; - hasUnkeyedEntries: boolean; -} | null { - if (!Array.isArray(list)) { - return null; - } - - let duplicateIds = false; - let hasUnkeyedEntries = false; - const entries = new Map(); - for (const entry of list) { - if (!isPlainObject(entry) || typeof entry.id !== "string" || entry.id.length === 0) { - hasUnkeyedEntries = true; - continue; - } - if (entries.has(entry.id)) { - duplicateIds = true; - continue; - } - entries.set(entry.id, entry); - } - return { duplicateIds, entries, hasUnkeyedEntries }; -} - -function collectConfigLeafPaths(value: unknown, basePath: string, out: Set): void { - const canonicalPath = normalizeGatewayConfigPath(basePath); - if (value === undefined) { - if (canonicalPath) { - out.add(canonicalPath); - } - return; - } - - if (Array.isArray(value)) { - const keyedEntries = readKeyedArrayEntries(value); - if ( - keyedEntries && - !keyedEntries.duplicateIds && - !keyedEntries.hasUnkeyedEntries && - keyedEntries.entries.size > 0 - ) { - for (const entryValue of keyedEntries.entries.values()) { - collectConfigLeafPaths(entryValue, `${basePath}[]`, out); - } - return; - } - if (canonicalPath) { - out.add(canonicalPath); - } - return; - } - - if (!isPlainObject(value)) { - if (canonicalPath) { - out.add(canonicalPath); - } - return; - } - - const entries = Object.entries(value); - if (entries.length === 0) { - if (canonicalPath) { - out.add(canonicalPath); - } - return; - } - - for (const [key, child] of entries) { - collectConfigLeafPaths(child, basePath ? `${basePath}.${key}` : key, out); - } -} - -function collectChangedConfigPaths( - currentValue: unknown, - nextValue: unknown, - basePath = "", - out = new Set(), -): Set { - if (isDeepStrictEqual(currentValue, nextValue)) { - return out; - } - - if (currentValue === undefined || nextValue === undefined) { - collectConfigLeafPaths(currentValue ?? nextValue, basePath, out); - return out; - } - - if (Array.isArray(currentValue) || Array.isArray(nextValue)) { - if (!Array.isArray(currentValue) || !Array.isArray(nextValue)) { - collectConfigLeafPaths(currentValue, basePath, out); - collectConfigLeafPaths(nextValue, basePath, out); - return out; - } - - const currentEntries = readKeyedArrayEntries(currentValue); - const nextEntries = readKeyedArrayEntries(nextValue); - if ( - !currentEntries || - !nextEntries || - currentEntries.duplicateIds || - nextEntries.duplicateIds || - currentEntries.hasUnkeyedEntries || - nextEntries.hasUnkeyedEntries - ) { - out.add(normalizeGatewayConfigPath(basePath)); - return out; - } - - const ids = new Set([...currentEntries.entries.keys(), ...nextEntries.entries.keys()]); - for (const id of ids) { - collectChangedConfigPaths( - currentEntries.entries.get(id), - nextEntries.entries.get(id), - `${basePath}[]`, - out, - ); - } - return out; - } - - if (isPlainObject(currentValue) && isPlainObject(nextValue)) { - const keys = new Set([...Object.keys(currentValue), ...Object.keys(nextValue)]); - for (const key of keys) { - collectChangedConfigPaths( - currentValue[key], - nextValue[key], - basePath ? `${basePath}.${key}` : key, - out, - ); - } - return out; - } - - out.add(normalizeGatewayConfigPath(basePath)); - return out; -} - -function pathSegmentMatches(patternSegment: string, pathSegment: string): boolean { - return patternSegment === "*" || patternSegment === pathSegment; -} - -function isAllowedGatewayConfigPath(path: string): boolean { - const pathSegments = path.split("."); - return ALLOWED_GATEWAY_CONFIG_PATHS.some((pattern) => { - const patternSegments = pattern.split("."); - if (patternSegments.length > pathSegments.length) { - return false; - } - for (let i = 0; i < patternSegments.length; i += 1) { - const patternSegment = patternSegments.at(i); - const pathSegment = pathSegments.at(i); - if (!patternSegment || !pathSegment || !pathSegmentMatches(patternSegment, pathSegment)) { - return false; - } - } - return true; - }); -} - -export function assertGatewayConfigMutationAllowed(params: { - action: "config.apply" | "config.patch"; - currentConfig: Record; - raw: string; - replacePaths?: string[]; -}): void { - const parsed = parseGatewayConfigMutationRaw(params.raw, params.action); - const nextConfig = - params.action === "config.apply" - ? (parsed as Record) - : (applyMergePatch(params.currentConfig, parsed, { - mergeObjectArraysById: true, - replaceArrayPaths: new Set(params.replacePaths ?? []), - }) as Record); - const changedPaths = [...collectChangedConfigPaths(params.currentConfig, nextConfig)].toSorted(); - const disallowedPaths = changedPaths.filter((path) => !isAllowedGatewayConfigPath(path)); - if (disallowedPaths.length > 0) { - throw new Error( - `gateway ${params.action} cannot change protected config paths: ${disallowedPaths.join(", ")}. ` + - "Agent config writes are restricted to a fixed allowlist as an injection boundary; " + - "sender identity or user authorization cannot widen it, so do not retry or ask for approval. " + - "The operator must change protected paths outside the agent (openclaw.json or openclaw configure). " + - `Agent-tunable paths: ${ALLOWED_GATEWAY_CONFIG_PATHS.join(", ")}`, - ); - } - - // Block writes that newly enable any dangerous config flag. - // Uses the same flag enumeration as `openclaw security audit`. - const currentFlags = new Set( - collectEnabledInsecureOrDangerousFlags(params.currentConfig as OpenClawConfig), - ); - const nextFlags = collectEnabledInsecureOrDangerousFlags(nextConfig as OpenClawConfig); - const newlyEnabled = nextFlags.filter((f) => !currentFlags.has(f)); - if (newlyEnabled.length > 0) { - throw new Error( - `gateway ${params.action} cannot enable dangerous config flags: ${newlyEnabled.join(", ")}`, - ); - } -} diff --git a/src/agents/tools/gateway-tool-guard-coverage.test.ts b/src/agents/tools/gateway-tool-guard-coverage.test.ts deleted file mode 100644 index a97328bd302d..000000000000 --- a/src/agents/tools/gateway-tool-guard-coverage.test.ts +++ /dev/null @@ -1,655 +0,0 @@ -// Gateway config mutation guard coverage keeps agent-driven config edits inside -// the documented low-risk allowlist. -import { describe, expect, it } from "vitest"; -import { assertGatewayConfigMutationAllowedForTest } from "./gateway-config-guard.js"; - -function expectBlocked( - currentConfig: Record, - patch: Record, -): void { - expect(() => - assertGatewayConfigMutationAllowedForTest({ - action: "config.patch", - currentConfig, - raw: JSON.stringify(patch), - }), - ).toThrow(/cannot (?:change protected|enable dangerous)/); -} - -function expectAllowed( - currentConfig: Record, - patch: Record, -): void { - expect( - assertGatewayConfigMutationAllowedForTest({ - action: "config.patch", - currentConfig, - raw: JSON.stringify(patch), - }), - ).toBeUndefined(); -} - -function expectBlockedApply( - currentConfig: Record, - nextConfig: Record, -): void { - expect(() => - assertGatewayConfigMutationAllowedForTest({ - action: "config.apply", - currentConfig, - raw: JSON.stringify(nextConfig), - }), - ).toThrow(/cannot (?:change protected|enable dangerous)/); -} - -function expectAllowedApply( - currentConfig: Record, - nextConfig: Record, -): void { - expect( - assertGatewayConfigMutationAllowedForTest({ - action: "config.apply", - currentConfig, - raw: JSON.stringify(nextConfig), - }), - ).toBeUndefined(); -} - -describe("gateway config mutation guard coverage", () => { - it("explains the injection-boundary contract when refusing protected paths", () => { - // The refusal must be self-explanatory: agents were observed misreading a - // bare path error as an authorization failure and arguing with the owner. - expect(() => - assertGatewayConfigMutationAllowedForTest({ - action: "config.patch", - currentConfig: { agents: { defaults: { promptOverlays: ["a"] } } }, - raw: JSON.stringify({ agents: { defaults: { promptOverlays: ["b"] } } }), - }), - ).toThrow(/injection boundary.*cannot widen it.*Agent-tunable paths/s); - }); - - it("blocks global prompt overlay edits via config.patch", () => { - expectBlocked( - { agents: { defaults: { promptOverlays: { gpt5: { personality: "off" } } } } }, - { agents: { defaults: { promptOverlays: { gpt5: { personality: "best" } } } } }, - ); - }); - - it("allows global default model edits via config.patch", () => { - // Capability parity with agents.list[].model: model selection is not a - // credential/privilege boundary, and the per-agent spelling was already - // allowlisted, so blocking only the defaults shape protected nothing. - expectAllowed( - { agents: { defaults: { model: { primary: "openai/gpt-5.4" } } } }, - { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }, - ); - }); - - it("allows documented subagent thinking default edits via config.patch", () => { - expectAllowed( - {}, - { - agents: { - defaults: { - subagents: { thinking: "medium" }, - }, - }, - }, - ); - expectAllowed( - { - agents: { - defaults: { - subagents: { thinking: "low" }, - }, - }, - }, - { - agents: { - defaults: { - subagents: { thinking: "high" }, - }, - }, - }, - ); - }); - - it("allows documented per-agent subagent thinking edits via config.patch", () => { - expectAllowed( - { - agents: { - list: [{ id: "worker", subagents: { thinking: "low" } }], - }, - }, - { - agents: { - list: [{ id: "worker", subagents: { thinking: "medium" } }], - }, - }, - ); - expectAllowed( - { agents: { list: [] as Array> } }, - { - agents: { - list: [{ id: "helper", subagents: { thinking: "medium" } }], - }, - }, - ); - }); - - it("keeps neighboring subagent policy fields protected via config.patch", () => { - expectBlocked( - { agents: { defaults: { subagents: { allowAgents: ["worker"] } } } }, - { agents: { defaults: { subagents: { allowAgents: ["*"] } } } }, - ); - expectBlocked( - { - agents: { - list: [{ id: "worker", subagents: { requireAgentId: true } }], - }, - }, - { - agents: { - list: [{ id: "worker", subagents: { requireAgentId: false } }], - }, - }, - ); - }); - - it("allows visible reply delivery mode edits via config.patch", () => { - expectAllowed( - {}, - { - messages: { - visibleReplies: "automatic", - groupChat: { - visibleReplies: "automatic", - unmentionedInbound: "user_request", - }, - }, - }, - ); - expectAllowed( - { - messages: { - visibleReplies: "automatic", - groupChat: { visibleReplies: "message_tool" }, - }, - }, - { - messages: { - visibleReplies: "message_tool", - groupChat: { - visibleReplies: "automatic", - unmentionedInbound: "room_event", - }, - }, - }, - ); - }); - - it("blocks disabling sandbox mode via config.patch", () => { - expectBlocked( - { agents: { defaults: { sandbox: { mode: "all" } } } }, - { agents: { defaults: { sandbox: { mode: "off" } } } }, - ); - }); - - it("blocks enabling an installed-but-disabled plugin via config.patch", () => { - expectBlocked( - { plugins: { entries: { malicious: { enabled: false } } } }, - { plugins: { entries: { malicious: { enabled: true } } } }, - ); - }); - - it("blocks clearing tools.fs.workspaceOnly hardening via config.patch", () => { - expectBlocked( - { tools: { fs: { workspaceOnly: true } } }, - { tools: { fs: { workspaceOnly: false } } }, - ); - }); - - it("blocks enabling sandbox dangerouslyAllowContainerNamespaceJoin via config.patch", () => { - expectBlocked( - { - agents: { - defaults: { - sandbox: { - docker: { dangerouslyAllowContainerNamespaceJoin: false }, - }, - }, - }, - }, - { - agents: { - defaults: { - sandbox: { - docker: { dangerouslyAllowContainerNamespaceJoin: true }, - }, - }, - }, - }, - ); - }); - - it("blocks unlocking exec/shell/spawn on /tools/invoke via gateway.tools.allow", () => { - expectBlocked( - { gateway: { tools: { allow: [] as string[] } } }, - { gateway: { tools: { allow: ["exec", "shell", "spawn"] } } }, - ); - }); - - it("blocks in-place hooks.mappings sessionKey rewrite via mergeObjectArraysById", () => { - expectBlocked( - { - hooks: { - mappings: [{ id: "gmail", sessionKey: "hook:gmail:{{messages[0].id}}" }], - }, - }, - { - hooks: { - mappings: [{ id: "gmail", sessionKey: "hook:{{payload.session}}" }], - }, - }, - ); - }); - - it("blocks per-agent sandbox override under agents.list[]", () => { - expectBlocked( - { - agents: { - list: [{ id: "worker", sandbox: { mode: "all" } }], - }, - }, - { - agents: { - list: [{ id: "worker", sandbox: { mode: "off" } }], - }, - }, - ); - }); - - it("blocks id-less per-agent sandbox injection under agents.list[]", () => { - expectBlocked( - { agents: { list: [] as Array> } }, - { - agents: { - list: [{ sandbox: { mode: "off" } }], - }, - }, - ); - }); - - it("blocks per-agent tools.allow override under agents.list[]", () => { - expectBlocked( - { - agents: { - list: [{ id: "worker", tools: { allow: [] as string[] } }], - }, - }, - { - agents: { - list: [{ id: "worker", tools: { allow: ["exec", "shell", "spawn"] } }], - }, - }, - ); - }); - - it("blocks per-agent embeddedAgent override under agents.list[]", () => { - expectBlocked( - { - agents: { - list: [{ id: "worker", embeddedAgent: { executionContract: "strict-agentic" } }], - }, - }, - { - agents: { - list: [{ id: "worker", embeddedAgent: { executionContract: "none" } }], - }, - }, - ); - }); - - it("blocks subagent tool deny-list override via tools.subagents", () => { - expectBlocked( - { tools: { subagents: { tools: { allow: [] as string[] } } } }, - { tools: { subagents: { tools: { allow: ["gateway", "cron", "sessions_send"] } } } }, - ); - }); - - it("blocks gateway.auth.token rewrite via config.patch", () => { - expectBlocked( - { gateway: { auth: { mode: "token", token: "operator-secret" } } }, - { gateway: { auth: { token: "attacker-known-token" } } }, - ); - }); - - it("blocks gateway.tls.certPath redirect via config.patch", () => { - expectBlocked( - { gateway: { tls: { enabled: true, certPath: "/etc/openclaw/cert.pem" } } }, - { gateway: { tls: { certPath: "/tmp/attacker/cert.pem" } } }, - ); - }); - - it("blocks plugins.load.paths injection via config.patch", () => { - expectBlocked( - { plugins: { load: { paths: [] as string[] } } }, - { plugins: { load: { paths: ["/tmp/malicious-plugin"] } } }, - ); - }); - - it("blocks plugins.slots memory swap via config.patch", () => { - expectBlocked( - { plugins: { slots: { memory: "official-memory" } } }, - { plugins: { slots: { memory: "attacker-memory" } } }, - ); - }); - - it("blocks root sandbox override via config.patch", () => { - expectBlocked({ sandbox: { mode: "all" } }, { sandbox: { mode: "off" } }); - }); - - it("blocks plugins.allow edits via config.patch", () => { - expectBlocked( - { plugins: { allow: ["trusted-plugin"] } }, - { plugins: { allow: ["trusted-plugin", "evil-plugin"] } }, - ); - }); - - it("blocks hooks.token rewrites via config.patch", () => { - expectBlocked({ hooks: { token: "operator-secret" } }, { hooks: { token: "attacker-secret" } }); - }); - - it("blocks hooks.allowRequestSessionKey via config.patch", () => { - expectBlocked( - { hooks: { allowRequestSessionKey: false } }, - { hooks: { allowRequestSessionKey: true } }, - ); - }); - - it("blocks browser.ssrfPolicy rewrites via config.patch", () => { - expectBlocked( - { browser: { ssrfPolicy: { dangerouslyAllowPrivateNetwork: false } } }, - { browser: { ssrfPolicy: { dangerouslyAllowPrivateNetwork: true } } }, - ); - }); - - it("blocks mcp.servers rewrites via config.patch", () => { - expectBlocked( - { mcp: { servers: {} } }, - { mcp: { servers: { evil: { command: "nc", args: ["-e", "/bin/sh"] } } } }, - ); - }); - - it("blocks gateway.remote.url redirect via config.patch", () => { - expectBlocked( - { gateway: { remote: { url: "wss://gateway.example/ws" } } }, - { gateway: { remote: { url: "wss://attacker.example/collect" } } }, - ); - }); - - it("blocks global tools policy rewrites via config.patch", () => { - expectBlocked( - { tools: { allow: ["read"] } }, - { tools: { allow: ["read", "exec"], elevated: { enabled: true } } }, - ); - }); - - it("blocks memory.qmd.command rewrites via config.patch", () => { - expectBlocked( - { memory: { qmd: { command: "/usr/local/bin/qmd" } } }, - { memory: { qmd: { command: "/tmp/attacker.sh" } } }, - ); - }); - - it("blocks browser.executablePath rewrites via config.patch", () => { - expectBlocked( - { browser: { executablePath: "/usr/bin/chromium" } }, - { browser: { executablePath: "/tmp/pwn" } }, - ); - }); - - it("allows adding a new agent without protected subfields via config.patch", () => { - expectAllowed( - { - agents: { - list: [{ id: "worker", sandbox: { mode: "all" } }], - }, - }, - { - agents: { - list: [{ id: "helper", model: "sonnet-4.6" }], - }, - }, - ); - }); - - it("allows removing an agent without protected subfields via config.apply", () => { - expectAllowedApply( - { - agents: { - list: [ - { id: "worker", model: "sonnet-4.6" }, - { id: "helper", sandbox: { mode: "all" } }, - ], - }, - }, - { - agents: { - list: [{ id: "helper", sandbox: { mode: "all" } }], - }, - }, - ); - }); - - it("blocks removing an agent that carries a protected sandbox override via config.apply", () => { - expectBlockedApply( - { - agents: { - list: [ - { id: "worker", sandbox: { mode: "all" } }, - { id: "helper", model: "sonnet-4.6" }, - ], - }, - }, - { - agents: { - list: [{ id: "helper", model: "sonnet-4.6" }], - }, - }, - ); - }); - - it("allows reordering agents without protected changes via config.apply", () => { - expectAllowedApply( - { - agents: { - list: [ - { id: "worker", sandbox: { mode: "all" } }, - { id: "helper", sandbox: { mode: "all" } }, - ], - }, - }, - { - agents: { - list: [ - { id: "helper", sandbox: { mode: "all" } }, - { id: "worker", sandbox: { mode: "all" } }, - ], - }, - }, - ); - }); - - it("allows reordering agents when a dangerous per-agent sandbox flag is already enabled", () => { - // Reorders should not be interpreted as a fresh dangerous enablement when - // the exact agent record already carried the protected value. - expectAllowedApply( - { - agents: { - list: [ - { - id: "worker", - sandbox: { - docker: { dangerouslyAllowContainerNamespaceJoin: true }, - }, - }, - { id: "helper" }, - ], - }, - }, - { - agents: { - list: [ - { id: "helper" }, - { - id: "worker", - sandbox: { - docker: { dangerouslyAllowContainerNamespaceJoin: true }, - }, - }, - ], - }, - }, - ); - }); - - it("blocks adding a new agent with a protected sandbox override via config.patch", () => { - expectBlocked( - { - agents: { - list: [{ id: "worker", sandbox: { mode: "all" } }], - }, - }, - { - agents: { - list: [{ id: "helper", sandbox: { mode: "off" } }], - }, - }, - ); - }); - - it("still allows benign agent-driven tweaks", () => { - expectAllowed( - { - agents: { - defaults: { reasoningDefault: "low" }, - list: [{ id: "worker", model: "sonnet-4" }], - }, - }, - { - agents: { - defaults: { reasoningDefault: "medium" }, - list: [{ id: "worker", model: "opus-4.6" }], - }, - }, - ); - }); - - it("blocks config.apply replacing the config with protected changes", () => { - expectBlockedApply( - { - agents: { - defaults: { - sandbox: { mode: "all" }, - reasoningDefault: "low", - }, - }, - }, - { - agents: { - defaults: { - sandbox: { mode: "off" }, - reasoningDefault: "medium", - }, - }, - }, - ); - }); - - it("blocks config.apply replacing global prompt and model defaults", () => { - expectBlockedApply( - { - agents: { - defaults: { - model: { primary: "openai/gpt-5.4" }, - promptOverlays: { gpt5: { personality: "off" } }, - reasoningDefault: "low", - }, - }, - }, - { - agents: { - defaults: { - model: { primary: "openai/gpt-5.5" }, - promptOverlays: { gpt5: { personality: "best" } }, - reasoningDefault: "medium", - }, - }, - }, - ); - }); - - it("blocks config.apply duplicate-id protected rewrites", () => { - expectBlockedApply( - { - agents: { - list: [{ id: "worker", sandbox: { mode: "all" } }], - }, - }, - { - agents: { - list: [ - { id: "worker", sandbox: { mode: "off" } }, - { id: "worker", sandbox: { mode: "all" } }, - ], - }, - }, - ); - }); - - it("still allows benign config.apply replacements", () => { - expectAllowedApply( - { - agents: { - defaults: { reasoningDefault: "low" }, - list: [{ id: "worker", model: "sonnet-4" }], - }, - }, - { - agents: { - defaults: { reasoningDefault: "medium" }, - list: [{ id: "worker", model: "opus-4.6" }], - }, - }, - ); - }); - - it("allows requireMention edits at Telegram topic depth via config.patch", () => { - expectAllowed( - { - channels: { - telegram: { - groups: { - "-1001234567890": { - requireMention: true, - topics: { "99": { requireMention: true } }, - }, - }, - }, - }, - }, - { - channels: { - telegram: { - groups: { - "-1001234567890": { - topics: { "99": { requireMention: false } }, - }, - }, - }, - }, - }, - ); - }); -}); diff --git a/src/agents/tools/gateway-tool.test.ts b/src/agents/tools/gateway-tool.test.ts index bc8351ed9053..e653a085d43b 100644 --- a/src/agents/tools/gateway-tool.test.ts +++ b/src/agents/tools/gateway-tool.test.ts @@ -1,77 +1,8 @@ -// Gateway tool restart tests cover the sentinel handoff that lets an agent -// resume private work after the gateway process restarts. import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RestartSentinelPayload } from "../../infra/restart-sentinel.js"; -import type { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; import { createGatewayTool } from "./gateway-tool.js"; -type ScheduleGatewayRestartArgs = Parameters[0]; - -const { - extractDeliveryInfoMock, - formatDoctorNonInteractiveHintMock, - isRestartEnabledMock, - callGatewayToolMock, - clearRestartSentinelMock, - scheduleGatewaySigusr1RestartMock, - writeRestartSentinelMock, -} = vi.hoisted(() => ({ - isRestartEnabledMock: vi.fn(() => true), +const { callGatewayToolMock } = vi.hoisted(() => ({ callGatewayToolMock: vi.fn(async () => ({ ok: true })), - extractDeliveryInfoMock: vi.fn(() => ({ - deliveryContext: { - channel: "slack", - to: "slack:C123", - accountId: "workspace-1", - }, - threadId: "thread-42", - })), - formatDoctorNonInteractiveHintMock: vi.fn( - () => - "Recommended follow-up: run openclaw doctor --non-interactive in a terminal or approvals-capable OpenClaw surface.", - ), - writeRestartSentinelMock: vi.fn(async (_payload: RestartSentinelPayload) => undefined), - clearRestartSentinelMock: vi.fn(async () => undefined), - scheduleGatewaySigusr1RestartMock: vi.fn((_opts?: ScheduleGatewayRestartArgs) => ({ - ok: true, - pid: 123, - signal: "SIGUSR1" as const, - delayMs: 250, - mode: "emit" as const, - coalesced: false, - cooldownMsApplied: 0, - emitHooksQueued: true, - })), -})); - -vi.mock("../../config/commands.js", () => ({ - isRestartEnabled: isRestartEnabledMock, -})); - -vi.mock("../../config/sessions.js", () => ({ - extractDeliveryInfo: extractDeliveryInfoMock, -})); - -vi.mock("../../infra/restart-sentinel.js", async () => { - const actual = await vi.importActual( - "../../infra/restart-sentinel.js", - ); - return { - ...actual, - formatDoctorNonInteractiveHint: formatDoctorNonInteractiveHintMock, - clearRestartSentinel: clearRestartSentinelMock, - writeRestartSentinel: writeRestartSentinelMock, - }; -}); - -vi.mock("../../infra/restart.js", () => ({ - scheduleGatewaySigusr1Restart: scheduleGatewaySigusr1RestartMock, -})); - -vi.mock("../../logging/subsystem.js", () => ({ - createSubsystemLogger: vi.fn(() => ({ - info: vi.fn(), - })), })); vi.mock("./gateway.js", () => ({ @@ -79,302 +10,32 @@ vi.mock("./gateway.js", () => ({ readGatewayCallOptions: vi.fn(() => ({})), })); -function requireRestartSentinelPayload(): RestartSentinelPayload { - const calls = writeRestartSentinelMock.mock.calls; - const payload = calls[calls.length - 1]?.[0]; - if (!payload) { - throw new Error("expected restart sentinel payload"); - } - return payload; -} - -function requireScheduledRestartArgs(): NonNullable { - const calls = scheduleGatewaySigusr1RestartMock.mock.calls; - const args = calls[calls.length - 1]?.[0]; - if (!args) { - throw new Error("expected scheduled restart args"); - } - return args; -} - -describe("gateway tool restart continuation", () => { +describe("gateway tool", () => { beforeEach(() => { - isRestartEnabledMock.mockReset(); - isRestartEnabledMock.mockReturnValue(true); - extractDeliveryInfoMock.mockReset(); - extractDeliveryInfoMock.mockReturnValue({ - deliveryContext: { - channel: "slack", - to: "slack:C123", - accountId: "workspace-1", - }, - threadId: "thread-42", - }); - formatDoctorNonInteractiveHintMock.mockReset(); - formatDoctorNonInteractiveHintMock.mockReturnValue( - "Recommended follow-up: run openclaw doctor --non-interactive in a terminal or approvals-capable OpenClaw surface.", - ); - writeRestartSentinelMock.mockReset(); - writeRestartSentinelMock.mockResolvedValue(undefined); - clearRestartSentinelMock.mockClear(); - scheduleGatewaySigusr1RestartMock.mockReset(); - scheduleGatewaySigusr1RestartMock.mockReturnValue({ - ok: true, - pid: 123, - signal: "SIGUSR1", - delayMs: 250, - mode: "emit", - coalesced: false, - cooldownMsApplied: 0, - emitHooksQueued: true, - }); - callGatewayToolMock.mockReset(); - callGatewayToolMock.mockResolvedValue({ ok: true }); + callGatewayToolMock.mockClear(); }); - it("does not expose system-event continuations to the agent tool", async () => { - const tool = createGatewayTool(); - - const parameters = tool.parameters as { - properties?: { - continuationKind?: unknown; - }; - }; - expect(parameters.properties?.continuationKind).toBeUndefined(); - }); - - it("advertises restart delays as non-negative integers", async () => { - const tool = createGatewayTool(); - - const parameters = tool.parameters as { - properties?: { - delayMs?: { minimum?: number; type?: string }; - replacePaths?: { items?: { type?: string }; type?: string }; - restartDelayMs?: { minimum?: number; type?: string }; - timeoutMs?: { minimum?: number; type?: string }; - }; - }; - expect(parameters.properties?.delayMs).toMatchObject({ type: "integer", minimum: 0 }); - expect(parameters.properties?.replacePaths).toMatchObject({ - type: "array", - items: { type: "string" }, - }); - expect(parameters.properties?.restartDelayMs).toMatchObject({ type: "integer", minimum: 0 }); - expect(parameters.properties?.timeoutMs).toMatchObject({ type: "integer", minimum: 1 }); - }); - - it("instructs agents to use continuationMessage for internal post-restart work", async () => { - const tool = createGatewayTool(); - - expect(tool.description).toContain("replacePaths"); - expect(tool.description).toContain("Internal continuation: one-shot continuationMessage"); - expect(tool.description).toContain("visible follow-up uses message tool"); - expect(tool.description).toContain("continuationMessage"); - expect(tool.description).toContain("Never write restart sentinel directly"); - }); - - it("writes an agentTurn continuation into the restart sentinel", async () => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - const result = await tool.execute?.("tool-call-1", { - action: "restart", - delayMs: 250, - reason: "continue after reboot", - note: "Gateway restarting now", - continuationMessage: "Reply with exactly: Yay! I did it!", - }); - - expect(writeRestartSentinelMock).not.toHaveBeenCalled(); - // The sentinel is emitted by the restart scheduler hook, so failed restart - // delivery can still clean up a prepared file before the process exits. - await requireScheduledRestartArgs().emitHooks?.beforeEmit?.(); - - const payload = requireRestartSentinelPayload(); - expect(payload.kind).toBe("restart"); - expect(payload.status).toBe("ok"); - expect(payload.sessionKey).toBe("agent:main:main"); - expect(payload.deliveryContext).toEqual({ - channel: "slack", - to: "slack:C123", - accountId: "workspace-1", - }); - expect(payload.threadId).toBe("thread-42"); - expect(payload.message).toBe("Gateway restarting now"); - expect(payload.continuation).toEqual({ - kind: "agentTurn", - message: "Reply with exactly: Yay! I did it!", - }); - const restartArgs = requireScheduledRestartArgs(); - expect(restartArgs.delayMs).toBe(250); - expect(restartArgs.reason).toBe("continue after reboot"); - expect(restartArgs.sessionKey).toBe("agent:main:main"); - expect(typeof restartArgs.emitHooks?.beforeEmit).toBe("function"); - expect(typeof restartArgs.emitHooks?.afterEmitRejected).toBe("function"); - expect(result?.details).toMatchObject({ - ok: true, - delayMs: 250, - coalesced: false, - emitHooksQueued: true, - continuationQueued: true, - }); - }); - - it("keeps the bounded restart reason UTF-16 well-formed", async () => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - await tool.execute?.("tool-call-utf16", { - action: "restart", - reason: `${"x".repeat(199)}πŸš€tail`, - }); - - expect(requireScheduledRestartArgs().reason).toBe("x".repeat(199)); - }); - - it("uses the runtime session, not model-supplied params, for scheduler ownership and sentinel routing (#86742)", async () => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:session-A", - config: {}, - }); - - await tool.execute?.("tool-call-1", { - action: "restart", - sessionKey: "agent:main:session-B", - continuationMessage: "Reply after restart", - }); - - expect(requireScheduledRestartArgs().sessionKey).toBe("agent:main:session-A"); - await requireScheduledRestartArgs().emitHooks?.beforeEmit?.(); - expect(requireRestartSentinelPayload().sessionKey).toBe("agent:main:session-A"); - }); - - it("reports continuationQueued=false when a coalesced restart belongs to another session (#86742)", async () => { - scheduleGatewaySigusr1RestartMock.mockReturnValue({ - ok: true, - pid: 123, - signal: "SIGUSR1", - delayMs: 0, - mode: "emit", - coalesced: true, - cooldownMsApplied: 0, - emitHooksQueued: false, - }); - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - const result = await tool.execute?.("tool-call-1", { - action: "restart", - continuationMessage: "Reply after restart", - }); - - expect(writeRestartSentinelMock).not.toHaveBeenCalled(); - expect(result?.details).toMatchObject({ - coalesced: true, - emitHooksQueued: false, - continuationQueued: false, - }); - }); - - it.each([-1, 1.5, "soon"])("rejects invalid restart delayMs value %s", async (delayMs) => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - await expect( - tool.execute?.("tool-call-invalid-delay", { - action: "restart", - delayMs, - }), - ).rejects.toThrow("delayMs must be a non-negative integer"); - expect(scheduleGatewaySigusr1RestartMock).not.toHaveBeenCalled(); - }); - - it("accepts string restart delayMs values through the shared numeric reader", async () => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - await tool.execute?.("tool-call-string-delay", { - action: "restart", - delayMs: "250", - }); - - expect(requireScheduledRestartArgs().delayMs).toBe(250); - }); - - it("coerces legacy continuationKind inputs to an agentTurn", async () => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - await tool.execute?.("tool-call-1", { - action: "restart", - continuationKind: "systemEvent", - continuationMessage: "Reply after restart", - }); - - await requireScheduledRestartArgs().emitHooks?.beforeEmit?.(); - - // Older model-facing arguments should not reintroduce system-event - // continuations; visible replies still go through the message tool. - expect(requireRestartSentinelPayload().continuation).toEqual({ - kind: "agentTurn", - message: "Reply after restart", - }); - }); - - it("does not infer a continuation for session-scoped restarts", async () => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - await tool.execute?.("tool-call-1", { - action: "restart", - delayMs: 250, - reason: "restart requested", - }); - - await requireScheduledRestartArgs().emitHooks?.beforeEmit?.(); - - const payload = requireRestartSentinelPayload(); - expect(payload.sessionKey).toBe("agent:main:main"); - expect(payload.continuation).toBeNull(); - }); - - it("removes the prepared sentinel when restart emission is rejected", async () => { - const tool = createGatewayTool({ - agentSessionKey: "agent:main:main", - config: {}, - }); - - await tool.execute?.("tool-call-1", { - action: "restart", - }); - - const scheduledArgs = requireScheduledRestartArgs(); - await scheduledArgs.emitHooks?.beforeEmit?.(); - await scheduledArgs.emitHooks?.afterEmitRejected?.(); - - expect(clearRestartSentinelMock).toHaveBeenCalledOnce(); - }); - - it("does not expose update.run", () => { + it("exposes only read actions", () => { const tool = createGatewayTool(); const parameters = tool.parameters as { properties?: { action?: { enum?: string[] } }; }; - expect(parameters.properties?.action?.enum).not.toContain("update.run"); + expect(parameters.properties?.action?.enum).toEqual(["config.get", "config.schema.lookup"]); + expect(tool.description).toBe( + "Read gateway config + schema. Writes/restart: use openclaw tool.", + ); }); + + it.each(["restart", "config.apply", "config.patch", "update.run"])( + "rejects removed action %s", + async (action) => { + const tool = createGatewayTool(); + + await expect(tool.execute?.("tool-call", { action })).rejects.toThrow( + `Unknown action: ${action}`, + ); + expect(callGatewayToolMock).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index dd1f00a1e5bb..e65f4630b2db 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -1,64 +1,23 @@ -/** - * gateway built-in tool. - * - * Exposes selected Gateway control/config actions with fail-closed config mutation boundaries. - */ -import { isRecord as isPlainObject } from "@openclaw/normalization-core/record-coerce"; -import { - normalizeOptionalString, - readStringValue, -} from "@openclaw/normalization-core/string-coerce"; -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +/** Read-only Gateway config tool for regular agents. */ +import { readStringValue } from "@openclaw/normalization-core/string-coerce"; import { Type } from "typebox"; -import { isRestartEnabled } from "../../config/commands.flags.js"; -import { resolveConfigSnapshotHash } from "../../config/io.js"; -import { normalizeConfigPatchReplacePaths } from "../../config/patch-replace-paths.js"; -import { extractDeliveryInfo } from "../../config/sessions.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { GatewayClientRequestError } from "../../gateway/client.js"; -import { - buildRestartSuccessContinuation, - clearRestartSentinel, - formatDoctorNonInteractiveHint, - type RestartSentinelPayload, - writeRestartSentinel, -} from "../../infra/restart-sentinel.js"; -import { scheduleGatewaySigusr1Restart } from "../../infra/restart.js"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; import { parseConfigPathArrayIndex } from "../../shared/path-array-index.js"; -import { optionalNonNegativeIntegerSchema, stringEnum } from "../schema/typebox.js"; +import { stringEnum } from "../schema/typebox.js"; import { type AnyAgentTool, jsonResult, - readNonNegativeIntegerParam, - readStringArrayParam, readStringParam, textResult, ToolInputError, } from "./common.js"; -import { assertGatewayConfigMutationAllowed } from "./gateway-config-guard.js"; import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js"; import { callGatewayTool, readGatewayCallOptions } from "./gateway.js"; -const log = createSubsystemLogger("gateway-tool"); - // Keep complete JSON below the smallest default tool-result presentation budget. const MAX_GATEWAY_CONFIG_GET_TEXT_CHARS = 12_000; const CONFIG_SCHEMA_PATH_NOT_FOUND_MESSAGE = "config schema path not found"; -function resolveBaseHashFromSnapshot(snapshot: unknown): string | undefined { - if (!snapshot || typeof snapshot !== "object") { - return undefined; - } - const hashValue = (snapshot as { hash?: unknown }).hash; - const rawValue = (snapshot as { raw?: unknown }).raw; - const hash = resolveConfigSnapshotHash({ - hash: readStringValue(hashValue), - raw: readStringValue(rawValue), - }); - return hash ?? undefined; -} - function getSnapshotConfig(snapshot: unknown): Record { if (!snapshot || typeof snapshot !== "object") { throw new Error("config.get response is not an object."); @@ -130,17 +89,6 @@ function createGatewayConfigGetToolResult(result: unknown) { return textResult(text, { ok: true }); } -// Direct RPC callers need the validated config echoed after writes; the -// agent-facing gateway tool does not, and replaying it bloats transcripts. -function stripConfigWriteResultPayload(result: unknown): unknown { - if (!isPlainObject(result) || !Object.hasOwn(result, "config")) { - return result; - } - const stripped = { ...result }; - delete stripped.config; - return stripped; -} - function isConfigSchemaPathNotFoundError(error: unknown): boolean { return ( error instanceof GatewayClientRequestError && @@ -149,159 +97,25 @@ function isConfigSchemaPathNotFoundError(error: unknown): boolean { ); } -const GATEWAY_ACTIONS = [ - "restart", - "config.get", - "config.schema.lookup", - "config.apply", - "config.patch", -] as const; +const GATEWAY_ACTIONS = ["config.get", "config.schema.lookup"] as const; -// NOTE: Using a flattened object schema instead of Type.Union([Type.Object(...), ...]) -// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. -// The discriminator (action) determines which properties are relevant; runtime validates. const GatewayToolSchema = Type.Object({ action: stringEnum(GATEWAY_ACTIONS), - // restart - delayMs: optionalNonNegativeIntegerSchema(), - reason: Type.Optional(Type.String()), - continuationMessage: Type.Optional(Type.String()), - // config.get, config.schema.lookup, config.apply ...gatewayCallOptionSchemaProperties(), - // config.get, config.schema.lookup path: Type.Optional(Type.String()), - // config.apply, config.patch - raw: Type.Optional(Type.String()), - baseHash: Type.Optional(Type.String()), - replacePaths: Type.Optional(Type.Array(Type.String(), { maxItems: 256 })), - // config.apply, config.patch - sessionKey: Type.Optional(Type.String()), - note: Type.Optional(Type.String()), - restartDelayMs: optionalNonNegativeIntegerSchema(), }); -// NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here: -// - OpenAI rejects tool schemas that include these keywords at the *top-level*. -// - Claude/Vertex has other JSON Schema quirks. -// Conditional requirements (like `raw` for config.apply) are enforced at runtime. -export function createGatewayTool(opts?: { - agentSessionKey?: string; - config?: OpenClawConfig; -}): AnyAgentTool { +export function createGatewayTool(): AnyAgentTool { return { label: "Gateway", name: "gateway", - description: - "Gateway restart/config. Before edit: config.schema.lookup exact path. Patch merges; apply replaces. Array removal: exact replacePaths. Writes reload/restart. Human note after restart. Internal continuation: one-shot continuationMessage; visible follow-up uses message tool. Never write restart sentinel directly.", + description: "Read gateway config + schema. Writes/restart: use openclaw tool.", parameters: GatewayToolSchema, execute: async (_toolCallId, args) => { const params = args as Record; const action = readStringParam(params, "action", { required: true }); - if (action === "restart") { - if (!isRestartEnabled(opts?.config)) { - throw new Error("Gateway restart is disabled (commands.restart=false)."); - } - const sessionKey = - normalizeOptionalString(opts?.agentSessionKey) ?? - normalizeOptionalString(params.sessionKey); - const delayMs = readNonNegativeIntegerParam(params, "delayMs"); - const rawReason = normalizeOptionalString(params.reason); - const reason = rawReason ? truncateUtf16Safe(rawReason, 200) : undefined; - const note = normalizeOptionalString(params.note); - const continuationMessage = normalizeOptionalString(params.continuationMessage); - // Extract channel + threadId for routing after restart. - // Uses generic :thread: parsing plus plugin-owned session grammars. - const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey); - const payload: RestartSentinelPayload = { - kind: "restart", - status: "ok", - ts: Date.now(), - sessionKey, - deliveryContext, - threadId, - message: note ?? reason ?? null, - continuation: buildRestartSuccessContinuation({ - sessionKey, - continuationMessage, - }), - doctorHint: formatDoctorNonInteractiveHint(), - stats: { - mode: "gateway.restart", - reason, - }, - }; - log.info( - `gateway tool: restart requested (delayMs=${delayMs ?? "default"}, reason=${reason ?? "none"})`, - ); - let sentinelWritten = false; - const scheduled = scheduleGatewaySigusr1Restart({ - delayMs, - reason, - // Ownership and sentinel routing use the same trusted session identity, - // so model-supplied params cannot queue work into another session. - sessionKey, - emitHooks: { - beforeEmit: async () => { - await writeRestartSentinel(payload); - sentinelWritten = true; - }, - afterEmitRejected: async () => { - if (sentinelWritten) { - await clearRestartSentinel(); - } - }, - }, - }); - return jsonResult({ - ...scheduled, - ...(payload.continuation ? { continuationQueued: scheduled.emitHooksQueued } : {}), - }); - } - const gatewayOpts = readGatewayCallOptions(params); - const resolveGatewayWriteMeta = (): { - sessionKey: string | undefined; - note: string | undefined; - restartDelayMs: number | undefined; - } => { - const sessionKey = - normalizeOptionalString(opts?.agentSessionKey) ?? - normalizeOptionalString(params.sessionKey); - const note = normalizeOptionalString(params.note); - const restartDelayMs = readNonNegativeIntegerParam(params, "restartDelayMs"); - return { sessionKey, note, restartDelayMs }; - }; - - const resolveConfigWriteParams = async (): Promise<{ - raw: string; - baseHash: string; - snapshotConfig: Record; - sessionKey: string | undefined; - note: string | undefined; - restartDelayMs: number | undefined; - replacePaths: string[] | undefined; - }> => { - const raw = readStringParam(params, "raw", { required: true }); - const rawReplacePaths = - action === "config.patch" ? readStringArrayParam(params, "replacePaths") : undefined; - const replacePaths = rawReplacePaths - ? [...normalizeConfigPatchReplacePaths(rawReplacePaths)] - : undefined; - const snapshot = await callGatewayTool("config.get", gatewayOpts, {}); - // Always fetch config.get so we can compare protected exec settings - // against the current snapshot before forwarding any write RPC. - const snapshotConfig = getSnapshotConfig(snapshot); - let baseHash = readStringParam(params, "baseHash"); - if (!baseHash) { - baseHash = resolveBaseHashFromSnapshot(snapshot); - } - if (!baseHash) { - throw new Error("Missing baseHash from config snapshot."); - } - return { raw, baseHash, snapshotConfig, replacePaths, ...resolveGatewayWriteMeta() }; - }; - if (action === "config.get") { const path = readStringParam(params, "path"); const snapshot = await callGatewayTool("config.get", gatewayOpts, {}); @@ -328,42 +142,6 @@ export function createGatewayTool(opts?: { throw error; } } - if (action === "config.apply") { - const { raw, baseHash, snapshotConfig, sessionKey, note, restartDelayMs } = - await resolveConfigWriteParams(); - assertGatewayConfigMutationAllowed({ - action: "config.apply", - currentConfig: snapshotConfig, - raw, - }); - const result = await callGatewayTool("config.apply", gatewayOpts, { - raw, - baseHash, - sessionKey, - note, - restartDelayMs, - }); - return jsonResult({ ok: true, result: stripConfigWriteResultPayload(result) }); - } - if (action === "config.patch") { - const { raw, baseHash, snapshotConfig, sessionKey, note, restartDelayMs, replacePaths } = - await resolveConfigWriteParams(); - assertGatewayConfigMutationAllowed({ - action: "config.patch", - currentConfig: snapshotConfig, - raw, - replacePaths, - }); - const result = await callGatewayTool("config.patch", gatewayOpts, { - raw, - baseHash, - sessionKey, - note, - restartDelayMs, - ...(replacePaths ? { replacePaths } : {}), - }); - return jsonResult({ ok: true, result: stripConfigWriteResultPayload(result) }); - } throw new Error(`Unknown action: ${action}`); }, }; diff --git a/src/agents/tools/system-agent-tool.test.ts b/src/agents/tools/system-agent-tool.test.ts index 1fb2d95f8e73..391e46d312fd 100644 --- a/src/agents/tools/system-agent-tool.test.ts +++ b/src/agents/tools/system-agent-tool.test.ts @@ -180,10 +180,17 @@ describe("openclaw tool", () => { ); expect( resolveSystemAgentProposalTransition({ - args: { action: "setup", workspace: "/tmp/work" }, + args, resultText: toolText(result), }), - ).toEqual({ proposal: proposalRef.current }); + ).toEqual({ + proposal: proposalRef.current, + operation: { + kind: "setup", + workspace: "/tmp/work", + model: "openai/gpt-5.5", + }, + }); }); it("voids setup approval when the requested model changes", async () => { @@ -411,13 +418,19 @@ describe("openclaw tool", () => { args, resultText: "needs-approval: this action changes state.", }), - ).toEqual({ proposal: hash }); + ).toEqual({ + proposal: hash, + operation: { kind: "set-default-model", model: "openai/gpt-5.5" }, + }); expect( resolveSystemAgentProposalTransition({ args, resultText: `needs-approval:${hash}\nThis action changes state.`, }), - ).toEqual({ proposal: hash }); + ).toEqual({ + proposal: hash, + operation: { kind: "set-default-model", model: "openai/gpt-5.5" }, + }); // A voided approval clears it. expect( resolveSystemAgentProposalTransition({ diff --git a/src/cli/gateway-cli/run-loop.test.ts b/src/cli/gateway-cli/run-loop.test.ts index 4f43de314a87..7ce8376c33e3 100644 --- a/src/cli/gateway-cli/run-loop.test.ts +++ b/src/cli/gateway-cli/run-loop.test.ts @@ -1579,7 +1579,7 @@ describe("runGatewayLoop", () => { expect(close).not.toHaveBeenCalled(); expect(start).toHaveBeenCalledTimes(1); expect(gatewayLog.warn).toHaveBeenCalledWith( - "SIGUSR1 restart ignored (not authorized; commands.restart=false or use gateway tool).", + "SIGUSR1 restart ignored (not authorized; commands.restart=false).", ); expect(gatewayLog.warn).toHaveBeenCalledTimes(2); expect(gatewayLog.warn).toHaveBeenNthCalledWith( diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 2505b88839ea..20abcb359c98 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -832,9 +832,7 @@ export async function runGatewayLoop(params: { if (!authorized) { markGatewaySigusr1RestartHandled(); if (!isGatewaySigusr1RestartExternallyAllowed()) { - gatewayLog.warn( - "SIGUSR1 restart ignored (not authorized; commands.restart=false or use gateway tool).", - ); + gatewayLog.warn("SIGUSR1 restart ignored (not authorized; commands.restart=false)."); gatewayLog.warn( "An unauthorized SIGUSR1 restart signal was received and ignored. " + "If a pending gateway restart needs to be applied, run `openclaw gateway restart` " + diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a9ae37ac36ee..26dac7870081 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1669,7 +1669,7 @@ export const FIELD_HELP: Record = { "commands.plugins": "Allow /plugins chat command to list discovered plugins and toggle plugin enablement in config (default: false).", "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", - "commands.restart": "Allow /restart and gateway restart tool actions (default: true).", + "commands.restart": "Allow /restart and external SIGUSR1 restart requests (default: true).", "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", "commands.ownerAllowFrom": "Explicit owner allowlist for owner-scoped commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", diff --git a/src/gateway/tool-resolution.exclude.test.ts b/src/gateway/tool-resolution.exclude.test.ts index 1f4761526df0..ccd55a5ca702 100644 --- a/src/gateway/tool-resolution.exclude.test.ts +++ b/src/gateway/tool-resolution.exclude.test.ts @@ -150,13 +150,21 @@ describe("resolveGatewayScopedTools excludeToolNames", () => { ]); expect(nonOwnerResult.tools.map((tool) => tool.name)).toEqual(["read", "sessions_spawn"]); const args = readCreateToolsArgs(1); - expect(args.pluginToolDenylist).toEqual(["cron", "gateway", "sessions", "nodes", "computer"]); + expect(args.pluginToolDenylist).toEqual([ + "cron", + "gateway", + "sessions", + "nodes", + "computer", + "openclaw", + ]); expect(args.inheritedToolDenylist).toEqual([ "cron", "gateway", "sessions", "nodes", "computer", + "openclaw", ]); }); diff --git a/src/security/dangerous-config-flags.ts b/src/security/dangerous-config-flags.ts index 0c61fce5ed99..a6782661591b 100644 --- a/src/security/dangerous-config-flags.ts +++ b/src/security/dangerous-config-flags.ts @@ -8,7 +8,7 @@ import { collectEnabledInsecureOrDangerousFlagsFromContracts } from "./dangerous import { collectEnabledInsecureOrDangerousFlagsFromCurrentSnapshot } from "./dangerous-config-flags-current.js"; /** - * Collect enabled insecure/dangerous config flags for audit warnings and gateway tool previews. + * Collect enabled insecure/dangerous config flags for audit and startup warnings. * Plugin flags use current metadata when requested, then fall back to resolving manifest contracts. */ export function collectEnabledInsecureOrDangerousFlags( diff --git a/src/security/dangerous-tools.ts b/src/security/dangerous-tools.ts index 8f0a189b43b8..3365afcf1204 100644 --- a/src/security/dangerous-tools.ts +++ b/src/security/dangerous-tools.ts @@ -27,7 +27,7 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [ "sessions_send", // Persistent automation control plane β€” can create/update/remove scheduled runs "cron", - // Gateway control plane β€” prevents gateway reconfiguration via HTTP + // Gateway config can expose secrets and host topology "gateway", // Node command relay can reach system.run on paired hosts "nodes", @@ -37,8 +37,8 @@ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [ ] as const; /** - * Persistent control-plane tools that can change Gateway configuration or - * create scheduled automation. + * Sensitive control-plane tools. `cron` can persist automation; `gateway` + * exposes configuration and schema details even though its agent actions are read-only. */ export const GATEWAY_CONTROL_PLANE_TOOLS = ["cron", "gateway"] as const; diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json index be3e10fb0ed4..f5130ca15879 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.discord-group.json @@ -1148,62 +1148,22 @@ }, { "deferLoading": true, - "description": "Gateway restart/config/update. Before config edit: config.schema.lookup exact dot path. Partial merge: config.patch; full replace only: config.apply. Removing array entries via patch needs exact array replacePaths. Writes hot-reload/restart as needed. Always human note for post-restart delivery. Internal continuation: one-shot continuationMessage; its visible follow-up uses message tool. Never write restart sentinel directly.", + "description": "Read gateway config + schema. Writes/restart: use openclaw tool.", "inputSchema": { "properties": { "action": { - "enum": [ - "restart", - "config.get", - "config.schema.lookup", - "config.apply", - "config.patch", - "update.run" - ], + "enum": ["config.get", "config.schema.lookup"], "type": "string" }, - "baseHash": { - "type": "string" - }, - "continuationMessage": { - "type": "string" - }, - "delayMs": { - "minimum": 0, - "type": "integer" - }, "gatewayToken": { "type": "string" }, "gatewayUrl": { "type": "string" }, - "note": { - "type": "string" - }, "path": { "type": "string" }, - "raw": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "replacePaths": { - "items": { - "type": "string" - }, - "maxItems": 256, - "type": "array" - }, - "restartDelayMs": { - "minimum": 0, - "type": "integer" - }, - "sessionKey": { - "type": "string" - }, "timeoutMs": { "minimum": 1, "type": "integer" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json index f8dc144f7e46..d3e12d6f2251 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.heartbeat-turn.json @@ -1180,62 +1180,22 @@ }, { "deferLoading": true, - "description": "Gateway restart/config/update. Before config edit: config.schema.lookup exact dot path. Partial merge: config.patch; full replace only: config.apply. Removing array entries via patch needs exact array replacePaths. Writes hot-reload/restart as needed. Always human note for post-restart delivery. Internal continuation: one-shot continuationMessage; its visible follow-up uses message tool. Never write restart sentinel directly.", + "description": "Read gateway config + schema. Writes/restart: use openclaw tool.", "inputSchema": { "properties": { "action": { - "enum": [ - "restart", - "config.get", - "config.schema.lookup", - "config.apply", - "config.patch", - "update.run" - ], + "enum": ["config.get", "config.schema.lookup"], "type": "string" }, - "baseHash": { - "type": "string" - }, - "continuationMessage": { - "type": "string" - }, - "delayMs": { - "minimum": 0, - "type": "integer" - }, "gatewayToken": { "type": "string" }, "gatewayUrl": { "type": "string" }, - "note": { - "type": "string" - }, "path": { "type": "string" }, - "raw": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "replacePaths": { - "items": { - "type": "string" - }, - "maxItems": 256, - "type": "array" - }, - "restartDelayMs": { - "minimum": 0, - "type": "integer" - }, - "sessionKey": { - "type": "string" - }, "timeoutMs": { "minimum": 1, "type": "integer" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json index 40f137576e60..6979f493a3e7 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/codex-dynamic-tools.telegram-direct.json @@ -1144,62 +1144,22 @@ }, { "deferLoading": true, - "description": "Gateway restart/config/update. Before config edit: config.schema.lookup exact dot path. Partial merge: config.patch; full replace only: config.apply. Removing array entries via patch needs exact array replacePaths. Writes hot-reload/restart as needed. Always human note for post-restart delivery. Internal continuation: one-shot continuationMessage; its visible follow-up uses message tool. Never write restart sentinel directly.", + "description": "Read gateway config + schema. Writes/restart: use openclaw tool.", "inputSchema": { "properties": { "action": { - "enum": [ - "restart", - "config.get", - "config.schema.lookup", - "config.apply", - "config.patch", - "update.run" - ], + "enum": ["config.get", "config.schema.lookup"], "type": "string" }, - "baseHash": { - "type": "string" - }, - "continuationMessage": { - "type": "string" - }, - "delayMs": { - "minimum": 0, - "type": "integer" - }, "gatewayToken": { "type": "string" }, "gatewayUrl": { "type": "string" }, - "note": { - "type": "string" - }, "path": { "type": "string" }, - "raw": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "replacePaths": { - "items": { - "type": "string" - }, - "maxItems": 256, - "type": "array" - }, - "restartDelayMs": { - "minimum": 0, - "type": "integer" - }, - "sessionKey": { - "type": "string" - }, "timeoutMs": { "minimum": 1, "type": "integer" diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md index d94157d0a2f2..a9d4b5022e04 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/discord-group-codex-message-tool.md @@ -208,8 +208,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 52583, - "roughTokens": 13146 + "chars": 52524, + "roughTokens": 13131 }, "openClawDeveloperInstructions": { "chars": 3431, @@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6989 }, "totalWithDynamicToolsJson": { - "chars": 80541, - "roughTokens": 20136 + "chars": 80482, + "roughTokens": 20121 }, "userInputText": { "chars": 1442, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md index 359fcf5d9ed7..647b269e93a3 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-direct-codex-message-tool.md @@ -208,8 +208,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 52310, - "roughTokens": 13078 + "chars": 52251, + "roughTokens": 13063 }, "openClawDeveloperInstructions": { "chars": 2322, @@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6610 }, "totalWithDynamicToolsJson": { - "chars": 78750, - "roughTokens": 19688 + "chars": 78691, + "roughTokens": 19673 }, "userInputText": { "chars": 1033, diff --git a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md index 5a96b6de21bd..cfd90d96cb6e 100644 --- a/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md +++ b/test/fixtures/agents/prompt-snapshots/codex-runtime-happy-path/telegram-heartbeat-codex-tool.md @@ -209,8 +209,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 0 }, "dynamicToolsJson": { - "chars": 53600, - "roughTokens": 13400 + "chars": 53541, + "roughTokens": 13386 }, "openClawDeveloperInstructions": { "chars": 2341, @@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the "roughTokens": 6745 }, "totalWithDynamicToolsJson": { - "chars": 80579, - "roughTokens": 20145 + "chars": 80520, + "roughTokens": 20130 }, "userInputText": { "chars": 1271,