diff --git a/Dockerfile b/Dockerfile index 99107c8c25e..d0425a65fe1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,7 @@ COPY --from=builder /opt/nemoclaw/dist/ /opt/nemoclaw/dist/ COPY nemoclaw/openclaw.plugin.json /opt/nemoclaw/ COPY nemoclaw/package.json nemoclaw/package-lock.json /opt/nemoclaw/ COPY nemoclaw-blueprint/ /opt/nemoclaw-blueprint/ +RUN chmod -R a+rX /opt/nemoclaw-blueprint/ # Install runtime dependencies only (no devDependencies, no build step) WORKDIR /opt/nemoclaw diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index 64f85ff0f10..97e61bfa690 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -21,6 +21,22 @@ function createBuildContextDir(tmpDir: string = os.tmpdir()): string { return fs.mkdtempSync(path.join(tmpDir, "nemoclaw-build-")); } +function normalizeReadModesForDockerCopy(rootDir: string): void { + const stat = fs.lstatSync(rootDir); + if (stat.isDirectory()) { + fs.chmodSync(rootDir, (stat.mode & 0o777) | 0o555); + for (const entry of fs.readdirSync(rootDir)) { + normalizeReadModesForDockerCopy(path.join(rootDir, entry)); + } + return; + } + + if (stat.isFile()) { + const mode = stat.mode & 0o777; + fs.chmodSync(rootDir, mode | 0o444 | (mode & 0o111 ? 0o111 : 0)); + } +} + function stageLegacySandboxBuildContext( rootDir: string, tmpDir: string = os.tmpdir(), @@ -31,6 +47,7 @@ function stageLegacySandboxBuildContext( fs.cpSync(path.join(rootDir, "nemoclaw-blueprint"), path.join(buildCtx, "nemoclaw-blueprint"), { recursive: true, }); + normalizeReadModesForDockerCopy(path.join(buildCtx, "nemoclaw-blueprint")); fs.cpSync(path.join(rootDir, "scripts"), path.join(buildCtx, "scripts"), { recursive: true }); fs.rmSync(path.join(buildCtx, "nemoclaw", "node_modules"), { recursive: true, force: true }); @@ -92,6 +109,7 @@ function stageOptimizedSandboxBuildContext( recursive: true, }, ); + normalizeReadModesForDockerCopy(stagedBlueprintDir); fs.mkdirSync(stagedScriptsDir, { recursive: true }); fs.copyFileSync( @@ -154,6 +172,7 @@ function collectBuildContextStats( export { collectBuildContextStats, + normalizeReadModesForDockerCopy, stageLegacySandboxBuildContext, stageOptimizedSandboxBuildContext, }; diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 1e3e1d200a4..21b1a7d1d1d 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -8,11 +8,149 @@ import { describe, expect, it } from "vitest"; import { collectBuildContextStats, + normalizeReadModesForDockerCopy, stageLegacySandboxBuildContext, stageOptimizedSandboxBuildContext, } from "../dist/lib/sandbox/build-context"; describe("sandbox build context staging", () => { + function writeBuildContextFixture(sourceRoot: string) { + const blueprintManifestDir = path.join( + sourceRoot, + "nemoclaw-blueprint", + "model-specific-setup", + "openclaw", + ); + + function writeFixture(relativePath: string, content = "fixture\n", mode = 0o644) { + const target = path.join(sourceRoot, relativePath); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, { mode }); + fs.chmodSync(target, mode); + } + + writeFixture("Dockerfile"); + for (const fileName of [ + "package.json", + "package-lock.json", + "tsconfig.json", + "openclaw.plugin.json", + ]) { + writeFixture(path.join("nemoclaw", fileName), "{}\n"); + } + writeFixture(path.join("nemoclaw", "src", "index.ts")); + writeFixture(path.join("nemoclaw-blueprint", "blueprint.yaml")); + writeFixture(path.join("nemoclaw-blueprint", "policies", "openclaw-sandbox.yaml")); + writeFixture(path.join("nemoclaw-blueprint", "scripts", "http-proxy-fix.js")); + writeFixture( + path.join( + "nemoclaw-blueprint", + "openclaw-plugins", + "kimi-inference-compat", + "openclaw.plugin.json", + ), + "{}\n", + ); + writeFixture( + path.join("nemoclaw-blueprint", "openclaw-plugins", "kimi-inference-compat", "index.js"), + "fixture\n", + 0o600, + ); + writeFixture( + path.join( + "nemoclaw-blueprint", + "model-specific-setup", + "openclaw", + "kimi-k2.6-managed-inference.json", + ), + "{}\n", + 0o600, + ); + fs.chmodSync(path.join(sourceRoot, "nemoclaw-blueprint", "model-specific-setup"), 0o700); + fs.chmodSync(blueprintManifestDir, 0o700); + writeFixture(path.join("scripts", "nemoclaw-start.sh")); + writeFixture(path.join("scripts", "codex-acp-wrapper.sh")); + writeFixture(path.join("scripts", "lib", "sandbox-init.sh")); + writeFixture(path.join("scripts", "generate-openclaw-config.py")); + writeFixture(path.join("scripts", "seed-wechat-accounts.py")); + } + + function expectStagedBlueprintModes(buildCtx: string) { + const stagedBlueprint = path.join(buildCtx, "nemoclaw-blueprint"); + const stagedManifestDir = path.join(stagedBlueprint, "model-specific-setup", "openclaw"); + const stagedManifest = path.join(stagedManifestDir, "kimi-k2.6-managed-inference.json"); + const stagedPlugin = path.join( + stagedBlueprint, + "openclaw-plugins", + "kimi-inference-compat", + "index.js", + ); + + expect((fs.statSync(stagedManifestDir).mode & 0o777).toString(8)).toBe("755"); + expect((fs.statSync(stagedManifest).mode & 0o777).toString(8)).toBe("644"); + expect((fs.statSync(stagedPlugin).mode & 0o777).toString(8)).toBe("644"); + } + + it("normalizes copied blueprint modes with chmod a+rX semantics", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-unit-")); + const blueprintDir = path.join(tmpDir, "nemoclaw-blueprint"); + const manifestDir = path.join(blueprintDir, "model-specific-setup", "openclaw"); + const manifestPath = path.join(manifestDir, "kimi-k2.6-managed-inference.json"); + const executablePath = path.join(blueprintDir, "scripts", "helper.sh"); + const symlinkPath = path.join(blueprintDir, "manifest-link.json"); + + try { + fs.mkdirSync(manifestDir, { recursive: true }); + fs.mkdirSync(path.dirname(executablePath), { recursive: true }); + fs.writeFileSync(manifestPath, "{}\n", { mode: 0o600 }); + fs.writeFileSync(executablePath, "#!/bin/sh\n", { mode: 0o700 }); + fs.symlinkSync(manifestPath, symlinkPath); + fs.chmodSync(blueprintDir, 0o700); + fs.chmodSync(path.join(blueprintDir, "model-specific-setup"), 0o700); + fs.chmodSync(manifestDir, 0o700); + fs.chmodSync(manifestPath, 0o600); + fs.chmodSync(executablePath, 0o700); + + normalizeReadModesForDockerCopy(blueprintDir); + + expect((fs.statSync(blueprintDir).mode & 0o777).toString(8)).toBe("755"); + expect((fs.statSync(manifestDir).mode & 0o777).toString(8)).toBe("755"); + expect((fs.statSync(manifestPath).mode & 0o777).toString(8)).toBe("644"); + expect((fs.statSync(executablePath).mode & 0o777).toString(8)).toBe("755"); + expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("optimized staging makes copied blueprint manifests world-readable", () => { + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-source-")); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-mode-")); + + try { + writeBuildContextFixture(sourceRoot); + const { buildCtx } = stageOptimizedSandboxBuildContext(sourceRoot, tmpDir); + expectStagedBlueprintModes(buildCtx); + } finally { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("legacy staging makes copied blueprint manifests world-readable", () => { + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-source-")); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-legacy-mode-")); + + try { + writeBuildContextFixture(sourceRoot); + const { buildCtx } = stageLegacySandboxBuildContext(sourceRoot, tmpDir); + expectStagedBlueprintModes(buildCtx); + } finally { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("optimized staging excludes blueprint .venv and extra scripts while preserving required files", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-build-context-opt-")); diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index dd18082bdeb..d751ac2fe76 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -252,6 +252,35 @@ describe("sandbox provisioning: base runtime tools", () => { }); describe("sandbox provisioning: copied OpenClaw helper permissions (#2861)", () => { + it("normalizes copied blueprint permissions before non-root config generation", () => { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-blueprint-mode-")); + const blueprintRoot = path.join(tmp, "opt", "nemoclaw-blueprint"); + const manifestDir = path.join(blueprintRoot, "model-specific-setup", "openclaw"); + const manifestPath = path.join(manifestDir, "kimi-k2.6-managed-inference.json"); + + try { + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync(manifestPath, "{}\n", { mode: 0o600 }); + fs.chmodSync(path.join(blueprintRoot, "model-specific-setup"), 0o700); + fs.chmodSync(manifestDir, 0o700); + fs.chmodSync(manifestPath, 0o600); + + const command = dockerRunCommandBetween( + dockerfile, + "# Copy built plugin and blueprint", + "# Install runtime dependencies only", + ).replaceAll("/opt/nemoclaw-blueprint", blueprintRoot); + const { result } = runLoggedDockerShell(command, tmp); + + expect(result.status, result.stderr).toBe(0); + expect((fs.statSync(manifestDir).mode & 0o777).toString(8)).toBe("755"); + expect((fs.statSync(manifestPath).mode & 0o777).toString(8)).toBe("644"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("normalizes the config generator mode after Docker COPY preserves a restrictive source mode", () => { const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-helper-mode-"));