diff --git a/src/lib/adapters/openshell/resolve.test.ts b/src/lib/adapters/openshell/resolve.test.ts index a62e4c72ca..1f187c59bd 100644 --- a/src/lib/adapters/openshell/resolve.test.ts +++ b/src/lib/adapters/openshell/resolve.test.ts @@ -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({ diff --git a/src/lib/adapters/openshell/resolve.ts b/src/lib/adapters/openshell/resolve.ts index 4e71900d9f..fdb3833571 100644 --- a/src/lib/adapters/openshell/resolve.ts +++ b/src/lib/adapters/openshell/resolve.ts @@ -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", ]; diff --git a/src/lib/onboard/docker-driver-gateway-failure.test.ts b/src/lib/onboard/docker-driver-gateway-failure.test.ts index b8fc814514..30cb5d2805 100644 --- a/src/lib/onboard/docker-driver-gateway-failure.test.ts +++ b/src/lib/onboard/docker-driver-gateway-failure.test.ts @@ -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"); diff --git a/src/lib/onboard/docker-driver-gateway-failure.ts b/src/lib/onboard/docker-driver-gateway-failure.ts index 4dd097283c..4326733b99 100644 --- a/src/lib/onboard/docker-driver-gateway-failure.ts +++ b/src/lib/onboard/docker-driver-gateway-failure.ts @@ -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:"); @@ -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) { diff --git a/src/lib/onboard/docker-driver-gateway-runtime.test.ts b/src/lib/onboard/docker-driver-gateway-runtime.test.ts index 665840b215..e108ef0575 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.test.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.test.ts @@ -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({ diff --git a/src/lib/onboard/docker-driver-gateway-runtime.ts b/src/lib/onboard/docker-driver-gateway-runtime.ts index 299c366df6..5f68498236 100644 --- a/src/lib/onboard/docker-driver-gateway-runtime.ts +++ b/src/lib/onboard/docker-driver-gateway-runtime.ts @@ -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", ]) { @@ -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", ]) {