mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-08 16:56:09 +00:00
feat(codex): migrate native plugins
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -77,9 +77,33 @@ Verification:
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -111,12 +135,15 @@ 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.
|
||||
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 1 docs, tests, and proof
|
||||
### 2.6 PR stack docs, tests, and proof
|
||||
|
||||
This PR owns:
|
||||
PR 1 owns:
|
||||
|
||||
- Harness docs for native mention usage and the removal of bridge tool
|
||||
semantics.
|
||||
@@ -126,3 +153,11 @@ This PR owns:
|
||||
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.
|
||||
|
||||
@@ -277,10 +277,19 @@ Use native Codex mention syntax in a Codex-mode turn:
|
||||
[@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.
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user