Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/lib/adapters/openshell/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ describe("lib/resolve-openshell", () => {
).toBe("/usr/local/bin/openshell");
});

it("falls back to /opt/homebrew/bin (Apple Silicon Homebrew prefix, #5334)", () => {
expect(
resolveOpenshell({
commandVResult: null,
checkExecutable: (p) => p === "/opt/homebrew/bin/openshell",
}),
).toBe("/opt/homebrew/bin/openshell");
});

it("prefers /opt/homebrew/bin over /usr/local/bin (#5334)", () => {
expect(
resolveOpenshell({
commandVResult: null,
checkExecutable: (p) =>
p === "/opt/homebrew/bin/openshell" || p === "/usr/local/bin/openshell",
}),
).toBe("/opt/homebrew/bin/openshell");
});

it("falls back to /usr/bin", () => {
expect(
resolveOpenshell({
Expand Down
9 changes: 9 additions & 0 deletions src/lib/adapters/openshell/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,17 @@ export function resolveOpenshell(opts: ResolveOpenshellOptions = {}): string | n
}

// Step 2: fallback candidates
//
// `/opt/homebrew/bin` is the Apple Silicon Homebrew prefix. It is frequently
// absent from the non-interactive/login shell that drives onboarding (Homebrew
// only adds it via `brew shellenv`, which many profiles source after the
// non-interactive guard), so `command -v openshell` above can miss a perfectly
// good Homebrew install. Probing the prefix directly keeps NemoClaw coherent
// with a Homebrew-installed OpenShell instead of reporting "openshell not
// found" while the binary sits in `/opt/homebrew/bin` (#5334).
const candidates = [
...(home?.startsWith("/") ? [`${home}/.local/bin/openshell`] : []),
"/opt/homebrew/bin/openshell",
"/usr/local/bin/openshell",
"/usr/bin/openshell",
];
Expand Down
24 changes: 24 additions & 0 deletions src/lib/onboard/docker-driver-gateway-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,30 @@ describe("reportDockerDriverGatewayStartFailure (#3111)", () => {
expect(joined).not.toContain("before becoming ready");
});

it("reports an unhealthy-within-timeout gateway without asserting liveness, and points at status commands (#5334)", () => {
reportDockerDriverGatewayStartFailure("/tmp/nonexistent-gateway.log", makeExitState(), {
exitOnFailure: false,
});
const joined = errSpy.mock.calls.map((c: string[]) => c.join(" ")).join("\n");
expect(joined).toContain("did not become healthy within the timeout");
// Must not claim the process is still running: the caller can reach here
// after liveness dropped before the 'exit' event fired (#5334 review).
expect(joined).not.toContain("still running");
expect(joined).toContain("openshell status");
expect(joined).toContain("openshell gateway info");
});

it("prefers the specific exit description over the generic line when the gateway exited (#5334)", () => {
reportDockerDriverGatewayStartFailure(
"/tmp/nonexistent-gateway.log",
makeExitState({ exited: true, code: 1, describeExit: () => "exited with code 1" }),
{ exitOnFailure: false },
);
const joined = errSpy.mock.calls.map((c: string[]) => c.join(" ")).join("\n");
expect(joined).toContain("exited with code 1");
expect(joined).not.toContain("did not become healthy within the timeout");
});

it("includes a tail of the gateway log when the file exists", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gw-fail-"));
const log = path.join(dir, "openshell-gateway.log");
Expand Down
10 changes: 10 additions & 0 deletions src/lib/onboard/docker-driver-gateway-failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ export function reportDockerDriverGatewayStartFailure(
console.error(" Docker-driver gateway failed to start.");
if (childExit.exited) {
console.error(` Gateway process ${childExit.describeExit()} before becoming ready.`);
} else {
// #5334: the start loop also reaches this reporter when the poll budget is
// exhausted, or when the process's liveness dropped before its 'exit' event
// was observed. We therefore do NOT assert the process is "still running"
// (that would misreport a gateway that already died) and instead state only
// the observable fact: it never became healthy in time. The status commands
// in the Troubleshooting footer below are what reveal why.
console.error(" The gateway process did not become healthy within the timeout.");
}
if (tail) {
console.error(" Gateway log tail:");
Expand All @@ -71,6 +79,8 @@ export function reportDockerDriverGatewayStartFailure(
}
console.error(" Troubleshooting:");
console.error(` tail -100 ${logPath}`);
console.error(" openshell status");
console.error(" openshell gateway info");
console.error(" docker info --format '{{json .CDISpecDirs}}'");

if (exitOnFailure) {
Expand Down
30 changes: 30 additions & 0 deletions src/lib/onboard/docker-driver-gateway-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,36 @@ describe("docker-driver gateway runtime helpers", () => {
});
});

it("falls back to /opt/homebrew/bin for the standalone gateway binary (#5334)", () => {
withEnv({ NEMOCLAW_OPENSHELL_GATEWAY_BIN: undefined }, () => {
const { helpers } = makeHelpers({
// A cached CLI binary in a directory with no sibling gateway forces the
// resolver past sibling resolution into the prefix fallback list.
getCachedOpenshellBinary: () => "/nonexistent/dir/openshell",
});
vi.spyOn(fs, "existsSync").mockImplementation(
((candidate) =>
String(candidate) === "/opt/homebrew/bin/openshell-gateway") as typeof fs.existsSync,
);

expect(helpers.resolveOpenShellGatewayBinary()).toBe("/opt/homebrew/bin/openshell-gateway");
});
});

it("falls back to /opt/homebrew/bin for the standalone sandbox binary (#5334)", () => {
withEnv({ NEMOCLAW_OPENSHELL_SANDBOX_BIN: undefined }, () => {
const { helpers } = makeHelpers({
getCachedOpenshellBinary: () => "/nonexistent/dir/openshell",
});
vi.spyOn(fs, "existsSync").mockImplementation(
((candidate) =>
String(candidate) === "/opt/homebrew/bin/openshell-sandbox") as typeof fs.existsSync,
);

expect(helpers.resolveOpenShellSandboxBinary()).toBe("/opt/homebrew/bin/openshell-sandbox");
});
});

it("matches the docker compatibility gateway parent process", () => {
const pid = 12_348;
const { helpers, runCapture } = makeHelpers({
Expand Down
6 changes: 6 additions & 0 deletions src/lib/onboard/docker-driver-gateway-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,12 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa
if (configured && configured.trim()) return path.resolve(configured.trim());
const sibling = resolveSiblingBinary("openshell-gateway");
if (sibling) return sibling;
// Keep the standalone gateway fallbacks coherent with the CLI resolver
// (resolveOpenshell): `/opt/homebrew/bin` is the Apple Silicon Homebrew
// prefix and is often missing from the onboarding shell's PATH (#5334).
for (const candidate of [
path.join(os.homedir(), ".local", "bin", "openshell-gateway"),
"/opt/homebrew/bin/openshell-gateway",
"/usr/local/bin/openshell-gateway",
"/usr/bin/openshell-gateway",
]) {
Expand All @@ -135,8 +139,10 @@ export function createDockerDriverGatewayRuntimeHelpers(deps: DockerDriverGatewa
if (configured && configured.trim()) return path.resolve(configured.trim());
const sibling = resolveSiblingBinary("openshell-sandbox");
if (sibling) return sibling;
// Apple Silicon Homebrew prefix kept in sync with the other resolvers (#5334).
for (const candidate of [
path.join(os.homedir(), ".local", "bin", "openshell-sandbox"),
"/opt/homebrew/bin/openshell-sandbox",
"/usr/local/bin/openshell-sandbox",
"/usr/bin/openshell-sandbox",
]) {
Expand Down
Loading