mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-19 04:42:01 +08:00
Compare commits
2 Commits
v2026.6.8
...
dev/kevinl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc8e2196e9 | ||
|
|
e045e45210 |
128
docs/plan/codex-native-plugin-apps.md
Normal file
128
docs/plan/codex-native-plugin-apps.md
Normal file
@@ -0,0 +1,128 @@
|
||||
---
|
||||
title: Codex Native Plugin Apps
|
||||
description: Milestone specs for removing OpenClaw Codex plugin dynamic tools and relying on Codex app-server native plugin support.
|
||||
---
|
||||
|
||||
Draft implementation specification.
|
||||
|
||||
## 1. Milestone feature specs
|
||||
|
||||
### 1.1 Remove OpenClaw Codex plugin dynamic tools
|
||||
|
||||
Goal: remove the synthetic OpenClaw tool layer that converted configured Codex
|
||||
plugins into OpenClaw dynamic tools.
|
||||
|
||||
User-visible behavior:
|
||||
|
||||
- Gateway tool discovery no longer exposes plugin tools for Codex-native apps.
|
||||
- Codex-mode conversations no longer spawn a second ephemeral Codex thread just
|
||||
to invoke a Codex plugin.
|
||||
- Existing Codex app-server turns continue to receive ordinary OpenClaw dynamic
|
||||
tools that are not native Codex plugin replacements.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Delete the Codex plugin tool registration, inventory, activation, and invoker
|
||||
modules.
|
||||
- Remove the Codex plugin wildcard tool contract from the bundled plugin
|
||||
manifest.
|
||||
- Remove bridge-specific config schema, UI hints, docs, and tests.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- No bundled Codex manifest contract declares plugin-derived OpenClaw tools.
|
||||
- No Codex plugin config key enables a synthetic OpenClaw tool bridge.
|
||||
- Tool-contract tests keep generic wildcard coverage without referencing Codex
|
||||
plugins.
|
||||
|
||||
Verification:
|
||||
|
||||
- Targeted config, manifest, and plugin-tool tests.
|
||||
- A live dev-gateway proof shows no Codex plugin dynamic tool appears while
|
||||
native plugin invocation still works.
|
||||
|
||||
### 1.2 Invoke plugins in the main Codex session thread
|
||||
|
||||
Goal: rely on Codex app-server's native mention handling in the session thread
|
||||
that OpenClaw already uses for Codex-mode turns.
|
||||
|
||||
User-visible behavior:
|
||||
|
||||
- Users invoke native Codex plugins with mention syntax such as
|
||||
`[@Google Calendar](plugin://google-calendar)` inside a Codex-mode message.
|
||||
- Plugin calls share the same Codex transcript, approval semantics, and app
|
||||
authorization flow as ordinary Codex app-server plugin use.
|
||||
- OpenClaw no longer duplicates plugin auth or transcript behavior.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Keep OpenClaw forwarding user text to `turn/start` on the bound Codex thread.
|
||||
- Document native mention usage in the Codex harness and migration docs.
|
||||
- Do not add compatibility parsing or translation for the removed OpenClaw tool
|
||||
names.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- A TUI-submitted Codex-mode message containing a native plugin mention reaches
|
||||
the Codex app-server turn on the bound thread.
|
||||
- The live behavior uses native plugin/app events, not an OpenClaw tool call.
|
||||
|
||||
Verification:
|
||||
|
||||
- Showboat demo against the dev gateway and TUI with logs or transcript
|
||||
evidence for the native plugin mention and resulting plugin behavior.
|
||||
|
||||
### 1.3 Migration activation
|
||||
|
||||
Goal: preserve useful Codex plugin migration by activating selected plugins in
|
||||
Codex app-server, not in OpenClaw's tool registry.
|
||||
|
||||
This milestone is intentionally deferred to the stacked migration PR. The first
|
||||
PR only lands the native invocation and configuration substrate that migration
|
||||
uses.
|
||||
|
||||
## 2. Implementation plan
|
||||
|
||||
### 2.1 Remove the OpenClaw tool bridge as an invocation path
|
||||
|
||||
Codex-native apps should not be exposed as OpenClaw `codex_plugin_*` dynamic
|
||||
tools. The native thread path keeps transcript, approval, and app authorization
|
||||
inside the Codex app-server session.
|
||||
|
||||
### 2.2 Keep app-server plugin/app methods typed
|
||||
|
||||
OpenClaw still needs typed JSON-RPC coverage for native Codex plugin/app
|
||||
configuration surfaces such as `plugin/list`, `plugin/install`, `app/list`, and
|
||||
`hooks/list`. These methods are app-server control-plane calls, not OpenClaw
|
||||
tool registrations.
|
||||
|
||||
### 2.3 Tolerate app-server permission-profile drift
|
||||
|
||||
Live app-server `thread/start` and `thread/resume` responses may contain newer
|
||||
special filesystem path kinds before OpenClaw updates its generated schema.
|
||||
Normalize unknown special path kinds to the stable `unknown` shape so native
|
||||
plugin invocation is not blocked at thread startup.
|
||||
|
||||
### 2.4 Invoke native plugins from the bound Codex thread
|
||||
|
||||
Users invoke plugins with native Codex mention syntax, for example
|
||||
`[@Google Calendar](plugin://google-calendar)`, in the same Codex-mode message
|
||||
that OpenClaw forwards to `turn/start`.
|
||||
|
||||
### 2.5 Migrate selected plugins through app-server
|
||||
|
||||
Deferred to the stacked migration PR. That PR adds Codex source discovery,
|
||||
planning, apply-time install/reload behavior, and migration CLI/docs updates.
|
||||
|
||||
### 2.6 PR 1 docs, tests, and proof
|
||||
|
||||
This PR owns:
|
||||
|
||||
- Harness docs for native mention usage and the removal of bridge tool
|
||||
semantics.
|
||||
- App-server schema normalization tests for current live `permissionProfile`
|
||||
responses.
|
||||
- Generic wildcard plugin-tool contract tests that keep OpenClaw's plugin tool
|
||||
registry behavior independent of Codex-native plugin ids.
|
||||
- Showboat/dev-gateway/TUI proof that a native plugin mention reaches the main
|
||||
Codex app-server thread without a `codex_plugin_*` OpenClaw tool call.
|
||||
@@ -264,6 +264,24 @@ For live and Docker smoke tests, auth usually comes from the Codex CLI account
|
||||
or an OpenClaw `openai-codex` auth profile. Local stdio app-server launches can
|
||||
also fall back to `CODEX_API_KEY` / `OPENAI_API_KEY` when no account is present.
|
||||
|
||||
## Native Codex plugins and apps
|
||||
|
||||
OpenClaw relies on Codex app-server's native plugin and app support for Codex
|
||||
mode. It does not register synthetic OpenClaw tools for Codex plugins, and it
|
||||
does not start a separate ephemeral Codex thread to invoke a plugin. Plugin
|
||||
mentions stay in the main Codex app-server thread for the session.
|
||||
|
||||
Use native Codex mention syntax in a Codex-mode turn:
|
||||
|
||||
```md
|
||||
[@Google Calendar](plugin://google-calendar) Find a free slot tomorrow afternoon.
|
||||
```
|
||||
|
||||
Codex plugin migration is covered by the stacked migration PR. The runtime
|
||||
contract here is the same before and after migration: the Codex app-server owns
|
||||
plugin activation, app authorization, permission prompts, and transcript
|
||||
semantics.
|
||||
|
||||
## Workspace bootstrap files
|
||||
|
||||
Codex handles `AGENTS.md` itself through native project-doc discovery. OpenClaw
|
||||
|
||||
@@ -138,12 +138,61 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
});
|
||||
await expect(fs.access(codexHome)).resolves.toBeUndefined();
|
||||
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toBe(
|
||||
"[features]\napps = true\n\n[apps._default]\nenabled = true\n",
|
||||
);
|
||||
expect(startOptions.env).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("upserts native Codex app defaults into an existing app-server config", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const startOptions = createStartOptions();
|
||||
try {
|
||||
const codexHome = resolveCodexAppServerHomeDir(agentDir);
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(codexHome, "config.toml"),
|
||||
[
|
||||
'model = "gpt-5.5"',
|
||||
"",
|
||||
"[features]",
|
||||
"apps = false",
|
||||
"codex_hooks = true",
|
||||
"",
|
||||
"[apps._default]",
|
||||
"enabled = false",
|
||||
"open_world_enabled = false",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
await bridgeCodexAppServerStartOptions({
|
||||
startOptions,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toBe(
|
||||
[
|
||||
'model = "gpt-5.5"',
|
||||
"",
|
||||
"[features]",
|
||||
"apps = true",
|
||||
"codex_hooks = true",
|
||||
"",
|
||||
"[apps._default]",
|
||||
"enabled = true",
|
||||
"open_world_enabled = false",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit CODEX_HOME and HOME overrides", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const codexHome = path.join(agentDir, "custom-codex-home");
|
||||
@@ -169,6 +218,9 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
});
|
||||
await expect(fs.access(codexHome)).resolves.toBeUndefined();
|
||||
await expect(fs.access(nativeHome)).resolves.toBeUndefined();
|
||||
await expect(fs.readFile(path.join(codexHome, "config.toml"), "utf8")).resolves.toContain(
|
||||
"[features]\napps = true",
|
||||
);
|
||||
expect(startOptions.clearEnv).toEqual(["CODEX_HOME", "HOME", "FOO"]);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
|
||||
@@ -25,6 +25,7 @@ const CODEX_HOME_ENV_VAR = "CODEX_HOME";
|
||||
const HOME_ENV_VAR = "HOME";
|
||||
const CODEX_APP_SERVER_HOME_DIRNAME = "codex-home";
|
||||
const CODEX_APP_SERVER_NATIVE_HOME_DIRNAME = "home";
|
||||
const CODEX_APP_SERVER_CONFIG_FILENAME = "config.toml";
|
||||
const CODEX_API_KEY_ENV_VAR = "CODEX_API_KEY";
|
||||
const OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY";
|
||||
const CODEX_APP_SERVER_API_KEY_ENV_VARS = [CODEX_API_KEY_ENV_VAR, OPENAI_API_KEY_ENV_VAR];
|
||||
@@ -111,6 +112,7 @@ async function withAgentCodexHomeEnvironment(
|
||||
: path.join(codexHome, CODEX_APP_SERVER_NATIVE_HOME_DIRNAME);
|
||||
await fs.mkdir(codexHome, { recursive: true });
|
||||
await fs.mkdir(nativeHome, { recursive: true });
|
||||
await ensureCodexAppServerAppsConfig(codexHome);
|
||||
const nextStartOptions: CodexAppServerStartOptions = {
|
||||
...startOptions,
|
||||
env: {
|
||||
@@ -128,6 +130,84 @@ async function withAgentCodexHomeEnvironment(
|
||||
return nextStartOptions;
|
||||
}
|
||||
|
||||
async function ensureCodexAppServerAppsConfig(codexHome: string): Promise<void> {
|
||||
const configPath = path.join(codexHome, CODEX_APP_SERVER_CONFIG_FILENAME);
|
||||
let current = "";
|
||||
try {
|
||||
current = await fs.readFile(configPath, "utf8");
|
||||
} catch (error) {
|
||||
if (!isNodeErrorCode(error, "ENOENT")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const next = upsertTomlBoolean(
|
||||
upsertTomlBoolean(current, "features", "apps"),
|
||||
"apps._default",
|
||||
"enabled",
|
||||
);
|
||||
if (next !== current) {
|
||||
await fs.writeFile(configPath, next, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function upsertTomlBoolean(content: string, section: string, key: string): string {
|
||||
const lines = content.split(/\r?\n/);
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
||||
lines.pop();
|
||||
}
|
||||
const sectionHeader = `[${section}]`;
|
||||
const sectionStart = lines.findIndex((line) => line.trim() === sectionHeader);
|
||||
if (sectionStart === -1) {
|
||||
const nextLines = [...lines];
|
||||
if (nextLines.length > 0) {
|
||||
nextLines.push("");
|
||||
}
|
||||
nextLines.push(sectionHeader, `${key} = true`);
|
||||
return `${nextLines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
const sectionEnd = findTomlSectionEnd(lines, sectionStart + 1);
|
||||
const keyIndex = findTomlKey(lines, key, sectionStart + 1, sectionEnd);
|
||||
const nextLines = [...lines];
|
||||
if (keyIndex === -1) {
|
||||
nextLines.splice(sectionEnd, 0, `${key} = true`);
|
||||
} else if (nextLines[keyIndex] !== `${key} = true`) {
|
||||
nextLines[keyIndex] = `${key} = true`;
|
||||
}
|
||||
return `${nextLines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function findTomlSectionEnd(lines: string[], start: number): number {
|
||||
for (let index = start; index < lines.length; index += 1) {
|
||||
if (/^\s*\[[^\]]+\]\s*$/.test(lines[index])) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
function findTomlKey(lines: string[], key: string, start: number, end: number): number {
|
||||
const keyPattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`);
|
||||
for (let index = start; index < end; index += 1) {
|
||||
const line = lines[index];
|
||||
if (line.trimStart().startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
if (keyPattern.test(line)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === code);
|
||||
}
|
||||
|
||||
function withoutClearedCodexIsolationEnv(clearEnv: string[] | undefined): string[] | undefined {
|
||||
if (!clearEnv) {
|
||||
return undefined;
|
||||
|
||||
@@ -41,11 +41,19 @@ const validateTurnCompletedNotification = ajv.compile<v2.TurnCompletedNotificati
|
||||
const validateTurnStartResponse = ajv.compile<CodexTurnStartResponse>(turnStartResponseSchema);
|
||||
|
||||
export function assertCodexThreadStartResponse(value: unknown): CodexThreadStartResponse {
|
||||
return assertCodexShape(validateThreadStartResponse, value, "thread/start response");
|
||||
return assertCodexShape(
|
||||
validateThreadStartResponse,
|
||||
normalizeThreadPermissionProfile(value),
|
||||
"thread/start response",
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCodexThreadResumeResponse(value: unknown): CodexThreadResumeResponse {
|
||||
return assertCodexShape(validateThreadResumeResponse, value, "thread/resume response");
|
||||
return assertCodexShape(
|
||||
validateThreadResumeResponse,
|
||||
normalizeThreadPermissionProfile(value),
|
||||
"thread/resume response",
|
||||
);
|
||||
}
|
||||
|
||||
export function assertCodexTurnStartResponse(value: unknown): CodexTurnStartResponse {
|
||||
@@ -95,6 +103,95 @@ function readCodexShape<T>(validate: ValidateFunction<T>, value: unknown): T | u
|
||||
return validate(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeThreadPermissionProfile(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const response = value as { permissionProfile?: unknown };
|
||||
const permissionProfile = normalizePermissionProfile(response.permissionProfile);
|
||||
if (permissionProfile === response.permissionProfile) {
|
||||
return value;
|
||||
}
|
||||
return { ...value, permissionProfile };
|
||||
}
|
||||
|
||||
function normalizePermissionProfile(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const profile = value as { type?: unknown; fileSystem?: unknown };
|
||||
if (profile.type !== "managed") {
|
||||
return value;
|
||||
}
|
||||
const fileSystem = normalizePermissionProfileFileSystem(profile.fileSystem);
|
||||
if (fileSystem === profile.fileSystem) {
|
||||
return value;
|
||||
}
|
||||
return { ...value, fileSystem };
|
||||
}
|
||||
|
||||
function normalizePermissionProfileFileSystem(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const fileSystem = value as { type?: unknown; entries?: unknown };
|
||||
if (fileSystem.type !== "restricted" || !Array.isArray(fileSystem.entries)) {
|
||||
return value;
|
||||
}
|
||||
let changed = false;
|
||||
const entries = fileSystem.entries.map((entry) => {
|
||||
const normalized = normalizePermissionProfileFileSystemEntry(entry);
|
||||
if (normalized !== entry) {
|
||||
changed = true;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
return changed ? { ...value, entries } : value;
|
||||
}
|
||||
|
||||
function normalizePermissionProfileFileSystemEntry(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const entry = value as { path?: unknown };
|
||||
const normalizedPath = normalizePermissionProfileFileSystemPath(entry.path);
|
||||
return normalizedPath === entry.path ? value : { ...value, path: normalizedPath };
|
||||
}
|
||||
|
||||
function normalizePermissionProfileFileSystemPath(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
const path = value as { type?: unknown; value?: unknown };
|
||||
if (path.type !== "special" || !path.value || typeof path.value !== "object") {
|
||||
return value;
|
||||
}
|
||||
const special = path.value as { kind?: unknown; subpath?: unknown };
|
||||
if (typeof special.kind !== "string" || isKnownPermissionProfileSpecialPathKind(special.kind)) {
|
||||
return value;
|
||||
}
|
||||
return {
|
||||
...value,
|
||||
value: {
|
||||
kind: "unknown",
|
||||
path: special.kind,
|
||||
subpath:
|
||||
typeof special.subpath === "string" || special.subpath === null ? special.subpath : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isKnownPermissionProfileSpecialPathKind(kind: string): boolean {
|
||||
return (
|
||||
kind === "root" ||
|
||||
kind === "minimal" ||
|
||||
kind === "project_roots" ||
|
||||
kind === "tmpdir" ||
|
||||
kind === "slash_tmp" ||
|
||||
kind === "unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTurn(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
|
||||
@@ -89,6 +89,12 @@ type CodexAppServerRequestResultMap = {
|
||||
"account/read": v2.GetAccountResponse;
|
||||
"feedback/upload": v2.FeedbackUploadResponse;
|
||||
"mcpServerStatus/list": v2.ListMcpServerStatusResponse;
|
||||
"plugin/list": v2.PluginListResponse;
|
||||
"plugin/install": v2.PluginInstallResponse;
|
||||
"app/list": v2.AppsListResponse;
|
||||
"hooks/list": v2.HooksListResponse;
|
||||
"config/mcpServer/reload": undefined;
|
||||
"config/batchWrite": undefined;
|
||||
"model/list": v2.ModelListResponse;
|
||||
"review/start": v2.ReviewStartResponse;
|
||||
"skills/list": v2.SkillsListResponse;
|
||||
|
||||
@@ -166,4 +166,43 @@ describe("Codex app-server dynamic tool schema boundary contract", () => {
|
||||
|
||||
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/start"]);
|
||||
});
|
||||
|
||||
it("accepts newer permission-profile special filesystem path kinds", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return {
|
||||
...threadStartResult(),
|
||||
permissionProfile: {
|
||||
type: "managed",
|
||||
network: { enabled: true },
|
||||
fileSystem: {
|
||||
type: "restricted",
|
||||
entries: [
|
||||
{
|
||||
path: {
|
||||
type: "special",
|
||||
value: { kind: "future_project_roots", subpath: null },
|
||||
},
|
||||
access: "write",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
|
||||
await startOrResumeThread({
|
||||
client: { request } as never,
|
||||
params: createParams(sessionFile, workspaceDir),
|
||||
cwd: workspaceDir,
|
||||
dynamicTools: [],
|
||||
appServer: createAppServerOptions(),
|
||||
});
|
||||
|
||||
expect(request).toHaveBeenCalledWith("thread/start", expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
19
src/plugins/tool-contracts.test.ts
Normal file
19
src/plugins/tool-contracts.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findUndeclaredPluginToolNames } from "./tool-contracts.js";
|
||||
|
||||
describe("plugin tool contracts", () => {
|
||||
it("allows explicit prefix wildcard contracts for config-derived tool names", () => {
|
||||
expect(
|
||||
findUndeclaredPluginToolNames({
|
||||
declaredNames: ["derived_tool_*"],
|
||||
toolNames: ["derived_tool_calendar", "derived_tool_slack"],
|
||||
}),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
findUndeclaredPluginToolNames({
|
||||
declaredNames: ["derived_tool_*"],
|
||||
toolNames: ["memory_search"],
|
||||
}),
|
||||
).toEqual(["memory_search"]);
|
||||
});
|
||||
});
|
||||
@@ -22,5 +22,11 @@ export function findUndeclaredPluginToolNames(params: {
|
||||
toolNames: readonly string[];
|
||||
}): string[] {
|
||||
const declared = new Set(normalizePluginToolNames(params.declaredNames));
|
||||
return normalizePluginToolNames(params.toolNames).filter((name) => !declared.has(name));
|
||||
const wildcardPrefixes = [...declared]
|
||||
.filter((name) => name.endsWith("*"))
|
||||
.map((name) => name.slice(0, -1))
|
||||
.filter(Boolean);
|
||||
return normalizePluginToolNames(params.toolNames).filter(
|
||||
(name) => !declared.has(name) && !wildcardPrefixes.some((prefix) => name.startsWith(prefix)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -522,6 +522,53 @@ describe("resolvePluginTools optional tools", () => {
|
||||
expectLoaderSelectedOnlyPluginIds(["optional-demo"]);
|
||||
});
|
||||
|
||||
it("loads a concrete plugin tool selected through a wildcard manifest contract", () => {
|
||||
const baseContext = createContext();
|
||||
const config = {
|
||||
...baseContext.config,
|
||||
plugins: {
|
||||
...baseContext.config.plugins,
|
||||
allow: [...baseContext.config.plugins.allow, "wildcard-demo"],
|
||||
entries: {
|
||||
"wildcard-demo": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
const registry = createToolRegistry([
|
||||
{
|
||||
pluginId: "wildcard-demo",
|
||||
optional: true,
|
||||
source: "/tmp/wildcard-demo.js",
|
||||
names: ["derived_tool_calendar"],
|
||||
declaredNames: ["derived_tool_*"],
|
||||
factory: () => makeTool("derived_tool_calendar"),
|
||||
},
|
||||
]);
|
||||
loadOpenClawPluginsMock.mockReturnValue(registry);
|
||||
installToolManifestSnapshot({
|
||||
config,
|
||||
plugin: {
|
||||
id: "wildcard-demo",
|
||||
origin: "bundled",
|
||||
enabledByDefault: false,
|
||||
channels: [],
|
||||
providers: [],
|
||||
contracts: {
|
||||
tools: ["derived_tool_*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tools = resolvePluginTools({
|
||||
context: { ...baseContext, config } as never,
|
||||
toolAllowlist: ["derived_tool_calendar"],
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
|
||||
expectResolvedToolNames(tools, ["derived_tool_calendar"]);
|
||||
expectLoaderSelectedOnlyPluginIds(["wildcard-demo"]);
|
||||
});
|
||||
|
||||
it("auto-loads cold registry for path-based config-origin plugins without pre-warming (#76598)", () => {
|
||||
const context = {
|
||||
...createContext(),
|
||||
|
||||
@@ -179,7 +179,9 @@ function isOptionalToolEntryPotentiallyAllowed(params: {
|
||||
if (params.names.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return params.names.some((name) => params.allowlist.has(normalizeToolName(name)));
|
||||
return params.names.some((name) =>
|
||||
allowlistMatchesToolNameOrContract(params.allowlist, normalizeToolName(name)),
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -348,6 +350,25 @@ function pluginToolNamesMatchAllowlist(params: {
|
||||
return isOptionalToolEntryPotentiallyAllowed(params);
|
||||
}
|
||||
|
||||
function toolContractPrefix(name: string): string | undefined {
|
||||
if (!name.endsWith("*")) {
|
||||
return undefined;
|
||||
}
|
||||
const prefix = normalizeToolName(name.slice(0, -1));
|
||||
return prefix || undefined;
|
||||
}
|
||||
|
||||
function allowlistMatchesToolNameOrContract(allowlist: Set<string>, name: string): boolean {
|
||||
const normalized = normalizeToolName(name);
|
||||
if (allowlist.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const wildcardPrefix = toolContractPrefix(normalized);
|
||||
return wildcardPrefix
|
||||
? [...allowlist].some((allowed) => normalizeToolName(allowed).startsWith(wildcardPrefix))
|
||||
: false;
|
||||
}
|
||||
|
||||
function listManifestToolNamesForAllowlist(params: {
|
||||
plugin: PluginManifestRecord;
|
||||
toolNames: readonly string[];
|
||||
@@ -365,13 +386,13 @@ function listManifestToolNamesForAllowlist(params: {
|
||||
return [...params.toolNames];
|
||||
}
|
||||
const matchedToolNames = params.toolNames.filter((name) =>
|
||||
params.allowlist.has(normalizeToolName(name)),
|
||||
allowlistMatchesToolNameOrContract(params.allowlist, name),
|
||||
);
|
||||
if (!allowlistIncludesDefaultPluginTools(params.allowlist)) {
|
||||
return matchedToolNames;
|
||||
}
|
||||
const defaultToolNames = params.toolNames.filter(
|
||||
(name) => !isManifestToolOptional(params.plugin, name),
|
||||
(name) => !toolContractPrefix(name) && !isManifestToolOptional(params.plugin, name),
|
||||
);
|
||||
return [...new Set([...defaultToolNames, ...matchedToolNames])];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user