diff --git a/.agents/skills/tinyclaw-admin/SKILL.md b/.agents/skills/tinyclaw-admin/SKILL.md index 55037101..2ded653c 100644 --- a/.agents/skills/tinyclaw-admin/SKILL.md +++ b/.agents/skills/tinyclaw-admin/SKILL.md @@ -174,11 +174,18 @@ curl -s http://localhost:3777/api/logs?limit=50 | jq ## Direct settings.json editing -When the API server is not running, edit `~/.tinyclaw/settings.json` directly. Use `jq` for safe atomic edits: +> **WARNING — agents should not edit settings.json directly.** Direct edits bypass all validation and can corrupt or destroy the running configuration. Agents running with `--dangerously-skip-permissions` have unrestricted file access, which makes an accidental overwrite unrecoverable. Always prefer the REST API (`PUT /api/agents`, `PUT /api/teams`, `PUT /api/settings`) which validates input, merges safely, and keeps the system consistent. +> +> Direct editing is a last resort for **human operators only** when the API server is not running and there is no other option. + +If a human operator must edit `~/.tinyclaw/settings.json` while the daemon is stopped, back up the file first and use `jq` for atomic edits: ```bash SETTINGS="$HOME/.tinyclaw/settings.json" +# Always back up first +cp "$SETTINGS" "$SETTINGS.bak" + # Add an agent jq --arg id "analyst" --argjson agent '{"name":"Analyst","provider":"anthropic","model":"sonnet","working_directory":"'$HOME'/tinyclaw-workspace/analyst"}' \ '.agents[$id] = $agent' "$SETTINGS" > "$SETTINGS.tmp" && mv "$SETTINGS.tmp" "$SETTINGS" @@ -193,6 +200,10 @@ jq --arg id "research" --argjson team '{"name":"Research Team","agents":["analys After editing `settings.json`, run `tinyclaw restart` to pick up changes. +## POST /api/setup is for initial setup only + +`POST /api/setup` **fully replaces** settings.json — it does not merge. It will be rejected with HTTP 409 if agents are already configured. There is no override. To modify a running system, use `PUT /api/agents/:id`, `PUT /api/teams/:id`, or `PUT /api/settings`. + ## Modifying TinyClaw source code When modifying TinyClaw's own code (features, bug fixes, new routes, etc.): diff --git a/lib/daemon.sh b/lib/daemon.sh index 11e1ea08..29d82776 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -373,8 +373,14 @@ status_daemon() { # --- Agent skills management (called by start_daemon) --- -# Ensure all agent workspaces have .agents/skills synced from SCRIPT_DIR -# and .claude/skills as a symlink to .agents/skills +# Ensure all agent workspaces have .agents/skills populated from SCRIPT_DIR +# and .claude/skills as a symlink to .agents/skills. +# +# Each built-in skill is installed as a symlink into the agent's .agents/skills/ +# directory rather than a copy. This means: +# - Built-in skills stay current without wiping agent-created skills. +# - Skills created by agents (not in the source) are left untouched. +# - Stale symlinks (skill removed from source) are cleaned up automatically. ensure_agent_skills_links() { local skills_src="$SCRIPT_DIR/.agents/skills" [ -d "$skills_src" ] || return 0 @@ -389,14 +395,38 @@ ensure_agent_skills_links() { local agent_dir="$agents_dir/$agent_id" [ -d "$agent_dir" ] || continue - # Sync default skills into .agents/skills mkdir -p "$agent_dir/.agents/skills" + + # Install each built-in skill as a symlink (absolute path). + # If a real directory exists with the same name (agent-created copy), + # leave it alone — don't overwrite agent work. for skill_dir in "$skills_src"/*/; do [ -d "$skill_dir" ] || continue local skill_name skill_name="$(basename "$skill_dir")" - rm -rf "$agent_dir/.agents/skills/$skill_name" - cp -r "$skill_dir" "$agent_dir/.agents/skills/$skill_name" + local dest="$agent_dir/.agents/skills/$skill_name" + + if [ -L "$dest" ]; then + # Already a symlink — update it to point to current source + ln -sfn "$skill_dir" "$dest" + elif [ -d "$dest" ]; then + # Real directory: agent-created skill, leave it alone + : + else + # Not present — create symlink + ln -s "$skill_dir" "$dest" + fi + done + + # Remove symlinks that point to skills no longer in source + # (built-in skill was deleted upstream). Real directories are kept. + for dest in "$agent_dir/.agents/skills"/*/; do + [ -L "${dest%/}" ] || continue + local skill_name + skill_name="$(basename "$dest")" + if [ ! -d "$skills_src/$skill_name" ]; then + rm "$agent_dir/.agents/skills/$skill_name" + fi done # Ensure .claude/skills is a symlink to ../.agents/skills diff --git a/packages/server/src/routes/settings.ts b/packages/server/src/routes/settings.ts index eb52ff46..11b6d20f 100644 --- a/packages/server/src/routes/settings.ts +++ b/packages/server/src/routes/settings.ts @@ -43,9 +43,24 @@ app.put('/api/settings', async (c) => { }); // POST /api/setup — run initial setup (write settings + create directories) +// Blocked if settings.json already exists with agents configured. +// To reconfigure a live system, use PUT /api/agents/:id, PUT /api/teams/:id, +// or PUT /api/settings instead. app.post('/api/setup', async (c) => { const settings = (await c.req.json()) as Settings; + // Guard: refuse to overwrite an existing configured installation. + // There is intentionally no bypass parameter — agents can discover and + // exploit query parameters. Use the individual PUT endpoints to modify + // a running configuration. + if (fs.existsSync(SETTINGS_FILE)) { + const existing = (() => { try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } catch { return null; } })(); + if (existing?.agents && Object.keys(existing.agents).length > 0) { + log('WARN', '[API] Setup blocked: settings.json already has agents configured.'); + return c.json({ ok: false, error: 'Settings already configured. Use PUT /api/agents, /api/teams, or /api/settings to modify a running system.' }, 409); + } + } + if (settings.workspace?.path) { settings.workspace.path = expandHomePath(settings.workspace.path); } @@ -57,8 +72,15 @@ app.post('/api/setup', async (c) => { } } - // Write settings.json + // Back up existing settings before overwriting fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); + if (fs.existsSync(SETTINGS_FILE)) { + const backupPath = `${SETTINGS_FILE}.bak`; + fs.copyFileSync(SETTINGS_FILE, backupPath); + log('INFO', `[API] Setup: backed up existing settings to ${backupPath}`); + } + + // Write settings.json fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + '\n'); log('INFO', '[API] Setup: settings.json written');