From cc1c5f800fc7ad18ab3abc7cea9332a1c29ec470 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 03:05:56 +0000 Subject: [PATCH] fix(tools): harden schemas and oauth tool names --- src/agents/pi-embedded-runner.ts | 5 ++++ src/agents/pi-tools.test.ts | 48 +++++++++++++++++--------------- src/agents/pi-tools.ts | 44 ++++++++++++++++++++++++++--- src/agents/tools/gateway-tool.ts | 18 +++--------- 4 files changed, 74 insertions(+), 41 deletions(-) diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 46eb7e2e7..8913b0f06 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -59,6 +59,7 @@ import { ensureAuthProfileStore, getApiKeyForModel, resolveAuthProfileOrder, + resolveModelAuthMode, } from "./model-auth.js"; import { ensureClawdbotModelsJson } from "./models-config.js"; import { @@ -853,6 +854,8 @@ export async function compactEmbeddedPiSession(params: { agentDir, config: params.config, abortSignal: runAbortController.signal, + modelProvider: model.provider, + modelAuthMode: resolveModelAuthMode(model.provider, params.config), // No currentChannelId/currentThreadTs for compaction - not in message context }); const machineName = await getMachineDisplayName(); @@ -1234,6 +1237,8 @@ export async function runEmbeddedPiAgent(params: { agentDir, config: params.config, abortSignal: runAbortController.signal, + modelProvider: model.provider, + modelAuthMode: resolveModelAuthMode(model.provider, params.config), currentChannelId: params.currentChannelId, currentThreadTs: params.currentThreadTs, replyToMode: params.replyToMode, diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index e891f758e..07e98b292 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -31,35 +31,19 @@ describe("createClawdbotCodingTools", () => { expect(parameters.required ?? []).toContain("action"); }); - it("requires raw for gateway config.apply tool calls", () => { + it("exposes raw for gateway config.apply tool calls", () => { const tools = createClawdbotCodingTools(); const gateway = tools.find((tool) => tool.name === "gateway"); expect(gateway).toBeDefined(); const parameters = gateway?.parameters as { - allOf?: Array>; + type?: unknown; + required?: string[]; + properties?: Record; }; - const conditional = parameters.allOf?.find( - (entry) => "if" in entry && "then" in entry, - ) as - | { if?: Record; then?: Record } - | undefined; - - expect(conditional).toBeDefined(); - const thenRequired = conditional?.then?.required as string[] | undefined; - expect(thenRequired ?? []).toContain("raw"); - - const action = ( - conditional?.if?.properties as Record | undefined - )?.action as { const?: unknown; enum?: unknown[] } | undefined; - const values = new Set(); - if (typeof action?.const === "string") values.add(action.const); - if (Array.isArray(action?.enum)) { - for (const value of action.enum) { - if (typeof value === "string") values.add(value); - } - } - expect(values.has("config.apply")).toBe(true); + expect(parameters.type).toBe("object"); + expect(parameters.properties?.raw).toBeDefined(); + expect(parameters.required ?? []).not.toContain("raw"); }); it("flattens anyOf-of-literals to enum for provider compatibility", () => { @@ -174,6 +158,24 @@ describe("createClawdbotCodingTools", () => { expect(tools.some((tool) => tool.name === "process")).toBe(true); }); + it("renames blocked tool names only for Anthropic OAuth", () => { + const tools = createClawdbotCodingTools({ + modelProvider: "anthropic", + modelAuthMode: "oauth", + }); + const names = new Set(tools.map((tool) => tool.name)); + expect(names.has("Bash")).toBe(true); + expect(names.has("Read")).toBe(true); + expect(names.has("Write")).toBe(true); + expect(names.has("Edit")).toBe(true); + + // Ensure the blocked lowercase variants are not present in the schema. + expect(names.has("bash")).toBe(false); + expect(names.has("read")).toBe(false); + expect(names.has("write")).toBe(false); + expect(names.has("edit")).toBe(false); + }); + it("provides top-level object schemas for all tools", () => { const tools = createClawdbotCodingTools(); const offenders = tools diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 0de7ed7bb..1fa265f21 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -23,6 +23,7 @@ import { type ProcessToolDefaults, } from "./bash-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; +import type { ModelAuthMode } from "./model-auth.js"; import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js"; import { assertSandboxPath } from "./sandbox-paths.js"; import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js"; @@ -283,6 +284,28 @@ function normalizeToolNames(list?: string[]) { return list.map((entry) => entry.trim().toLowerCase()).filter(Boolean); } +/** + * Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens. + * Renaming to capitalized versions bypasses the block while maintaining compatibility + * with Anthropic API keys and other providers. + */ +const OAUTH_BLOCKED_TOOL_NAMES: Record = { + bash: "Bash", + read: "Read", + write: "Write", + edit: "Edit", +}; + +function renameBlockedToolsForOAuth(tools: AnyAgentTool[]): AnyAgentTool[] { + return tools.map((tool) => { + const newName = OAUTH_BLOCKED_TOOL_NAMES[tool.name]; + if (newName) { + return { ...tool, name: newName }; + } + return tool; + }); +} + const DEFAULT_SUBAGENT_TOOL_DENY = [ "sessions_list", "sessions_history", @@ -532,6 +555,16 @@ export function createClawdbotCodingTools(options?: { agentDir?: string; config?: ClawdbotConfig; abortSignal?: AbortSignal; + /** + * Provider of the currently selected model (used for provider-specific tool quirks). + * Example: "anthropic", "openai", "google", "openai-codex". + */ + modelProvider?: string; + /** + * Auth mode for the current provider. We only need this for Anthropic OAuth + * tool-name blocking quirks. + */ + modelAuthMode?: ModelAuthMode; /** Current channel ID for auto-threading (Slack). */ currentChannelId?: string; /** Current thread timestamp for auto-threading (Slack). */ @@ -634,8 +667,11 @@ export function createClawdbotCodingTools(options?: { ) : normalized; - // NOTE: Keep canonical (lowercase) tool names here. - // pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names - // on the wire and maps them back for tool dispatch. - return withAbort; + // Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens. + // Only apply the rename when we are actually using (or likely using) Anthropic OAuth. + const provider = options?.modelProvider?.trim(); + const authMode = options?.modelAuthMode; + const isAnthropicOAuth = + provider === "anthropic" && (authMode === "oauth" || authMode === "mixed"); + return isAnthropicOAuth ? renameBlockedToolsForOAuth(withAbort) : withAbort; } diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index 60b7da983..f2db7f968 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -37,20 +37,10 @@ const GatewayToolSchema = Type.Object({ note: Type.Optional(Type.String()), restartDelayMs: Type.Optional(Type.Number()), }); -// Keep top-level object schemas while enforcing conditional requirements. -(GatewayToolSchema as typeof GatewayToolSchema & { allOf?: unknown[] }).allOf = - [ - { - if: { - properties: { - action: { const: "config.apply" }, - }, - required: ["action"], - }, - // biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword. - then: { required: ["raw"] }, - }, - ]; +// 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;