mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-26 01:01:58 +08:00
Compare commits
5 Commits
v2026.6.8
...
dev/kevinl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64afe05084 | ||
|
|
ea461328e2 | ||
|
|
2c99d1f209 | ||
|
|
fc8e2196e9 | ||
|
|
e045e45210 |
@@ -10,10 +10,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugin skills/Windows: publish plugin-provided skill directories as junctions on Windows so standard users without Developer Mode can register plugin skills without symlink EPERM failures. Fixes #77958. (#77971) Thanks @hclsys and @jarro.
|
||||
- MS Teams: surface blocked Bot Framework egress by logging JWKS fetch network failures and adding a Bot Connector send hint for transport-level reply failures. Fixes #77674. (#78081) Thanks @Beandon13.
|
||||
- Gateway/sessions: fast-path already-qualified model refs while building session-list rows so `openclaw sessions` and Control UI session lists avoid heavyweight model resolution on large stores. (#77902) Thanks @ragesaq.
|
||||
- Codex: migrate selected source-installed `openai-curated` Codex plugins into Codex app-server native activation so plugin mentions run in the main Codex session thread instead of through OpenClaw dynamic tools. Thanks @kevinslin.
|
||||
- PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement.
|
||||
- Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc.
|
||||
- Talk/voice: unify realtime relay, transcription relay, managed-room handoff, Voice Call, Google Meet, VoiceClaw, and native clients around a shared Talk session controller and add the Gateway-managed `talk.session.*` RPC surface.
|
||||
- Diagnostics/Talk: export bounded Talk lifecycle/audio metrics and session recovery metrics through OpenTelemetry and Prometheus without exposing transcripts, audio payloads, room ids, turn ids, or session ids.
|
||||
- Google Meet/Voice Call: make Twilio dial-in joins speak through the realtime Gemini voice bridge with paced audio streaming, backpressure-aware buffering, barge-in queue clearing, same-session agent consult routing, duplicate-consult coalescing, and no TwiML fallback during realtime speech, giving Meet participants a much snappier OpenClaw voice agent. (#77064) Thanks @scoootscooob.
|
||||
- Voice Call/realtime: add opt-in OpenClaw agent voice context capsules and consult-cadence guidance so Gemini/OpenAI realtime calls can sound like the configured agent without consulting the full agent on every ordinary turn. Thanks @scoootscooob.
|
||||
- Docker/Gateway: harden the gateway container by dropping `NET_RAW` and `NET_ADMIN` capabilities and enabling `no-new-privileges` in the bundled `docker-compose.yml`. Thanks @VintageAyu.
|
||||
|
||||
@@ -21,9 +21,11 @@ openclaw migrate list
|
||||
openclaw migrate claude --dry-run
|
||||
openclaw migrate codex --dry-run
|
||||
openclaw migrate codex --skill gog-vault77-google-workspace
|
||||
openclaw migrate codex --plugin google-calendar
|
||||
openclaw migrate hermes --dry-run
|
||||
openclaw migrate hermes
|
||||
openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
|
||||
openclaw migrate apply codex --yes --plugin google-calendar
|
||||
openclaw migrate apply codex --yes
|
||||
openclaw migrate apply claude --yes
|
||||
openclaw migrate apply hermes --yes
|
||||
@@ -54,6 +56,9 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
|
||||
<ParamField path="--skill <name>" type="string">
|
||||
Select one skill copy item by skill name or item id. Repeat the flag to migrate multiple skills. When omitted, interactive Codex migrations show a checkbox selector and non-interactive migrations keep all planned skills.
|
||||
</ParamField>
|
||||
<ParamField path="--plugin <name>" type="string">
|
||||
Select one source-installed `openai-curated` Codex plugin to activate through Codex app-server. Repeat the flag to migrate multiple plugins.
|
||||
</ParamField>
|
||||
<ParamField path="--no-backup" type="boolean">
|
||||
Skip the pre-apply backup. Requires `--force` when local OpenClaw state exists.
|
||||
</ParamField>
|
||||
@@ -135,13 +140,19 @@ openclaw migrate apply codex --yes --skill gog-vault77-google-workspace
|
||||
`.system` cache.
|
||||
- Personal AgentSkills under `$HOME/.agents/skills`, copied into the current
|
||||
OpenClaw agent workspace when you want per-agent ownership.
|
||||
- Source-installed `openai-curated` Codex plugins selected with
|
||||
`--plugin <name>`, activated through the target Codex app-server with
|
||||
`plugin/install`. OpenClaw does not turn these into OpenClaw tools; invoke
|
||||
them from Codex-mode turns with native mentions such as
|
||||
`[@Google Calendar](plugin://google-calendar)`.
|
||||
|
||||
### Manual-review Codex state
|
||||
|
||||
Codex native plugins, `config.toml`, and native `hooks/hooks.json` are not
|
||||
activated automatically. Plugins may expose MCP servers, apps, hooks, or other
|
||||
executable behavior, so the provider reports them for review instead of loading
|
||||
them into OpenClaw. Config and hook files are copied into the migration report
|
||||
Cached Codex plugin bundles, non-`openai-curated` marketplaces, `config.toml`,
|
||||
and native `hooks/hooks.json` are not activated automatically. Plugins may
|
||||
expose MCP servers, apps, hooks, or other executable behavior, so the provider
|
||||
reports unsafe or undiscoverable bundles for review instead of copying plugin
|
||||
bytes into OpenClaw. Config and hook files are copied into the migration report
|
||||
for manual review.
|
||||
|
||||
## Hermes provider
|
||||
|
||||
163
docs/plan/codex-native-plugin-apps.md
Normal file
163
docs/plan/codex-native-plugin-apps.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
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.
|
||||
|
||||
User-visible behavior:
|
||||
|
||||
- `openclaw migrate codex --plugin <name>` can install or enable selected
|
||||
source-installed `openai-curated` Codex plugins.
|
||||
- Migration enables the bundled `codex` plugin and updates `plugins.allow` only
|
||||
when needed for the Codex harness itself.
|
||||
- Migration does not write tool allowlist entries or bridge config for Codex
|
||||
plugins.
|
||||
|
||||
Implementation scope:
|
||||
|
||||
- Discover source-installed plugins through app-server `plugin/list` and
|
||||
related app state through `app/list`.
|
||||
- Keep apply-time `plugin/install` plus app, MCP server, and skill reloads.
|
||||
- Report inaccessible or unauthorized apps on plugin items.
|
||||
- Remove apply-time fallback to bridge config.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Selected plugins are installed through app-server APIs.
|
||||
- Failed app authorization does not create fallback tool config.
|
||||
- Restrictive plugin allowlists are updated only for the bundled `codex` plugin.
|
||||
|
||||
Verification:
|
||||
|
||||
- Migration provider tests cover planning, selected plugin install, restrictive
|
||||
allowlists, and app authorization failures.
|
||||
|
||||
## 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
|
||||
|
||||
This stacked PR adds Codex source discovery, planning, apply-time install/reload
|
||||
behavior, and migration CLI/docs updates. The migration provider records native
|
||||
plugin items as `plugin` install actions with `nativeThreadPlugin: true`, then
|
||||
applies them with Codex app-server control-plane calls instead of OpenClaw tool
|
||||
registration.
|
||||
|
||||
### 2.6 PR stack docs, tests, and proof
|
||||
|
||||
PR 1 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.
|
||||
|
||||
This PR owns:
|
||||
|
||||
- CLI migration docs for `openclaw migrate codex --plugin <name>` and native
|
||||
mention usage after migration.
|
||||
- Harness docs that explain the migration-specific native setup behavior.
|
||||
- Migration provider tests for selected plugin discovery, install, reload,
|
||||
restrictive allowlist handling, and related-app authorization failures.
|
||||
@@ -264,6 +264,33 @@ 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.
|
||||
```
|
||||
|
||||
When `openclaw migrate codex --plugin ...` sees source-installed
|
||||
`openai-curated` Codex plugins through app-server discovery, migration applies
|
||||
only the native setup:
|
||||
|
||||
- enables the bundled `codex` plugin when needed
|
||||
- adds `codex` to `plugins.allow` when that allowlist is already restrictive
|
||||
- installs or enables the selected `openai-curated` plugin with `plugin/install`
|
||||
- reloads Codex skills, MCP servers, and apps through app-server APIs
|
||||
|
||||
After migration, the Codex app-server owns plugin activation, app authorization,
|
||||
permission prompts, and transcript semantics. If a related app is inaccessible
|
||||
or needs authorization, migration reports that on the plugin item instead of
|
||||
creating an OpenClaw fallback tool.
|
||||
|
||||
## 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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import path from "node:path";
|
||||
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
|
||||
import {
|
||||
applyMigrationConfigPatchItem,
|
||||
markMigrationItemConflict,
|
||||
markMigrationItemError,
|
||||
readMigrationConfigPatchDetails,
|
||||
summarizeMigrationItems,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
import {
|
||||
archiveMigrationItem,
|
||||
copyMigrationFileItem,
|
||||
@@ -11,8 +17,276 @@ import type {
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "../app-server/config.js";
|
||||
import type { v2 } from "../app-server/protocol-generated/typescript/index.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import { buildCodexMigrationPlan } from "./plan.js";
|
||||
|
||||
const OPENAI_CURATED_MARKETPLACE = "openai-curated";
|
||||
const CODEX_PLUGIN_APPLY_TIMEOUT_MS = 60_000;
|
||||
const CODEX_CONFIG_ALLOWLIST_ITEM_IDS = new Set(["config:codex-plugin-allowlist"]);
|
||||
|
||||
type CodexMigrationAppServerRequest = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
let appServerRequestForTests: CodexMigrationAppServerRequest | undefined;
|
||||
|
||||
function readCodexPluginConfigFromOpenClawConfig(config: unknown): unknown {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return undefined;
|
||||
}
|
||||
const plugins = (config as { plugins?: unknown }).plugins;
|
||||
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = (plugins as { entries?: unknown }).entries;
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||
return undefined;
|
||||
}
|
||||
const codex = (entries as Record<string, unknown>).codex;
|
||||
if (!codex || typeof codex !== "object" || Array.isArray(codex)) {
|
||||
return undefined;
|
||||
}
|
||||
return (codex as { config?: unknown }).config;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function readConfigPath(config: unknown, path: readonly string[]): unknown {
|
||||
let current: unknown = config;
|
||||
for (const segment of path) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function writeConfigPath(root: Record<string, unknown>, path: readonly string[], value: unknown) {
|
||||
let current = root;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
const existing = current[segment];
|
||||
if (!isRecord(existing)) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
}
|
||||
const leaf = path.at(-1);
|
||||
if (leaf) {
|
||||
current[leaf] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeStringAllowlist(existing: unknown, values: readonly string[]): string[] | undefined {
|
||||
if (existing !== undefined && !Array.isArray(existing)) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(existing) && !existing.every((value) => typeof value === "string")) {
|
||||
return undefined;
|
||||
}
|
||||
const next = new Set<string>(Array.isArray(existing) ? existing : []);
|
||||
for (const value of values) {
|
||||
next.add(value);
|
||||
}
|
||||
return [...next];
|
||||
}
|
||||
|
||||
async function defaultAppServerRequest(
|
||||
ctx: MigrationProviderContext,
|
||||
): Promise<CodexMigrationAppServerRequest> {
|
||||
const runtimeOptions = resolveCodexAppServerRuntimeOptions({
|
||||
pluginConfig: readCodexPluginConfigFromOpenClawConfig(ctx.config),
|
||||
});
|
||||
const startOptions =
|
||||
typeof ctx.source === "string" && ctx.source.trim()
|
||||
? {
|
||||
...runtimeOptions.start,
|
||||
env: {
|
||||
...runtimeOptions.start.env,
|
||||
CODEX_HOME: ctx.source,
|
||||
},
|
||||
}
|
||||
: runtimeOptions.start;
|
||||
return async (method: string, requestParams?: unknown): Promise<unknown> =>
|
||||
await requestCodexAppServerJson({
|
||||
method,
|
||||
requestParams,
|
||||
timeoutMs: CODEX_PLUGIN_APPLY_TIMEOUT_MS,
|
||||
startOptions,
|
||||
config: ctx.config,
|
||||
});
|
||||
}
|
||||
|
||||
function readPluginDetail(item: MigrationItem):
|
||||
| {
|
||||
pluginName: string;
|
||||
marketplaceName: string;
|
||||
accessible?: boolean;
|
||||
}
|
||||
| undefined {
|
||||
const pluginName = item.details?.pluginName;
|
||||
const marketplaceName = item.details?.marketplaceName;
|
||||
const accessible = item.details?.accessible;
|
||||
if (typeof pluginName !== "string" || typeof marketplaceName !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pluginName,
|
||||
marketplaceName,
|
||||
...(typeof accessible === "boolean" ? { accessible } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshCodexPluginRuntime(request: CodexMigrationAppServerRequest): Promise<void> {
|
||||
await request("plugin/list", { cwds: [] } satisfies v2.PluginListParams);
|
||||
await request("skills/list", {
|
||||
cwds: [],
|
||||
forceReload: true,
|
||||
} satisfies v2.SkillsListParams);
|
||||
await request("config/mcpServer/reload", undefined);
|
||||
await request("app/list", {
|
||||
limit: 100,
|
||||
forceRefetch: true,
|
||||
} satisfies v2.AppsListParams);
|
||||
}
|
||||
|
||||
async function applyCodexPluginActivationItems(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
items: MigrationItem[];
|
||||
}): Promise<MigrationItem[]> {
|
||||
if (params.items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const request = appServerRequestForTests ?? (await defaultAppServerRequest(params.ctx));
|
||||
const listed = (await request("plugin/list", {
|
||||
cwds: [],
|
||||
} satisfies v2.PluginListParams)) as v2.PluginListResponse;
|
||||
const marketplace = listed.marketplaces.find(
|
||||
(entry) => entry.name === OPENAI_CURATED_MARKETPLACE,
|
||||
);
|
||||
const applied: MigrationItem[] = [];
|
||||
let changed = false;
|
||||
for (const item of params.items) {
|
||||
const detail = readPluginDetail(item);
|
||||
if (!detail) {
|
||||
applied.push({ ...item, status: "error", reason: "missing plugin migration metadata" });
|
||||
continue;
|
||||
}
|
||||
if (detail.marketplaceName !== OPENAI_CURATED_MARKETPLACE) {
|
||||
applied.push({
|
||||
...item,
|
||||
status: "error",
|
||||
reason: "only openai-curated Codex plugins can be activated by migration",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const plugin = marketplace?.plugins.find(
|
||||
(candidate) =>
|
||||
candidate.name === detail.pluginName ||
|
||||
candidate.id === detail.pluginName ||
|
||||
candidate.id === `${detail.pluginName}@${OPENAI_CURATED_MARKETPLACE}`,
|
||||
);
|
||||
if (!marketplace || !plugin) {
|
||||
applied.push({
|
||||
...item,
|
||||
status: "error",
|
||||
reason: `openai-curated Codex plugin "${detail.pluginName}" was not found in target app-server inventory`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (plugin.installed && plugin.enabled && detail.accessible === false) {
|
||||
applied.push({
|
||||
...item,
|
||||
status: "error",
|
||||
reason: `plugin "${detail.pluginName}" is installed and enabled but its app is not accessible; reauthorize the app before migration can enable it`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (plugin.installed && plugin.enabled) {
|
||||
applied.push({
|
||||
...item,
|
||||
status: "migrated",
|
||||
reason: "already installed and enabled",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!marketplace.path) {
|
||||
applied.push({
|
||||
...item,
|
||||
status: "error",
|
||||
reason: "openai-curated marketplace path is unavailable",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const installResponse = (await request("plugin/install", {
|
||||
marketplacePath: marketplace.path,
|
||||
pluginName: detail.pluginName,
|
||||
} satisfies v2.PluginInstallParams)) as v2.PluginInstallResponse;
|
||||
changed = true;
|
||||
const appsNeedingAuth = installResponse.appsNeedingAuth ?? [];
|
||||
if (appsNeedingAuth.length > 0) {
|
||||
applied.push({
|
||||
...item,
|
||||
status: "error",
|
||||
reason: `plugin installed but requires app authorization before migration can enable it: ${appsNeedingAuth
|
||||
.map((app) => app.name || app.id)
|
||||
.join(", ")}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
applied.push({ ...item, status: "migrated" });
|
||||
}
|
||||
if (changed) {
|
||||
await refreshCodexPluginRuntime(request);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
async function applyCodexAllowlistConfigPatchItem(
|
||||
ctx: MigrationProviderContext,
|
||||
item: MigrationItem,
|
||||
): Promise<MigrationItem> {
|
||||
if (item.status !== "planned") {
|
||||
return item;
|
||||
}
|
||||
const details = readMigrationConfigPatchDetails(item);
|
||||
const values = details?.value;
|
||||
if (!details || !Array.isArray(values) || !values.every((value) => typeof value === "string")) {
|
||||
return markMigrationItemError(item, "missing allowlist config patch");
|
||||
}
|
||||
const configApi = ctx.runtime?.config;
|
||||
if (!configApi?.current || !configApi.mutateConfigFile) {
|
||||
return markMigrationItemError(item, "config runtime unavailable");
|
||||
}
|
||||
const current = configApi.current() as MigrationProviderContext["config"];
|
||||
const merged = mergeStringAllowlist(readConfigPath(current, details.path), values);
|
||||
if (!merged && !ctx.overwrite) {
|
||||
return markMigrationItemConflict(item, "target exists");
|
||||
}
|
||||
try {
|
||||
await configApi.mutateConfigFile({
|
||||
base: "runtime",
|
||||
afterWrite: { mode: "auto" },
|
||||
mutate(draft) {
|
||||
const existing = readConfigPath(draft, details.path);
|
||||
const next = mergeStringAllowlist(existing, values);
|
||||
if (!next && !ctx.overwrite) {
|
||||
throw new Error("target exists");
|
||||
}
|
||||
writeConfigPath(draft as Record<string, unknown>, details.path, next ?? values);
|
||||
},
|
||||
});
|
||||
return { ...item, status: "migrated" };
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
return reason === "target exists"
|
||||
? markMigrationItemConflict(item, reason)
|
||||
: markMigrationItemError(item, reason);
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyCodexMigrationPlan(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
plan?: MigrationPlan;
|
||||
@@ -20,13 +294,37 @@ export async function applyCodexMigrationPlan(params: {
|
||||
const plan = params.plan ?? (await buildCodexMigrationPlan(params.ctx));
|
||||
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "codex");
|
||||
const items: MigrationItem[] = [];
|
||||
const pluginActivationItems = plan.items.filter(
|
||||
(item) => item.kind === "plugin" && item.action === "install" && item.status === "planned",
|
||||
);
|
||||
const appliedPluginItemsById = new Map(
|
||||
(
|
||||
await applyCodexPluginActivationItems({
|
||||
ctx: params.ctx,
|
||||
items: pluginActivationItems,
|
||||
})
|
||||
).map((item) => [item.id, item]),
|
||||
);
|
||||
for (const item of plan.items) {
|
||||
const appliedPluginItem = appliedPluginItemsById.get(item.id);
|
||||
if (appliedPluginItem) {
|
||||
items.push(appliedPluginItem);
|
||||
continue;
|
||||
}
|
||||
if (item.status !== "planned") {
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item.action === "archive") {
|
||||
items.push(await archiveMigrationItem(item, reportDir));
|
||||
} else if (
|
||||
item.kind === "config" &&
|
||||
item.action === "merge" &&
|
||||
CODEX_CONFIG_ALLOWLIST_ITEM_IDS.has(item.id)
|
||||
) {
|
||||
items.push(await applyCodexAllowlistConfigPatchItem(params.ctx, item));
|
||||
} else if (item.kind === "config" && item.action === "merge") {
|
||||
items.push(await applyMigrationConfigPatchItem(params.ctx, item));
|
||||
} else {
|
||||
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
|
||||
}
|
||||
@@ -41,3 +339,9 @@ export async function applyCodexMigrationPlan(params: {
|
||||
await writeMigrationReport(result, { title: "Codex Migration Report" });
|
||||
return result;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
setAppServerRequestForTests(request: CodexMigrationAppServerRequest | undefined): void {
|
||||
appServerRequestForTests = request;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
createMigrationItem,
|
||||
createMigrationConfigPatchItem,
|
||||
createMigrationManualItem,
|
||||
hasMigrationConfigPatchConflict,
|
||||
MIGRATION_REASON_TARGET_EXISTS,
|
||||
summarizeMigrationItems,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
@@ -11,9 +13,43 @@ import type {
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { exists, sanitizeName } from "./helpers.js";
|
||||
import { discoverCodexSource, hasCodexSource, type CodexSkillSource } from "./source.js";
|
||||
import {
|
||||
discoverCodexSource,
|
||||
hasCodexSource,
|
||||
type CodexInstalledPluginSource,
|
||||
type CodexSkillSource,
|
||||
} from "./source.js";
|
||||
import { resolveCodexMigrationTargets } from "./targets.js";
|
||||
|
||||
const OPENAI_CURATED_MARKETPLACE = "openai-curated";
|
||||
|
||||
type CodexMigrationContext = MigrationProviderContext & {
|
||||
plugins?: string[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function readConfigPath(config: unknown, path: readonly string[]): unknown {
|
||||
let current: unknown = config;
|
||||
for (const segment of path) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function appendUnique(values: readonly string[], value: string): string[] {
|
||||
return values.includes(value) ? [...values] : [...values, value];
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((entry): entry is string => typeof entry === "string");
|
||||
}
|
||||
|
||||
function uniqueSkillName(skill: CodexSkillSource, counts: Map<string, number>): string {
|
||||
const base = sanitizeName(skill.name) || "codex-skill";
|
||||
if ((counts.get(base) ?? 0) <= 1) {
|
||||
@@ -67,10 +103,143 @@ async function buildSkillItems(params: {
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizePluginSelectionRef(value: string): string {
|
||||
return sanitizeName(value).replace(new RegExp(`@${OPENAI_CURATED_MARKETPLACE}$`, "u"), "");
|
||||
}
|
||||
|
||||
function readSelectedPlugins(ctx: MigrationProviderContext): Set<string> | undefined {
|
||||
const selected = (ctx as CodexMigrationContext).plugins;
|
||||
if (!selected || selected.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return new Set(
|
||||
selected
|
||||
.map((plugin) => normalizePluginSelectionRef(plugin))
|
||||
.filter((plugin) => plugin.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function codexPluginKey(plugin: CodexInstalledPluginSource): string {
|
||||
return sanitizeName(plugin.name) || sanitizeName(plugin.id) || "codex-plugin";
|
||||
}
|
||||
|
||||
function selectCodexPlugins(params: {
|
||||
plugins: CodexInstalledPluginSource[];
|
||||
selected?: Set<string>;
|
||||
}): CodexInstalledPluginSource[] {
|
||||
if (!params.selected) {
|
||||
return params.plugins;
|
||||
}
|
||||
const availableRefs = new Map<string, CodexInstalledPluginSource>();
|
||||
for (const plugin of params.plugins) {
|
||||
const refs = [
|
||||
plugin.name,
|
||||
plugin.id,
|
||||
codexPluginKey(plugin),
|
||||
plugin.id.replace(new RegExp(`@${OPENAI_CURATED_MARKETPLACE}$`, "u"), ""),
|
||||
];
|
||||
for (const ref of refs) {
|
||||
availableRefs.set(normalizePluginSelectionRef(ref), plugin);
|
||||
}
|
||||
}
|
||||
const selectedPlugins: CodexInstalledPluginSource[] = [];
|
||||
const unknown: string[] = [];
|
||||
for (const ref of params.selected) {
|
||||
const plugin = availableRefs.get(ref);
|
||||
if (!plugin) {
|
||||
unknown.push(ref);
|
||||
continue;
|
||||
}
|
||||
if (!selectedPlugins.some((existing) => existing.id === plugin.id)) {
|
||||
selectedPlugins.push(plugin);
|
||||
}
|
||||
}
|
||||
if (unknown.length > 0) {
|
||||
const available = params.plugins.map((plugin) => plugin.name).toSorted();
|
||||
throw new Error(
|
||||
`No migratable Codex plugin matched ${unknown.map((item) => `"${item}"`).join(", ")}. Available plugins: ${
|
||||
available.length > 0 ? available.join(", ") : "none"
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
return selectedPlugins.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function buildCodexPluginConfigItems(params: { ctx: MigrationProviderContext }): MigrationItem[] {
|
||||
const items: MigrationItem[] = [];
|
||||
items.push(
|
||||
createMigrationConfigPatchItem({
|
||||
id: "config:codex-enabled",
|
||||
target: "plugins.entries.codex.enabled",
|
||||
path: ["plugins", "entries", "codex", "enabled"],
|
||||
value: true,
|
||||
message:
|
||||
"Enable the bundled Codex plugin so migrated Codex plugins run through the native app-server thread.",
|
||||
conflict:
|
||||
!params.ctx.overwrite &&
|
||||
hasMigrationConfigPatchConflict(
|
||||
params.ctx.config,
|
||||
["plugins", "entries", "codex", "enabled"],
|
||||
true,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const pluginAllow = readConfigPath(params.ctx.config, ["plugins", "allow"]);
|
||||
if (isStringArray(pluginAllow)) {
|
||||
const value = appendUnique(pluginAllow, "codex");
|
||||
items.push(
|
||||
createMigrationConfigPatchItem({
|
||||
id: "config:codex-plugin-allowlist",
|
||||
target: "plugins.allow",
|
||||
path: ["plugins", "allow"],
|
||||
value,
|
||||
message: "Include Codex in the plugin allowlist so the enabled plugin can load.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function buildCodexPluginItems(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
plugins: CodexInstalledPluginSource[];
|
||||
}): MigrationItem[] {
|
||||
if (params.plugins.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const items: MigrationItem[] = [];
|
||||
for (const plugin of params.plugins) {
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: `plugin:${codexPluginKey(plugin)}`,
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
source: `${OPENAI_CURATED_MARKETPLACE}:${plugin.name}`,
|
||||
status: "planned",
|
||||
message: `Activate Codex plugin "${plugin.displayName}" through Codex app-server.`,
|
||||
details: {
|
||||
pluginId: plugin.id,
|
||||
pluginName: plugin.name,
|
||||
displayName: plugin.displayName,
|
||||
marketplaceName: OPENAI_CURATED_MARKETPLACE,
|
||||
...(plugin.marketplacePath ? { marketplacePath: plugin.marketplacePath } : {}),
|
||||
sourceInstalled: plugin.installed,
|
||||
sourceEnabled: plugin.enabled,
|
||||
nativeThreadPlugin: true,
|
||||
...(plugin.accessible !== undefined ? { accessible: plugin.accessible } : {}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
items.push(...buildCodexPluginConfigItems({ ctx: params.ctx }));
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function buildCodexMigrationPlan(
|
||||
ctx: MigrationProviderContext,
|
||||
): Promise<MigrationPlan> {
|
||||
const source = await discoverCodexSource(ctx.source);
|
||||
const source = await discoverCodexSource(ctx.source, { config: ctx.config });
|
||||
if (!hasCodexSource(source)) {
|
||||
throw new Error(
|
||||
`Codex state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
|
||||
@@ -85,14 +254,19 @@ export async function buildCodexMigrationPlan(
|
||||
overwrite: ctx.overwrite,
|
||||
})),
|
||||
);
|
||||
const selectedPlugins = selectCodexPlugins({
|
||||
plugins: source.nativePlugins,
|
||||
selected: readSelectedPlugins(ctx),
|
||||
});
|
||||
items.push(...buildCodexPluginItems({ ctx, plugins: selectedPlugins }));
|
||||
for (const [index, plugin] of source.plugins.entries()) {
|
||||
items.push(
|
||||
createMigrationManualItem({
|
||||
id: `plugin:${sanitizeName(plugin.name) || sanitizeName(path.basename(plugin.source))}:${index + 1}`,
|
||||
source: plugin.source,
|
||||
message: `Codex native plugin "${plugin.name}" was found but not activated automatically.`,
|
||||
message: `Codex native plugin "${plugin.name}" was found in the cache scan but not activated automatically.`,
|
||||
recommendation:
|
||||
"Review the plugin bundle first, then install trusted compatible plugins with openclaw plugins install <path>.",
|
||||
"Codex plugin migration can activate source-installed openai-curated plugins only when app-server discovery is available; review other cached plugin bundles manually.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -116,9 +290,19 @@ export async function buildCodexMigrationPlan(
|
||||
"Conflicts were found. Re-run with --overwrite to replace conflicting skill targets after item-level backups.",
|
||||
]
|
||||
: []),
|
||||
...(source.pluginDiscoveryError
|
||||
? [
|
||||
`Codex app-server plugin discovery was unavailable (${source.pluginDiscoveryError}). Cached plugin bundles are reported for manual review only.`,
|
||||
]
|
||||
: []),
|
||||
...(selectedPlugins.length > 0
|
||||
? [
|
||||
"Source-installed openai-curated Codex plugins will be activated through Codex app-server during apply and invoked by native plugin mentions on the main Codex thread. Plugin bytes are not copied manually.",
|
||||
]
|
||||
: []),
|
||||
...(source.plugins.length > 0
|
||||
? [
|
||||
"Codex native plugins are reported for manual review only. OpenClaw does not auto-activate plugin bundles, hooks, MCP servers, or apps from another Codex home.",
|
||||
"Cached Codex plugin bundles are manual-review fallback items. OpenClaw does not copy plugin bytes or activate non-openai-curated marketplaces.",
|
||||
]
|
||||
: []),
|
||||
...(source.archivePaths.length > 0
|
||||
@@ -136,6 +320,7 @@ export async function buildCodexMigrationPlan(
|
||||
warnings,
|
||||
nextSteps: [
|
||||
"Run openclaw doctor after applying the migration.",
|
||||
"Invoke migrated Codex plugins from Codex-mode turns with native mentions such as [@Google Calendar](plugin://google-calendar).",
|
||||
"Review skipped Codex plugin/config/hook items before installing or recreating them in OpenClaw.",
|
||||
],
|
||||
metadata: {
|
||||
|
||||
@@ -3,7 +3,9 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __testing as applyTesting } from "./apply.js";
|
||||
import { buildCodexMigrationProvider } from "./provider.js";
|
||||
import { __testing as sourceTesting } from "./source.js";
|
||||
|
||||
const tempRoots = new Set<string>();
|
||||
|
||||
@@ -31,20 +33,154 @@ function makeContext(params: {
|
||||
workspaceDir: string;
|
||||
overwrite?: boolean;
|
||||
reportDir?: string;
|
||||
plugins?: string[];
|
||||
config?: MigrationProviderContext["config"];
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
}): MigrationProviderContext {
|
||||
return {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: params.workspaceDir,
|
||||
config:
|
||||
params.config ??
|
||||
({
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: params.workspaceDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MigrationProviderContext["config"],
|
||||
} as MigrationProviderContext["config"]),
|
||||
source: params.source,
|
||||
stateDir: params.stateDir,
|
||||
overwrite: params.overwrite,
|
||||
reportDir: params.reportDir,
|
||||
...(params.plugins ? { plugins: params.plugins } : {}),
|
||||
...(params.runtime ? { runtime: params.runtime } : {}),
|
||||
logger,
|
||||
} as MigrationProviderContext;
|
||||
}
|
||||
|
||||
function createConfigRuntime(initialConfig: MigrationProviderContext["config"]): {
|
||||
runtime: NonNullable<MigrationProviderContext["runtime"]>;
|
||||
getConfig: () => MigrationProviderContext["config"];
|
||||
} {
|
||||
let currentConfig = structuredClone(initialConfig);
|
||||
const runtime = {
|
||||
config: {
|
||||
current: () => currentConfig,
|
||||
mutateConfigFile: async (params: {
|
||||
mutate: (draft: MigrationProviderContext["config"]) => void | Promise<void>;
|
||||
}) => {
|
||||
const draft = structuredClone(currentConfig);
|
||||
await params.mutate(draft);
|
||||
currentConfig = draft;
|
||||
return { nextConfig: currentConfig };
|
||||
},
|
||||
},
|
||||
} as unknown as NonNullable<MigrationProviderContext["runtime"]>;
|
||||
return { runtime, getConfig: () => currentConfig };
|
||||
}
|
||||
|
||||
function pluginListResponse(params: {
|
||||
gmail?: { installed?: boolean; enabled?: boolean };
|
||||
slack?: { installed?: boolean; enabled?: boolean };
|
||||
includeOtherMarketplace?: boolean;
|
||||
}) {
|
||||
return {
|
||||
marketplaces: [
|
||||
{
|
||||
name: "openai-curated",
|
||||
path: "/tmp/openai-curated",
|
||||
interface: null,
|
||||
plugins: [
|
||||
{
|
||||
id: "gmail@openai-curated",
|
||||
name: "gmail",
|
||||
source: { type: "local", path: "/tmp/openai-curated/gmail" },
|
||||
installed: params.gmail?.installed ?? true,
|
||||
enabled: params.gmail?.enabled ?? true,
|
||||
installPolicy: "AVAILABLE",
|
||||
authPolicy: "ON_USE",
|
||||
availability: "AVAILABLE",
|
||||
interface: { displayName: "Gmail" },
|
||||
},
|
||||
{
|
||||
id: "slack@openai-curated",
|
||||
name: "slack",
|
||||
source: { type: "local", path: "/tmp/openai-curated/slack" },
|
||||
installed: params.slack?.installed ?? true,
|
||||
enabled: params.slack?.enabled ?? false,
|
||||
installPolicy: "AVAILABLE",
|
||||
authPolicy: "ON_USE",
|
||||
availability: "AVAILABLE",
|
||||
interface: { displayName: "Slack" },
|
||||
},
|
||||
],
|
||||
},
|
||||
...(params.includeOtherMarketplace
|
||||
? [
|
||||
{
|
||||
name: "openai-primary-runtime",
|
||||
path: "/tmp/openai-primary-runtime",
|
||||
interface: null,
|
||||
plugins: [
|
||||
{
|
||||
id: "documents@openai-primary-runtime",
|
||||
name: "documents",
|
||||
source: { type: "local", path: "/tmp/openai-primary-runtime/documents" },
|
||||
installed: true,
|
||||
enabled: true,
|
||||
installPolicy: "AVAILABLE",
|
||||
authPolicy: "ON_USE",
|
||||
availability: "AVAILABLE",
|
||||
interface: { displayName: "Documents" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
marketplaceLoadErrors: [],
|
||||
featuredPluginIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
function appsListResponse(params: { accessible?: boolean; extraAppAccessible?: boolean } = {}) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
distributionChannel: null,
|
||||
branding: null,
|
||||
appMetadata: null,
|
||||
labels: null,
|
||||
installUrl: null,
|
||||
isAccessible: params.accessible ?? true,
|
||||
isEnabled: true,
|
||||
pluginDisplayNames: ["Gmail"],
|
||||
},
|
||||
...(params.extraAppAccessible === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "gmail-extra",
|
||||
name: "Gmail extra",
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
distributionChannel: null,
|
||||
branding: null,
|
||||
appMetadata: null,
|
||||
labels: null,
|
||||
installUrl: null,
|
||||
isAccessible: params.extraAppAccessible,
|
||||
isEnabled: true,
|
||||
pluginDisplayNames: ["Gmail"],
|
||||
},
|
||||
]),
|
||||
],
|
||||
nextCursor: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,6 +197,9 @@ async function createCodexFixture(): Promise<{
|
||||
const stateDir = path.join(root, "state");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
vi.stubEnv("HOME", homeDir);
|
||||
sourceTesting.setAppServerRequestForTests(async () => {
|
||||
throw new Error("app-server unavailable");
|
||||
});
|
||||
await writeFile(path.join(codexHome, "skills", "tweet-helper", "SKILL.md"), "# Tweet helper\n");
|
||||
await writeFile(path.join(codexHome, "skills", ".system", "system-skill", "SKILL.md"));
|
||||
await writeFile(path.join(homeDir, ".agents", "skills", "personal-style", "SKILL.md"));
|
||||
@@ -84,6 +223,8 @@ async function createCodexFixture(): Promise<{
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
sourceTesting.setAppServerRequestForTests(undefined);
|
||||
applyTesting.setAppServerRequestForTests(undefined);
|
||||
for (const root of tempRoots) {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -146,11 +287,107 @@ describe("buildCodexMigrationProvider", () => {
|
||||
);
|
||||
expect(plan.warnings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("Codex native plugins are reported for manual review only"),
|
||||
expect.stringContaining("Codex app-server plugin discovery was unavailable"),
|
||||
expect.stringContaining("Cached Codex plugin bundles are manual-review fallback items"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("plans installed openai-curated Codex plugins for native app-server activation", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({ includeOtherMarketplace: true });
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
});
|
||||
sourceTesting.setAppServerRequestForTests(request);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("plugin/list", { cwds: [] });
|
||||
expect(request).toHaveBeenCalledWith("app/list", {
|
||||
limit: 100,
|
||||
forceRefetch: true,
|
||||
});
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "plugin:gmail",
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
details: expect.objectContaining({
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "gmail",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "plugin:slack",
|
||||
kind: "plugin",
|
||||
action: "install",
|
||||
status: "planned",
|
||||
details: expect.objectContaining({
|
||||
marketplaceName: "openai-curated",
|
||||
pluginName: "slack",
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "config:codex-enabled",
|
||||
kind: "config",
|
||||
action: "merge",
|
||||
details: expect.objectContaining({
|
||||
path: ["plugins", "entries", "codex", "enabled"],
|
||||
value: true,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "plugin:documents:1" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters Codex plugin migration with repeated plugin selections", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
sourceTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
});
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
plugins: ["gmail"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "plugin:gmail" })]),
|
||||
);
|
||||
expect(plan.items).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: "plugin:slack" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("copies planned skills and archives native config during apply", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const reportDir = path.join(fixture.root, "report");
|
||||
@@ -184,6 +421,388 @@ describe("buildCodexMigrationProvider", () => {
|
||||
await expect(fs.access(path.join(reportDir, "report.json"))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("activates selected source-installed curated plugins natively during apply", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
sourceTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
throw new Error(`unexpected plan ${method}`);
|
||||
});
|
||||
const applyRequest = vi.fn(async (method: string, params: unknown) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({
|
||||
gmail: { installed: false, enabled: false },
|
||||
slack: { installed: true, enabled: false },
|
||||
});
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return { authPolicy: "ON_USE", appsNeedingAuth: [] };
|
||||
}
|
||||
if (method === "skills/list") {
|
||||
return { data: [] };
|
||||
}
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return undefined;
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
throw new Error(`unexpected apply ${method} ${JSON.stringify(params)}`);
|
||||
});
|
||||
applyTesting.setAppServerRequestForTests(applyRequest);
|
||||
const config = {
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
const { runtime, getConfig } = createConfigRuntime(config);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
plugins: ["gmail"],
|
||||
config,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
reportDir: path.join(fixture.root, "report"),
|
||||
config,
|
||||
runtime,
|
||||
}),
|
||||
plan,
|
||||
);
|
||||
|
||||
expect(applyRequest).toHaveBeenCalledWith("plugin/install", {
|
||||
marketplacePath: "/tmp/openai-curated",
|
||||
pluginName: "gmail",
|
||||
});
|
||||
expect(applyRequest).not.toHaveBeenCalledWith(
|
||||
"plugin/install",
|
||||
expect.objectContaining({ pluginName: "slack" }),
|
||||
);
|
||||
expect(applyRequest).toHaveBeenCalledWith("skills/list", {
|
||||
cwds: [],
|
||||
forceReload: true,
|
||||
});
|
||||
expect(applyRequest).toHaveBeenCalledWith("config/mcpServer/reload", undefined);
|
||||
expect(applyRequest).toHaveBeenCalledWith("app/list", {
|
||||
limit: 100,
|
||||
forceRefetch: true,
|
||||
});
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "plugin:gmail", status: "migrated" }),
|
||||
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
|
||||
]),
|
||||
);
|
||||
expect(getConfig()).toMatchObject({
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getConfig()).not.toMatchObject({
|
||||
plugins: { entries: { codex: { config: expect.anything() } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges Codex into existing plugin allowlists during apply", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
sourceTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
throw new Error(`unexpected plan ${method}`);
|
||||
});
|
||||
applyTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
throw new Error(`unexpected apply ${method}`);
|
||||
});
|
||||
const config = {
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
plugins: { allow: ["browser"] },
|
||||
tools: { alsoAllow: ["browser"] },
|
||||
} as MigrationProviderContext["config"];
|
||||
const { runtime, getConfig } = createConfigRuntime(config);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
plugins: ["gmail"],
|
||||
config,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "config:codex-plugin-allowlist", status: "planned" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
reportDir: path.join(fixture.root, "report"),
|
||||
config,
|
||||
runtime,
|
||||
}),
|
||||
plan,
|
||||
);
|
||||
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "config:codex-plugin-allowlist", status: "migrated" }),
|
||||
]),
|
||||
);
|
||||
expect(getConfig()).toMatchObject({
|
||||
plugins: { allow: ["browser", "codex"] },
|
||||
tools: { alsoAllow: ["browser"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports native plugin activation errors when app auth is still required", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
sourceTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
throw new Error(`unexpected plan ${method}`);
|
||||
});
|
||||
const applyRequest = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({ gmail: { installed: false, enabled: false } });
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
return {
|
||||
authPolicy: "ON_USE",
|
||||
appsNeedingAuth: [
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
description: null,
|
||||
installUrl: "https://example.invalid/auth",
|
||||
needsAuth: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "skills/list") {
|
||||
return { data: [] };
|
||||
}
|
||||
if (method === "config/mcpServer/reload") {
|
||||
return undefined;
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
throw new Error(`unexpected apply ${method}`);
|
||||
});
|
||||
applyTesting.setAppServerRequestForTests(applyRequest);
|
||||
const config = {
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
const { runtime } = createConfigRuntime(config);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
plugins: ["gmail"],
|
||||
config,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
reportDir: path.join(fixture.root, "report"),
|
||||
config,
|
||||
runtime,
|
||||
}),
|
||||
plan,
|
||||
);
|
||||
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "plugin:gmail",
|
||||
status: "error",
|
||||
reason: expect.stringContaining("requires app authorization"),
|
||||
}),
|
||||
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports native plugin activation errors when an already-installed app is inaccessible", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
sourceTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse({ accessible: false });
|
||||
}
|
||||
throw new Error(`unexpected plan ${method}`);
|
||||
});
|
||||
const applyRequest = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
throw new Error(`unexpected apply ${method}`);
|
||||
});
|
||||
applyTesting.setAppServerRequestForTests(applyRequest);
|
||||
const config = {
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
const { runtime } = createConfigRuntime(config);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
plugins: ["gmail"],
|
||||
config,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
reportDir: path.join(fixture.root, "report"),
|
||||
config,
|
||||
runtime,
|
||||
}),
|
||||
plan,
|
||||
);
|
||||
|
||||
expect(applyRequest).not.toHaveBeenCalledWith("plugin/install", expect.anything());
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "plugin:gmail",
|
||||
status: "error",
|
||||
reason: expect.stringContaining("app is not accessible"),
|
||||
}),
|
||||
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports native plugin activation errors when any related app is inaccessible", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
sourceTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse({ accessible: true, extraAppAccessible: false });
|
||||
}
|
||||
throw new Error(`unexpected plan ${method}`);
|
||||
});
|
||||
applyTesting.setAppServerRequestForTests(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({});
|
||||
}
|
||||
throw new Error(`unexpected apply ${method}`);
|
||||
});
|
||||
const config = {
|
||||
agents: { defaults: { workspace: fixture.workspaceDir } },
|
||||
} as MigrationProviderContext["config"];
|
||||
const { runtime } = createConfigRuntime(config);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
const plan = await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
plugins: ["gmail"],
|
||||
config,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
reportDir: path.join(fixture.root, "report"),
|
||||
config,
|
||||
runtime,
|
||||
}),
|
||||
plan,
|
||||
);
|
||||
|
||||
expect(result.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "plugin:gmail",
|
||||
status: "error",
|
||||
reason: expect.stringContaining("app is not accessible"),
|
||||
}),
|
||||
expect.objectContaining({ id: "config:codex-enabled", status: "migrated" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call plugin install during dry-run planning", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "plugin/list") {
|
||||
return pluginListResponse({ gmail: { installed: false, enabled: false } });
|
||||
}
|
||||
if (method === "app/list") {
|
||||
return appsListResponse();
|
||||
}
|
||||
if (method === "plugin/install") {
|
||||
throw new Error("dry-run must not install");
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
});
|
||||
sourceTesting.setAppServerRequestForTests(request);
|
||||
const provider = buildCodexMigrationProvider();
|
||||
|
||||
await provider.plan(
|
||||
makeContext({
|
||||
source: fixture.codexHome,
|
||||
stateDir: fixture.stateDir,
|
||||
workspaceDir: fixture.workspaceDir,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(request).not.toHaveBeenCalledWith("plugin/install", expect.anything());
|
||||
});
|
||||
|
||||
it("reports existing skill targets as conflicts unless overwrite is set", async () => {
|
||||
const fixture = await createCodexFixture();
|
||||
await writeFile(path.join(fixture.workspaceDir, "skills", "tweet-helper", "SKILL.md"));
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "../app-server/config.js";
|
||||
import type { v2 } from "../app-server/protocol-generated/typescript/index.js";
|
||||
import { requestCodexAppServerJson } from "../app-server/request.js";
|
||||
import {
|
||||
exists,
|
||||
isDirectory,
|
||||
@@ -12,6 +16,8 @@ import {
|
||||
const SKILL_FILENAME = "SKILL.md";
|
||||
const MAX_SCAN_DEPTH = 6;
|
||||
const MAX_DISCOVERED_DIRS = 2000;
|
||||
const OPENAI_CURATED_MARKETPLACE = "openai-curated";
|
||||
const CODEX_PLUGIN_DISCOVERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type CodexSkillSource = {
|
||||
name: string;
|
||||
@@ -19,12 +25,23 @@ export type CodexSkillSource = {
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
type CodexPluginSource = {
|
||||
export type CodexPluginSource = {
|
||||
name: string;
|
||||
source: string;
|
||||
manifestPath: string;
|
||||
};
|
||||
|
||||
export type CodexInstalledPluginSource = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
marketplaceName: typeof OPENAI_CURATED_MARKETPLACE;
|
||||
marketplacePath?: string;
|
||||
installed: boolean;
|
||||
enabled: boolean;
|
||||
accessible?: boolean;
|
||||
};
|
||||
|
||||
type CodexArchiveSource = {
|
||||
id: string;
|
||||
path: string;
|
||||
@@ -41,10 +58,16 @@ type CodexSource = {
|
||||
configPath?: string;
|
||||
hooksPath?: string;
|
||||
skills: CodexSkillSource[];
|
||||
nativePlugins: CodexInstalledPluginSource[];
|
||||
pluginDiscoveryError?: string;
|
||||
plugins: CodexPluginSource[];
|
||||
archivePaths: CodexArchiveSource[];
|
||||
};
|
||||
|
||||
type CodexMigrationAppServerRequest = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
let appServerRequestForTests: CodexMigrationAppServerRequest | undefined;
|
||||
|
||||
function defaultCodexHome(): string {
|
||||
return resolveHomePath(process.env.CODEX_HOME?.trim() || "~/.codex");
|
||||
}
|
||||
@@ -118,7 +141,158 @@ async function discoverPluginDirs(codexHome: string): Promise<CodexPluginSource[
|
||||
return [...discovered.values()].toSorted((a, b) => a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
export async function discoverCodexSource(input?: string): Promise<CodexSource> {
|
||||
function displayNameForPlugin(plugin: v2.PluginSummary): string {
|
||||
const displayName = plugin.interface?.displayName?.trim();
|
||||
return displayName || plugin.name || plugin.id;
|
||||
}
|
||||
|
||||
function pluginNameFromSummary(plugin: v2.PluginSummary): string {
|
||||
const name = plugin.name.trim();
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
return plugin.id.replace(new RegExp(`@${OPENAI_CURATED_MARKETPLACE}$`, "u"), "");
|
||||
}
|
||||
|
||||
function pluginAccessible(
|
||||
plugin: v2.PluginSummary,
|
||||
apps: readonly v2.AppInfo[],
|
||||
): boolean | undefined {
|
||||
const displayName = displayNameForPlugin(plugin).toLowerCase();
|
||||
const pluginName = pluginNameFromSummary(plugin).toLowerCase();
|
||||
const matchingApps = apps.filter((app) => {
|
||||
const pluginNames = new Set(
|
||||
app.pluginDisplayNames
|
||||
.map((name) => name.trim().toLowerCase())
|
||||
.filter((name) => name.length > 0),
|
||||
);
|
||||
return pluginNames.has(displayName) || pluginNames.has(pluginName);
|
||||
});
|
||||
if (matchingApps.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return matchingApps.every((app) => app.isAccessible && app.isEnabled);
|
||||
}
|
||||
|
||||
function readCodexPluginConfigFromOpenClawConfig(config: unknown): unknown {
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
||||
return undefined;
|
||||
}
|
||||
const plugins = (config as { plugins?: unknown }).plugins;
|
||||
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = (plugins as { entries?: unknown }).entries;
|
||||
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
|
||||
return undefined;
|
||||
}
|
||||
const codex = (entries as Record<string, unknown>).codex;
|
||||
if (!codex || typeof codex !== "object" || Array.isArray(codex)) {
|
||||
return undefined;
|
||||
}
|
||||
return (codex as { config?: unknown }).config;
|
||||
}
|
||||
|
||||
async function defaultAppServerRequest(params: {
|
||||
codexHome: string;
|
||||
pluginConfig?: unknown;
|
||||
config?: OpenClawConfig;
|
||||
}): Promise<CodexMigrationAppServerRequest> {
|
||||
const runtimeOptions = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
|
||||
const startOptions = {
|
||||
...runtimeOptions.start,
|
||||
env: {
|
||||
...runtimeOptions.start.env,
|
||||
CODEX_HOME: params.codexHome,
|
||||
},
|
||||
};
|
||||
return async (method: string, requestParams?: unknown): Promise<unknown> =>
|
||||
await requestCodexAppServerJson({
|
||||
method,
|
||||
requestParams,
|
||||
timeoutMs: CODEX_PLUGIN_DISCOVERY_TIMEOUT_MS,
|
||||
startOptions,
|
||||
config: params.config,
|
||||
});
|
||||
}
|
||||
|
||||
async function listAllApps(request: CodexMigrationAppServerRequest): Promise<v2.AppInfo[]> {
|
||||
const apps: v2.AppInfo[] = [];
|
||||
let cursor: string | null | undefined;
|
||||
do {
|
||||
const params = {
|
||||
...(cursor !== undefined ? { cursor } : {}),
|
||||
limit: 100,
|
||||
forceRefetch: true,
|
||||
} satisfies v2.AppsListParams;
|
||||
const response = (await request("app/list", params)) as v2.AppsListResponse;
|
||||
apps.push(...response.data);
|
||||
cursor = response.nextCursor;
|
||||
} while (cursor);
|
||||
return apps;
|
||||
}
|
||||
|
||||
async function discoverInstalledOpenAiCuratedPlugins(params: {
|
||||
codexHome: string;
|
||||
pluginConfig?: unknown;
|
||||
config?: OpenClawConfig;
|
||||
appServerRequest?: CodexMigrationAppServerRequest;
|
||||
}): Promise<{ plugins: CodexInstalledPluginSource[]; error?: string }> {
|
||||
try {
|
||||
const request =
|
||||
params.appServerRequest ??
|
||||
appServerRequestForTests ??
|
||||
(await defaultAppServerRequest({
|
||||
codexHome: params.codexHome,
|
||||
pluginConfig: params.pluginConfig,
|
||||
config: params.config,
|
||||
}));
|
||||
const [listed, apps] = await Promise.all([
|
||||
request("plugin/list", {
|
||||
cwds: [],
|
||||
} satisfies v2.PluginListParams) as Promise<v2.PluginListResponse>,
|
||||
listAllApps(request),
|
||||
]);
|
||||
const marketplace = listed.marketplaces.find(
|
||||
(entry) => entry.name === OPENAI_CURATED_MARKETPLACE,
|
||||
);
|
||||
if (!marketplace) {
|
||||
return { plugins: [] };
|
||||
}
|
||||
const plugins = marketplace.plugins
|
||||
.filter((plugin) => plugin.installed)
|
||||
.map((plugin): CodexInstalledPluginSource => {
|
||||
const accessible = pluginAccessible(plugin, apps);
|
||||
const source: CodexInstalledPluginSource = {
|
||||
id: plugin.id,
|
||||
name: pluginNameFromSummary(plugin),
|
||||
displayName: displayNameForPlugin(plugin),
|
||||
marketplaceName: OPENAI_CURATED_MARKETPLACE,
|
||||
installed: plugin.installed,
|
||||
enabled: plugin.enabled,
|
||||
};
|
||||
if (marketplace.path) {
|
||||
source.marketplacePath = marketplace.path;
|
||||
}
|
||||
if (accessible !== undefined) {
|
||||
source.accessible = accessible;
|
||||
}
|
||||
return source;
|
||||
})
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
return { plugins };
|
||||
} catch (err) {
|
||||
return { plugins: [], error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverCodexSource(
|
||||
input?: string,
|
||||
options: {
|
||||
config?: OpenClawConfig;
|
||||
appServerRequest?: CodexMigrationAppServerRequest;
|
||||
} = {},
|
||||
): Promise<CodexSource> {
|
||||
const codexHome = resolveHomePath(input?.trim() || defaultCodexHome());
|
||||
const codexSkillsDir = path.join(codexHome, "skills");
|
||||
const agentsSkillsDir = personalAgentsSkillsDir();
|
||||
@@ -133,6 +307,12 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
|
||||
root: agentsSkillsDir,
|
||||
sourceLabel: "personal AgentSkill",
|
||||
});
|
||||
const appServerPlugins = await discoverInstalledOpenAiCuratedPlugins({
|
||||
codexHome,
|
||||
pluginConfig: readCodexPluginConfigFromOpenClawConfig(options.config),
|
||||
config: options.config,
|
||||
appServerRequest: options.appServerRequest,
|
||||
});
|
||||
const plugins = await discoverPluginDirs(codexHome);
|
||||
const archivePaths: CodexArchiveSource[] = [];
|
||||
if (await exists(configPath)) {
|
||||
@@ -155,7 +335,9 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
|
||||
const skills = [...codexSkills, ...personalAgentSkills].toSorted((a, b) =>
|
||||
a.source.localeCompare(b.source),
|
||||
);
|
||||
const high = Boolean(codexSkills.length || plugins.length || archivePaths.length);
|
||||
const high = Boolean(
|
||||
codexSkills.length || appServerPlugins.plugins.length || plugins.length || archivePaths.length,
|
||||
);
|
||||
const medium = personalAgentSkills.length > 0;
|
||||
return {
|
||||
root: codexHome,
|
||||
@@ -166,6 +348,8 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
|
||||
...((await exists(configPath)) ? { configPath } : {}),
|
||||
...((await exists(hooksPath)) ? { hooksPath } : {}),
|
||||
skills,
|
||||
nativePlugins: appServerPlugins.plugins,
|
||||
...(appServerPlugins.error ? { pluginDiscoveryError: appServerPlugins.error } : {}),
|
||||
plugins,
|
||||
archivePaths,
|
||||
};
|
||||
@@ -174,3 +358,9 @@ export async function discoverCodexSource(input?: string): Promise<CodexSource>
|
||||
export function hasCodexSource(source: CodexSource): boolean {
|
||||
return source.confidence !== "low";
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
setAppServerRequestForTests(request: CodexMigrationAppServerRequest | undefined): void {
|
||||
appServerRequestForTests = request;
|
||||
},
|
||||
};
|
||||
|
||||
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