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
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/lib/sandbox/build-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ function createBuildContextDir(tmpDir: string = os.tmpdir()): string {
return fs.mkdtempSync(path.join(tmpDir, "nemoclaw-build-"));
}

function normalizeReadModesForDockerCopy(rootDir: string): void {
Comment thread
ChunkyMonkey11 marked this conversation as resolved.
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(),
Expand All @@ -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 });

Expand Down Expand Up @@ -92,6 +109,7 @@ function stageOptimizedSandboxBuildContext(
recursive: true,
},
);
normalizeReadModesForDockerCopy(stagedBlueprintDir);

fs.mkdirSync(stagedScriptsDir, { recursive: true });
fs.copyFileSync(
Expand Down Expand Up @@ -154,6 +172,7 @@ function collectBuildContextStats(

export {
collectBuildContextStats,
normalizeReadModesForDockerCopy,
stageLegacySandboxBuildContext,
stageOptimizedSandboxBuildContext,
};
138 changes: 138 additions & 0 deletions test/sandbox-build-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
29 changes: 29 additions & 0 deletions test/sandbox-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
Loading