Compare commits

..

2 Commits

Author SHA1 Message Date
kevinlin-openai
facf66eee6 fix(codex): honor OpenClaw app enablement overrides 2026-06-25 17:54:28 -07:00
kevinlin-openai
55ad878457 fix(codex): refresh missing plugin app inventory 2026-06-25 17:05:19 -07:00
5 changed files with 403 additions and 21 deletions

View File

@@ -155,9 +155,13 @@ shorthand before OpenClaw builds app-server start options, and unresolved
structured SecretRefs fail before any token or header is sent. When native Codex
plugins are configured, OpenClaw uses the connected app-server's plugin control
plane to install or refresh those plugins and then refreshes app inventory so
plugin-owned apps are visible to the Codex thread. Only connect OpenClaw to
remote app-servers that are trusted to accept OpenClaw-managed plugin installs
and app inventory refreshes.
plugin-owned apps are visible to the Codex thread. `app/list` is still the
authoritative inventory and metadata source, but OpenClaw policy decides whether
`thread/start` sends `config.apps[appId].enabled = true` for a listed accessible
app even if Codex currently marks it disabled. Unknown or missing app ids remain
fail-closed; this path only activates marketplace plugins via `plugin/install`
and refreshes inventory. Only connect OpenClaw to remote app-servers that are
trusted to accept OpenClaw-managed plugin installs and app inventory refreshes.
## Approval and sandbox modes

View File

@@ -465,7 +465,13 @@ do not receive Gateway env API-key fallback; use an explicit auth profile or the
remote app-server's own account.
When native Codex plugins are configured, OpenClaw installs or refreshes those
plugins through the connected app-server before exposing plugin-owned apps to
the Codex thread.
the Codex thread. `app/list` remains the source of truth for app ids,
accessibility, and metadata, but OpenClaw owns the per-thread enablement
decision: if policy allows a listed accessible app, OpenClaw sends
`thread/start.config.apps[appId].enabled = true` even when `app/list` currently
reports that app disabled. This path does not invent app installation for
unknown ids; OpenClaw only activates marketplace plugins with `plugin/install`
and then refreshes inventory.
If a subscription profile hits a Codex usage limit, OpenClaw records the reset
time when Codex reports one and tries the next ordered auth profile for the same

View File

@@ -254,7 +254,7 @@ describe("Codex plugin thread config", () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return { data: [appInfo("google-calendar-app", true)], nextCursor: null };
return { data: [appInfo("google-calendar-app", true, false)], nextCursor: null };
}
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
@@ -317,6 +317,117 @@ describe("Codex plugin thread config", () => {
]);
});
it("re-enables an OpenClaw-allowed app even when app/list reports it disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true, false)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.inventory?.records[0]?.apps).toStrictEqual([
{
id: "google-calendar-app",
name: "google-calendar-app",
accessible: true,
enabled: false,
needsAuth: false,
},
]);
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(config.diagnostics).toStrictEqual([]);
});
it("refreshes missing app inventory when plugin activation becomes unnecessary", async () => {
const appCache = new CodexAppInventoryCache();
const appListParams: v2.AppsListParams[] = [];
let pluginListCalls = 0;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
pluginListCalls += 1;
const active = pluginListCalls > 1;
return pluginList([
pluginSummary("google-calendar", { installed: active, enabled: active }),
]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
if (method === "app/list") {
appListParams.push(params as v2.AppsListParams);
return {
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
} satisfies v2.AppsListResponse;
}
throw new Error(`unexpected request ${method}`);
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
request,
});
expect(config.configPatch?.apps).toMatchObject({
"google-calendar-app": {
enabled: true,
},
});
expect(request.mock.calls.map(([method]) => method)).not.toContain("plugin/install");
expect(appListParams).toEqual([
{
cursor: undefined,
limit: 100,
forceRefetch: true,
},
]);
});
it("does not expose plugin apps missing from the app inventory snapshot", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -375,11 +486,59 @@ describe("Codex plugin thread config", () => {
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible or enabled for google-calendar.",
message: "google-calendar-app is not accessible for google-calendar.",
},
]);
});
it("does not expose apps for plugins that OpenClaw policy leaves disabled", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () => ({
data: [appInfo("google-calendar-app", true)],
nextCursor: null,
}),
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
enabled: false,
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([]);
});
it("force-refreshes app inventory when proven plugin apps are not ready", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
@@ -572,9 +731,7 @@ describe("Codex plugin thread config", () => {
let installed = false;
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "plugin/list") {
return pluginList([
pluginSummary("google-calendar", { installed, enabled: installed }),
]);
return pluginList([pluginSummary("google-calendar", { installed, enabled: installed })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
@@ -738,6 +895,70 @@ describe("Codex plugin thread config", () => {
]);
});
it("fails closed when app inventory entries are malformed", async () => {
const appCache = new CodexAppInventoryCache();
await appCache.refreshNow({
key: "runtime",
nowMs: 0,
request: async () =>
({
data: [{ ...appInfo("google-calendar-app", true), id: "" }] as unknown as v2.AppInfo[],
nextCursor: null,
}) satisfies v2.AppsListResponse,
});
const config = await buildCodexPluginThreadConfig({
pluginConfig: {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
},
},
},
},
appCache,
appCacheKey: "runtime",
nowMs: 1,
request: async (method) => {
if (method === "plugin/list") {
return pluginList([pluginSummary("google-calendar", { installed: true, enabled: true })]);
}
if (method === "plugin/read") {
return pluginDetail("google-calendar", [appSummary("google-calendar-app")]);
}
throw new Error(`unexpected request ${method}`);
},
});
expect(config.configPatch).toEqual({
apps: {
_default: {
enabled: false,
destructive_enabled: false,
open_world_enabled: false,
},
},
});
expect(config.policyContext.apps).toStrictEqual({});
expect(config.diagnostics).toStrictEqual([
{
code: "app_not_ready",
plugin: {
configKey: "google-calendar",
marketplaceName: CODEX_PLUGINS_MARKETPLACE_NAME,
pluginName: "google-calendar",
enabled: true,
allowDestructiveActions: true,
destructiveApprovalMode: "allow",
},
message: "google-calendar-app is not accessible for google-calendar.",
},
]);
});
it("uses durable policy and app cache key in the cheap input fingerprint", async () => {
const appCache = new CodexAppInventoryCache();
const first = buildCodexPluginThreadConfigInputFingerprint({

View File

@@ -125,6 +125,9 @@ export async function buildCodexPluginThreadConfig(
nowMs: params.nowMs,
suppressAppInventoryRefresh: true,
});
const appInventoryRefreshDeferredForActivation =
inventory.records.some((record) => record.activationRequired) &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (shouldWaitForInitialAppInventory(params, policy, inventory)) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
@@ -166,10 +169,19 @@ export async function buildCodexPluginThreadConfig(
});
}
}
if (activationResults.some((activation) => activation.ok && activation.installAttempted)) {
const postInstallRefreshRequired = activationResults.some(
(activation) => activation.ok && activation.installAttempted,
);
// Activation can become unnecessary or fail before it refreshes apps. Rebuild the
// deferred missing snapshot so unrelated active plugin apps are not silently erased.
const deferredMissingRefreshRequired =
appInventoryRefreshDeferredForActivation &&
!postInstallRefreshRequired &&
shouldRefreshMissingAppInventory(params, policy, inventory);
if (postInstallRefreshRequired || deferredMissingRefreshRequired) {
await refreshAppInventoryNow(params, appCache, {
forceRefetch: true,
reason: "post_install",
reason: postInstallRefreshRequired ? "post_install" : "deferred_missing",
targetAppIds: collectInventoryOwnedAppIds(inventory),
});
inventory = await readCodexPluginInventory({
@@ -219,24 +231,22 @@ export async function buildCodexPluginThreadConfig(
const policyApps: Record<string, PluginAppPolicyContextEntry> = {};
const pluginAppIds: Record<string, string[]> = {};
for (const record of inventory.records) {
if (record.activationRequired) {
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (!activation?.ok) {
continue;
}
const activation = activationResults.find(
(item) => item.identity.configKey === record.policy.configKey,
);
if (activation?.ok === false || (record.activationRequired && !activation?.ok)) {
continue;
}
if (record.appOwnership !== "proven") {
continue;
}
pluginAppIds[record.policy.configKey] = [...record.ownedAppIds].toSorted();
for (const app of resolveThreadConfigAppsForRecord({ record, inventory })) {
if (!app.accessible || !app.enabled) {
if (!isPluginAppReadyForThreadStart(app)) {
diagnostics.push({
code: "app_not_ready",
plugin: record.policy,
message: `${app.id} is not accessible or enabled for ${record.policy.pluginName}.`,
message: `${app.id} is not accessible for ${record.policy.pluginName}.`,
});
continue;
}
@@ -362,9 +372,18 @@ function shouldWaitForInitialAppInventory(
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
// Install/enable first so the initial app/list can observe newly activated plugin apps.
if (inventory.records.some((record) => record.activationRequired)) {
return false;
}
return shouldRefreshMissingAppInventory(params, policy, inventory);
}
function shouldRefreshMissingAppInventory(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
inventory: CodexPluginInventory,
): boolean {
return Boolean(
params.appCacheKey &&
policy.pluginPolicies.some((plugin) => plugin.enabled) &&
@@ -419,6 +438,13 @@ function resolveThreadConfigAppsForRecord(params: {
return params.record.apps;
}
function isPluginAppReadyForThreadStart(app: CodexPluginOwnedApp): boolean {
// `app/list` is the source of truth for inventory and access posture, but
// OpenClaw owns the per-thread enablement decision. A listed app that is
// accessible can be re-enabled for this thread via `config.apps[app.id]`.
return app.accessible;
}
function shouldForceRefreshForNotReadyPluginApps(
params: BuildCodexPluginThreadConfigParams,
policy: ResolvedCodexPluginsPolicy,
@@ -434,7 +460,7 @@ function shouldForceRefreshForNotReadyPluginApps(
(record) =>
record.appOwnership === "proven" &&
record.ownedAppIds.length > 0 &&
(record.apps.length === 0 || record.apps.some((app) => !app.accessible || !app.enabled)),
(record.apps.length === 0 || record.apps.some((app) => !app.accessible)),
);
}

View File

@@ -4416,6 +4416,131 @@ describe("runCodexAppServerAttempt", () => {
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("sends a thread/start app enable override when app/list cached the app as disabled", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
const pluginConfig = {
codexPlugins: {
enabled: true,
plugins: {
"google-calendar": {
marketplaceName: "openai-curated",
pluginName: "google-calendar",
},
},
},
};
const appServer = resolveCodexAppServerRuntimeOptions({
pluginConfig: readCodexPluginConfig(pluginConfig),
});
defaultCodexAppInventoryCache.clear();
await defaultCodexAppInventoryCache.refreshNow({
key: buildCodexPluginAppCacheKey({
appServer,
agentDir,
runtimeIdentity: getMockRuntimeIdentity(),
}),
request: async () => ({
data: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
logoUrl: null,
logoUrlDark: null,
distributionChannel: null,
branding: null,
appMetadata: null,
labels: null,
installUrl: null,
isAccessible: true,
isEnabled: false,
pluginDisplayNames: [],
},
],
nextCursor: null,
}),
});
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness(async (method) => {
if (method === "plugin/list") {
return {
marketplaces: [
{
name: "openai-curated",
path: "/marketplaces/openai-curated",
interface: null,
plugins: [
{
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
],
},
],
marketplaceLoadErrors: [],
featuredPluginIds: [],
};
}
if (method === "plugin/read") {
return {
plugin: {
marketplaceName: "openai-curated",
marketplacePath: "/marketplaces/openai-curated",
summary: {
id: "google-calendar",
name: "google-calendar",
source: { type: "remote" },
installed: true,
enabled: true,
installPolicy: "AVAILABLE",
authPolicy: "ON_USE",
availability: "AVAILABLE",
interface: null,
},
description: null,
skills: [],
apps: [
{
id: "google-calendar-app",
name: "Google Calendar",
description: null,
installUrl: null,
needsAuth: false,
},
],
mcpServers: ["google-calendar"],
},
};
}
if (method === "app/list") {
throw new Error("app/list should use the cached inventory entry");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.agentDir = agentDir;
const run = runCodexAppServerAttempt(params, { pluginConfig });
await waitForMethod("turn/start");
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
const threadStart = requests.find((entry) => entry.method === "thread/start");
const threadStartParams = threadStart?.params as
| { config?: { apps?: Record<string, { enabled?: boolean }> } }
| undefined;
expect(threadStartParams?.config?.apps?.["google-calendar-app"]?.enabled).toBe(true);
expect(requests.map((entry) => entry.method)).not.toContain("app/list");
});
it("keys plugin app inventory by inherited API key fallback credentials", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");