diff --git a/src/lib/onboard/config-sync.test.ts b/src/lib/onboard/config-sync.test.ts index 56af39a0cb..02775908dc 100644 --- a/src/lib/onboard/config-sync.test.ts +++ b/src/lib/onboard/config-sync.test.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,6 +10,33 @@ import { describe, expect, it } from "vitest"; import { buildSandboxConfigSyncScript, writeSandboxConfigSyncFile } from "./config-sync"; +const itUnix = process.platform === "win32" ? it.skip : it; + +function writeFakeCommand(binDir: string, name: string, stdout: string): void { + const file = path.join(binDir, name); + fs.writeFileSync(file, `#!/bin/sh\nprintf '%s\\n' '${stdout}'\n`, { mode: 0o755 }); +} + +function runConfigSyncScript(script: string, homeDir: string, fakeUid: string): void { + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sync-bin-")); + try { + writeFakeCommand(fakeBin, "id", fakeUid); + writeFakeCommand(fakeBin, "stat", fakeUid); + const result = spawnSync("bash", ["-c", script], { + cwd: homeDir, + env: { ...process.env, HOME: homeDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + encoding: "utf8", + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + } finally { + fs.rmSync(fakeBin, { recursive: true, force: true }); + } +} + +function modeBits(file: string): number { + return fs.statSync(file).mode & 0o777; +} + describe("sandbox config sync helpers", () => { it("builds a sandbox sync script that records provider selection without rewriting OpenClaw config", () => { const script = buildSandboxConfigSyncScript({ @@ -22,10 +50,17 @@ describe("sandbox config sync helpers", () => { providerLabel: "Other OpenAI-compatible endpoint", }); - expect(script).toMatch(/mkdir -p -m 700 ~\/\.nemoclaw/); - expect(script).toMatch(/chmod 700 ~\/\.nemoclaw/); - expect(script).toMatch(/cat > ~\/\.nemoclaw\/config\.json/); - expect(script).toMatch(/chmod 600 ~\/\.nemoclaw\/config\.json/); + expect(script).toMatch(/nemoclaw_dir="\$\{HOME:-\/sandbox\}\/\.nemoclaw"/); + expect(script).toMatch(/mkdir -p -m 700 "\$nemoclaw_dir"/); + expect(script).toMatch(/nemoclaw_dir_uid="\$\(stat -c '%u' "\$nemoclaw_dir"/); + expect(script).toMatch(/current_uid="\$\(id -u/); + expect(script).toMatch( + /if \[ -n "\$nemoclaw_dir_uid" \] && \[ "\$nemoclaw_dir_uid" = "\$current_uid" \]; then/, + ); + expect(script).toMatch(/chmod 700 "\$nemoclaw_dir"/); + expect(script).toMatch(/cat > "\$nemoclaw_config"/); + expect(script).toMatch(/chmod 600 "\$nemoclaw_config"/); + expect(script).not.toMatch(/^chmod 700 ~\/\.nemoclaw$/m); expect(script).toContain('"model": "nemotron-3-nano:30b"'); expect(script).toContain('"credentialEnv": "OPENAI_API_KEY"'); expect(script).not.toMatch(/cat > ~\/\.openclaw\/openclaw\.json/); @@ -62,6 +97,65 @@ describe("sandbox config sync helpers", () => { expect(script).not.toContain("bedrock-runtime.us-east-1.amazonaws.com"); }); + itUnix("tightens user-owned NemoClaw config dirs and files", () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sync-home-")); + try { + const nemoclawDir = path.join(homeDir, ".nemoclaw"); + fs.mkdirSync(nemoclawDir, { mode: 0o755 }); + const script = buildSandboxConfigSyncScript({ + endpointType: "custom", + endpointUrl: "https://inference.local/v1", + ncpPartner: null, + model: "nemotron-3-nano:30b", + profile: "inference-local", + credentialEnv: "OPENAI_API_KEY", + provider: "compatible-endpoint", + providerLabel: "Other OpenAI-compatible endpoint", + }); + + runConfigSyncScript(script, homeDir, "1234"); + + expect(modeBits(nemoclawDir)).toBe(0o700); + expect(modeBits(path.join(nemoclawDir, "config.json"))).toBe(0o600); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + + itUnix("does not chmod a NemoClaw config dir owned by another user", () => { + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sync-home-")); + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-sync-bin-")); + try { + const nemoclawDir = path.join(homeDir, ".nemoclaw"); + fs.mkdirSync(nemoclawDir, { mode: 0o755 }); + writeFakeCommand(fakeBin, "id", "1234"); + writeFakeCommand(fakeBin, "stat", "0"); + const script = buildSandboxConfigSyncScript({ + endpointType: "custom", + endpointUrl: "https://inference.local/v1", + ncpPartner: null, + model: "nemotron-3-nano:30b", + profile: "inference-local", + credentialEnv: "OPENAI_API_KEY", + provider: "compatible-endpoint", + providerLabel: "Other OpenAI-compatible endpoint", + }); + + const result = spawnSync("bash", ["-c", script], { + cwd: homeDir, + env: { ...process.env, HOME: homeDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + encoding: "utf8", + }); + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(modeBits(nemoclawDir)).toBe(0o755); + expect(modeBits(path.join(nemoclawDir, "config.json"))).toBe(0o600); + } finally { + fs.rmSync(fakeBin, { recursive: true, force: true }); + fs.rmSync(homeDir, { recursive: true, force: true }); + } + }); + it("writes sandbox sync scripts to a mkdtemp-backed temp file", () => { const scriptFile = writeSandboxConfigSyncFile("echo test"); try { diff --git a/src/lib/onboard/config-sync.ts b/src/lib/onboard/config-sync.ts index 410c82f97b..a576b48fe8 100644 --- a/src/lib/onboard/config-sync.ts +++ b/src/lib/onboard/config-sync.ts @@ -37,12 +37,18 @@ export function buildSandboxConfigSyncScript(selectionConfig: ProviderSelectionC // chance to perform its own startup initialization. return ` set -euo pipefail -mkdir -p -m 700 ~/.nemoclaw -chmod 700 ~/.nemoclaw -cat > ~/.nemoclaw/config.json <<'EOF_NEMOCLAW_CFG' +nemoclaw_dir="\${HOME:-/sandbox}/.nemoclaw" +nemoclaw_config="$nemoclaw_dir/config.json" +mkdir -p -m 700 "$nemoclaw_dir" +nemoclaw_dir_uid="$(stat -c '%u' "$nemoclaw_dir" 2>/dev/null || echo '')" +current_uid="$(id -u 2>/dev/null || echo '')" +if [ -n "$nemoclaw_dir_uid" ] && [ "$nemoclaw_dir_uid" = "$current_uid" ]; then + chmod 700 "$nemoclaw_dir" +fi +cat > "$nemoclaw_config" <<'EOF_NEMOCLAW_CFG' ${JSON.stringify(selectionConfig, null, 2)} EOF_NEMOCLAW_CFG -chmod 600 ~/.nemoclaw/config.json +chmod 600 "$nemoclaw_config" config_dir=/sandbox/.openclaw if [ -d "$config_dir" ]; then config_dir_owner="$(stat -c '%U' "$config_dir" 2>/dev/null || echo unknown)"