migrate-from-v1 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited migrate-from-v1 (Agent Skill) and scored it 96/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 1 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 1 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
bash migrate-v2.sh already ran the deterministic migration. It handled:
store/auth and aliased into messaging_groupsYour job is the parts that need human judgment: triage any failed steps, seed the owner, clean up CLAUDE.local.md files, reconcile configs, and port any fork customizations.
Read logs/setup-migration/handoff.json first — it has overall_status, per-step results in steps, and a followups list.
Before anything else, check that logs/setup-migration/handoff.json exists. If it doesn't, the user is invoking this skill before migrate-v2.sh ran. Stop and tell them, verbatim:
This skill finishes a migration that migrate-v2.sh started. Run that first, in your terminal — not from inside Claude:>
``bash bash migrate-v2.sh ``>
It needs interactive prompts (channel selection, service switchover) and runs Node/pnpm bootstrap, Docker, OneCLI setup, and a container build that don't fit inside a Claude session. When it finishes, it'll hand control back to Claude automatically — at which point this skill picks up.
Do not attempt to run the script yourself, simulate its effects, or pick up the migration mid-stream. The deterministic side has dependencies on a real interactive shell.
Once handoff.json exists, proceed to Phase 0.
Before any deeper migration work, prove v2 actually answers messages on the user's real channels. v1 is paused, not touched — flipping back is a service restart.
Walk handoff.steps. Fix only the failures that would stop the bot from routing one message; defer the rest to its later phase.
Tell the user the switch is non-destructive (v1 is paused, not modified; reverting is one command). Help them stop v1's service unit and start v2's, tail the host log for a clean boot, and have them send a real test message. Use AskUserQuestion to confirm the bot responded.
If yes, continue to Phase 1. If no, diagnose from logs/nanoclaw.log and re-test — don't proceed to deeper work on a broken router.
Re-visit anything you skipped in 0a before declaring the migration done. Most surface naturally in later phases (1c-groups ↔ Phase 2, 1e-tasks ↔ task verification).
v2 auto-creates a users row for every sender it sees (via extractAndUpsertUser in src/modules/permissions/index.ts). By the time this skill runs, the owner's row likely already exists — it just needs the owner role granted.
User ID format: always <channel_type>:<platform_handle>. Each channel populates this differently:
telegram:<numeric_user_id> (e.g. telegram:6037840640)discord:<snowflake_user_id> (e.g. discord:123456789012345678)whatsapp:<phone>@s.whatsapp.net (e.g. whatsapp:[email protected])slack:<user_id> (e.g. slack:U04ABCDEF)<channel_type>:<platform_id>Steps:
users table: SELECT id, kind, display_name FROM users.AskUserQuestion: "Is <display_name> (<id>) you?" — Yes / No, let me type it.AskUserQuestion.user_roles via getUserRoles(userId). If an owner row already exists, skip. Otherwise grant it with grantRole. grantRole inserts a new row per call, so the getUserRoles check keeps this re-runnable.Use the DB helpers in src/modules/permissions/db/user-roles.ts (getUserRoles, grantRole). Init the DB first, then call the helpers:
import path from 'path';
import { initDb } from '../src/db/connection.js';
import { runMigrations } from '../src/db/migrations/index.js';
import { DATA_DIR } from '../src/config.js';
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
const db = initDb(path.join(DATA_DIR, 'v2.db'));
runMigrations(db); // idempotent
const userId = '<user_id>';
if (!getUserRoles(userId).some((r) => r.role === 'owner')) {
grantRole({
user_id: userId,
role: 'owner',
agent_group_id: null, // owner role must be global
granted_by: null,
granted_at: new Date().toISOString(),
});
}After seeding the owner, discuss the access policy. v2's messaging_groups.unknown_sender_policy controls who can interact with the bot. migrate-v2.sh set it to public so the bot would respond during the switchover test, but the user may want to tighten it.
Present the options via AskUserQuestion:
public, current) — anyone can message the bot. Good for personal DM bots.strict) — only users the access gate accepts (owner, admin, or agent_group_members) can trigger the bot. Others are silently dropped.request_approval) — unknown senders trigger an approval request to the owner. Good for group chats where you want to vet new members.The unknown_sender_policy column accepts exactly these three values; use the parenthesized value for <chosen_policy> below.
If the user picks option 2 or 3, seed the known users from v1's message history. The v1 database is at <handoff.v1_path>/store/messages.db. It has a messages table with sender and sender_name columns. For each group:
-- v1: unique senders per chat (excluding bot messages)
SELECT DISTINCT sender, sender_name
FROM messages
WHERE chat_jid = '<v1_jid>' AND is_from_me = 0 AND sender IS NOT NULLThe sender value is a platform handle (e.g. 6037840640 for Telegram). Build the v2 user ID by inferring the channel type from the chat JID prefix (use parseJid from setup/migrate-v2/shared.ts) and combining: <channel_type>:<sender>.
For each sender:
users(id, kind, display_name) if not already present.agent_group_members(user_id, agent_group_id) for each agent group wired to that messaging group.Show the user the list of senders being imported and let them deselect any they don't want.
Then update the messaging groups:
UPDATE messaging_groups SET unknown_sender_policy = '<chosen_policy>'
WHERE id IN (SELECT id FROM messaging_groups WHERE channel_type IN (<migrated_channels>))The migration copied v1's entire CLAUDE.md into CLAUDE.local.md for each group. This file now contains v1 boilerplate that v2 handles through its own composed fragments (container/CLAUDE.md + .claude-fragments/module-*.md). The user's customizations are buried inside.
For each group that has a CLAUDE.local.md:
is_main=1 in v1's registered_groups, the template was groups/main/CLAUDE.mdgroups/global/CLAUDE.mdhandoff.json → v1_pathcontainer/CLAUDE.md + module-core.mdcontainer/CLAUDE.mdcontainer/CLAUDE.mdcontainer/CLAUDE.mduser_roles, not is_main.claude-shared.md symlinkmodule-scheduling.mdmodule-scheduling.mdunknown_sender_policy + user_roles/workspace/group/ → /workspace/agent//workspace/project/ → these paths don't exist in v2; discuss with the user/workspace/ipc/ → gone; remove references/workspace/extra/ → v2 uses container.json additionalMounts; keep but note the path may change# Name heading and first paragraph (identity) — this is the user's agent personality.AskUserQuestion: "Here's what I'd keep — look right?" with options to approve, edit, or keep the original.If a CLAUDE.local.md has no user customizations (pure template copy), write a minimal file with just the identity heading.
migrate-v2.sh writes container.json directly from v1's container_config (the additionalMounts shape is identical). If the v1 config was unparseable, it falls back to a .v1-container-config.json sidecar.
For each group, check:
container.json exists, read it and verify the additionalMounts host paths are still valid on this machine. Flag any that don't exist..v1-container-config.json exists (parse failure fallback), read it, discuss with the user, and write a proper container.json. Then delete the sidecar.env or packages fields — env may overlap with OneCLI vault, packages (apt/npm) are portable.Check whether the user's v1 install was a customized fork.
cd <v1_path>
git remote -v
git log --oneline <upstream>/main..HEAD 2>/dev/nullIf no commits ahead of upstream: stock v1, skip this phase.
If there are commits:
AskUserQuestion: "How do you want to handle your v1 customizations?"container/skills/*, .claude/skills/*, docs/*. Grep each copied file for v1-only references that won't resolve in v2 and flag them to the user: workspace paths (/workspace/group/, /workspace/project/, /workspace/ipc/, /workspace/extra/), the v1 IPC mechanism, registered_groups / is_main, the v1 sender allowlist, and store/messages.db.docs/v1-fork-reference/ for later.src/*, container/agent-runner/src/*) is NOT portable — v2's architecture is fundamentally different. Stash to docs/v1-fork-reference/ with a README explaining what each file did. Don't translate.handoff.v1_path.... + last 4 characters).git status to recover state.The setup flow at setup/index.ts has individual steps you can invoke if something is missing or failed:
pnpm exec tsx setup/index.ts --step <name>| Step | When to use |
|---|---|
onecli | OneCLI not installed or not healthy |
auth | No Anthropic credential in vault |
container | Container image needs rebuild |
service | Service not installed or not running |
mounts | Mount allowlist missing |
verify | End-to-end health check (run after everything else) |
environment | System check (Node, dirs) |
pnpm exec tsx setup/index.ts --step verifylogs/setup-migration/handoff.json — offer to save as docs/migration-<date>.md first.nanoclaw-v2-<slug> / com.nanoclaw-v2-<slug>), so derive it from src/install-slug.ts rather than guessing: # Linux
UNIT=$(pnpm exec tsx -e "import{getSystemdUnit}from'./src/install-slug.js';console.log(getSystemdUnit())")
systemctl --user restart "$UNIT"
# macOS
LABEL=$(pnpm exec tsx -e "import{getLaunchdLabel}from'./src/install-slug.js';console.log(getLaunchdLabel())")
launchctl kickstart -k "gui/$(id -u)/$LABEL"~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.