diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 42d86d72197..1251b9eef55 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1472,6 +1472,79 @@ 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:-}" + 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 + 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 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 + 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 + 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 +1609,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 +1781,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..ebdd2410514 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -1394,6 +1394,137 @@ 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, + 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)} ${JSON.stringify(configPath)}`, + ].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), + `---\nsummary: "${name} template"\n---\n# ${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); + 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 }); + } + }); + + 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 +1708,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() { :; }',