fix(tools): harden schemas and oauth tool names

This commit is contained in:
Peter Steinberger
2026-01-10 03:05:56 +00:00
parent f241859c98
commit cc1c5f800f
4 changed files with 74 additions and 41 deletions

View File

@@ -59,6 +59,7 @@ import {
ensureAuthProfileStore, ensureAuthProfileStore,
getApiKeyForModel, getApiKeyForModel,
resolveAuthProfileOrder, resolveAuthProfileOrder,
resolveModelAuthMode,
} from "./model-auth.js"; } from "./model-auth.js";
import { ensureClawdbotModelsJson } from "./models-config.js"; import { ensureClawdbotModelsJson } from "./models-config.js";
import { import {
@@ -853,6 +854,8 @@ export async function compactEmbeddedPiSession(params: {
agentDir, agentDir,
config: params.config, config: params.config,
abortSignal: runAbortController.signal, abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
// No currentChannelId/currentThreadTs for compaction - not in message context // No currentChannelId/currentThreadTs for compaction - not in message context
}); });
const machineName = await getMachineDisplayName(); const machineName = await getMachineDisplayName();
@@ -1234,6 +1237,8 @@ export async function runEmbeddedPiAgent(params: {
agentDir, agentDir,
config: params.config, config: params.config,
abortSignal: runAbortController.signal, abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
currentChannelId: params.currentChannelId, currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs, currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode, replyToMode: params.replyToMode,

View File

@@ -31,35 +31,19 @@ describe("createClawdbotCodingTools", () => {
expect(parameters.required ?? []).toContain("action"); 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 tools = createClawdbotCodingTools();
const gateway = tools.find((tool) => tool.name === "gateway"); const gateway = tools.find((tool) => tool.name === "gateway");
expect(gateway).toBeDefined(); expect(gateway).toBeDefined();
const parameters = gateway?.parameters as { const parameters = gateway?.parameters as {
allOf?: Array<Record<string, unknown>>; type?: unknown;
required?: string[];
properties?: Record<string, unknown>;
}; };
const conditional = parameters.allOf?.find( expect(parameters.type).toBe("object");
(entry) => "if" in entry && "then" in entry, expect(parameters.properties?.raw).toBeDefined();
) as expect(parameters.required ?? []).not.toContain("raw");
| { if?: Record<string, unknown>; then?: Record<string, unknown> }
| undefined;
expect(conditional).toBeDefined();
const thenRequired = conditional?.then?.required as string[] | undefined;
expect(thenRequired ?? []).toContain("raw");
const action = (
conditional?.if?.properties as Record<string, unknown> | undefined
)?.action as { const?: unknown; enum?: unknown[] } | undefined;
const values = new Set<string>();
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);
}); });
it("flattens anyOf-of-literals to enum for provider compatibility", () => { 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); 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", () => { it("provides top-level object schemas for all tools", () => {
const tools = createClawdbotCodingTools(); const tools = createClawdbotCodingTools();
const offenders = tools const offenders = tools

View File

@@ -23,6 +23,7 @@ import {
type ProcessToolDefaults, type ProcessToolDefaults,
} from "./bash-tools.js"; } from "./bash-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js";
import type { ModelAuthMode } from "./model-auth.js";
import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js"; import type { SandboxContext, SandboxToolPolicy } from "./sandbox.js";
import { assertSandboxPath } from "./sandbox-paths.js"; import { assertSandboxPath } from "./sandbox-paths.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.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); 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<string, string> = {
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 = [ const DEFAULT_SUBAGENT_TOOL_DENY = [
"sessions_list", "sessions_list",
"sessions_history", "sessions_history",
@@ -532,6 +555,16 @@ export function createClawdbotCodingTools(options?: {
agentDir?: string; agentDir?: string;
config?: ClawdbotConfig; config?: ClawdbotConfig;
abortSignal?: AbortSignal; 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). */ /** Current channel ID for auto-threading (Slack). */
currentChannelId?: string; currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */ /** Current thread timestamp for auto-threading (Slack). */
@@ -634,8 +667,11 @@ export function createClawdbotCodingTools(options?: {
) )
: normalized; : normalized;
// NOTE: Keep canonical (lowercase) tool names here. // Anthropic blocks specific lowercase tool names (bash, read, write, edit) with OAuth tokens.
// pi-ai's Anthropic OAuth transport remaps tool names to Claude Code-style names // Only apply the rename when we are actually using (or likely using) Anthropic OAuth.
// on the wire and maps them back for tool dispatch. const provider = options?.modelProvider?.trim();
return withAbort; const authMode = options?.modelAuthMode;
const isAnthropicOAuth =
provider === "anthropic" && (authMode === "oauth" || authMode === "mixed");
return isAnthropicOAuth ? renameBlockedToolsForOAuth(withAbort) : withAbort;
} }

View File

@@ -37,20 +37,10 @@ const GatewayToolSchema = Type.Object({
note: Type.Optional(Type.String()), note: Type.Optional(Type.String()),
restartDelayMs: Type.Optional(Type.Number()), restartDelayMs: Type.Optional(Type.Number()),
}); });
// Keep top-level object schemas while enforcing conditional requirements. // NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here:
(GatewayToolSchema as typeof GatewayToolSchema & { allOf?: unknown[] }).allOf = // - 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.
if: {
properties: {
action: { const: "config.apply" },
},
required: ["action"],
},
// biome-ignore lint/suspicious/noThenProperty: JSON Schema keyword.
then: { required: ["raw"] },
},
];
export function createGatewayTool(opts?: { export function createGatewayTool(opts?: {
agentSessionKey?: string; agentSessionKey?: string;