Fix model and tool normalization regressions

Summary:
- Fix model and tool normalization regressions, including explicit tool-policy grants for messaging profile warnings.
- Keep Codex and Microsoft Foundry auth handling compatible with aws-sdk auth profile modes after rebasing onto current main.

Verification:
- pnpm test src/agents/pi-tools.policy.test.ts
- pnpm tsgo:extensions
- pnpm tsgo:extensions:test
- pnpm test extensions/codex/src/app-server/auth-bridge.test.ts extensions/microsoft-foundry/index.test.ts
- pnpm test:extensions:package-boundary
- pnpm lint --threads=8
- git diff --check
- GitHub PR checks green on 4ad136106b
This commit is contained in:
Val Alexander
2026-05-07 02:29:28 -05:00
committed by GitHub
parent d4e04f33a6
commit 62ccd8b644
21 changed files with 362 additions and 25 deletions

1
.gitignore vendored
View File

@@ -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*

View File

@@ -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.

View File

@@ -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) => {

View File

@@ -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,

View File

@@ -10,6 +10,16 @@ export const CODEX_NATIVE_FIRST_DYNAMIC_TOOL_EXCLUDES = [
"update_plan",
] as const;
const DYNAMIC_TOOL_NAME_ALIASES: Record<string, string> = {
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<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsProfile" | "codexDynamicToolsExclude">,
@@ -22,10 +32,12 @@ export function applyCodexDynamicToolProfile<T extends { name: string }>(
}
}
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)));
}

View File

@@ -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);

View File

@@ -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<T extends { name: string }>(
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,

View File

@@ -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();

View File

@@ -304,7 +304,8 @@ export function extractNarrativeText(messages: unknown[]): string | null {
part &&
typeof part === "object" &&
!Array.isArray(part) &&
(part as Record<string, unknown>).type === "text" &&
((part as Record<string, unknown>).type === "text" ||
(part as Record<string, unknown>).type === "output_text") &&
typeof (part as Record<string, unknown>).text === "string",
)
.map((part) => (part as { text: string }).text)

View File

@@ -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",

View File

@@ -100,7 +100,7 @@ type FoundryModelCompat = {
type FoundryAuthProfileConfig = {
provider: string;
mode: "api_key" | "oauth" | "token";
mode: "api_key" | "aws-sdk" | "oauth" | "token";
email?: string;
};

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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");

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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<string>();
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.`,
);
}

View File

@@ -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";
}

View File

@@ -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-");

View File

@@ -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 ?? {};