Skip to content
Merged
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/onboard/docker-gpu-patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ export type DockerContainerInspect = {
GroupAdd?: string[] | null;
Dns?: string[] | null;
DnsSearch?: string[] | null;
DeviceRequests?: Array<{
Driver?: string;
DeviceIDs?: string[] | null;
}> | null;
ShmSize?: number;
ReadonlyPaths?: string[] | null;
MaskedPaths?: string[] | null;
Expand Down Expand Up @@ -676,6 +680,21 @@ export function buildDockerGpuCloneRunArgs(
const args: string[] = ["--name", dockerContainerName(inspect), ...mode.args];
const gpuAugment = mode.kind !== "startup-command";

// Hermes restart persistence recreates the OpenShell-managed container
// without selecting NemoClaw's compatibility GPU mode. Preserve Docker's
// native CDI requests from the inspected container so that recreation does
// not silently drop OpenShell's GPU attachment (and the injected libcuda).
if (!gpuAugment) {
const cdiDeviceIds = new Set(
(host.DeviceRequests ?? [])
.filter((request) => request.Driver === "cdi")
.flatMap((request) => stringArray(request.DeviceIDs))
.map((deviceId) => deviceId.trim())
.filter(Boolean),
);
for (const deviceId of cdiDeviceIds) args.push("--device", deviceId);
}

pushStringFlag(args, "--hostname", config.Hostname);
pushStringFlag(args, "--user", config.User);
pushStringFlag(args, "--workdir", config.WorkingDir);
Expand Down
42 changes: 42 additions & 0 deletions src/lib/onboard/docker-startup-command-patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe("Docker startup-command patch", () => {
]),
);
expect(cloneArgs).not.toContain("--gpus");
expect(cloneArgs).not.toContain("--device");
expect(cloneArgs).toEqual(expect.arrayContaining(["--env", "NVIDIA_VISIBLE_DEVICES=void"]));
expect(cloneArgs).not.toEqual(expect.arrayContaining(["--cap-add", "SYS_PTRACE"]));
expect(cloneArgs).not.toEqual(
Expand All @@ -85,6 +86,47 @@ describe("Docker startup-command patch", () => {
);
});

it("preserves OpenShell's native CDI GPU request during restart-persistence recreation", () => {
const inspect = inspectFixture();
inspect.HostConfig!.DeviceRequests = [
{
Driver: "cdi",
DeviceIDs: ["nvidia.com/gpu=all"],
},
];
const dockerRunDetached = vi.fn((_args: readonly string[]) => ({
status: 0,
stdout: "new-container-id\n",
}));

recreateOpenShellDockerSandboxWithStartupCommand(
{
sandboxName: "alpha",
timeoutSecs: 1,
waitForSupervisor: false,
openshellSandboxCommand: ["env", "nemoclaw-start"],
},
{
dockerCapture: vi.fn((args: readonly string[]) =>
args[0] === "ps" ? "old-container-id\n" : JSON.stringify([inspect]),
),
dockerRunDetached,
dockerRename: vi.fn(() => ({ status: 0 })),
dockerStop: vi.fn(() => ({ status: 0 })),
sleep: vi.fn(),
now: () => new Date("2026-07-10T00:00:00Z"),
},
);

const cloneArgs = dockerRunDetached.mock.calls[0]?.[0] ?? [];
expect(cloneArgs).toEqual(expect.arrayContaining(["--device", "nvidia.com/gpu=all"]));
expect(cloneArgs).not.toContain("--gpus");
expect(cloneArgs).not.toEqual(expect.arrayContaining(["--cap-add", "SYS_PTRACE"]));
expect(cloneArgs).not.toEqual(
expect.arrayContaining(["--security-opt", "apparmor=unconfined"]),
);
});

it("rejects an empty restart-persistence command before Docker mutation", () => {
expect(() =>
recreateOpenShellDockerSandboxWithStartupCommand({
Expand Down
36 changes: 34 additions & 2 deletions test/e2e/live/messaging-providers-slack-runtime-proof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ export type InstalledSlackRuntimeProof = {
channelId: string;
};

export const SLACK_MANAGED_NPM_PROJECT_DISCOVERY_SOURCE = String.raw`
function addManagedNpmProjectSlackCandidates(projectsDir, addExternalCandidate) {
let entries;
try {
entries = fs.readdirSync(projectsDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
if (!entry.isDirectory()) continue;
const projectRoot = path.join(projectsDir, entry.name);
let dependencies;
try {
dependencies = JSON.parse(
fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"),
).dependencies;
} catch {
continue;
}
if (!dependencies || !Object.hasOwn(dependencies, "@openclaw/slack")) continue;
addExternalCandidate(
path.join(projectRoot, "node_modules", "@openclaw", "slack"),
);
}
}
`;

export const SLACK_INSTALLED_RUNTIME_PROOF_SOURCE = String.raw`
import { execFileSync } from "node:child_process";
import fs from "node:fs";
Expand All @@ -30,6 +57,8 @@ import { pathToFileURL } from "node:url";

const allowLegacyTestApi = process.env.NEMOCLAW_E2E_ALLOW_LEGACY_SLACK_TEST_API === "1";

${SLACK_MANAGED_NPM_PROJECT_DISCOVERY_SOURCE}

function invariant(condition, message) {
if (!condition) throw new Error(message);
}
Expand Down Expand Up @@ -78,8 +107,11 @@ function resolveOpenClawSlackApiLocation() {
}
};

addExternalCandidate(
path.join(process.env.OPENCLAW_STATE_DIR || "/sandbox/.openclaw", "extensions", "slack"),
const openclawStateDir = process.env.OPENCLAW_STATE_DIR || "/sandbox/.openclaw";
addExternalCandidate(path.join(openclawStateDir, "extensions", "slack"));
addManagedNpmProjectSlackCandidates(
path.join(openclawStateDir, "npm", "projects"),
addExternalCandidate,
);
addExternalCandidate(process.env.OPENCLAW_SLACK_PACKAGE_ROOT);
addCoreCandidate(process.env.OPENCLAW_PACKAGE_ROOT);
Expand Down
13 changes: 8 additions & 5 deletions test/e2e/live/openshell-gateway-auth-source-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ const OPENSHELL_GATEWAY_AUTH_CONTRACT_VERSION = "0.0.72";
test(
`OpenShell ${OPENSHELL_GATEWAY_AUTH_CONTRACT_VERSION} Docker-driver gateway auth uses NemoClaw mTLS plus sandbox JWT`,
{ timeout: LIVE_TIMEOUT_MS },
(fixtures) =>
runOpenShellGatewayAuthSourceContractScenario(fixtures, {
buildDockerDriverGatewayLaunch,
ensureDockerDriverGatewayLocalTlsBundle,
}),
({ artifacts, cleanup, host, skip }) =>
runOpenShellGatewayAuthSourceContractScenario(
{ artifacts, cleanup, host, skip },
{
buildDockerDriverGatewayLaunch,
ensureDockerDriverGatewayLocalTlsBundle,
},
),
);
49 changes: 49 additions & 0 deletions test/e2e/support/messaging-providers-runtime-proofs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
import {
parseInstalledSlackProof,
SLACK_INSTALLED_RUNTIME_PROOF_SOURCE,
SLACK_MANAGED_NPM_PROJECT_DISCOVERY_SOURCE,
} from "../live/messaging-providers-slack-runtime-proof.ts";
import { TELEGRAM_INSTALLED_RUNTIME_PROOF_SOURCE } from "../live/messaging-providers-telegram-runtime-proof.ts";

Expand Down Expand Up @@ -155,6 +156,54 @@ describe("messaging provider installed-runtime proofs", () => {
expect(SLACK_INSTALLED_RUNTIME_PROOF_SOURCE).toContain("/api/chat.postMessage");
});

it("finds Slack only in its canonical managed npm project", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-managed-project-"));
const projectsDir = path.join(dir, "npm", "projects");
const slackProject = path.join(projectsDir, "openclaw-slack-reviewed");
const unrelatedProject = path.join(projectsDir, "unrelated-plugin");
const malformedProject = path.join(projectsDir, "malformed-plugin");
const slackPackageRoot = path.join(slackProject, "node_modules", "@openclaw", "slack");

try {
fs.mkdirSync(slackPackageRoot, { recursive: true });
fs.writeFileSync(
path.join(slackProject, "package.json"),
JSON.stringify({ dependencies: { "@openclaw/slack": "2026.6.10" } }),
);
fs.mkdirSync(path.join(unrelatedProject, "node_modules", "@openclaw", "slack"), {
recursive: true,
});
fs.writeFileSync(
path.join(unrelatedProject, "package.json"),
JSON.stringify({ dependencies: { "@openclaw/discord": "2026.6.10" } }),
);
fs.mkdirSync(malformedProject, { recursive: true });
fs.writeFileSync(path.join(malformedProject, "package.json"), "not json");

const source = [
'import fs from "node:fs";',
'import path from "node:path";',
SLACK_MANAGED_NPM_PROJECT_DISCOVERY_SOURCE,
"const candidates = [];",
"addManagedNpmProjectSlackCandidates(",
" process.env.NEMOCLAW_TEST_PROJECTS_DIR,",
" (candidate) => candidates.push(path.resolve(candidate)),",
");",
"process.stdout.write(JSON.stringify(candidates));",
].join("\n");
const result = spawnSync(process.execPath, ["--input-type=module", "-"], {
encoding: "utf8",
env: { ...process.env, NEMOCLAW_TEST_PROJECTS_DIR: projectsDir },
input: source,
});

expect(result.status, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual([path.resolve(slackPackageRoot)]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("reports loader stderr without accepting stderr as a Slack proof (#6467)", () => {
const proof = JSON.stringify({
ok: true,
Expand Down
Loading