fix: install Codex for OpenAI onboarding (#79709)

* fix: install codex for openai onboarding

* docs: require crabbox e2e bug proof
This commit is contained in:
Peter Steinberger
2026-05-09 03:57:40 -04:00
committed by GitHub
parent 8ff6707e85
commit 5849c32402
9 changed files with 273 additions and 18 deletions
+49
View File
@@ -131,6 +131,55 @@ unclear:
blacksmith testbox list
```
## Efficient Bug E2E Verification
Use the smallest Crabbox lane that proves the reported user path, not just the
touched code. Aim for one after-fix E2E proof before commenting, closing, or
opening a PR for a user-visible bug.
Pick the lane by symptom:
- Docker/setup/install bug: build a package tarball and run the matching
`scripts/e2e/*-docker.sh` or package script. This proves npm packaging,
install paths, runtime deps, config writes, and container behavior.
- Provider/model/auth bug: use a live lane when a `.profile`/Testbox profile key
is available; otherwise use the repo's mock provider lane and state clearly
that live provider auth was not exercised.
- Channel delivery bug: use the channel Docker/live lane when available; include
setup, config, gateway start, send/receive or agent-turn proof, and redacted
logs.
- Gateway/session/tool bug: prefer an end-to-end CLI or Gateway RPC command that
creates real state and inspects the resulting files/API output.
- Pure parser/config bug: targeted tests may be enough, but still run a
Crabbox command when OS, package, Docker, secrets, or service lifecycle could
change behavior.
Efficient flow:
1. Reproduce or prove the pre-fix symptom when feasible. If the issue cannot be
reproduced, capture the exact command and observed behavior instead.
2. Patch locally and run narrow local tests for edit speed.
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
package install, Docker setup, onboarding, channel add, gateway start, or
agent turn as appropriate.
4. Record proof as: Testbox id, command, environment shape, redacted secret
source, and copied success/failure output.
5. If the issue says "cannot reproduce", ask for the missing config/log fields
that would distinguish the tested path from the reporter's path.
Keep it efficient:
- Reuse existing E2E scripts and helper assertions before writing ad hoc shell.
- Use one-shot Crabbox for a single proof; use a reusable Testbox only when
several commands must share built images, installed packages, or live state.
- Prefer `OPENCLAW_CURRENT_PACKAGE_TGZ` with Docker/package lanes when testing a
candidate tarball; prefer the repo's package helper instead of direct source
execution when the bug might be packaging/install related.
- Keep secrets redacted. It is fine to report key presence, source, and length;
never print secret values.
- Include `--timing-json` on broad or flaky runs when command duration or sync
behavior matters.
## Reuse And Keepalive
For most Blacksmith-backed Crabbox calls, one-shot is enough. Use reuse only
@@ -132,6 +132,10 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
## Enforce the bug-fix evidence bar
- Never merge a bug-fix PR based only on issue text, PR text, or AI rationale.
- Whenever feasible, use Crabbox (`$crabbox`) for end-to-end verification before
commenting that a bug is unreproducible, closing an issue, or opening/landing
a fix PR. Prefer a real packaged/Docker/live lane that exercises the reported
user flow over unit-only proof.
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
@@ -139,6 +143,9 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
- If Crabbox/E2E proof is blocked, say exactly why and use the closest available
local, Docker, mocked, or targeted proof. Do not present unit tests as real
behavior proof.
## Close low-signal manual PRs carefully
+1
View File
@@ -143,6 +143,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- OpenAI/Codex: install the Codex runtime plugin from npm during OpenAI onboarding and load it automatically for implicit OpenAI model routes, while preserving manual PI runtime overrides. Fixes #79358.
- Gateway: avoid false degraded event-loop health during rapid health/readiness/status probes unless sustained load has delay co-evidence, while keeping hard delay detection immediate. (#77028) Thanks @rubencu.
- Codex app-server: keep native hook relays alive for long-running turns so shell and file approvals stay reachable until the configured run window finishes. (#77533) Thanks @rubencu.
- Gateway/agent: pass the session-key agent id into inline image attachment validation so the first image in a fresh per-agent session uses the agent's vision-capable model override instead of the text-only system default. Fixes #79407. Thanks @pandadev66.
@@ -45,6 +45,7 @@ export async function ensureCodexRuntimePluginForModelSelection(params: {
defaultChoice: "npm",
},
trustedSourceLinkedOfficialInstall: true,
preferRemoteInstall: true,
},
prompter: params.prompter,
runtime: params.runtime,
@@ -1,7 +1,20 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import type { CodexRuntimePluginInstallResult } from "../../codex-runtime-plugin-install.js";
import { applyNonInteractivePluginProviderChoice } from "./auth-choice.plugin-providers.js";
const ensureCodexRuntimePluginForModelSelection = vi.hoisted(() =>
vi.fn(
async ({ cfg }: { cfg: OpenClawConfig }): Promise<CodexRuntimePluginInstallResult> => ({
cfg,
required: false,
installed: false,
}),
),
);
vi.mock("../../codex-runtime-plugin-install.js", () => ({
ensureCodexRuntimePluginForModelSelection,
}));
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../../../plugins/provider-auth-choice-preference.js", () => ({
resolvePreferredProviderForAuthChoice,
@@ -29,6 +42,11 @@ beforeEach(() => {
resolveOwningPluginIdsForProvider.mockReturnValue(undefined as never);
resolveProviderPluginChoice.mockReturnValue(undefined);
resolvePluginProviders.mockReturnValue([] as never);
ensureCodexRuntimePluginForModelSelection.mockImplementation(async ({ cfg }) => ({
cfg,
required: false,
installed: false,
}));
});
function createRuntime() {
@@ -208,4 +226,48 @@ describe("applyNonInteractivePluginProviderChoice", () => {
}),
);
});
it("ensures Codex after a non-interactive OpenAI provider choice sets the default model", async () => {
const runtime = createRuntime();
const selectedConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
} as OpenClawConfig;
const installedConfig = {
...selectedConfig,
plugins: { entries: { codex: { enabled: true } } },
} as OpenClawConfig;
const runNonInteractive = vi.fn(async () => selectedConfig);
ensureCodexRuntimePluginForModelSelection.mockResolvedValue({
cfg: installedConfig,
required: true,
installed: true,
status: "installed",
});
resolvePluginProviders.mockReturnValue([{ id: "openai", pluginId: "openai" }] as never);
resolveProviderPluginChoice.mockReturnValue({
provider: { id: "openai", pluginId: "openai", label: "OpenAI" },
method: { runNonInteractive },
});
const result = await applyNonInteractivePluginProviderChoice({
nextConfig: { agents: { defaults: {} } } as OpenClawConfig,
authChoice: "openai-api-key",
opts: {} as never,
runtime: runtime as never,
baseConfig: { agents: { defaults: {} } } as OpenClawConfig,
resolveApiKey: vi.fn(),
toApiKeyCredential: vi.fn(),
});
expect(runNonInteractive).toHaveBeenCalledOnce();
expect(ensureCodexRuntimePluginForModelSelection).toHaveBeenCalledWith(
expect.objectContaining({
cfg: selectedConfig,
model: "openai/gpt-5.5",
runtime,
workspaceDir: expect.any(String),
}),
);
expect(result).toBe(installedConfig);
});
});
@@ -5,6 +5,7 @@ import {
} from "../../../agents/agent-scope.js";
import type { ApiKeyCredential } from "../../../agents/auth-profiles/types.js";
import { resolveDefaultAgentWorkspaceDir } from "../../../agents/workspace.js";
import { resolveAgentModelPrimaryValue } from "../../../config/model-input.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { enablePluginInConfig } from "../../../plugins/enable.js";
import { resolvePreferredProviderForAuthChoice } from "../../../plugins/provider-auth-choice-preference.js";
@@ -16,6 +17,8 @@ import type {
} from "../../../plugins/types.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { createLazyRuntimeSurface } from "../../../shared/lazy-runtime.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import { ensureCodexRuntimePluginForModelSelection } from "../../codex-runtime-plugin-install.js";
import type { OnboardOptions } from "../../onboard-types.js";
const PROVIDER_PLUGIN_CHOICE_PREFIX = "provider-plugin:";
@@ -29,6 +32,47 @@ const loadAuthChoicePluginProvidersRuntime = createLazyRuntimeSurface(
({ authChoicePluginProvidersRuntime }) => authChoicePluginProvidersRuntime,
);
function createNonInteractivePluginInstallPrompter(runtime: RuntimeEnv): WizardPrompter {
const unavailable = <T>(message: string): Promise<T> =>
Promise.reject(new Error(`Non-interactive setup cannot prompt for plugin install: ${message}`));
return {
async intro(title) {
runtime.log(title);
},
async outro(message) {
runtime.log(message);
},
async note(message, title) {
runtime.log(title ? `${title}\n${message}` : message);
},
async select(params) {
return unavailable(params.message);
},
async multiselect(params) {
return unavailable(params.message);
},
async text(params) {
return unavailable(params.message);
},
async confirm(params) {
return unavailable(params.message);
},
progress(label) {
runtime.log(label);
return {
update(message) {
runtime.log(message);
},
stop(message) {
if (message) {
runtime.log(message);
}
},
};
},
};
}
export async function applyNonInteractivePluginProviderChoice(params: {
nextConfig: OpenClawConfig;
authChoice: string;
@@ -139,7 +183,7 @@ export async function applyNonInteractivePluginProviderChoice(params: {
return null;
}
return method.runNonInteractive({
const result = await method.runNonInteractive({
authChoice: params.authChoice,
config: enableResult.config,
baseConfig: params.baseConfig,
@@ -150,4 +194,20 @@ export async function applyNonInteractivePluginProviderChoice(params: {
resolveApiKey: params.resolveApiKey,
toApiKeyCredential: params.toApiKeyCredential,
});
if (!result) {
return result;
}
const selectedModel = resolveAgentModelPrimaryValue(result.agents?.defaults?.model);
if (!selectedModel) {
return result;
}
return (
await ensureCodexRuntimePluginForModelSelection({
cfg: result,
model: selectedModel,
prompter: createNonInteractivePluginInstallPrompter(params.runtime),
runtime: params.runtime,
workspaceDir,
})
).cfg;
}
+11 -6
View File
@@ -39,6 +39,7 @@ export type OnboardingPluginInstallEntry = {
label: string;
install: PluginPackageInstall;
trustedSourceLinkedOfficialInstall?: boolean;
preferRemoteInstall?: boolean;
};
export type OnboardingPluginInstallStatus = "installed" | "skipped" | "failed" | "timed_out";
@@ -730,14 +731,18 @@ export async function ensureOnboardingPluginInstalled(params: {
const { entry, prompter, runtime, workspaceDir } = params;
let next = params.cfg;
const allowLocal = hasGitWorkspace(workspaceDir);
const bundledLocalPath = resolveBundledLocalPath({ entry, workspaceDir });
const bundledLocalPath = entry.preferRemoteInstall
? null
: resolveBundledLocalPath({ entry, workspaceDir });
const localPath =
bundledLocalPath ??
resolveLocalPath({
entry,
workspaceDir,
allowLocal,
});
(entry.preferRemoteInstall
? null
: resolveLocalPath({
entry,
workspaceDir,
allowLocal,
}));
const clawhubSpec = resolveClawHubSpecForOnboarding(entry.install);
const npmSpec = resolveNpmSpecForOnboarding(entry.install);
const updateChannel = resolveRegistryUpdateChannel({
+16 -1
View File
@@ -1425,12 +1425,27 @@ describe("resolveGatewayStartupPluginIds", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
modelId: "openai/gpt-5.5",
enabledPluginIds: ["codex"],
}),
expected: ["demo-channel", "browser", "codex", "memory-core"],
});
});
it("does not include Codex when an OpenAI model is manually pinned to PI", () => {
expectStartupPluginIdsCase({
config: {
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
models: {
"openai/gpt-5.5": { agentRuntime: { id: "pi" } },
},
},
},
} as OpenClawConfig,
expected: ["demo-channel", "browser", "memory-core"],
});
});
it("ignores legacy per-agent runtime during startup planning", () => {
expectStartupPluginIdsCase({
config: createStartupConfig({
+65 -10
View File
@@ -331,6 +331,62 @@ function canStartConfiguredGenerationProviderPlugin(params: {
);
}
function canStartRequiredAgentHarnessPlugin(params: {
plugin: InstalledPluginIndexRecord;
pluginsConfig: ReturnType<typeof normalizePluginsConfigWithRegistry>;
activationSource: {
plugins: ReturnType<typeof normalizePluginsConfigWithRegistry>;
rootConfig?: OpenClawConfig;
};
config: OpenClawConfig;
requiredAgentHarnessRuntimes: ReadonlySet<string>;
platform?: NodeJS.Platform;
}): boolean {
if (
!params.plugin.startup.agentHarnesses.some((runtime) =>
params.requiredAgentHarnessRuntimes.has(runtime),
)
) {
return false;
}
if (!params.pluginsConfig.enabled || !params.activationSource.plugins.enabled) {
return false;
}
if (
params.pluginsConfig.deny.includes(params.plugin.pluginId) ||
params.activationSource.plugins.deny.includes(params.plugin.pluginId)
) {
return false;
}
if (
params.pluginsConfig.entries[params.plugin.pluginId]?.enabled === false ||
params.activationSource.plugins.entries[params.plugin.pluginId]?.enabled === false
) {
return false;
}
if (
params.pluginsConfig.allow.length > 0 &&
!params.pluginsConfig.allow.includes(params.plugin.pluginId)
) {
return false;
}
if (
params.activationSource.plugins.allow.length > 0 &&
!params.activationSource.plugins.allow.includes(params.plugin.pluginId)
) {
return false;
}
const activationState = resolveEffectivePluginActivationState({
id: params.plugin.pluginId,
origin: params.plugin.origin,
config: params.pluginsConfig,
rootConfig: params.config,
enabledByDefault: isPluginEnabledByDefaultForPlatform(params.plugin, params.platform),
activationSource: params.activationSource,
});
return activationState.enabled || params.plugin.origin === "bundled";
}
function canStartConfiguredSpeechProviderPlugin(params: {
plugin: InstalledPluginIndexRecord;
manifest: PluginManifestRecord | undefined;
@@ -672,17 +728,16 @@ export function resolveGatewayStartupPluginPlanFromRegistry(params: {
});
}
if (
plugin.startup.agentHarnesses.some((runtime) => requiredAgentHarnessRuntimes.has(runtime))
) {
const activationState = resolveEffectivePluginActivationState({
id: plugin.pluginId,
origin: plugin.origin,
config: pluginsConfig,
rootConfig: params.config,
enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin, params.platform),
canStartRequiredAgentHarnessPlugin({
plugin,
pluginsConfig,
activationSource,
});
return activationState.enabled;
config: params.config,
requiredAgentHarnessRuntimes,
platform: params.platform,
})
) {
return true;
}
if (
canStartConfiguredRootPlugin({