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
80 changes: 80 additions & 0 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
132 changes: 132 additions & 0 deletions test/nemoclaw-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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() { :; }',
Expand Down
Loading