From 11c13188219b4c77e59d5601eb9e3c07ac0d260e Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Sun, 10 May 2026 16:34:33 +0800 Subject: [PATCH 1/2] fix(sandbox): seed default workspace templates when bootstrap is skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NemoClaw sets `agents.defaults.skipBootstrap=true` in openclaw.json so the gateway's first agent turn answers the caller's prompt instead of running OpenClaw's interactive identity-setup conversation. The flag is binary in OpenClaw 2026.4.24, though: it short-circuits ensureAgentWorkspace() before any of the bundled workspace templates (SOUL.md, USER.md, IDENTITY.md, AGENTS.md, TOOLS.md, HEARTBEAT.md) are written, leaving /sandbox/.openclaw/workspace/ empty. Add a `seed_default_workspace_templates` helper that copies the same bundled templates (skipping BOOTSTRAP.md, which would re-trigger the interactive turn) into the workspace whenever the directory is pristine. Wire it into both entrypoint paths — directly in non-root mode and via `gosu sandbox` in root mode so the seeded files inherit sandbox:sandbox ownership. Fixes #3240 Signed-off-by: Jason Ma Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/nemoclaw-start.sh | 56 +++++++++++++++++++++ test/nemoclaw-start.test.ts | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 42d86d72197..51323574270 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1472,6 +1472,55 @@ migrate_legacy_layout() { echo "[migration] Completed ${label} layout migration (${data_dir} removed)" >&2 } + +# Seed default OpenClaw workspace template files when the workspace is +# pristine. OpenClaw normally writes these from bundled templates at first +# agent boot via ensureAgentWorkspace(), but when +# `agents.defaults.skipBootstrap=true` (set by NemoClaw to suppress the +# interactive identity-setup turn) that path short-circuits before any +# template is written, leaving /sandbox/.openclaw/workspace/ empty. +# Reuse OpenClaw's own bundled templates so seeded content matches what +# upstream would have produced. BOOTSTRAP.md is intentionally excluded — +# its presence is what triggers the interactive turn we are skipping. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/3240 +seed_default_workspace_templates() { + local workspace_dir="${1:-/sandbox/.openclaw/workspace}" + local templates_dir="${2:-}" + [ -e "$workspace_dir" ] || return 0 + if [ -L "$workspace_dir" ]; then + echo "[SECURITY] refusing to seed symlinked workspace dir: $workspace_dir" >&2 + return 0 + fi + [ -d "$workspace_dir" ] || return 0 + # Only seed pristine workspaces — never clobber user content. + if [ -n "$(ls -A "$workspace_dir" 2>/dev/null)" ]; then + return 0 + fi + if [ -z "$templates_dir" ]; then + local npm_root + npm_root="$(npm root -g 2>/dev/null)" || return 0 + [ -n "$npm_root" ] || return 0 + templates_dir="${npm_root}/openclaw/dist/docs/reference/templates" + fi + if [ ! -d "$templates_dir" ]; then + echo "[setup] openclaw templates dir not found at ${templates_dir}; skipping workspace seed" >&2 + return 0 + fi + local file src dst seeded=0 + for file in AGENTS.md SOUL.md IDENTITY.md USER.md TOOLS.md HEARTBEAT.md; do + src="$templates_dir/$file" + dst="$workspace_dir/$file" + if [ -f "$src" ] && [ ! -e "$dst" ]; then + if cp "$src" "$dst" 2>/dev/null; then + seeded=$((seeded + 1)) + fi + fi + done + if [ "$seeded" -gt 0 ]; then + echo "[setup] seeded ${seeded} default workspace template(s) into ${workspace_dir}" >&2 + fi +} + # ── Main ───────────────────────────────────────────────────────── # Migrate legacy symlink layout before anything else reads .openclaw @@ -1536,6 +1585,7 @@ if [ "$(id -u)" -ne 0 ]; then } fix_openclaw_ownership normalize_mutable_config_perms + seed_default_workspace_templates write_auth_profile harden_auth_profiles @@ -1707,6 +1757,12 @@ NODE } provision_agent_workspaces +# Seed default workspace templates if the default workspace is empty. +# Run as the sandbox user so the seeded files inherit sandbox:sandbox +# ownership (the function's own cp calls would otherwise produce +# root-owned files in this branch). See function comment for context. +gosu sandbox bash -c "$(declare -f seed_default_workspace_templates); seed_default_workspace_templates" + # Defence-in-depth: verify /tmp file permissions before launching services. # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 0f988b8a196..b4b07852b5c 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1394,6 +1394,103 @@ describe("NC-2227-01: legacy migration behavior", () => { }); }); +describe("seed_default_workspace_templates (#3240)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + function runSeed(workspaceDir: string, templatesDir: string, scriptPath: string) { + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + extractShellFunctionFromSource(src, "seed_default_workspace_templates"), + `seed_default_workspace_templates ${JSON.stringify(workspaceDir)} ${JSON.stringify(templatesDir)}`, + ].join("\n"), + { mode: 0o700 }, + ); + return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + } + + it("seeds the documented workspace templates and skips BOOTSTRAP.md", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seed-")); + const workspaceDir = path.join(tmpDir, "workspace"); + const templatesDir = path.join(tmpDir, "templates"); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(templatesDir, { recursive: true }); + for (const name of [ + "AGENTS.md", + "SOUL.md", + "IDENTITY.md", + "USER.md", + "TOOLS.md", + "HEARTBEAT.md", + "BOOTSTRAP.md", + ]) { + fs.writeFileSync(path.join(templatesDir, name), `# ${name} template content\n`); + } + try { + const result = runSeed(workspaceDir, templatesDir, path.join(tmpDir, "seed.sh")); + expect(result.status).toBe(0); + for (const name of [ + "AGENTS.md", + "SOUL.md", + "IDENTITY.md", + "USER.md", + "TOOLS.md", + "HEARTBEAT.md", + ]) { + expect(fs.existsSync(path.join(workspaceDir, name))).toBe(true); + } + // BOOTSTRAP.md must NOT be seeded — its presence triggers the + // interactive identity-setup turn that skipBootstrap=true is meant + // to suppress. + expect(fs.existsSync(path.join(workspaceDir, "BOOTSTRAP.md"))).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not clobber an already-populated workspace", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seed-existing-")); + const workspaceDir = path.join(tmpDir, "workspace"); + const templatesDir = path.join(tmpDir, "templates"); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync(path.join(workspaceDir, "USER.md"), "user content"); + fs.writeFileSync(path.join(templatesDir, "USER.md"), "template content"); + fs.writeFileSync(path.join(templatesDir, "SOUL.md"), "soul template"); + try { + const result = runSeed(workspaceDir, templatesDir, path.join(tmpDir, "seed.sh")); + expect(result.status).toBe(0); + expect(fs.readFileSync(path.join(workspaceDir, "USER.md"), "utf-8")).toBe("user content"); + // Workspace was non-empty, so no other templates were copied in. + expect(fs.existsSync(path.join(workspaceDir, "SOUL.md"))).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("refuses to seed a symlinked workspace dir", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seed-symlink-")); + const realDir = path.join(tmpDir, "real"); + const linkDir = path.join(tmpDir, "link"); + const templatesDir = path.join(tmpDir, "templates"); + fs.mkdirSync(realDir); + fs.mkdirSync(templatesDir); + fs.symlinkSync(realDir, linkDir); + fs.writeFileSync(path.join(templatesDir, "SOUL.md"), "soul template"); + try { + const result = runSeed(linkDir, templatesDir, path.join(tmpDir, "seed.sh")); + expect(result.status).toBe(0); + expect(result.stderr).toContain("refusing to seed symlinked workspace dir"); + expect(fs.existsSync(path.join(realDir, "SOUL.md"))).toBe(false); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + +}); + describe("Slack token rewriter (#2085)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); @@ -1577,6 +1674,7 @@ describe("Telegram diagnostics (#2766)", () => { 'verify_no_slack_secrets_on_disk() { :; }', 'write_auth_profile() { :; }', 'harden_auth_profiles() { :; }', + 'seed_default_workspace_templates() { :; }', 'chown() { :; }', 'chown_tree_no_symlink_follow() { :; }', 'start_persistent_gateway_log_mirror() { :; }', From 891e2cd3417cab663b66e910120359ca2f9a2f19 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 10 May 2026 21:01:35 -0700 Subject: [PATCH 2/2] fix(sandbox): harden workspace template seeding Signed-off-by: Aaron Erickson --- scripts/nemoclaw-start.sh | 28 ++++++++++++++++++++++++-- test/nemoclaw-start.test.ts | 40 ++++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 51323574270..1251b9eef55 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1486,6 +1486,23 @@ migrate_legacy_layout() { seed_default_workspace_templates() { local workspace_dir="${1:-/sandbox/.openclaw/workspace}" local templates_dir="${2:-}" + local config_file="${3:-/sandbox/.openclaw/openclaw.json}" + + if [ ! -f "$config_file" ]; then + return 0 + fi + if ! command -v node >/dev/null 2>&1; then + return 0 + fi + if ! node - "$config_file" <<'NODE' >/dev/null 2>&1; then +const fs = require("fs"); +const configPath = process.argv[2]; +const cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); +process.exit(cfg?.agents?.defaults?.skipBootstrap === true ? 0 : 1); +NODE + return 0 + fi + [ -e "$workspace_dir" ] || return 0 if [ -L "$workspace_dir" ]; then echo "[SECURITY] refusing to seed symlinked workspace dir: $workspace_dir" >&2 @@ -1506,13 +1523,20 @@ seed_default_workspace_templates() { echo "[setup] openclaw templates dir not found at ${templates_dir}; skipping workspace seed" >&2 return 0 fi - local file src dst seeded=0 + local file src dst tmp seeded=0 for file in AGENTS.md SOUL.md IDENTITY.md USER.md TOOLS.md HEARTBEAT.md; do src="$templates_dir/$file" dst="$workspace_dir/$file" if [ -f "$src" ] && [ ! -e "$dst" ]; then - if cp "$src" "$dst" 2>/dev/null; then + tmp="${dst}.tmp.$$" + if awk ' + NR == 1 && $0 == "---" { in_frontmatter = 1; next } + in_frontmatter && $0 == "---" { in_frontmatter = 0; next } + !in_frontmatter { print } + ' "$src" >"$tmp" 2>/dev/null && mv "$tmp" "$dst" 2>/dev/null; then seeded=$((seeded + 1)) + else + rm -f "$tmp" 2>/dev/null || true fi fi done diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index b4b07852b5c..ebdd2410514 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1397,14 +1397,24 @@ describe("NC-2227-01: legacy migration behavior", () => { describe("seed_default_workspace_templates (#3240)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - function runSeed(workspaceDir: string, templatesDir: string, scriptPath: string) { + function runSeed( + workspaceDir: string, + templatesDir: string, + scriptPath: string, + options: { skipBootstrap?: boolean } = {}, + ) { + const configPath = path.join(path.dirname(scriptPath), "openclaw.json"); + fs.writeFileSync( + configPath, + JSON.stringify({ agents: { defaults: { skipBootstrap: options.skipBootstrap ?? true } } }), + ); fs.writeFileSync( scriptPath, [ "#!/usr/bin/env bash", "set -euo pipefail", extractShellFunctionFromSource(src, "seed_default_workspace_templates"), - `seed_default_workspace_templates ${JSON.stringify(workspaceDir)} ${JSON.stringify(templatesDir)}`, + `seed_default_workspace_templates ${JSON.stringify(workspaceDir)} ${JSON.stringify(templatesDir)} ${JSON.stringify(configPath)}`, ].join("\n"), { mode: 0o700 }, ); @@ -1426,7 +1436,10 @@ describe("seed_default_workspace_templates (#3240)", () => { "HEARTBEAT.md", "BOOTSTRAP.md", ]) { - fs.writeFileSync(path.join(templatesDir, name), `# ${name} template content\n`); + fs.writeFileSync( + path.join(templatesDir, name), + `---\nsummary: "${name} template"\n---\n# ${name} template content\n`, + ); } try { const result = runSeed(workspaceDir, templatesDir, path.join(tmpDir, "seed.sh")); @@ -1445,6 +1458,27 @@ describe("seed_default_workspace_templates (#3240)", () => { // interactive identity-setup turn that skipBootstrap=true is meant // to suppress. expect(fs.existsSync(path.join(workspaceDir, "BOOTSTRAP.md"))).toBe(false); + expect(fs.readFileSync(path.join(workspaceDir, "SOUL.md"), "utf-8")).toBe( + "# SOUL.md template content\n", + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not seed unless OpenClaw bootstrap is explicitly skipped", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-seed-bootstrap-on-")); + const workspaceDir = path.join(tmpDir, "workspace"); + const templatesDir = path.join(tmpDir, "templates"); + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(templatesDir, { recursive: true }); + fs.writeFileSync(path.join(templatesDir, "SOUL.md"), "soul template"); + try { + const result = runSeed(workspaceDir, templatesDir, path.join(tmpDir, "seed.sh"), { + skipBootstrap: false, + }); + expect(result.status).toBe(0); + expect(fs.existsSync(path.join(workspaceDir, "SOUL.md"))).toBe(false); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }