diff --git a/.gitignore b/.gitignore index ac9d57de4ec..efb6b5e820f 100644 --- a/.gitignore +++ b/.gitignore @@ -220,3 +220,4 @@ extensions/**/.openclaw-runtime-deps-stamp.json # Output dir for scripts/run-opengrep.sh (local opengrep scans) /.opengrep-out/ /.crabbox-artifacts +.comux* diff --git a/CHANGELOG.md b/CHANGELOG.md index c486b9b75dd..a62b4ac412d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,12 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/tools: fail `exec host=node` before `system.run` when the selected node is known to be disconnected, with an actionable reconnect message instead of a raw node invoke failure. Thanks @BunsDev. +- Agents/models: accept legacy `anthropic-cli/*` model refs as Claude CLI runtime refs instead of failing model resolution with `Unknown model`. Thanks @BunsDev. +- Agents/tools: keep restrictive-profile tool-section warnings scoped to the configured sections whose tools are still missing from `alsoAllow`, so already re-allowed filesystem tools do not make exec-only fixes look broader than they are. Thanks @BunsDev. +- Agents/tools: avoid warning messaging-only agents about inherited global `tools.exec` or `tools.fs` sections when the agent profile did not configure those tool sections itself. Thanks @BunsDev. +- Codex dynamic tools: normalize runtime `toolsAllow` entries the same way as Pi tool policy, so aliases like `bash` and `apply-patch` still expose the intended OpenClaw tools. Thanks @BunsDev. +- Memory/dreaming: read OpenAI-style `output_text` assistant parts from narrative subagent transcripts, so light-phase Dream Diary entries are not dropped as empty. Thanks @BunsDev. - llm-task: resolve configured model aliases before embedded dispatch so `model="gemini-flash"` and other aliases route to the intended provider instead of the agent default. Fixes #54166. - Media generation: resolve slash-containing model-only overrides like `fal-ai/flux/dev` through registered provider model metadata so FAL image/video models do not get misparsed as provider `fal-ai`. Fixes #77444. - CLI backends: keep versioned OAuth identity matches reusable when auth profile ids rotate, so Claude CLI sessions do not reset and lose continuity during same-account OAuth refresh/profile alias changes. Fixes #78541. diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 8093dcfc05d..160557c51e0 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -69,6 +69,9 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => { : ""); return apiKey ? { apiKey, provider: credential.provider, email: credential.email } : null; } + if (credential.type !== "oauth") { + return null; + } let oauthCredential = credential; if ((oauthCredential.expires ?? 0) <= Date.now()) { const refreshed = await providerRuntimeMocks.refreshProviderOAuthCredentialWithPlugin({ @@ -545,6 +548,35 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("rejects unsupported Codex auth profile credential types before OAuth refresh", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai-codex:aws", + credential: { + type: "aws-sdk", + provider: "openai-codex", + }, + }); + + await expect( + applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai-codex:aws", + }), + ).rejects.toThrow( + 'Codex app-server auth profile "openai-codex:aws" does not contain usable credentials.', + ); + expect(oauthMocks.refreshOpenAICodexToken).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("falls back to CODEX_API_KEY when no auth profile and no Codex account is available", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async (method: string) => { diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 641e7ff04e0..fba62d2d39c 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -272,6 +272,9 @@ async function resolveLoginParamsForCredential( ? buildChatgptAuthTokensParams(profileId, credential, accessToken) : undefined; } + if (credential.type !== "oauth") { + return undefined; + } const resolvedCredential = await resolveOAuthCredentialForCodexAppServer(profileId, credential, { agentDir: params.agentDir, forceRefresh: params.forceOAuthRefresh, diff --git a/extensions/codex/src/app-server/dynamic-tool-profile.ts b/extensions/codex/src/app-server/dynamic-tool-profile.ts index f6b28a7e8f3..e6dc0797759 100644 --- a/extensions/codex/src/app-server/dynamic-tool-profile.ts +++ b/extensions/codex/src/app-server/dynamic-tool-profile.ts @@ -10,6 +10,16 @@ export const CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES = [ "update_plan", ] as const; +const DYNAMIC_TOOL_NAME_ALIASES: Record = { + bash: "exec", + "apply-patch": "apply_patch", +}; + +export function normalizeCodexDynamicToolName(name: string): string { + const normalized = name.trim().toLowerCase(); + return DYNAMIC_TOOL_NAME_ALIASES[normalized] ?? normalized; +} + export function applyCodexDynamicToolProfile( tools: T[], config: Pick, @@ -22,10 +32,12 @@ export function applyCodexDynamicToolProfile( } } for (const name of config.codexDynamicToolsExclude ?? []) { - const trimmed = name.trim(); + const trimmed = normalizeCodexDynamicToolName(name); if (trimmed) { excludes.add(trimmed); } } - return excludes.size === 0 ? tools : tools.filter((tool) => !excludes.has(tool.name)); + return excludes.size === 0 + ? tools + : tools.filter((tool) => !excludes.has(normalizeCodexDynamicToolName(tool.name))); } diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index e05f95520fc..1d7a2f0917d 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -504,6 +504,16 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("normalizes Codex dynamic toolsAllow entries before filtering", () => { + const tools = ["exec", "apply_patch", "read", "message"].map((name) => ({ name })); + + expect( + __testing + .filterCodexDynamicToolsForAllowlist(tools, [" BASH ", "apply-patch", "READ"]) + .map((tool) => tool.name), + ).toEqual(["exec", "apply_patch", "read"]); + }); + it("forces the message dynamic tool for message-tool-only source replies", () => { const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index b72a7af686b..298b1702c03 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -64,7 +64,10 @@ import { type CodexPluginConfig, } from "./config.js"; import { projectContextEngineAssemblyForCodex } from "./context-engine-projection.js"; -import { applyCodexDynamicToolProfile } from "./dynamic-tool-profile.js"; +import { + applyCodexDynamicToolProfile, + normalizeCodexDynamicToolName, +} from "./dynamic-tool-profile.js"; import { createCodexDynamicToolBridge, type CodexDynamicToolBridge } from "./dynamic-tools.js"; import { handleCodexAppServerElicitationRequest } from "./elicitation-bridge.js"; import { CodexAppServerEventProjector } from "./event-projector.js"; @@ -1678,10 +1681,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { modelHasVision, hasInboundImages: (params.images?.length ?? 0) > 0, }); - const filteredTools = - params.toolsAllow && params.toolsAllow.length > 0 - ? visionFilteredTools.filter((tool) => params.toolsAllow?.includes(tool.name)) - : visionFilteredTools; + const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, params.toolsAllow); return normalizeAgentRuntimeTools({ runtimePlan: params.runtimePlan, tools: filteredTools, @@ -1695,6 +1695,19 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { }); } +function filterCodexDynamicToolsForAllowlist( + tools: T[], + toolsAllow?: string[], +): T[] { + if (!toolsAllow || toolsAllow.length === 0) { + return tools; + } + const allowSet = new Set( + toolsAllow.map((name) => normalizeCodexDynamicToolName(name)).filter(Boolean), + ); + return tools.filter((tool) => allowSet.has(normalizeCodexDynamicToolName(tool.name))); +} + function shouldForceMessageTool(params: EmbeddedRunAttemptParams): boolean { return params.sourceReplyDeliveryMode === "message_tool_only"; } @@ -2117,6 +2130,7 @@ export const __testing = { buildCodexNativeHookRelayId, applyCodexDynamicToolProfile, buildDynamicTools, + filterCodexDynamicToolsForAllowlist, filterToolsForVisionInputs, handleDynamicToolCallWithTimeout, resolveOpenClawCodingToolsSessionKeys, diff --git a/extensions/memory-core/src/dreaming-narrative.test.ts b/extensions/memory-core/src/dreaming-narrative.test.ts index 1ce74ca1651..bf662fe53b8 100644 --- a/extensions/memory-core/src/dreaming-narrative.test.ts +++ b/extensions/memory-core/src/dreaming-narrative.test.ts @@ -101,6 +101,16 @@ describe("extractNarrativeText", () => { expect(extractNarrativeText(messages)).toBe("First paragraph.\nSecond paragraph."); }); + it("extracts from OpenAI output_text assistant parts", () => { + const messages = [ + { + role: "assistant", + content: [{ type: "output_text", text: "The light phase found a diary thread." }], + }, + ]; + expect(extractNarrativeText(messages)).toBe("The light phase found a diary thread."); + }); + it("returns null when no assistant message exists", () => { const messages = [{ role: "user", content: "hello" }]; expect(extractNarrativeText(messages)).toBeNull(); diff --git a/extensions/memory-core/src/dreaming-narrative.ts b/extensions/memory-core/src/dreaming-narrative.ts index 4a1bf197c1c..909552433e7 100644 --- a/extensions/memory-core/src/dreaming-narrative.ts +++ b/extensions/memory-core/src/dreaming-narrative.ts @@ -304,7 +304,8 @@ export function extractNarrativeText(messages: unknown[]): string | null { part && typeof part === "object" && !Array.isArray(part) && - (part as Record).type === "text" && + ((part as Record).type === "text" || + (part as Record).type === "output_text") && typeof (part as Record).text === "string", ) .map((part) => (part as { text: string }).text) diff --git a/extensions/microsoft-foundry/index.test.ts b/extensions/microsoft-foundry/index.test.ts index fb96bfc8b02..86961f3108a 100644 --- a/extensions/microsoft-foundry/index.test.ts +++ b/extensions/microsoft-foundry/index.test.ts @@ -698,6 +698,40 @@ describe("microsoft-foundry plugin", () => { ]); }); + it("keeps Foundry profile selection compatible with unrelated AWS SDK profile modes", async () => { + const provider = registerProvider(); + const config: OpenClawConfig = { + ...buildFoundryConfig({ + profileIds: ["microsoft-foundry:entra"], + orderedProfileIds: ["microsoft-foundry:entra"], + }), + auth: { + profiles: { + "amazon-bedrock:default": { + provider: "amazon-bedrock", + mode: "aws-sdk", + }, + "microsoft-foundry:entra": { + provider: "microsoft-foundry", + mode: "api_key", + }, + }, + order: { + "microsoft-foundry": ["microsoft-foundry:entra"], + }, + }, + }; + + await provider.onModelSelected?.({ + config, + model: "microsoft-foundry/gpt-5.4", + prompter: {} as never, + agentDir: defaultFoundryAgentDir, + }); + + expect(config.auth?.order?.["microsoft-foundry"]).toEqual(["microsoft-foundry:entra"]); + }); + it("persists discovered deployments alongside the selected default model", () => { const result = buildFoundryAuthResult({ profileId: "microsoft-foundry:entra", diff --git a/extensions/microsoft-foundry/shared.ts b/extensions/microsoft-foundry/shared.ts index 58868bd580f..ff9c0d2f749 100644 --- a/extensions/microsoft-foundry/shared.ts +++ b/extensions/microsoft-foundry/shared.ts @@ -100,7 +100,7 @@ type FoundryModelCompat = { type FoundryAuthProfileConfig = { provider: string; - mode: "api_key" | "oauth" | "token"; + mode: "api_key" | "aws-sdk" | "oauth" | "token"; email?: string; }; diff --git a/src/agents/bash-tools.exec-host-node-phases.ts b/src/agents/bash-tools.exec-host-node-phases.ts index cabbcb26a2a..7b7654a0928 100644 --- a/src/agents/bash-tools.exec-host-node-phases.ts +++ b/src/agents/bash-tools.exec-host-node-phases.ts @@ -119,6 +119,11 @@ export async function resolveNodeExecutionTarget( throw err; } const nodeInfo = nodes.find((entry) => entry.nodeId === nodeId); + if (nodeInfo?.connected === false) { + throw new Error( + `exec host=node requires a connected node (${nodeId} is currently disconnected). Start or reconnect the companion app or node host, or select a connected node.`, + ); + } const declaredCommands = Array.isArray(nodeInfo?.commands) ? nodeInfo.commands : []; const supportsSystemRun = declaredCommands.includes("system.run"); if (!supportsSystemRun) { diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 08be30eb17e..b55fd997010 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -401,6 +401,36 @@ describe("executeNodeHostCommand", () => { ); }); + it("rejects disconnected node targets before invoking system.run", async () => { + listNodesMock.mockResolvedValueOnce([ + { + nodeId: "node-1", + commands: ["system.run", "system.run.prepare"], + connected: false, + platform: process.platform, + }, + ]); + + await expect( + executeNodeHostCommand({ + command: "git log --oneline -5", + workdir: "/tmp/work", + env: {}, + security: "allowlist", + ask: "off", + requestedNode: "node-1", + defaultTimeoutSec: 30, + approvalRunningNoticeMs: 0, + warnings: [], + agentId: "requested-agent", + sessionKey: "requested-session", + }), + ).rejects.toThrow( + "exec host=node requires a connected node (node-1 is currently disconnected)", + ); + expect(callGatewayToolMock).not.toHaveBeenCalled(); + }); + it("returns a non-empty placeholder for silent node exec results", async () => { callGatewayToolMock.mockImplementationOnce( async (method: string, _options: unknown, params: MockNodeInvokeParams | undefined) => { diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 4b2f049fb65..a588a1e8c43 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -362,6 +362,21 @@ const INSUFFICIENT_QUOTA_PAYLOAD = '{"type":"error","error":{"type":"insufficient_quota","message":"Your account has insufficient quota balance to run this request."}}'; describe("runWithModelFallback", () => { + it("normalizes anthropic-cli refs to the Claude CLI provider before execution", async () => { + const run = vi.fn().mockResolvedValue("ok"); + + const result = await runWithModelFallback({ + cfg: {} as OpenClawConfig, + provider: "anthropic-cli", + model: "claude-opus-4-7", + run, + }); + + expect(run).toHaveBeenCalledWith("claude-cli", "claude-opus-4-7"); + expect(result.provider).toBe("claude-cli"); + expect(result.model).toBe("claude-opus-4-7"); + }); + it("skips auth store bootstrap when no auth profile sources exist", async () => { authSourceCheckMock.hasAnyAuthProfileStoreSource.mockReturnValue(false); const run = vi.fn().mockResolvedValueOnce("ok"); diff --git a/src/agents/model-selection-cli.test.ts b/src/agents/model-selection-cli.test.ts index 56d67df1786..37444d0787d 100644 --- a/src/agents/model-selection-cli.test.ts +++ b/src/agents/model-selection-cli.test.ts @@ -25,6 +25,10 @@ describe("isCliProvider", () => { expect(isCliProvider("claude-cli", {} as OpenClawConfig)).toBe(true); }); + it("accepts the anthropic-cli auth-choice id as a Claude CLI provider alias", () => { + expect(isCliProvider("anthropic-cli", {} as OpenClawConfig)).toBe(true); + }); + it("returns false for provider ids", () => { expect(isCliProvider("example-cli", {} as OpenClawConfig)).toBe(false); }); diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 7a8ac14c91b..cc34850de9d 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js"; +import { migrateLegacyRuntimeModelRef } from "./model-runtime-aliases.js"; import { buildAllowedModelSet, inferUniqueProviderFromConfiguredModels, @@ -220,6 +221,7 @@ describe("model-selection", () => { expect(normalizeProviderId("qwen")).toBe("qwen"); expect(normalizeProviderId("kimi-code")).toBe("kimi"); expect(normalizeProviderId("kimi-coding")).toBe("kimi"); + expect(normalizeProviderId("anthropic-cli")).toBe("claude-cli"); expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock"); expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock"); expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock"); @@ -390,6 +392,12 @@ describe("model-selection", () => { defaultProvider: "google-vertex", expected: { provider: "google-vertex", model: "gemini-3.1-flash-lite-preview" }, }, + { + name: "normalizes anthropic-cli refs to the Claude CLI provider alias", + variants: ["anthropic-cli/claude-opus-4-7"], + defaultProvider: "openai", + expected: { provider: "claude-cli", model: "claude-opus-4-7" }, + }, ]; it("parses and normalizes provider/model refs", () => { @@ -398,6 +406,17 @@ describe("model-selection", () => { } }); + it("migrates anthropic-cli legacy runtime refs to canonical Anthropic refs", () => { + expect(migrateLegacyRuntimeModelRef("anthropic-cli/claude-opus-4-7")).toEqual({ + ref: "anthropic/claude-opus-4-7", + legacyProvider: "claude-cli", + provider: "anthropic", + model: "claude-opus-4-7", + runtime: "claude-cli", + cli: true, + }); + }); + it("round-trips normalized refs through modelKey", () => { const parsed = parseModelRef(" opus-4.6 ", "anthropic", { allowPluginNormalization: false, diff --git a/src/agents/pi-tools.policy.test.ts b/src/agents/pi-tools.policy.test.ts index 8658b3004e3..bc995ad183e 100644 --- a/src/agents/pi-tools.policy.test.ts +++ b/src/agents/pi-tools.policy.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js"; import { filterToolsByPolicy, isToolAllowedByPolicyName, @@ -590,6 +591,97 @@ describe("resolveEffectiveToolPolicy", () => { expect(result.profileAlsoAllow).not.toContain("process"); }); + it("does not warn an agent profile about inherited global tool sections (#47487)", async () => { + const warnLogs = createWarnLogCapture("openclaw-pi-tools-policy-test"); + try { + const cfg = { + tools: { + exec: { security: "allowlist" }, + fs: { workspaceOnly: true }, + }, + agents: { + list: [ + { + id: "sage", + tools: { + profile: "messaging", + alsoAllow: ["image"], + }, + }, + ], + }, + } as OpenClawConfig; + + resolveEffectiveToolPolicy({ config: cfg, agentId: "sage" }); + + expect(await warnLogs.findText('tools policy: profile "messaging"')).toBeUndefined(); + } finally { + warnLogs.cleanup(); + } + }); + + it("still warns when an agent profile has its own configured exec section (#47487)", async () => { + const warnLogs = createWarnLogCapture("openclaw-pi-tools-policy-test"); + try { + const cfg = { + agents: { + list: [ + { + id: "sage", + tools: { + profile: "messaging", + exec: { security: "allowlist" }, + }, + }, + ], + }, + } as OpenClawConfig; + + resolveEffectiveToolPolicy({ config: cfg, agentId: "sage" }); + + const warning = await warnLogs.findText('tools policy: profile "messaging"'); + expect(warning).toContain('(agent "sage")'); + expect(warning).toContain("configured tool sections (tools.exec)"); + expect(warning).toContain('Add alsoAllow: ["exec", "process"]'); + } finally { + warnLogs.cleanup(); + } + }); + + it("only lists configured sections whose grants are still missing (#47487)", async () => { + const warnLogs = createWarnLogCapture("openclaw-pi-tools-policy-test"); + try { + const cfg = { + agents: { + list: [ + { + id: "echo", + tools: { + profile: "messaging", + alsoAllow: ["read", "write", "edit"], + exec: { security: "allowlist" }, + fs: { workspaceOnly: true }, + }, + }, + ], + }, + } as OpenClawConfig; + + resolveEffectiveToolPolicy({ config: cfg, agentId: "echo" }); + + const warning = await warnLogs.findText('tools policy: profile "messaging"'); + expect(warning).toContain('(agent "echo")'); + expect(warning).toContain("configured tool sections (tools.exec)"); + expect(warning).not.toContain("tools.exec / tools.fs"); + expect(warning).toContain('Add alsoAllow: ["exec", "process"]'); + expect(warning).not.toContain('"read"'); + expect(warning).not.toContain('"write"'); + expect(warning).not.toContain('"edit"'); + } finally { + warnLogs.cleanup(); + } + }); + it("explicit alsoAllow with exec still grants exec under messaging profile", () => { const cfg = { tools: { diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 63a1a9d11a6..d2039c56353 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -372,27 +372,40 @@ function hasExplicitToolSection(section: unknown): boolean { /** Detect tool config sections that previously widened profiles implicitly. * Used only for migration warnings — not merged into profileAlsoAllow. #47487 */ +type ImplicitProfileGrantDetection = { + entries: Array<{ section: string; grants: string[] }>; +}; + function detectImplicitProfileGrants(params: { globalTools?: OpenClawConfig["tools"]; agentTools?: AgentToolsConfig; -}): string[] | undefined { - const implicit = new Set(); + includeGlobalSections: boolean; +}): ImplicitProfileGrantDetection | undefined { + const entries: ImplicitProfileGrantDetection["entries"] = []; if ( hasExplicitToolSection(params.agentTools?.exec) || - hasExplicitToolSection(params.globalTools?.exec) + (params.includeGlobalSections && hasExplicitToolSection(params.globalTools?.exec)) ) { - implicit.add("exec"); - implicit.add("process"); + entries.push({ section: "tools.exec", grants: ["exec", "process"] }); } if ( hasExplicitToolSection(params.agentTools?.fs) || - hasExplicitToolSection(params.globalTools?.fs) + (params.includeGlobalSections && hasExplicitToolSection(params.globalTools?.fs)) ) { - implicit.add("read"); - implicit.add("write"); - implicit.add("edit"); + entries.push({ section: "tools.fs", grants: ["read", "write", "edit"] }); } - return implicit.size > 0 ? Array.from(implicit) : undefined; + if (entries.length === 0) { + return undefined; + } + return { entries }; +} + +function formatImplicitToolSections(sections: string[]): string { + return sections.join(" / "); +} + +function formatToolListForWarning(toolNames: string[]): string { + return toolNames.map((toolName) => `"${toolName}"`).join(", "); } export function resolveEffectiveToolPolicy(params: { @@ -415,6 +428,7 @@ export function resolveEffectiveToolPolicy(params: { const globalTools = params.config?.tools; const profile = agentTools?.profile ?? globalTools?.profile; + const profileSource = agentTools?.profile ? "agent" : globalTools?.profile ? "global" : undefined; const providerPolicy = resolveProviderToolPolicy({ byProvider: globalTools?.byProvider, modelProvider: params.modelProvider, @@ -431,20 +445,30 @@ export function resolveEffectiveToolPolicy(params: { // Warn affected users about removed implicit grants (#47487), but only when // the active profile/explicit alsoAllow do not already grant those tools. if (profile) { - const implicitGrants = detectImplicitProfileGrants({ globalTools, agentTools }); + const implicitGrants = detectImplicitProfileGrants({ + globalTools, + agentTools, + includeGlobalSections: profileSource === "global", + }); if (implicitGrants) { const profilePolicy = mergeAlsoAllowPolicy( resolveToolProfilePolicy(profile), explicitProfileAlsoAllow, ); - const uncovered = implicitGrants.filter( - (toolName) => !isToolAllowedByPolicyName(toolName, profilePolicy), - ); + const uncoveredEntries = implicitGrants.entries + .map((entry) => ({ + section: entry.section, + grants: entry.grants.filter( + (toolName) => !isToolAllowedByPolicyName(toolName, profilePolicy), + ), + })) + .filter((entry) => entry.grants.length > 0); + const uncovered = uncoveredEntries.flatMap((entry) => entry.grants); if (uncovered.length > 0) { logWarn( `tools policy: profile "${profile}"${agentId ? ` (agent "${agentId}")` : ""} has ` + - `configured tool sections (tools.exec / tools.fs) that no longer implicitly widen ` + - `the profile. Add alsoAllow: [${uncovered.map((t) => `"${t}"`).join(", ")}] ` + + `configured tool sections (${formatImplicitToolSections(uncoveredEntries.map((entry) => entry.section))}) that no longer implicitly widen ` + + `the profile. Add alsoAllow: [${formatToolListForWarning(uncovered)}] ` + `explicitly if these tools should be available. See #47487.`, ); } diff --git a/src/agents/provider-id.ts b/src/agents/provider-id.ts index 6b8d0a2939b..dd13825aeed 100644 --- a/src/agents/provider-id.ts +++ b/src/agents/provider-id.ts @@ -14,6 +14,9 @@ export function normalizeProviderId(provider: string): string { if (normalized === "opencode-go-auth") { return "opencode-go"; } + if (normalized === "anthropic-cli") { + return "claude-cli"; + } if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") { return "kimi"; } diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index b9269dbddd1..c921d2b60c7 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -306,6 +306,25 @@ describe("resolvePluginSkillDirs", () => { }); }); + it("cleans up generated plugin skill links when no workspace is active", async () => { + const pluginSkillsDir = await tempDirs.make("managed-plugin-skills-"); + const staleRoot = await tempDirs.make("stale-plugin-skills-"); + const staleSkill = path.join(staleRoot, "stale-skill"); + await fs.mkdir(staleSkill, { recursive: true }); + fsSync.symlinkSync(staleSkill, path.join(pluginSkillsDir, "stale-skill"), "dir"); + + const dirs = resolvePluginSkillDirs({ + workspaceDir: undefined, + config: {} as OpenClawConfig, + pluginSkillsDir, + }); + + expect(dirs).toEqual([]); + await expect(fs.lstat(path.join(pluginSkillsDir, "stale-skill"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("resolves Claude bundle command roots through the normal plugin skill path", async () => { const workspaceDir = await tempDirs.make("openclaw-"); const pluginRoot = await tempDirs.make("openclaw-claude-bundle-"); diff --git a/src/agents/skills/plugin-skills.ts b/src/agents/skills/plugin-skills.ts index 757c926df5d..0a172de8c40 100644 --- a/src/agents/skills/plugin-skills.ts +++ b/src/agents/skills/plugin-skills.ts @@ -27,6 +27,9 @@ export function resolvePluginSkillDirs(params: { }): string[] { const workspaceDir = (params.workspaceDir ?? "").trim(); if (!workspaceDir) { + publishPluginSkills([], { + pluginSkillsDir: params.pluginSkillsDir, + }); return []; } const config = params.config ?? {};