Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b2803b7
fix nemohermes first-run onboarding
ericksoa Apr 30, 2026
48db501
fix: prefer onboard session sandbox without node
ericksoa Apr 30, 2026
976f90b
fix: address nemohermes review feedback
ericksoa May 1, 2026
fa34ccc
fix: keep installer banner bash 3 compatible
ericksoa May 1, 2026
0fa1fbb
fix: use freshly installed openshell during install
ericksoa May 1, 2026
2345f3d
fix: reclaim stale dashboard forwards
ericksoa May 1, 2026
e760c48
fix: restore agent forward after policies
ericksoa May 1, 2026
92ca8da
fix: restore hermes forward after install checks
ericksoa May 1, 2026
28a49f0
fix: retry hermes forward restore after install
ericksoa May 1, 2026
d2c2ebc
fix: keep hermes forward warning bash compatible
ericksoa May 1, 2026
f5788d3
fix: verify hermes forward restore by health
ericksoa May 1, 2026
88847fb
fix: nohup hermes install forward restore
ericksoa May 1, 2026
425f13b
fix: keep hermes install forward alive
ericksoa May 1, 2026
6060f17
fix: detach hermes install forward stdin
ericksoa May 1, 2026
f1070b1
fix: keep hermes install forward watched
ericksoa May 1, 2026
7f09071
fix: disown hermes install forward watcher
ericksoa May 1, 2026
f1ec801
fix: detach hermes forward watcher with node
ericksoa May 1, 2026
a741ad2
fix: address installer shellcheck findings
ericksoa May 1, 2026
8ddc35a
fix: refresh openshell gateway on first run
ericksoa May 1, 2026
128d635
chore: apply install script formatting
ericksoa May 1, 2026
770421a
fix: validate installer watcher pid before kill
ericksoa May 1, 2026
b3d6d5f
fix: require exact agent health probe response
ericksoa May 1, 2026
67e7e3f
fix: accept hermes json health probe
ericksoa May 1, 2026
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
1,731 changes: 344 additions & 1,387 deletions scripts/install.sh

Large diffs are not rendered by default.

23 changes: 2 additions & 21 deletions src/lib/adapters/openshell/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolveOpenshell } from "./resolve";
import { resolveOpenshell } from "../../dist/lib/resolve-openshell";

describe("lib/resolve-openshell", () => {
it("returns command -v result when absolute path", () => {
Expand Down Expand Up @@ -64,25 +64,6 @@ describe("lib/resolve-openshell", () => {
).toBe("/usr/local/bin/openshell");
});

it("falls back to the Apple Silicon Homebrew prefix at /opt/homebrew/bin (#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: 0 additions & 9 deletions src/lib/adapters/openshell/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,17 +50,8 @@ 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
149 changes: 149 additions & 0 deletions src/lib/agent-onboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
// Import from compiled dist/ so coverage is attributed correctly.
import { printDashboardUi } from "../../dist/lib/agent-onboard";
import type { AgentDefinition } from "./agent-defs";

function makeAgent(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
return {
name: "agent",
displayName: "Agent",
healthProbe: { url: "http://127.0.0.1:19000/", port: 19000, timeout_seconds: 5 },
forwardPort: 19000,
dashboard: { kind: "ui", label: "UI", path: "/" },
configPaths: {
dir: "/tmp/agent",
configFile: "/tmp/agent/config.yaml",
envFile: null,
format: "yaml",
},
stateDirs: [],
versionCommand: "agent --version",
expectedVersion: null,
hasDevicePairing: false,
phoneHomeHosts: [],
messagingPlatforms: [],
dockerfileBasePath: null,
dockerfilePath: null,
startScriptPath: null,
policyAdditionsPath: null,
policyPermissivePath: null,
pluginDir: null,
legacyPaths: null,
agentDir: "/tmp/agent",
manifestPath: "/tmp/agent/manifest.yaml",
...overrides,
};
}

const apiAgent = makeAgent({
name: "hermes",
displayName: "Hermes Agent",
forwardPort: 8642,
dashboard: { kind: "api", label: "OpenAI-compatible API", path: "/v1" },
});

const uiAgent = makeAgent({
name: "ficticious-ui",
displayName: "Ficticious",
forwardPort: 19000,
dashboard: { kind: "ui", label: "UI", path: "/" },
});

// Regression fixture for issue #2078 — matches the text a user sees when
// no token is available and prevents the wording from regressing to
// something that implies port 8642 is a browser UI.
const buildUrlsLoopback = (token: string | null, port: number): string[] => {
const hash = token ? `#token=${token}` : "";
return [`http://127.0.0.1:${port}/${hash}`];
};

describe("printDashboardUi — regression for #2078 (port 8642 is not a chat UI)", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const noteSpy = vi.fn();

beforeEach(() => {
logSpy.mockClear();
noteSpy.mockReset();
});

afterEach(() => {
logSpy.mockClear();
});

afterAll(() => {
logSpy.mockRestore();
});

it("labels an API-kind agent as the API — not a UI — and does not embed a token in the URL", () => {
printDashboardUi("sandbox-x", "secret-token", apiAgent, {
note: noteSpy,
buildControlUiUrls: buildUrlsLoopback,
});

const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n");
expect(output).toContain("Hermes Agent OpenAI-compatible API");
expect(output).not.toContain("UI (tokenized URL");
expect(output).toContain("Port 8642 must be forwarded before connecting.");
expect(output).toContain("http://127.0.0.1:8642/v1");
// Token-in-URL-fragment auth does not apply to the OpenAI API endpoint.
expect(output).not.toContain("#token=secret-token");
});

it("prints the API URL consistently whether or not a gateway token was read", () => {
printDashboardUi("sandbox-x", null, apiAgent, {
note: noteSpy,
buildControlUiUrls: buildUrlsLoopback,
});

const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n");
expect(output).toContain("Hermes Agent OpenAI-compatible API");
expect(output).toContain("http://127.0.0.1:8642/v1");
// The API endpoint does not require the gateway token — don't confuse
// the user with the OpenClaw-style "token missing" warning.
expect(noteSpy).not.toHaveBeenCalled();
});

it("prints tokenized URL with save-now warning for UI-kind agents", () => {
printDashboardUi("sandbox-y", "tok", uiAgent, {
note: noteSpy,
buildControlUiUrls: buildUrlsLoopback,
});

const output = logSpy.mock.calls.map((args) => String(args[0])).join("\n");
expect(output).toContain(
"Ficticious UI (tokenized URL; treat it like a password; save it now - it will not be printed again)",
);
expect(output).toContain("Port 19000 must be forwarded before opening this URL.");
expect(output).toContain("http://127.0.0.1:19000/#token=tok");
});
});

describe("handleAgentSetup guards", () => {
it("fails onboarding instead of completing when the agent binary or health probe is missing", () => {
const source = fs.readFileSync(path.join(import.meta.dirname, "agent-onboard.ts"), "utf-8");

expect(source).toContain("verifyAgentBinaryAvailable");
expect(source).toContain(
'resolved="$(command -v ${shellQuote(executable)} 2>/dev/null || true)"',
);
expect(source).toContain('[ "$resolved" = ${shellQuote(binaryPath)} ]');
expect(source).toContain("failAgentSetup");
expect(source).toContain('onboardSession.markStepFailed("agent_setup"');
expect(source).toContain("gateway did not respond within");
expect(source).not.toContain("gateway may still be starting");
});

it("accepts Hermes JSON health responses without substring false positives", () => {
const source = fs.readFileSync(path.join(import.meta.dirname, "agent-onboard.ts"), "utf-8");

expect(source).toContain("function isHealthProbeOk");
expect(source).toContain("JSON.parse(body)");
expect(source).toContain('parsed.status === "ok"');
expect(source).not.toContain('.includes("ok")');
});
});
Loading
Loading