Skip to content

Commit c9835c1

Browse files
lwy-2claude
andcommitted
fix: degrade shell-completion EACCES to warning instead of hard exit 1
When doctor --fix runs shell-completion install against a read-only rc file (~/.bashrc with mode 444), print a one-line warning and exit 0 instead of throwing Error('Failed to install completion: EACCES...') and exiting 1. Introduces tryInstallCompletion wrapper that catches installCompletion errors, logs a user-facing note, and returns false. Both call sites in doctorShellCompletion (slow-profile upgrade and fresh install) use the wrapper and only emit success messages on actual success. Closes #99237 Co-Authored-By: Claude <[email protected]>
1 parent 5e61da3 commit c9835c1

2 files changed

Lines changed: 79 additions & 6 deletions

File tree

src/commands/doctor-completion.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
1-
// Doctor completion tests cover final doctor status summaries and completion messaging.
1+
// Doctor completion tests cover final doctor status summaries, completion messaging,
2+
// and graceful degradation when shell-completion install hits permission errors.
23
import fs from "node:fs/promises";
34
import os from "node:os";
45
import path from "node:path";
5-
import { afterEach, describe, expect, it } from "vitest";
6+
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import * as completionRuntime from "../cli/completion-runtime.js";
8+
import { createNonExitingRuntime } from "../runtime.js";
69
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
710
import {
811
checkShellCompletionStatus,
12+
doctorShellCompletion,
913
shellCompletionStatusToHealthFindings,
1014
shellCompletionStatusToRepairEffects,
1115
type ShellCompletionStatus,
1216
} from "./doctor-completion.js";
17+
import type { DoctorPrompter } from "./doctor-prompter.js";
1318

1419
const originalEnv = captureEnv(["HOME", "OPENCLAW_STATE_DIR", "SHELL"]);
1520
const tempDirs: string[] = [];
@@ -102,3 +107,42 @@ describe("shell completion health mapping", () => {
102107
expect(shellCompletionStatusToRepairEffects(current)).toEqual([]);
103108
});
104109
});
110+
111+
describe("doctorShellCompletion EACCES degradation", () => {
112+
afterEach(() => {
113+
vi.restoreAllMocks();
114+
});
115+
116+
it("degrades to a warning when installCompletion fails with EACCES during slow-profile upgrade", async () => {
117+
// Mock the completion-runtime helpers so checkShellCompletionStatus returns
118+
// a slow-dynamic status with an existing cache, which reaches the
119+
// installCompletion code path without needing cache generation.
120+
vi.spyOn(completionRuntime, "usesSlowDynamicCompletion").mockResolvedValue(true);
121+
vi.spyOn(completionRuntime, "completionCacheExists").mockResolvedValue(true);
122+
vi.spyOn(completionRuntime, "isCompletionInstalled").mockResolvedValue(true);
123+
vi.spyOn(completionRuntime, "resolveShellFromEnv").mockReturnValue("bash" as const);
124+
const installSpy = vi
125+
.spyOn(completionRuntime, "installCompletion")
126+
.mockRejectedValue(new Error("EACCES: permission denied, open '/sandbox/.bashrc'"));
127+
128+
const prompter: DoctorPrompter = {
129+
confirm: vi.fn(async () => true),
130+
confirmAutoFix: vi.fn(async () => true),
131+
confirmAggressiveAutoFix: vi.fn(async () => true),
132+
confirmRuntimeRepair: vi.fn(async () => true),
133+
select: vi.fn(
134+
async (_params: unknown, fallback: unknown) => fallback,
135+
) as DoctorPrompter["select"],
136+
shouldRepair: true,
137+
shouldForce: false,
138+
repairMode: "fix" as const,
139+
};
140+
141+
// Must not throw — the EACCES error must degrade to a warning.
142+
await expect(
143+
doctorShellCompletion(createNonExitingRuntime(), prompter),
144+
).resolves.toBeUndefined();
145+
146+
expect(installSpy).toHaveBeenCalledOnce();
147+
});
148+
});

src/commands/doctor-completion.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,39 @@ import {
1515
type CompletionShell,
1616
} from "../cli/completion-runtime.js";
1717
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
18+
import { formatErrorMessage } from "../infra/errors.js";
1819
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
1920
import type { RuntimeEnv } from "../runtime.js";
2021
import type { DoctorPrompter } from "./doctor-prompter.js";
2122

2223
const COMPLETION_CACHE_WRITE_TIMEOUT_MS = 30_000;
2324

25+
/**
26+
* Wraps installCompletion so that filesystem permission errors (EACCES) and
27+
* other non-critical failures degrade to a warning instead of a hard error.
28+
* Doctor treats shell-completion installation as optional — an unwritable rc
29+
* file must never cause exit code 1.
30+
*/
31+
async function tryInstallCompletion(
32+
shell: string,
33+
yes: boolean,
34+
binName: string,
35+
cliName: string,
36+
): Promise<boolean> {
37+
try {
38+
await installCompletion(shell, yes, binName);
39+
return true;
40+
} catch (error) {
41+
const message = formatErrorMessage(error);
42+
note(
43+
`Shell completion not installed: ${message}. ` +
44+
`Run \`${cliName} completion install\` against a writable rc file manually.`,
45+
"Shell completion",
46+
);
47+
return false;
48+
}
49+
}
50+
2451
export type ShellCompletionStatusOptions = {
2552
shell?: CompletionShell;
2653
};
@@ -195,8 +222,9 @@ export async function doctorShellCompletion(
195222
}
196223
}
197224

198-
await installCompletion(status.shell, true, cliName);
199-
note(formatCompletionReloadNote(status.shell, "upgraded"), "Shell completion");
225+
if (await tryInstallCompletion(status.shell, true, cliName, cliName)) {
226+
note(formatCompletionReloadNote(status.shell, "upgraded"), "Shell completion");
227+
}
200228
return;
201229
}
202230

@@ -237,8 +265,9 @@ export async function doctorShellCompletion(
237265
return;
238266
}
239267

240-
await installCompletion(status.shell, true, cliName);
241-
note(formatCompletionReloadNote(status.shell, "installed"), "Shell completion");
268+
if (await tryInstallCompletion(status.shell, true, cliName, cliName)) {
269+
note(formatCompletionReloadNote(status.shell, "installed"), "Shell completion");
270+
}
242271
}
243272
}
244273
}

0 commit comments

Comments
 (0)