[codex] Fix doctor completion cache plugin loading (#76235)

* fix(completion): make cache generation mode explicit

* test(completion): type spawned cache options

* fix(completion): require explicit cache binary name
This commit is contained in:
Peter Steinberger
2026-07-05 07:33:54 -07:00
committed by GitHub
parent 7613f6ebb3
commit 17c0ad86cb
7 changed files with 89 additions and 20 deletions
+1
View File
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- **WhatsApp restart recovery:** stop automatic restart loops after logged-out or connection-replaced disconnects until the account reconnects. (#78511) Thanks @openperf.
- **Local Gateway CLI auth:** keep loopback CLI token/password calls off durable device scopes so read probes cannot block later write/admin commands behind a stale pairing baseline. (#95997) Thanks @vincentkoc.
- **Plugin module identity:** keep OpenClaw package chunks on Node's native module graph when jiti transforms plugin entries, preventing duplicate evaluation and class identity drift. (#88384) Thanks @vincentkoc.
- **Shell completion repair:** generate core-only caches during doctor and update repair while preserving full plugin command completion for onboarding and explicit user rebuilds. (#76235)
- **iMessage group warnings:** suppress the false drop-all startup warning when an effective group sender allowlist can admit groups, and point true empty-allowlist configurations at the correct remedy. (#100046)
- **Control UI mobile login:** keep Gateway recovery guidance visible after connection failures, make the disconnected gate scroll safely on constrained screens, and improve mobile keyboard and tap-target behavior. (#100208)
+12 -3
View File
@@ -158,7 +158,10 @@ async function main() {
// Profile uses slow dynamic pattern - upgrade to cached version
if (status.usesSlowPattern) {
console.log(theme.warn("Profile uses slow dynamic completion. Upgrading to cached version..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell });
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, {
shell: status.shell,
generationMode: "full",
});
if (cacheGenerated) {
await installCompletion(status.shell, false, CLI_NAME);
console.log(theme.success("Upgraded to cached completion."));
@@ -171,7 +174,10 @@ async function main() {
// Profile has completion but no cache - auto-fix
if (status.profileInstalled && !status.cacheExists) {
console.log(theme.warn("Profile has completion but cache is missing. Regenerating..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell });
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, {
shell: status.shell,
generationMode: "full",
});
if (cacheGenerated) {
console.log(theme.success("Cache regenerated successfully."));
} else {
@@ -208,7 +214,10 @@ async function main() {
// Generate cache first (required for fast shell startup)
if (!status.cacheExists) {
console.log(theme.muted("Generating completion cache..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell });
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, {
shell: status.shell,
generationMode: "full",
});
if (!cacheGenerated) {
console.log(theme.error("Failed to generate completion cache."));
return;
+4 -3
View File
@@ -1467,10 +1467,11 @@ async function tryInstallShellCompletion(opts: {
}
const status = await checkShellCompletionStatus(CLI_NAME);
const generationOptions = { generationMode: "core-only" } as const;
if (status.usesSlowPattern) {
defaultRuntime.log(theme.muted("Upgrading shell completion to cached version..."));
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, generationOptions);
if (cacheGenerated) {
await installShellCompletionForUpdate(status.shell, true);
}
@@ -1479,7 +1480,7 @@ async function tryInstallShellCompletion(opts: {
if (status.profileInstalled && !status.cacheExists) {
defaultRuntime.log(theme.muted("Regenerating shell completion cache..."));
await ensureCompletionCacheExists(CLI_NAME);
await ensureCompletionCacheExists(CLI_NAME, generationOptions);
return;
}
@@ -1503,7 +1504,7 @@ async function tryInstallShellCompletion(opts: {
return;
}
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME);
const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, generationOptions);
if (!cacheGenerated) {
defaultRuntime.log(theme.warn("Failed to generate completion cache."));
return;
+38 -1
View File
@@ -4,16 +4,23 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as noteModule from "../../packages/terminal-core/src/note.js";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../cli/completion-runtime.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import {
checkShellCompletionStatus,
doctorShellCompletion,
ensureCompletionCacheExists,
shellCompletionStatusToHealthFindings,
shellCompletionStatusToRepairEffects,
type ShellCompletionStatus,
} from "./doctor-completion.js";
const originalEnv = captureEnv(["HOME", "OPENCLAW_STATE_DIR", "SHELL"]);
const originalEnv = captureEnv([
"HOME",
"OPENCLAW_STATE_DIR",
"SHELL",
COMPLETION_SKIP_PLUGIN_COMMANDS_ENV,
]);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(async () => {
@@ -167,6 +174,36 @@ describe("doctorShellCompletion", () => {
spawnSyncMock.mockClear();
});
it.each([
{ generationMode: "core-only" as const, expectedSkipValue: "1" },
{ generationMode: "full" as const, expectedSkipValue: undefined },
])(
"uses explicit $generationMode cache generation even with an ambient skip guard",
async ({ generationMode, expectedSkipValue }) => {
const stateDir = tempDirs.make("openclaw-doctor-state-");
setTestEnvValue("OPENCLAW_STATE_DIR", stateDir);
setTestEnvValue(COMPLETION_SKIP_PLUGIN_COMMANDS_ENV, "1");
await expect(
ensureCompletionCacheExists("openclaw", {
shell: "powershell",
generationMode,
}),
).resolves.toBe(true);
expect(spawnSyncMock).toHaveBeenCalledWith(
process.execPath,
expect.arrayContaining(["completion", "--write-state", "--shell", "powershell"]),
expect.any(Object),
);
const spawnCalls = spawnSyncMock.mock.calls as unknown as Array<
[string, string[], { env?: NodeJS.ProcessEnv }]
>;
const spawnOptions = spawnCalls.at(-1)?.[2];
expect(spawnOptions?.env?.[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]).toBe(expectedSkipValue);
},
);
it.each([
{ code: "EACCES", usesSlowPattern: true, action: "upgraded" },
{ code: "EPERM", usesSlowPattern: true, action: "upgraded" },
+19 -7
View File
@@ -5,6 +5,7 @@ import { note } from "../../packages/terminal-core/src/note.js";
import { resolveCliName } from "../cli/cli-name.js";
import {
completionCacheExists,
COMPLETION_SKIP_PLUGIN_COMMANDS_ENV,
formatCompletionReloadCommand,
installCompletion,
isCompletionInstalled,
@@ -26,6 +27,10 @@ export type ShellCompletionStatusOptions = {
shell?: CompletionShell;
};
export type CompletionCacheGenerationOptions = ShellCompletionStatusOptions & {
generationMode: "core-only" | "full";
};
const PROFILE_WRITE_ERROR_CODES = new Set(["EACCES", "EPERM", "EROFS"]);
function findProfileWriteError(err: unknown): NodeJS.ErrnoException | undefined {
@@ -74,7 +79,7 @@ async function installCompletionForDoctor(
/** Generate the completion cache by spawning the CLI. */
async function generateCompletionCache(
options: ShellCompletionStatusOptions = {},
options: CompletionCacheGenerationOptions,
): Promise<boolean> {
const root = await resolveOpenClawPackageRoot({
moduleUrl: import.meta.url,
@@ -90,9 +95,16 @@ async function generateCompletionCache(
if (options.shell) {
args.push("--shell", options.shell);
}
const env = { ...process.env };
// The mode is explicit so ambient repair state cannot silently change a full user-facing cache.
if (options.generationMode === "core-only") {
env[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV] = "1";
} else {
delete env[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV];
}
const result = spawnSync(process.execPath, args, {
cwd: root,
env: process.env,
env,
encoding: "utf-8",
timeout: COMPLETION_CACHE_WRITE_TIMEOUT_MS,
});
@@ -217,7 +229,7 @@ export async function doctorShellCompletion(
);
if (!status.cacheExists) {
const generated = await generateCompletionCache();
const generated = await generateCompletionCache({ generationMode: "core-only" });
if (!generated) {
note(
`Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`,
@@ -236,7 +248,7 @@ export async function doctorShellCompletion(
`Shell completion is configured in your ${status.shell} profile but the cache is missing.\nRegenerating cache...`,
"Shell completion",
);
const generated = await generateCompletionCache();
const generated = await generateCompletionCache({ generationMode: "core-only" });
if (generated) {
note(`Completion cache regenerated at ${status.cachePath}`, "Shell completion");
} else {
@@ -259,7 +271,7 @@ export async function doctorShellCompletion(
});
if (shouldInstall) {
const generated = await generateCompletionCache();
const generated = await generateCompletionCache({ generationMode: "core-only" });
if (!generated) {
note(
`Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`,
@@ -275,8 +287,8 @@ export async function doctorShellCompletion(
/** Ensures the shell completion cache exists without prompting during setup/update flows. */
export async function ensureCompletionCacheExists(
binName = "openclaw",
options: ShellCompletionStatusOptions = {},
binName: string,
options: CompletionCacheGenerationOptions,
): Promise<boolean> {
const shell = options.shell ?? resolveShellFromEnv();
const cacheExists = await completionCacheExists(shell, binName);
+3 -1
View File
@@ -49,7 +49,9 @@ describe("setupWizardShellCompletion", () => {
await setupWizardShellCompletion({ flow: "quickstart", prompter, deps });
expect(prompter.confirm).not.toHaveBeenCalled();
expect(deps.ensureCompletionCacheExists).toHaveBeenCalledWith("openclaw");
expect(deps.ensureCompletionCacheExists).toHaveBeenCalledWith("openclaw", {
generationMode: "full",
});
expect(deps.installCompletion).toHaveBeenCalledWith("zsh", true, "openclaw");
expect(prompter.note).toHaveBeenCalled();
});
+12 -5
View File
@@ -7,7 +7,10 @@ import {
installCompletion,
resolveCompletionProfilePath,
} from "../cli/completion-runtime.js";
import type { ShellCompletionStatus } from "../commands/doctor-completion.js";
import type {
CompletionCacheGenerationOptions,
ShellCompletionStatus,
} from "../commands/doctor-completion.js";
import {
checkShellCompletionStatus,
ensureCompletionCacheExists,
@@ -20,7 +23,10 @@ import type { WizardFlow } from "./setup.types.js";
type CompletionDeps = {
resolveCliName: () => string;
checkShellCompletionStatus: (binName: string) => Promise<ShellCompletionStatus>;
ensureCompletionCacheExists: (binName: string) => Promise<boolean>;
ensureCompletionCacheExists: (
binName: string,
options: CompletionCacheGenerationOptions,
) => Promise<boolean>;
installCompletion: (shell: string, yes: boolean, binName?: string) => Promise<void>;
};
@@ -63,10 +69,11 @@ export async function setupWizardShellCompletion(params: {
const cliName = deps.resolveCliName();
const completionStatus = await deps.checkShellCompletionStatus(cliName);
const generationOptions = { generationMode: "full" } as const;
if (completionStatus.usesSlowPattern) {
// Case 1: Profile uses slow dynamic pattern - silently upgrade to cached version
const cacheGenerated = await deps.ensureCompletionCacheExists(cliName);
const cacheGenerated = await deps.ensureCompletionCacheExists(cliName, generationOptions);
if (cacheGenerated) {
await deps.installCompletion(completionStatus.shell, true, cliName);
}
@@ -75,7 +82,7 @@ export async function setupWizardShellCompletion(params: {
if (completionStatus.profileInstalled && !completionStatus.cacheExists) {
// Case 2: Profile has completion but no cache - auto-fix silently
await deps.ensureCompletionCacheExists(cliName);
await deps.ensureCompletionCacheExists(cliName, generationOptions);
return;
}
@@ -97,7 +104,7 @@ export async function setupWizardShellCompletion(params: {
}
// Generate cache first (required for fast shell startup)
const cacheGenerated = await deps.ensureCompletionCacheExists(cliName);
const cacheGenerated = await deps.ensureCompletionCacheExists(cliName, generationOptions);
if (!cacheGenerated) {
await params.prompter.note(
t("wizard.completion.cacheFailed", { command: `${cliName} completion --install` }),