
Backup
- 10 installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
Helps with ai & agent building tasks during AI-assisted development.
About
backup is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- backup
- AI & Agent Building
- AI-coding skill
Backup by the numbers
- 10 all-time installs (skills.sh)
- Ranked #11,959 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/official-skills --skill backupAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 10 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Backup — Snapshot Manager
One skill, three flows:
| Trigger | Flow | What happens |
|---|---|---|
/backup, "备份…", "做个快照", "back up my agent", "バックアップ", … | A. Create | pack current agent state → upload to sc-agent-backup |
/restore, "恢复…", "回滚到上次备份", "restore from backup", "復元", … | B. Restore | list → pick → download → apply each section (user-confirmed) |
/delete-backup, "删除备份…", "delete a backup", "バックアップ削除", … | C. Delete | list → pick → two user confirmations → DELETE /backups/{id} |
All flows talk to the same storage service (sc-agent-backup.internal) using the container's CONTAINER_JWT. Identity is derived from the JWT — you can never read, write, or delete another user's namespace.
Storage has a hard cap of 5 backups per user. Full uploads return a replace menu that the user picks from — never auto-pick the oldest.
Language policy
Every user-facing message you produce in these flows — prompts, summaries, confirmation questions, status updates, error explanations — MUST be in the user's conversation language. Detect the language from the user's most recent messages (fall back to user_settings.language if ambiguous, fall back to English if that's also unclear).
The prompt examples throughout this SKILL.md are written in Chinese or English for illustration only. Translate them. Keep the same structure and detail level, but render the prose in whatever the user is actually speaking.
This applies to the rules file too:
- Section headings (
## Mode,## Extra excludes,## Extra paths,## Label template,## Notes) stay in English — the parser depends on them. - Everything else (intro prose, comments inside code blocks, example text, the
Notessection, values like the label template) should match the user's language.
When you compose backup_rules.md after the first-time discussion (Flow A.1.0 case B.3), write the whole file in the user's language — only the five section headings stay English.
If a user's language preference isn't obvious from context and user_settings.language is missing, ask once: "What language should I use?" and stick with that.
---
Operational rules (read before touching any flow)
Rule 1 — Bash variables do NOT persist across tool calls
Every bash tool invocation starts a fresh shell. Variables set in one call (WORK=$(mktemp -d ...)) are empty in the next. Passing `"$WORK/api"` to `pack.py` in a later tool call expands to `"/api"` and pack.py fails.
Two safe patterns:
(a) One bash call for the whole scratchpad setup:
WORK=$(mktemp -d /tmp/backup-XXXXXX)
mkdir -p "$WORK/api"
echo "WORKDIR: $WORK" # print the literal path so you can copy-paste it into later calls
# ... write the three API JSONs to $WORK/api/ in this SAME bash call if possible ...(b) Use a literal, deterministic path you remember verbatim:
# In call 1:
WORK=/tmp/backup-20260427T164500Z
mkdir -p "$WORK/api"
# In call 2 (different shell — $WORK is now empty, so use the literal string):
python3 skills/backup/scripts/pack.py \
--api-dir /tmp/backup-20260427T164500Z/api \
--out /tmp/backup-20260427T164500Z/bundle.tar.gz \
--label "..."Pattern (b) is the robust choice when writing JSONs with your native file-write tool (which doesn't share a shell with bash at all).
If pack.py exits with ERROR: --api-dir does not exist, this is almost certainly the cause.
Rule 2 — On any script failure, STOP the flow and surface the error
When pack.py, upload.py, download.py, restore.py, delete.py, list.py, propose_rules.py, ensure_rules.py, or any other script in these flows exits non-zero or errors unexpectedly:
1. STOP the flow immediately. Do not try the next step. Do not retry silently. 2. Surface the exact stderr to the user (at least the first 10 lines), in their conversation language. Include the script name and exit code. 3. State explicitly that you stopped. "备份失败了,我已经停下,没做进一步操作。下面是错误:" 4. Wait for the user to direct you. Do not loop on "trying again" unless the user asks.
Anti-pattern to avoid: replying with generic acknowledgements like "了解", "ok", "understood", "明白" after a failure. If the last action failed and the user hasn't given you a fix, you don't have new work to acknowledge — stating the failure once and waiting is the correct behavior.
This applies even when the user's follow-up messages are unrelated. If the user asks an unrelated question after a backup failure, answer that question AND remember the backup is still in a failed state — don't pretend it's still progressing.
Rule 3 — Install paths
The backup skill installs to /data/workspace/skills/backup/. Script paths in all examples below should be interpreted as relative to the agent's working directory (`/data/workspace/`):
python3 skills/backup/scripts/pack.py ...
python3 skills/backup/scripts/upload.py ...
# etc.If you want to be defensive about cwd, use full absolute paths:
python3 /data/workspace/skills/backup/scripts/pack.py ...The examples in this doc use the short form. Don't prepend sc-backup-service/ — that's the upstream project name, not part of the install path.
Rule 4 — HARD BAN: do not edit skill code files
Any file under /data/workspace/skills/backup/ is read-only from your perspective as an agent. You must not invoke Edit, Write, bash-level sed/tee/redirect-overwrite, or any other mutation tool against:
skills/backup/SKILL.mdskills/backup/scripts/pack.pyskills/backup/scripts/propose_rules.pyskills/backup/scripts/upload.py/download.py/restore.py/list.py/delete.py/ensure_rules.py
Even if you believe you've identified a bug or a missing exclusion, the fix never goes in these files. Those paths are the universal skill contract; a local edit silently diverges this agent from every other Starchild agent using the skill.
All backup-behavior adjustments go in ONE place: /data/workspace/config/backup_rules.md. This file is yours (the user's copy) to shape freely.
The canonical wrong-vs-right pattern
When you notice a backup is too large because some path is getting bundled:
| Wrong | Right |
|---|---|
Edit skills/backup/scripts/pack.py to add .cache to DEFAULT_SKIP_DATA_TOP | Edit /data/workspace/config/backup_rules.md to add .cache under ## Extra excludes |
| Argue "it's a universal cache so I'll fix the skill for everyone" | Add to this agent's Extra excludes and move on. If it's truly universal, it's a design conversation for upstream — not a one-container patch |
Run sed -i or redirect-write into a scripts/*.py file | Never. Those files are tagged read-only by convention. |
Why this matters
1. Reproducibility. Another agent re-installing from backup.zip would have the old blacklist; your patch is silently invisible outside this container. 2. Upgrade safety. Next skill update re-extracts the zip and clobbers your local mutations. You'd lose the fix and not know why. 3. Shared-baseline trust. The skill docs, error messages, and behaviors all assume pack.py's blacklist matches what's documented. A locally-mutated pack.py makes debugging harder.
What belongs where
| Layer | What belongs here | Agent's relationship |
|---|---|---|
skill code (skills/backup/**) | Noise that is noise for EVERY agent by convention (logs/, __pycache__/, .npm-cache/, ChromaDB-derived indexes, .backup/.restore scratch) | Read-only. Invoke as-is. |
| `/data/workspace/config/backup_rules.md` | Preferences specific to this agent — anything that varies between containers (.local/, .cache/, sessions/, workspace/output/, …) | Read + write freely. |
If you catch yourself about to edit a skill file, stop and redirect the same change into `backup_rules.md`'s `Extra excludes` (or `Extra paths` for additions).
The first-time discussion flow (A.1.0 case B) is explicitly designed to catch user-specific exclusions up front so they land in the rules file from the start. If users keep coming back reporting unexpected items in backups, that's a signal to improve B.2's proactive flagging (e.g. add more name heuristics in the SKILL.md instructions to the agent) — not to patch the blacklist.
---
Bundle layout (shared by both flows)
backup/
manifest.json # bundle-internal self-description (v1.1)
# + contents.files[{path,size,sha256}]
# + contents.api[{path,size,sha256}]
# + exclusions[] (what got skipped and why)
files/
workspace/... # mirror of /data/workspace/ (minus skipped)
data/... # mirror of /data/* non-workspace (minus skipped)
api/
profile.json ← agent_profile(action="get")
settings.json ← user_settings(action="get")
scheduled_tasks.json ← scheduled_task list, normalized for register()What's IN by default: everything under /data/, including .env secrets plaintext and sessions/state.db + WAL.
What's OUT by default (blacklist — see pack.py for exact list):
| Kind | Why skipped |
|---|---|
/data/logs/, *.log files | logs, regenerate |
/data/memory/ (ChromaDB, FTS indexes, embedding cache) | fully derivable from workspace/memory/** |
/data/.npm-cache/ | cache |
/data/.bash_processes/, .startup-tasks/, hibernation_state.json | transient, resets on container restart |
workspace/.backup/, workspace/.restore/ | our own scratch (self-loop guard; skipped even in --mode full) |
workspace/.active-upload.json, workspace/.restore.log | transient flow markers |
__pycache__/, *.pyc anywhere | Python compile caches |
Note /data/workspace/config/backup_rules.md (user's persistent preferences, see Flow A.1.0) IS included by default — it lives under workspace/config/ like any other user file. Restoring a bundle restores the user's rules too, so their backup workflow stays consistent across restores.
--mode full disables all those skips (except the two self-loop items). Use it when you need a bit-for-bit dump, e.g. debugging or migrating to a bigger volume.
Integrity is layered:
- Storage side:
{backup_id}.manifest.jsonhas the whole-bundle sha256, returned asX-Sha256onGET /backups/{id}. - Bundle side:
manifest.jsonhas per-file{path, size, sha256}undercontents.files+contents.api. Restore rehashes every one before applying.
- `.env` contains plaintext secrets (API keys, tokens). The bundle is
tenant-scoped — only the user's own JWT can read it from storage. But treat the bundle itself as sensitive: don't copy it outside storage, don't share backup_id across accounts. AEAD encryption at the client is a future opt-in (see design doc §11).
The storage-side manifest (<backup_id>.manifest.json, kept by the server) is a separate file used for listing and quota accounting. Don't confuse the two.
---
Step 0 (BOTH FLOWS) — /workspace/.restore.log check
Before either flow does anything, inspect /workspace/.restore.log. This file exists iff a previous restore started applying sections and never finished (agent crash, container restart, user walked away). The agent is in a half-restored state and the right reaction differs by flow.
if [ -f /workspace/.restore.log ]; then
cat /workspace/.restore.log
fiEach line of the log is a JSON event:
{"ts": "...", "backup_id": "bk_...", "section": "memory", "status": "ok"}If the log exists…
Flow A (Create) → refuse:
检测到上次 restore 未完成({backup_id},已应用{N}/{M}section)。
现在备份会把这个半恢复状态存下来,请先 /restore 继续或放弃之前的恢复。Do not proceed to Step A.1.
Flow B (Restore) → ask the user:
检测到上次 restore 未完成:{backup_id}已应用{N}/{M}section({sections})。
>
1. 继续恢复 — 从下一个 section 接着跑(推荐,若进度可信)
2. 放弃并重新开始 — 清除 .restore.log,重走 Step B.13. 取消 — 保持当前半恢复状态,以后再说
Flow C (Delete) → proceed but warn: Deletion is orthogonal to a half-restore (deleting a backup doesn't touch live agent state). You MAY continue to Flow C, but the first thing you say to the user should be:
注意:上次 restore 未完成({backup_id})。删除备份不会影响当前半恢复状态,但建议先 /restore 把状态理清楚。要继续删除吗?If they want to proceed with delete, continue with Flow C below.
Never auto-pick. Never silently clear the log.
Log-write contract (only written during Flow B)
During Flow B §4b, append one line per section after it succeeds:
echo "{\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"backup_id\":\"$BID\",\"section\":\"memory\",\"status\":\"ok\"}" \
>> /workspace/.restore.logOn full success (all sections done + §4c cleanup), delete the file:
rm -f /workspace/.restore.logMissing file = "no restore in progress". File present = "something was interrupted". The whole contract is just those two states.
---
Flow A — Create a backup
Flow A has four phases. You MUST NOT skip from A.1 straight to A.3. The user has to see the plan and approve the scope before any tar.gz is built.
A.1 Inspect — load rules, gather API state, dry-run the pack plan
This phase produces the plan without building a bundle yet. Three sub-steps.
A.1.0 Load (or bootstrap) user's backup rules file
User's persistent preferences live at /data/workspace/config/backup_rules.md. It records their default mode, extra exclusions, extra paths, label template, and self-notes. This file is the glue between "pack.py stock defaults" and "what this specific user wants every time".
Two cases:
Case A: the rules file ALREADY exists
Parse it (see format below) and continue to A.1.1. Zero friction.
Case B: first-ever backup — discover, discuss, author
If the file is missing, do NOT silently drop in a generic template. Instead, scan the live /data/ tree, present a tailored proposal, and build the rules file from the user's decisions.
B.1 Run the classifier:
python3 skills/backup/scripts/propose_rules.pyThe script walks /data/workspace/ and /data/ top-level entries, tags each with one of four categories, and emits JSON. With the conservative default, all four categories matter for presentation but only `skip` changes behavior (only skip items get dropped; core + ask + unknown all get included):
| Category | Default action | How to present |
|---|---|---|
core | include | list under "✓ will be backed up" |
skip | skip | list under "✗ will be skipped (logs + runtime caches)" |
ask | include | list under "✓ will be backed up" with ⚠ marker + a one-line note (lets user opt out if they want) |
unknown | include | list under "✓ will be backed up" with a nudge ("not on my known list — tell me if you didn't mean to back this up") |
Each item carries {name, size_bytes, file_count, category, reason} so the presentation in B.2 can be concrete.
B.2 Present the conservative default: back up everything except logs and runtime caches. Show the user both lists (what gets included, what gets skipped) so they have full visibility. Then proactively point at specific items to exclude before asking the open-ended "anything else?" question. Don't make the user spot the 420MB .local/ themselves.
Pre-presentation processing (do this before rendering):
1. Sort each section by size descending. Biggest-first is more useful than alphabetical — .user-packages/ 420 MB at the top gets attention.
2. Identify "likely-skip" candidates using size + name heuristics, and surface them as ⚠ items with an explicit "consider excluding" note. Heuristics (apply to items the stock blacklist didn't already catch):
| Signal | Criterion | Recommendation |
|---|---|---|
| Size ≥ 50 MB | Any item | "⚠ this is big — do you need the history, or can we skip?" |
| Size ≥ 20 MB AND unrecognized | unknown category | "⚠ unknown + big — safest to skip unless you know what it is" |
Name matches .cache*, *_cache*, *.tmp*, .tmp, .thumbnails, .pip*, .pnpm*, .yarn* (case-insensitive, any depth in name) | Any size | "⚠ name suggests this is a cache; probably safe to skip" |
Name = .local | Any size | "⚠ can be anything — pipx installs, app data, pip-user. If you don't know what's here, probably safe to skip (setup.sh should reinstall binaries)" |
Name = sessions | Size ≥ 10 MB | "⚠ chat history is big — if you don't need old conversations, you can skip it" |
3. Show the recommendations as an explicit pre-filled list, not as per-item "should I skip this?" questions. The user can then confirm the list, remove some, or add more.
Below is a Chinese-language example — render the same structure in the user's conversation language. Keep the ✓ / ✗ / ⚠ markers (unambiguous symbols); translate the prose.
这是你第一次备份。默认策略:**除了日志和运行时缓存,其他全部带走**(最保守)。
下面是扫描结果(按大小倒序),我标了几个建议排除的:
✓ 会备份(core + ask + unknown 全都包含):
data/.user-packages/ 420 MB ⚠ 大(pip/npm --user 包)— 建议排除,setup.sh 能装回
data/.local/ 66 MB ⚠ 通常是 pipx / 应用 state — 建议排除(name 看着像 cache,不确定就别带)
data/.cache/ 25 MB ⚠ name 就是 cache — 建议排除
data/sessions/ 5.1 MB 对话历史 + WAL
data/scheduled_jobs.db 4 MB 任务执行历史
workspace/output/ 240 KB, 3 reports
workspace/memory/ 3.2 KB, 10 files
data/tasks.json 12 KB subagent 运行记录
workspace/tasks/ 12 KB, 5 jobs
workspace/my-custom-dir/ 12 KB ⚠ 我没见过这个,你创的吗?
data/preview_history.json 8 entries
workspace/config/ 2.1 KB agent.yaml 等
workspace/prompt/ 1.2 KB SOUL.md / USER.md
workspace/skills/ (.skill-lock.json)
workspace/setup.sh 412 B
workspace/.env 245 B ⚠ 原文 secrets
data/scheduled_jobs.json 5 entries
data/previews.json 2 active
data/some-mystery-file.dat 4 B ⚠ 我没见过的文件
✗ 会跳过(日志和运行时缓存 — pack.py 内置黑名单):
data/logs/ 36 MB
data/memory/ 10 MB ChromaDB + FTS(从 workspace/memory 重建即可)
data/.npm-cache/ 2.4 MB npm 缓存
data/.bash_processes, .startup-tasks/, hibernation_state.json (运行时瞬态)
workspace/.backup, workspace/.restore (skill 自己的 scratch)
---
我的建议:把下面这几项也加进跳过(都是上面带 ⚠ 的大/cache 嫌疑项):
- data/.user-packages/ (420 MB, pip 包)
- data/.local/ (66 MB, 通常是 pipx)
- data/.cache/ (25 MB, name = cache)
这样备份会从 ~700 MB 降到 ~8 MB。
回答两件事就行:
1. 上面这 3 条排除建议,你同意几条?("全部接受" / "只排除 .cache" / "都不排除,全要" / 自定义)
2. label 模板?(默认 "自动备份 {date}")Handling "unknown" items: the heuristic flags them but doesn't demand per-item answer — just surface them with one-line nudges. If the user doesn't mention them, they get backed up (conservative default).
Why proactive suggestions, not blacklist changes? The Skill vs rules boundary (Rule 4 above) — these items being big/cache-like is this agent's workload, not a universal truth. Another Starchild agent might have empty .local/ and no .cache/. Keep the universal defaults narrow; push per-agent decisions into backup_rules.md.
Why conservative in the rendering of recommendations? Phrase them as "my suggestion" / "建议排除", not as mandates. If user says "no, keep .local", respect it. They might know something you don't (e.g. they manually pip-installed some binary not in setup.sh).
B.3 Once you have the user's decisions, compose `backup_rules.md`. The file has two purposes:
1. Parsed configuration — five required headings the agent reads on subsequent backups 2. Living record — a human-readable snapshot of what was discussed, so three months from now the user can look at the file and remember WHY they made each choice
Compose it from:
- `## Mode` (parsed) = always
default(the blacklist embedded in pack.py matches "logs + runtime caches" exactly) - `## Extra excludes` (parsed) = paths the user asked to drop in B.2 Q1 (relative to
/data/; may be empty) - `## Extra paths` (parsed) = empty on first run (user didn't mention anything outside
/data/) - `## Label template` (parsed) = their answer to B.2 Q2, or the localized default
- `## Notes` (not parsed) = the discussion date + any context the user mentioned (why they keep certain things, reminders, preferences)
- `## File inventory` (not parsed, extra section) = a point-in-time snapshot from the
propose_rules.pyoutput, rendered as a decision table: what was on disk, what gets backed up, what gets skipped, and why
The inventory section is crucial. Do not generate a rules file without it. It's the difference between a useful lived-in document and an empty stub.
Write it to /data/workspace/config/backup_rules.md. Example below is in Chinese — translate prose, comments, labels, the inventory narrative into the user's language; keep the parsed `##` headings in English verbatim. Also render sizes/counts naturally in the user's language (e.g. "3.2 KB / 10 文件" / "10 ファイル" / "10 files"):
# Backup rules — 你的备份偏好
`/backup` 执行时 agent 会先读这个文件应用设置。直接编辑保存生效。
想恢复默认?删掉这个文件,下次 `/backup` 会重建。
## Mode
default
## Extra excludes
你在首次讨论时决定不备份的路径,每行一个,相对 /data/。
当前你选了"全都要",所以这里是空的。
以后想排除什么,直接在这里加,例如:
workspace/output
data/.user-packages
## Extra paths
/data/ 外想额外打包的绝对路径。首次讨论时你没提。
示例:
/home/myuser/some-config
## Label template
自动备份 {date}
## Notes
首次备份日期:2026-04-27
讨论时选择的策略:保守默认(除了日志和运行时缓存,其他全部打包)。
- my-custom-dir/ 是我自己创的笔记目录,一定要带上
- .user-packages 虽然 420MB 挺大,但我不确定 setup.sh 装得全,先留着
- 以后想瘦身再回来改
---
## File inventory (首次备份时的 /data/ 快照,2026-04-27)
这是**首次备份时**扫描到的 /data/ 内容和每项的处理方式。不是配置项——
agent 不会读这段。纯粹是给你自己看的"当时我是怎么想的"记录。
新增或改动目录后,这个表会过时——如果你想让 rules 始终反映最新结构,
请手动更新这段(或让 agent 帮你重新扫描)。
### workspace/
| 状态 | 路径 | 大小 | 备注 |
|---|---|---|---|
| ✓ | memory/ | 3.2 KB, 10 files | agent 记忆 |
| ✓ | prompt/ | 1.2 KB, 3 files | SOUL.md / USER.md / AGENTS.md |
| ✓ | config/ | 2.1 KB, 2 files | agent.yaml + backup_rules.md(就是这个文件) |
| ✓ | tasks/ | 12 KB, 5 jobs | 定时任务脚本 |
| ✓ | skills/ | — | .skill-lock.json(装的 skill 清单) |
| ✓ | setup.sh | 412 B | 启动钩子 |
| ✓ | .env | 245 B | ⚠ 原文 secrets (API keys) |
| ✓ | output/ | 240 KB, 3 reports | agent 生成的报告;保留 |
| ✓ | scripts/ | (空) | 将来可能用得到 |
| ✓ | my-custom-dir/ | 12 KB | 我的笔记目录 — 一定要带上 |
### data/ (non-workspace)
| 状态 | 路径 | 大小 | 备注 |
|---|---|---|---|
| ✓ | sessions/ | 5.1 MB | 对话历史(state.db + WAL) |
| ✓ | scheduled_jobs.json | 5 entries | 任务注册表 |
| ✓ | scheduled_jobs.db | 4 MB | 任务执行历史 |
| ✓ | preview_history.json | 8 entries | preview 服务历史 |
| ✓ | previews.json | 2 active | 活跃 preview |
| ✓ | tasks.json | 12 KB | subagent 运行记录 |
| ✓ | .user-packages/ | 420 MB | ⚠ 大;pip/npm --user 包 |
| ✓ | .local/ | 4.6 MB | pipx / 应用 state |
| ✓ | some-mystery-file.dat | 4 B | 不认识的文件;用户确认要留 |
| ✗ | logs/ | 36 MB | 日志(pack.py 自动跳过) |
| ✗ | memory/ | 10 MB | ChromaDB(可从 workspace/memory 重建,自动跳过) |
| ✗ | .npm-cache/ | 2.4 MB | npm 缓存(自动跳过) |
| ✗ | .bash_processes, .startup-tasks/, hibernation_state.json | — | 运行时瞬态(自动跳过) |Tailor the example to the actual propose_rules.py output for this user — don't copy the example paths literally. Every ✓/✗ row should come from the JSON, with the correct size, category, and a short reason in the user's language.
After writing, tell the user: "我把规则写进了 /data/workspace/config/backup_rules.md,里面也记录了这次讨论的完整文件清单和每项的处理方式。以后想调整偏好直接编辑这个文件;如果增加了新的目录想让它反映出来,告诉我帮你重扫描就行。" Then continue to A.1.1.
Fallback: "just use a generic template"
If the user says "don't ask me, just use the defaults" during B.2, skip the discussion and write the stock template instead:
python3 skills/backup/scripts/ensure_rules.py
# prints CREATED /data/workspace/config/backup_rules.mdThe template ensure_rules.py writes is in English (neutral baseline). If the user's conversation language isn't English, immediately offer: "I wrote the rules file in English as a baseline — want me to translate the prose and comments into {user's language}? (Section headings stay in English either way; the parser needs them.)" If they accept, rewrite the file in their language using your native file-write tool. If they pass, continue.
Format spec (for Case A parsing)
The markdown has five required fixed headings; extract values by section:
| Heading | Value | Becomes |
|---|---|---|
## Mode | single word (default or full) | --mode |
## Extra excludes | fenced code block; one path per line; # comments ignored | zero or more --extra-exclude |
## Extra paths | fenced code block; one absolute path per line; # comments ignored | zero or more --extra-path |
## Label template | fenced code block; single line with optional {date} placeholder | default --label (replace {date} with today's UTC date YYYY-MM-DD) |
## Notes | free-form text | ignore (user-private notes, not instructions) |
Additional headings like ## File inventory (common on rules files composed via B.3) are also ignored. Only the five above are parsed. This lets users add sections freely without breaking the parser.
Parsing rules:
- Ignore markdown around the headings (intro paragraphs, HTML comments, horizontal rules).
- Trim whitespace on each extracted line.
- Skip lines starting with
#inside fenced code blocks — those are user-commented examples. - If a section is absent or all its lines are commented out, treat it as empty.
- If the file is unparseable (e.g. user broke the headings), report to the user
无法解析 backup_rules.md,请检查格式或删除文件让我重建。(in user's language) and stop.
Values from this file become the starting point for the plan. The user can still override any of them in A.1.3's conversation.
A.1.1 API state read (agent tool calls) + write to a per-run workdir:
agent_profile(action="get") → 4 fields: {agentName, agentVibe, agentEmoji, agentCreature}
user_settings(action="get") → pick {language, timezone, what_to_call, agent_*}
scheduled_task(action="list") → normalize each task to {title, schedule, description, channels}
(strip runtime fields: last_run, status, error_log)Allocate one workdir per backup attempt (mktemp gives process-unique path, so concurrent /backup calls don't collide):
WORK=$(mktemp -d /tmp/backup-XXXXXX)
mkdir -p "$WORK/api"
# write the three JSONs to $WORK/api/:
# $WORK/api/profile.json
# $WORK/api/settings.json
# $WORK/api/scheduled_tasks.jsonRemember $WORK — every subsequent step uses it.
A.1.2 Dry-run pack to preview the plan (with rules applied):
Build the pack.py command from the rules you loaded in A.1.0. Example — if the rules file has Mode: default, Extra excludes: [workspace/output, sessions], Label template: 自动备份 {date}:
python3 skills/backup/scripts/pack.py \
--api-dir "$WORK/api" --out "$WORK/bundle.tar.gz" \
--mode default \
--extra-exclude workspace/output \
--extra-exclude sessions \
--label "自动备份 2026-04-27" \
--dry-runThe script walks /data/, applies the mode's blacklist + your --extra-exclude list, and prints a JSON plan to stdout with items[] and their status: ok | absent | excluded_*. This is zero-cost: no files copied, no network I/O.
A.1.3 STOP — show the plan, wait for user
Render the pack plan as a table to the user. Keep it compact, and surface any defaults you pulled from `backup_rules.md` so they know what's being carried over:
打包清单(mode=default,等待你确认):
📋 从 backup_rules.md 读取的默认:
label = "自动备份 2026-04-27"
额外 exclude: workspace/output, sessions
额外 include: (无)
✓ workspace/memory/ 3.2 KB (MEMORY.md + daily/ + topics/)
✓ workspace/prompt/ 850 B (USER.md + SOUL.md)
✓ workspace/config/ 2.1 KB (agent.yaml, backup_rules.md)
✓ workspace/tasks/ 12 KB (5 jobs, run.py + data)
✓ workspace/skills/.skill-lock.json 11 installed skills
✓ workspace/setup.sh 412 B
✓ workspace/.env 3 vars ⚠️ 原文 secrets
✓ data/scheduled_jobs.json 5 entries
✓ data/scheduled_jobs.db 4 MB (执行历史)
✓ data/preview_history.json 8 entries
✓ data/previews.json 2 active
✓ data/tasks.json 12 KB
✓ data/.user-packages/ 420 MB ⚠️ 大
跳过(默认 blacklist + rules 新增):
✗ data/logs/ 日志
✗ data/memory/ ChromaDB + FTS 索引(可从 workspace/memory 重建)
✗ data/.npm-cache/ npm 缓存
✗ data/.bash_processes, .startup-tasks/, hibernation_state.json (运行时状态)
✗ workspace/output/ ← rules.md 里 extra-exclude
✗ data/sessions/ ← rules.md 里 extra-exclude
✗ workspace/.backup, workspace/.restore (本 skill 自身 scratch,self-loop 保护)
---
请回答(按你 rules.md 里的默认做就直接说 "ok"):
1. 标签 (label)? (当前默认 "自动备份 2026-04-27")
2. mode? (当前默认 default)
3. 要调整的 exclude / include? (当前从 rules.md 读了 2 项)
关于偏好和本次选择的关系:
backup_rules.md是持久偏好——每次/backup的出发点。- A.1.3 的回答是本次一次性覆盖——只影响当前备份,不动 rules 文件。
- 用户想永久改变规则?告诉他去编辑
backup_rules.md,不是在对话里每次重说。
Do not proceed to A.2 until the user confirms. "就按 rules.md 来" is a valid answer (means: use all rules-file defaults as-is).
A.2 Pack the bundle
Once the user confirms, invoke the deterministic packer against the same $WORK dir you created in A.1.1:
python3 skills/backup/scripts/pack.py \
--label "升级前" \
--api-dir "$WORK/api" \
--mode default \
--out "$WORK/bundle.tar.gz"Optional flags for power-user overrides (typically empty):
--mode full— bypass the blacklist, pack everything (exceptworkspace/.backup/.restoreself-loops)--extra-exclude <rel>— skip one more path, relative to/data/(e.g.sessionsorworkspace/output). Repeat for multiple.--extra-path <abs>— include one more path outside/data/. Repeat for multiple.
The script: 1. Walks /data/workspace/* and /data/*, applies the mode's skip rules + user overrides, copies everything else into backup/files/workspace/** and backup/files/data/**. __pycache__ and *.pyc are pruned universally. 2. Reads $WORK/api/*.json into backup/api/**. 3. Computes sha256 of every bundled file and writes backup/manifest.json v1.1 with {version, source, mode, created_at, label, sections, contents.files[{path,size,sha256}], contents.api[{path,size,sha256}], exclusions[], plan_summary}. 4. tar czf the whole thing, prints {bundle_path, size_bytes, manifest} as JSON.
The exclusions[] array in the manifest records every path that was skipped and the reason (excluded_self_loop, excluded_user_rule, excluded_default_rule). Restore-time readers / auditors can use it to explain what the bundle does and doesn't contain.
A.3 Upload
python3 skills/backup/scripts/upload.py \
"$WORK/bundle.tar.gz" \
--label "升级前"Bundles < 10 MB go through one-shot POST /backups. Bundles ≥ 10 MB go through the resumable protocol (reserve → 10 MB chunks → auto-finalize). In both cases the script streams from disk — server and client RAM stay flat regardless of bundle size.
If the agent container restarts mid-upload and the state file /workspace/.active-upload.json is intact, the next invocation resumes at the server's current offset automatically. Sessions expire after 1 hour of inactivity. (Note: $WORK itself lives in /tmp/ and is wiped on container restart, so a mid-upload restart means the bundle is gone and resume will fail gracefully — the agent should repack and retry fresh.)
A.4 Handle the response
Exit codes (non-zero is always a real failure — branch on stdout content, not on exit code alone):
| Exit | Stdout signal | Meaning | What to do |
|---|---|---|---|
0 | JSON with "backup_id" | Uploaded. Also has size_bytes, sha256, remaining_slots. | Tell the user it's done; mention remaining slots. |
0 | JSON with "error": "quota_exceeded" + "current": [...] | Quota full (5/5). | Ask the user which to replace — never pick for them. Then re-run with --replace <backup_id>. |
1 | ERROR: ... on stderr | Network / auth / size / protocol failure. | Surface it; retry is usually safe (the script already does one jittered retry on transport errors). |
Example replace:
python3 skills/backup/scripts/upload.py \
"$WORK/bundle.tar.gz" --label "升级前" \
--replace bk_20260320_091500_a7f3A.5 Clean up
rm -rf "$WORK"One recursive delete removes api/ scratch, bundle.tar.gz, and any staging artefacts — all of this run's intermediate state lives under the single $WORK dir by convention. (If the agent lost track of $WORK, everything is under /tmp/backup-*/ and container restart will wipe it anyway.)
---
Flow B — Restore from a backup
┌──────────────────────────────────────────────────────────────┐
│ 0. CHECK → /workspace/.restore.log (see Step 0 above) │
│ 1. LIST → show characteristics of every backup │
│ 2. PICK → user chooses one by number │
│ 3. DOWNLOAD → pull into the agent container, verify sha256 │
│ 4. RESTORE → apply each component to its original target │
└──────────────────────────────────────────────────────────────┘Invariants (non-negotiable):
- No auto-pick (user picks the backup by number).
- No auto-apply (every section confirmed by user before tool call).
- Every API section writes a
.restore.logline on success.
Where each component goes
| Kind | Bundle path | Original target | How to restore |
|---|---|---|---|
| Agent memory (main) | files/workspace/memory/MEMORY.md | /data/workspace/memory/MEMORY.md | filesystem copy (via restore.py) |
| Daily memory | files/workspace/memory/daily/*.md | /data/workspace/memory/daily/ | filesystem copy |
| Topic memory | files/workspace/memory/topics/{slug}/ | /data/workspace/memory/topics/ | filesystem copy |
| User profile + memory | files/workspace/prompt/USER.md | /data/workspace/prompt/USER.md | filesystem copy |
| SOUL.md | files/workspace/prompt/SOUL.md | /data/workspace/prompt/SOUL.md | filesystem copy |
| Agent-level config | files/workspace/config/agent.yaml | /data/workspace/config/agent.yaml | filesystem copy |
| Custom LLM routes | files/workspace/config/custom_models.yaml | /data/workspace/config/custom_models.yaml | filesystem copy |
| Task scripts | files/workspace/tasks/{job_id}/* | /data/workspace/tasks/{job_id}/ | filesystem copy |
| Startup hook | files/workspace/setup.sh | /data/workspace/setup.sh | filesystem copy |
| Skill registry | files/workspace/skills/.skill-lock.json | /data/workspace/skills/.skill-lock.json | filesystem copy + npx skills install |
| Secrets | files/workspace/.env | /data/workspace/.env | filesystem copy (plaintext) |
| Scheduler registry | files/data/scheduled_jobs.json | /data/scheduled_jobs.json | filesystem copy (⚠ see B.4b) |
| Preview state | files/data/preview*.json | /data/preview*.json | filesystem copy |
| Opt-in large files | files/data/sessions/state.db etc. | /data/** | filesystem copy (user-picked at backup time) |
| Profile | api/profile.json | agent_profile API | agent_profile(action="update", ...) |
| User settings | api/settings.json | user_settings API | user_settings(action="update", ...) |
| Scheduled tasks | api/scheduled_tasks.json | scheduled_task API | register + ensure run.py from files/ + activate |
restore.py automates all file rows in one pass (both files/workspace/** and files/data/**). The three api/*.json rows are applied by you using Starchild native tools, one section at a time with user confirmation. This asymmetry is intentional — file overwrites are reversible from a diff; API state writes are not, so they deserve a heavier prompt.
B.1 List available backups
python3 skills/backup/scripts/list.pyPrints a numbered menu:
您的备份(3 / 5,按时间倒序):
[1] bk_20260424_143000_a7f3
标签: 升级前
时间: 2026-04-24 14:30 UTC (1 天前)
大小: 12.3 MB
内容: memory, tasks, soul, files
[2] bk_20260418_091500_b2c1
...Surface the menu to the user verbatim (or reformat only if you keep every field). Label / date / age / size / sections are the backup's "characteristics" and the user picks based on them.
Exit codes:
0→ request succeeded. stdout is either the pick menu or"(无备份)".- If the user has zero backups, tell them to run
/backupfirst — no/restoreis possible yet. - Otherwise ask the user to pick a number.
1→ network/auth failure. Surface stderr.
B.2 User picks one
Ask explicitly:
"请选择要恢复的备份编号(1–N),或输入 cancel 取消。"Do not pick for them. Do not default to [1]. If they reply cancel or anything ambiguous, stop and ask again. Map their number back to the backup_id from the list output, then continue.
B.3 Download into the agent container
python3 skills/backup/scripts/download.py <backup_id>download.py: 1. Streams GET /backups/{backup_id} from sc-agent-backup.internal using CONTAINER_JWT (client memory stays bounded). 2. Verifies whole-bundle sha256 against the storage's X-Sha256 header while streaming. Mismatch → exit 1, delete tmp. 3. Validates the tar (rejects absolute paths or .. members). 4. Extracts into `/data/workspace/.restore/{backup_id}/` — hidden dot-dir, per-backup_id isolated so two concurrent restores of different ids don't collide, and retrying the same id just re-extracts in place. 5. Reads manifest.json and verifies every listed file's sha256 + size (pre-apply integrity, defense-in-depth vs the whole-bundle hash). Mismatch → list bad/missing/extra files and exit 1. 6. Prints a human summary grouped by section (workspace/memory, workspace/prompt, …, api).
Show the summary to the user. This is the deep characteristics view — once the bundle is on disk and integrity-verified, you know exactly what's inside, not just the storage-side metadata from Step B.1.
B.4 Restore to original paths
B.4a File-based components (all of files/**) — automated
Always start with a dry-run:
python3 skills/backup/scripts/restore.py --backup-id <backup_id>The script first re-verifies the per-file manifest hashes (second pass after download.py's check — time passed between download and apply; we catch any tampering that happened in between), then walks files/workspace/** → /data/workspace/** and files/data/** → /data/**. Plan output:
Pre-apply integrity check: OK (18 files verified)
Restore plan (workspace=/data/workspace, data=/data)
new : 12 file(s)
unchanged : 3 file(s) (will skip)
modified : 2 file(s) (will overwrite with --force)
NEW:
+ [workspace] /data/workspace/tasks/btc-alert/run.py (1834 B)
+ [workspace] /data/workspace/.env (245 B)
+ [data] /data/scheduled_jobs.json (412 B)
MODIFIED (existing content differs):
~ [workspace] /data/workspace/prompt/SOUL.md
--- /data/workspace/prompt/SOUL.md
+++ .../files/workspace/prompt/SOUL.md
@@ -3,2 +3,3 @@
- Be concise
+- Be opinionated, back with dataIf the pre-apply hash check fails (ERROR: pre-apply integrity check FAILED), do not retry with `--skip-verify`. Delete /data/workspace/.restore/{backup_id}/, re-run download.py, and try again. If fresh download also fails, the bundle on storage is corrupt — use Flow C to delete it.
Walk the user through the diff for each modified file. Only after they approve:
# new files only (safe, no overwrites)
python3 skills/backup/scripts/restore.py --backup-id <backup_id> --apply
# new files AND overwrite modified files (user must have approved each)
python3 skills/backup/scripts/restore.py --backup-id <backup_id> --apply --forceIf the user wants to keep some local changes, manually copy only the subset they approve and skip --force.
B.4b API-based components — per-section, per-user-confirmation
Read each file from /data/workspace/.restore/{backup_id}/api/ and apply via native tools. Confirm each section with the user before calling the API. Order matters — restore settings first because timezone/language affect formatting downstream.
After each section succeeds, append a line to /workspace/.restore.log (see Step 0).
.restore/{backup_id}/api/settings.json ──▶ user_settings(action="update", settings=...)
.restore/{backup_id}/api/profile.json ──▶ agent_profile(action="update", profile=...)
.restore/{backup_id}/api/scheduled_tasks.json ──▶ for each task:
scheduled_task(action="register", ...)
(run.py already restored via B.4a)
scheduled_task(action="activate", job_id=...)- Memory is NOT re-applied via the API. The real source of truth is
files/workspace/memory/**(MEMORY.md, daily/, topics/) — already copied by B.4a. Callingmemory(action="add")for each entry would duplicate whatever's already in those files. - Skip existing tasks. Call
scheduled_task(action="list")first; skip any task with a matching title. - scheduled_jobs.json reconciliation. B.4a puts the old registry on disk. When the agent calls
scheduled_task(action="register")for tasks fromapi/scheduled_tasks.json, the scheduler deduplicates by title. If anything in the JSON doesn't match a live task, it's orphan — safe to leave alone.
B.4c Cleanup
rm -rf /data/workspace/.restore/{backup_id}/
rm -f /workspace/.restore.logBoth deletions matter. The per-backup_id extract dir is no longer needed; the restore log signals that everything applied successfully and future sessions don't need to prompt about it.
---
Flow C — Delete a backup
Deletion is permanent — the storage rms both {id}.tar.gz and {id}.manifest.json (see storage §4.5). There is no undo. That's why Flow C is gated behind two distinct user confirmations plus a script-level tripwire.
┌───────────────────────────────────────────────────────────────┐
│ 0. CHECK → /workspace/.restore.log (warn, then proceed) │
│ 1. LIST → reuse list.py, show menu │
│ 2. PICK → user picks the one to delete │
│ 3. CONFIRM #1 → show metadata, require "yes" / "delete" │
│ 4. CONFIRM #2 → user types the full backup_id verbatim │
│ 5. EXECUTE → delete.py {id} --confirm {id} │
│ 6. REPORT → show what got deleted │
└───────────────────────────────────────────────────────────────┘Invariants (non-negotiable):
- No auto-pick (user picks by number from C.1's menu).
- Two confirmations, not one. Confirm #1 is a yes/no on the picked item. Confirm #2 is the user retyping the full
backup_id— this protects against them nodding "yes" to the wrong pick. - If Confirm #2's typed string differs from the target
backup_idby even a single character, abort. Don't auto-correct. - Never call
delete.pybefore both confirms pass.
C.1 List
python3 skills/backup/scripts/list.pySame output as Flow B.1. Surface to the user verbatim. If the list is empty (stdout "(无备份)"), there's nothing to delete — stop and say so.
C.2 User picks
Ask explicitly:
"请选择要删除的备份编号(1–N),或输入 cancel 取消。"Do not pick for them. Map their number back to backup_id from the list output.
C.3 Confirm #1 — show the target, ask yes/no
Render the full metadata of the chosen backup. The example below is in Chinese — translate the prose to the user's language; structural labels like backup_id, label, created, size, sections stay verbatim:
即将删除:
backup_id : bk_20260424_143000_a7f3
label : 升级前
created : 2026-04-24 14:30 UTC (1 天前)
size : 12.3 MB
sections : memory, tasks, soul, files
⚠ 删除后**无法恢复**。服务端会同时清除 bundle 和 manifest,
下次 list 就看不到了。
继续删除吗? (yes / no)Acceptable affirmative inputs (in any language): "yes", "y", "delete", "confirm", plus the equivalent words in the user's language (e.g., Chinese 删除 / 确认, Japanese はい / 削除, Spanish sí, etc.). Anything else — including "maybe", "sure?", silence, or an unrelated message — abort with a language-appropriate "cancelled" message.
C.4 Confirm #2 — user types the full backup_id
Prompt the user to retype the backup_id verbatim. Translate the prose, keep the id literal:
最后一步确认:请把 backup_id 完整输入以确认删除
bk_20260424_143000_a7f3
输入:Wait for the user's next message. Trim surrounding whitespace only. Compare byte-for-byte:
- Exact match → proceed to C.5.
- Different by any character (typo, truncation, wrong id pasted) → abort with "输入的 ID 与目标不匹配,取消删除"。Do NOT attempt to guess what they meant.
- User wrote something else entirely (e.g. "yes", "confirm") → abort. Flow C requires the literal id string, not another "yes".
This second confirm exists specifically to catch: "I said yes but I actually wanted to delete a different one" and "I scroll-clicked the wrong number in C.2".
C.5 Execute
python3 skills/backup/scripts/delete.py \
bk_20260424_143000_a7f3 \
--confirm bk_20260424_143000_a7f3Both arguments MUST be the same string — the script's --confirm tripwire exits 2 on mismatch. This prevents a future agent refactor from accidentally calling delete.py without the user double-confirm.
C.6 Report
The script prints a JSON object to stdout on success:
{
"deleted_backup_id": "bk_20260424_143000_a7f3",
"user_label": "升级前",
"created_at": 1714000000,
"size_bytes": 12345678,
"sections": ["memory", "tasks", "soul", "files"]
}Tell the user:
已删除 bk_20260424_143000_a7f3(label: "升级前", 12.3 MB)。剩余 {N}/5 份备份。Exit codes:
| Exit | Meaning | What to do |
|---|---|---|
0 | Deleted. stdout has the metadata. | Confirm to user, mention remaining slots. |
1 | Network / auth / 404 / "vanished between list and delete". | Surface stderr; re-list to see current state. |
2 | --confirm mismatch tripwire — should never happen if you followed Flow C. | Do not retry with auto-filled --confirm. Go back to C.4. |
---
Example walkthrough (restore)
User: /restore
Agent: (Step 0: /workspace/.restore.log missing → proceed)
(runs list.py, shows the menu)
您的备份(3 / 5):
[1] bk_20260424_... 标签: 升级前
[2] bk_20260418_... 标签: 周备份
[3] bk_20260410_... 标签: (无)
请选择编号。
User: 1
Agent: (runs download.py bk_20260424_143000_a7f3)
Downloaded 12.3 MB, sha256 verified.
Bundle contents:
agent memory : 42 entries
user memory : 18 entries
identity : name, vibe, emoji
soul.md : 23 lines
user settings: name, timezone, language
tasks : 3 tasks
files : 7 files
(runs restore.py dry-run)
Restore plan:
new : 4 file(s)
unchanged : 1 file(s)
modified : 2 file(s) ← prompt/SOUL.md, ...
以下文件将被覆盖:
~ prompt/SOUL.md
[diff]
是否覆盖? (yes / no / only new)
User: only new
Agent: (runs restore.py --apply without --force)
Applied: wrote=4 skipped_modified=2 errors=0
API state to restore next: settings, profile, 42+18 memory, 3 tasks.
是否先恢复 settings? (yes / skip)
... (continues per-section, each appending to /workspace/.restore.log) ...
Agent: rm -rf /data/workspace/.restore/bk_20260424_143000_a7f3/ ✓
rm -f /workspace/.restore.log ✓
Restore complete.---
Guarantees
- Tenant isolation: both flows derive
user_idfrom theCONTAINER_JWT. Storage paths are/data/backups/{user_id}/...and/data/sessions/{user_id}/..., structurally unable to reach another user's namespace. - Bundle confidentiality: bundles contain
.envplaintext (by design, so restore can rebuild third-party integrations without making the user remember every API key). Only the JWT's ownuserInfoIDcan download a bundle — no cross-tenant read, no public URL. A stolenCONTAINER_JWT, however, would expose the secrets; treat bundle files moved outside storage as sensitive. - Network: scripts only talk to
http://sc-agent-backup.internal:8080. The storage itself rejects any non-fdaa::/16peer, so a misconfigured URL cannot leak to the public internet. - Atomicity (upload):
--replaceswaps via POSIX rename. A crash mid-write leaves the old backup intact and a harmless.tmpfile the storage reaps after 1 hour. - Resumability (upload): bundles ≥ 10 MB use chunked upload; an agent restart can resume at the server's current offset via
/workspace/.active-upload.json. Sessions expire after 1 h idle. - Sha256 end-to-end, two layers: (1) server computes whole-bundle sha256 on upload; client verifies it on download via
X-Sha256. (2) pack.py writes per-file sha256 intomanifest.contents; download.py and restore.py both rehash every listed file, so corruption introduced anywhere along tar → disk → extract → sit-on-disk → apply gets caught before any write to live workspace/data. - Path safety (restore):
download.pyrejects tar members with absolute paths or..;restore.pyonly writes under/data/workspace/(orWORKSPACEenv var). - Overwrite protection:
restore.pydefaults to dry-run, refuses to overwrite modified files without--force. - Ask-don't-decide: quota full → user picks which to replace; restore plan → user approves each section; half-restore on entry → user chooses continue / abandon / cancel.
---
Don'ts
- ❌ Don't edit any file under `skills/backup/` — see Rule 4. Backup behavior tweaks go in
/data/workspace/config/backup_rules.md, never inpack.py/propose_rules.py/SKILL.md/ any script. If you catch yourself about to invokeEdit/Write/sedon a skill file, stop. - ❌ Don't re-pack and re-upload in the same turn without user approval.
- ❌ Don't skip A.1.3 — the user has to see the plan and confirm before pack.
- ❌ Don't hit the public hostname. Everything goes through
.internal. - ❌ Don't back up a half-restored agent (Step 0 blocks this).
- ❌ Don't auto-pick which backup to replace, restore, or delete.
- ❌ Don't auto-apply API sections on restore — every one needs a user "yes".
- ❌ Don't skip either of Flow C's two confirmations. Two separate turns, not one combined "yes and I confirm id=X" — the whole point is to catch wrong-pick + wrong-intent separately.
- ❌ Don't auto-fill
--confirmfrom the targetbackup_idwithout running the user through C.3 and C.4 first. The tripwire is there to catch exactly that shortcut. - ❌ Don't
rm -f /workspace/.restore.logwithout the user's explicit "abandon" / normal completion. The file is a safety marker. - ⚠️ The bundle includes `.env` plaintext. Bundles are tenant-scoped in
storage (only the user's own JWT can download), but a bundle file pulled out of storage is still plaintext secrets. Don't copy backup_id / bundle file to other accounts, other projects, or public channels. If you need zero-knowledge storage, use client-side AEAD (future work).
---
Error handling
| Symptom | Likely cause | Fix |
|---|---|---|
upload.py exit 0, stdout has "error": "quota_exceeded" | Quota full (5/5) | Ask user to pick one to replace; re-run with --replace |
ensure_rules.py exit 1, "could not write" | /data/workspace/config/ not writable | Unusual — likely permission / disk full. Don't block the backup; continue with stock defaults and tell user the rules file couldn't be created |
propose_rules.py exit 1, "workspace or data dir does not exist" | Running outside a Starchild container / wrong WORKSPACE_DIR | Don't proceed to backup — the filesystem shape is wrong. Surface the error |
backup_rules.md unparseable (heading renamed, broken code block) | User mangled the file | Don't guess. Surface the parse issue to the user and offer to mv the bad file aside and regenerate the template |
upload.py exit 1, "cannot reach backup storage" | 6PN / DNS blip | Retry (script already does one backoff retry); if still failing, relaunch container |
upload.py exit 1, "unauthorized" | JWT expired or wrong key | Container JWT issue — relaunch the machine |
upload.py exit 1, "too large" | Bundle > 500 MB | Trim files/ and repack |
upload.py: "session expired on server mid-transfer" | Idle > 1 h during chunked upload | Just run the script again (start over) |
list.py exit 0, stdout "(无备份)" | User has no backups yet | Tell them to run /backup first |
list.py exit 1 | Network / auth / non-JSON reply | Surface stderr; relaunch if JWT expired |
download.py: 404 | backup_id typo, or cross-tenant (storage replies same 404) | Re-list, pick again |
download.py: whole-bundle sha256 mismatch | Transport corruption, or storage-side disk bit-rot | Retry download once; if still failing the server copy is bad — use Flow C to delete the corrupt backup |
download.py: per-file contents verification failed | Tar corruption, or bundle was produced by a buggy packer | List the specific bad/missing/extra files. Same remediation: re-download, then delete if persistent |
restore.py: pre-apply integrity check FAILED | Extract dir was modified after download (edit, cp over, etc.) | Delete /data/workspace/.restore/{backup_id}/, re-download, retry. Don't use --skip-verify outside of debugging |
download.py: 403 | Not running on Fly 6PN | Restore must happen inside the Starchild Fly machine |
restore.py: ERROR cannot write | Permission / disk full | Investigate; don't force |
restore.py: skipped_modified > 0 | Local changes differ from backup | Expected. Show each diff and let user decide per file |
delete.py exit 2 | --confirm didn't match backup_id | Agent skipped C.4 or auto-filled --confirm. Go back to C.4 and get the user to retype the id literally |
delete.py exit 1, "backup not found" | Already deleted, or cross-tenant 404 | Re-list via list.py; the backup is already gone |
delete.py exit 1, "vanished between list and delete" | Rare race — another session deleted the same id first | Re-list to confirm current state; the user's target is gone either way |
delete.py exit 1, unauthorized / forbidden | JWT expired / not on 6PN | Same remediation as upload/download variants above |
#!/usr/bin/env python3
"""Delete a backup from sc-agent-backup storage.
Usage:
python3 delete.py <backup_id> --confirm <backup_id>
Both positional `<backup_id>` and the `--confirm` value must match EXACTLY.
This is a tripwire, not a substitute for user confirmation — the SKILL.md
Flow C requires the agent to obtain TWO distinct user confirmations before
calling this script at all (see SKILL.md §C.3 and §C.4).
Before issuing DELETE, the script calls `GET /backups` to fetch the target
backup's metadata (label / created_at / size / sections). It prints that
metadata to stdout as JSON so the agent can tell the user what was deleted.
Environment:
CONTAINER_JWT Required.
BACKUP_STORAGE_URL Optional. Defaults to http://sc-agent-backup.internal:8080.
Exit codes:
0 backup deleted (server returned 204). stdout has the deleted backup's
metadata as JSON.
1 network / auth / 404 / other HTTP failure. Error on stderr.
2 --confirm value did NOT match backup_id (tripwire triggered —
the agent likely skipped the double-confirm flow).
"""
from __future__ import annotations
import argparse
import json
import os
import random
import re
import sys
import time
import urllib.error
import urllib.request
DEFAULT_STORAGE = "http://sc-agent-backup.internal:8080"
TIMEOUT = 30
RETRY_BASE_DELAY = 1.0
RETRY_JITTER = 2.0
_BACKUP_ID_RE = re.compile(r"^bk_\d{8}_\d{6}_[a-z0-9]{4}$")
def _storage_url() -> str:
return os.environ.get("BACKUP_STORAGE_URL", DEFAULT_STORAGE).rstrip("/")
def _jwt() -> str:
token = os.environ.get("CONTAINER_JWT", "").strip()
if not token:
print("ERROR: CONTAINER_JWT env var is not set.", file=sys.stderr)
sys.exit(1)
return token
class _NetworkError(Exception):
"""Transient transport failure worth one jittered retry."""
def _with_retry(fn):
last_err: _NetworkError | None = None
for attempt in range(2):
try:
return fn()
except _NetworkError as e:
last_err = e
if attempt == 0:
delay = RETRY_BASE_DELAY + random.uniform(0, RETRY_JITTER)
print(
f"WARN: transient network error ({e}); "
f"retrying once in {delay:.1f}s...",
file=sys.stderr,
)
time.sleep(delay)
print(
"ERROR: cannot reach backup storage over Fly internal network. "
"This script must run inside a Fly machine.",
file=sys.stderr,
)
print(f"DETAIL: {last_err}", file=sys.stderr)
sys.exit(1)
def _fetch_backup_meta(backup_id: str) -> dict | None:
"""GET /backups, return the entry matching backup_id (or None).
Using list-then-match instead of a direct HEAD/GET on /backups/{id}:
listing already filters by JWT tenant, so we get the metadata + a
no-match acts as the 'already gone' signal without an extra round trip.
"""
def _do():
req = urllib.request.Request(
f"{_storage_url()}/backups",
headers={"Authorization": f"Bearer {_jwt()}"},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as e:
return e.code, e.read()
except (urllib.error.URLError, OSError) as e:
raise _NetworkError(str(e)) from e
status, body = _with_retry(_do)
if status != 200:
try:
err = json.loads(body.decode("utf-8"))
except Exception:
err = {"raw": body.decode("utf-8", errors="replace")}
if status == 401:
print(f"ERROR: unauthorized — {err.get('message', 'bad JWT')}",
file=sys.stderr)
elif status == 403:
print("ERROR: forbidden — must run from Fly 6PN", file=sys.stderr)
else:
print(f"ERROR: list failed HTTP {status} — {err}", file=sys.stderr)
sys.exit(1)
try:
data = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
print("ERROR: storage returned non-JSON for /backups", file=sys.stderr)
sys.exit(1)
for b in data.get("backups", []):
if b.get("backup_id") == backup_id:
return b
return None
def _do_delete(backup_id: str) -> int:
"""Issue DELETE /backups/{id}. Returns HTTP status code."""
def _do():
req = urllib.request.Request(
f"{_storage_url()}/backups/{backup_id}",
headers={"Authorization": f"Bearer {_jwt()}"},
method="DELETE",
)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
return resp.status
except urllib.error.HTTPError as e:
return e.code
except (urllib.error.URLError, OSError) as e:
raise _NetworkError(str(e)) from e
return _with_retry(_do)
def main() -> None:
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__,
)
parser.add_argument("backup_id", type=str,
help="the backup_id to delete")
parser.add_argument(
"--confirm", type=str, required=True,
help="MUST equal backup_id exactly — tripwire against the agent "
"skipping the SKILL.md Flow C double-confirmation. Callers "
"that pass `--confirm` without first getting two distinct "
"user acknowledgements are violating the skill contract.",
)
args = parser.parse_args()
# ── Pre-flight: id format ─────────────────────────────────────────────
if not _BACKUP_ID_RE.match(args.backup_id):
print(f"ERROR: malformed backup_id: {args.backup_id!r} "
f"(expected bk_YYYYMMDD_HHMMSS_xxxx)", file=sys.stderr)
sys.exit(1)
# ── Tripwire: --confirm must match exactly ────────────────────────────
if args.confirm != args.backup_id:
print(
f"ERROR: --confirm value ({args.confirm!r}) does not match "
f"backup_id ({args.backup_id!r}).\n"
"This tripwire guards against accidental deletes. Per SKILL.md "
"Flow C, the agent must obtain TWO user confirmations before "
"invoking delete.py, and pass the backup_id as --confirm only "
"once the user has typed it back verbatim (Step C.4).",
file=sys.stderr,
)
sys.exit(2)
# ── Fetch metadata up-front so we can echo what got deleted ───────────
meta = _fetch_backup_meta(args.backup_id)
if meta is None:
# Same response server-side returns 404 for cross-tenant and
# genuinely missing — we treat both the same.
print(
f"ERROR: backup not found (already deleted, or does not belong "
f"to this user): {args.backup_id}",
file=sys.stderr,
)
sys.exit(1)
# ── Issue the DELETE ─────────────────────────────────────────────────
status = _do_delete(args.backup_id)
if status == 204:
result = {
"deleted_backup_id": meta.get("backup_id"),
"user_label": meta.get("user_label"),
"created_at": meta.get("created_at"),
"size_bytes": meta.get("size_bytes"),
"sections": meta.get("sections"),
}
print(json.dumps(result, ensure_ascii=False))
sys.exit(0)
if status == 404:
# Race: someone else deleted it between our list and delete. Still a
# success from the user's point of view (the backup is gone). But
# we report it as an error so the flow is transparent.
print(
"ERROR: backup vanished between list and delete (race). "
"Re-list to confirm it's gone.", file=sys.stderr,
)
sys.exit(1)
if status == 401:
print("ERROR: unauthorized — CONTAINER_JWT invalid or expired",
file=sys.stderr)
elif status == 403:
print("ERROR: forbidden — must run from Fly 6PN", file=sys.stderr)
else:
print(f"ERROR: delete failed HTTP {status}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Download a backup from sc-agent-backup and extract it into
/data/workspace/.restore/{backup_id}/.
Usage:
python3 download.py <backup_id>
Environment:
CONTAINER_JWT Required.
BACKUP_STORAGE_URL Optional. Defaults to http://sc-agent-backup.internal:8080.
WORKSPACE_DIR Optional. Defaults to /data/workspace.
Three layers of integrity protection (belt + suspenders + airbag):
1. X-Sha256 header: server returns the whole-bundle sha256; client streams
the body to tmp while hashing, rejects on mismatch (catches transport /
storage corruption).
2. Tar member path audit: absolute paths and ".." are refused before extract.
3. Per-file sha256 in manifest.contents: after extract, every file listed in
manifest is rehashed and compared (catches tar format corruption, an
edited/mismatched manifest, or post-extract tampering between download
and apply).
Extraction lands under /data/workspace/.restore/{backup_id}/ so two
concurrent restores of different backups never stomp each other, and
retrying the same backup just re-extracts in place.
Exit codes:
0 bundle downloaded, extracted, and integrity-verified. Prints a
human summary to stdout.
1 network / auth / bundle-corruption / manifest-mismatch. stderr has
a detailed message.
"""
from __future__ import annotations
import hashlib
import json
import os
import random
import re
import shutil
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
DEFAULT_STORAGE = "http://sc-agent-backup.internal:8080"
MAX_SIZE = 500 * 1024 * 1024
DOWNLOAD_TIMEOUT = 600 # seconds; room for 500MB over slow links
READ_CHUNK = 64 * 1024
RETRY_BASE_DELAY = 1.0
RETRY_JITTER = 2.0 # jittered retry to avoid thundering-herd on recovery
WORKSPACE = Path(os.environ.get("WORKSPACE_DIR", "/data/workspace"))
RESTORE_ROOT = WORKSPACE / ".restore"
_BACKUP_ID_RE = re.compile(r"^bk_\d{8}_\d{6}_[a-z0-9]{4}$")
def _storage_url() -> str:
return os.environ.get("BACKUP_STORAGE_URL", DEFAULT_STORAGE).rstrip("/")
def _jwt() -> str:
token = os.environ.get("CONTAINER_JWT", "").strip()
if not token:
print("ERROR: CONTAINER_JWT env var is not set.", file=sys.stderr)
sys.exit(1)
return token
class _NetworkError(Exception):
"""Transient transport failure worth one retry."""
def _stream_to_file(backup_id: str, dest: Path) -> str:
"""GET the bundle and stream it chunk-by-chunk to `dest`, returning the
server's X-Sha256 header. Raises _NetworkError on transport-level failure.
HTTP errors (401/403/404) call sys.exit(1) directly — not retryable."""
req = urllib.request.Request(
f"{_storage_url()}/backups/{backup_id}",
headers={"Authorization": f"Bearer {_jwt()}"},
method="GET",
)
try:
resp = urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT)
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
if e.code == 401:
print("ERROR: unauthorized — CONTAINER_JWT invalid or expired", file=sys.stderr)
elif e.code == 403:
print("ERROR: forbidden — must run from Fly 6PN", file=sys.stderr)
elif e.code == 404:
print(f"ERROR: backup not found: {backup_id}", file=sys.stderr)
elif e.code == 400:
print(f"ERROR: bad backup_id: {body}", file=sys.stderr)
else:
print(f"ERROR: HTTP {e.code} — {body}", file=sys.stderr)
sys.exit(1)
except (urllib.error.URLError, OSError) as e:
raise _NetworkError(str(e)) from e
sha_header = resp.headers.get("X-Sha256", "")
total = 0
try:
with open(dest, "wb") as f:
while True:
chunk = resp.read(READ_CHUNK)
if not chunk:
break
total += len(chunk)
if total > MAX_SIZE:
raise _NetworkError(f"bundle exceeds MAX_SIZE={MAX_SIZE}")
f.write(chunk)
except (urllib.error.URLError, OSError) as e:
raise _NetworkError(str(e)) from e
finally:
resp.close()
return sha_header
def _download_with_retry(backup_id: str, dest: Path) -> str:
"""One-shot, then one jittered retry on transport failure."""
last_err: _NetworkError | None = None
for attempt in range(2):
try:
return _stream_to_file(backup_id, dest)
except _NetworkError as e:
last_err = e
# Clean up any partial data before retrying so we start from byte 0.
try:
dest.unlink(missing_ok=True)
except Exception:
pass
if attempt == 0:
delay = RETRY_BASE_DELAY + random.uniform(0, RETRY_JITTER)
print(
f"WARN: transient network error ({e}); "
f"retrying once in {delay:.1f}s...",
file=sys.stderr,
)
time.sleep(delay)
print(
"ERROR: cannot reach backup storage over Fly internal network. "
"This script must run inside a Fly machine.",
file=sys.stderr,
)
print(f"DETAIL: {last_err}", file=sys.stderr)
sys.exit(1)
def _sha256_file(p: Path) -> str:
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(READ_CHUNK), b""):
h.update(chunk)
return h.hexdigest()
def download(backup_id: str) -> tuple[Path, str]:
"""Stream the bundle into a local tmp file. Returns (path, sha256_header).
The caller is responsible for unlinking the returned path after use.
Memory stays bounded at READ_CHUNK regardless of bundle size.
"""
tmp = Path(tempfile.mkstemp(suffix=".tar.gz", prefix=f"{backup_id}.")[1])
sha_header = _download_with_retry(backup_id, tmp)
if sha_header:
actual = _sha256_file(tmp)
if actual != sha_header:
tmp.unlink(missing_ok=True)
print(
f"ERROR: whole-bundle sha256 mismatch — got {actual}, "
f"expected {sha_header}. Bundle may be corrupt in transit or "
"on server disk. Pick another backup.",
file=sys.stderr,
)
sys.exit(1)
return tmp, sha_header
def extract(bundle_path: Path, extract_dir: Path) -> dict:
"""Extract bundle into extract_dir. Returns the parsed manifest.json.
If extract_dir already exists it's wiped first — retrying the same
backup replaces the prior extract atomically (from the user's POV).
"""
if extract_dir.exists():
shutil.rmtree(extract_dir)
extract_dir.mkdir(parents=True, exist_ok=True)
try:
with tarfile.open(bundle_path, "r:gz") as tar:
for member in tar.getmembers():
if member.name.startswith("/") or ".." in member.name.split("/"):
print(f"ERROR: dangerous path in archive: {member.name}",
file=sys.stderr)
sys.exit(1)
tar.extractall(extract_dir, filter="data")
except tarfile.TarError as e:
print(f"ERROR: invalid tar.gz: {e}", file=sys.stderr)
sys.exit(1)
manifest_path = extract_dir / "manifest.json"
if not manifest_path.exists():
print("ERROR: bundle has no manifest.json at root", file=sys.stderr)
sys.exit(1)
try:
return json.loads(manifest_path.read_text())
except json.JSONDecodeError as e:
print(f"ERROR: invalid manifest.json: {e}", file=sys.stderr)
sys.exit(1)
def verify_per_file(manifest: dict, extract_dir: Path) -> None:
"""Re-hash every file listed in manifest.contents and compare against the
recorded sha256. On ANY mismatch, print the full list of bad files to
stderr and exit 1 — do not let the caller proceed to apply.
Also checks for files listed in manifest that are missing on disk, AND
files on disk that aren't listed in manifest (extra files shouldn't be
applied — they'd indicate a pre-v1.1 bundle or tampering).
"""
contents = manifest.get("contents")
if not isinstance(contents, dict):
# Pre-v1.1 manifest — skip per-file check with a warning.
print(
"WARN: bundle manifest has no `contents` section (pre-v1.1 "
"bundle). Skipping per-file hash verification. Whole-bundle "
"sha256 was verified at download time.",
file=sys.stderr,
)
return
expected: dict[str, dict] = {}
for entry in contents.get("files", []):
expected[entry["path"]] = entry
for entry in contents.get("api", []):
expected[entry["path"]] = entry
# Walk disk, compare each file.
on_disk: dict[str, Path] = {}
for p in extract_dir.rglob("*"):
if not p.is_file():
continue
rel = p.relative_to(extract_dir).as_posix()
if rel == "manifest.json":
continue
on_disk[rel] = p
bad: list[str] = []
missing: list[str] = []
for path, entry in expected.items():
p = on_disk.get(path)
if p is None:
missing.append(path)
continue
if p.stat().st_size != entry.get("size"):
bad.append(f"{path} (size mismatch: "
f"got {p.stat().st_size}, expected {entry.get('size')})")
continue
digest = _sha256_file(p)
if digest != entry.get("sha256"):
bad.append(f"{path} (sha256 mismatch: "
f"got {digest[:16]}…, expected {entry.get('sha256', '')[:16]}…)")
extra = [p for p in on_disk if p not in expected]
if bad or missing or extra:
print("ERROR: bundle contents verification failed.", file=sys.stderr)
if bad:
print(f" corrupted files ({len(bad)}):", file=sys.stderr)
for b in bad[:20]:
print(f" {b}", file=sys.stderr)
if len(bad) > 20:
print(f" ... ({len(bad) - 20} more)", file=sys.stderr)
if missing:
print(f" missing files listed in manifest ({len(missing)}):",
file=sys.stderr)
for m in missing[:20]:
print(f" {m}", file=sys.stderr)
if len(missing) > 20:
print(f" ... ({len(missing) - 20} more)", file=sys.stderr)
if extra:
print(f" extra files NOT in manifest ({len(extra)}):",
file=sys.stderr)
for e in extra[:20]:
print(f" {e}", file=sys.stderr)
if len(extra) > 20:
print(f" ... ({len(extra) - 20} more)", file=sys.stderr)
print(
"\nDo NOT proceed to restore. Pick another backup, or delete this "
"one via Flow C and re-take a fresh backup.",
file=sys.stderr,
)
sys.exit(1)
def summarize(manifest: dict, extract_dir: Path) -> None:
print(f"Bundle extracted to {extract_dir}/")
print(f" source : {manifest.get('source', 'unknown')}")
print(f" version : {manifest.get('version')}")
print(f" label : {manifest.get('label') or '(no label)'}")
print(f" created_at: {manifest.get('created_at')}")
contents = manifest.get("contents", {}) or {}
files = contents.get("files") or []
api = contents.get("api") or []
total_size = sum(e.get("size", 0) for e in files) + sum(e.get("size", 0) for e in api)
print(f" files : {len(files)} (filesystem state) + {len(api)} (api state)")
print(f" total : {total_size:,} bytes")
print()
# Group files by top-level section under files/ for a human-readable
# breakdown (workspace/memory, workspace/prompt, data/, …).
buckets: dict[str, list[dict]] = {}
for entry in files:
parts = entry["path"].split("/", 2)
# path like "files/workspace/memory/MEMORY.md" → bucket "files/workspace"
if len(parts) >= 2:
key = "/".join(parts[:2])
else:
key = entry["path"]
buckets.setdefault(key, []).append(entry)
if buckets:
print("Filesystem sections:")
for key in sorted(buckets):
items = buckets[key]
size = sum(e.get("size", 0) for e in items)
print(f" {key:<30s} {len(items):>4d} files {size:>10,} bytes")
print()
if api:
print("API sections:")
for entry in sorted(api, key=lambda e: e["path"]):
print(f" {entry['path']:<30s} {entry.get('size', 0):>10,} bytes")
print()
warnings = (manifest.get("plan_summary") or {}).get("warnings") or []
if warnings:
print("Pack-time warnings:")
for w in warnings:
print(f" ⚠ {w}")
def main() -> None:
if len(sys.argv) != 2:
print("Usage: python3 download.py <backup_id>", file=sys.stderr)
sys.exit(1)
backup_id = sys.argv[1].strip()
if not _BACKUP_ID_RE.match(backup_id):
print(f"ERROR: invalid backup_id format: {backup_id!r}", file=sys.stderr)
sys.exit(1)
extract_dir = RESTORE_ROOT / backup_id
print(f"Downloading {backup_id} ...")
bundle_path, sha = download(backup_id)
size = bundle_path.stat().st_size
try:
print(f"Downloaded {size:,} bytes (bundle sha256 verified: {bool(sha)})")
manifest = extract(bundle_path, extract_dir)
verify_per_file(manifest, extract_dir)
print(f"Per-file hash verification: OK "
f"({len((manifest.get('contents') or {}).get('files', []))} + "
f"{len((manifest.get('contents') or {}).get('api', []))} files)")
print()
summarize(manifest, extract_dir)
finally:
bundle_path.unlink(missing_ok=True)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Ensure /data/workspace/config/backup_rules.md exists. Idempotent.
On first run this writes a default template; on subsequent runs it does
nothing (user's edited version is preserved). Prints one line to stdout
so the agent can confirm which branch fired:
CREATED /data/workspace/config/backup_rules.md
EXISTS /data/workspace/config/backup_rules.md
Exit codes:
0 ok (created, or already existed)
1 filesystem error (permission, disk full, etc.)
The default template covers every field pack.py can read from rules (mode,
extra excludes, extra paths, label template, notes). All sections are
present-but-inert (comment-only) so the file's mere existence doesn't
change default behavior — user must edit to activate anything.
Language: the template body is in English as a neutral baseline. The
SKILL.md Flow A.1.0 tells the agent to offer localizing it to the user's
conversation language after CREATE. Section headings stay in English in all
cases because the rules parser keys off them.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
RULES_PATH = Path(os.environ.get(
"BACKUP_RULES_PATH",
"/data/workspace/config/backup_rules.md",
))
TEMPLATE = """\
# Backup rules — your backup preferences
When you run `/backup`, the agent reads this file first and applies whatever
you set here to the pack step. Edit it anytime; changes take effect on the
next backup. To reset everything to defaults, delete this file — the agent
will rebuild it on the next run.
> **Language note.** The section headings (`## Mode`, `## Extra excludes`,
> `## Extra paths`, `## Label template`, `## Notes`) must stay in English —
> the agent's parser depends on them. Everything else (intro prose, comments
> inside code blocks, the label template, your notes) can be in any language
> you prefer. Ask the agent to localize this file to your language if you'd
> rather not read English.
---
## Mode
The pack mode applied every time, unless you override in-conversation.
```
default
```
Valid values:
- `default` — skip logs / caches / ChromaDB / runtime transients / skill scratch (recommended)
- `full` — include everything except this skill's own scratch (`.backup` / `.restore`)
---
## Extra excludes
Paths to skip on top of the default blacklist. One per line, **relative to
`/data/`**. Lines starting with `#` are comments and are ignored.
```
# Examples (remove the leading # to activate):
# workspace/output
# data/.user-packages
```
---
## Extra paths
Additional absolute paths to include from **outside** `/data/`. One per line.
```
# Example:
# /home/myuser/some-config
```
---
## Label template
Default label for each backup. `{date}` is replaced with today's UTC date
(YYYY-MM-DD). A label you provide in-conversation overrides this template.
```
auto-backup {date}
```
---
## Notes
Free-form space for your own reminders. The agent reads this section but
treats it as informational only — it won't execute anything you write here.
Example uses:
- "Do a `full` mode backup every Sunday."
- "If I add a new OPENAI key, test a backup afterward to make sure it picked up."
- "Last data loss happened because I forgot `output/` — don't skip it again."
"""
def main() -> None:
if RULES_PATH.exists():
print(f"EXISTS {RULES_PATH}")
return
try:
RULES_PATH.parent.mkdir(parents=True, exist_ok=True)
RULES_PATH.write_text(TEMPLATE, encoding="utf-8")
except OSError as e:
print(f"ERROR: could not write {RULES_PATH}: {e}", file=sys.stderr)
sys.exit(1)
print(f"CREATED {RULES_PATH}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""List this user's backups via GET /backups on sc-agent-backup.
Usage:
python3 list.py [--json]
By default prints a human-readable menu intended for an agent to surface to
the user as the pick list. With --json, prints the raw storage JSON for
programmatic consumption.
Environment:
CONTAINER_JWT Required.
BACKUP_STORAGE_URL Optional. Defaults to http://sc-agent-backup.internal:8080.
Exit codes:
0 request succeeded. Prints the menu (or "(无备份)" if the user has
no backups yet). The agent reads stdout to decide whether to ask
"pick one" or "run /backup first".
1 network / auth / protocol failure (error on stderr)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
DEFAULT_STORAGE = "http://sc-agent-backup.internal:8080"
def _storage_url() -> str:
return os.environ.get("BACKUP_STORAGE_URL", DEFAULT_STORAGE).rstrip("/")
def _jwt() -> str:
token = os.environ.get("CONTAINER_JWT", "").strip()
if not token:
print("ERROR: CONTAINER_JWT env var is not set.", file=sys.stderr)
sys.exit(1)
return token
def _fetch() -> dict:
req = urllib.request.Request(
f"{_storage_url()}/backups",
headers={"Authorization": f"Bearer {_jwt()}"},
method="GET",
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
body = resp.read().decode("utf-8")
except urllib.error.HTTPError as e:
detail = e.read().decode(errors="replace")
print(f"ERROR: HTTP {e.code} — {detail}", file=sys.stderr)
sys.exit(1)
except (urllib.error.URLError, OSError) as e:
print(
"ERROR: cannot reach backup storage over Fly internal network. "
"This script must run inside a Fly machine.",
file=sys.stderr,
)
print(f"DETAIL: {e}", file=sys.stderr)
sys.exit(1)
try:
return json.loads(body)
except json.JSONDecodeError:
print(f"ERROR: storage returned non-JSON: {body[:200]}", file=sys.stderr)
sys.exit(1)
def _human_size(n: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.1f} {unit}" if unit != "B" else f"{n} {unit}"
n /= 1024
return f"{n:.1f} TB"
def _relative_age(ts: int) -> str:
now = int(time.time())
d = max(0, now - ts)
if d < 60:
return f"{d} 秒前"
if d < 3600:
return f"{d // 60} 分钟前"
if d < 86400:
return f"{d // 3600} 小时前"
if d < 30 * 86400:
return f"{d // 86400} 天前"
return f"{d // (30 * 86400)} 个月前"
def _format_menu(data: dict) -> str:
backups = data.get("backups", [])
quota = data.get("quota", "?")
if not backups:
return "(无备份)"
lines = [f"您的备份({len(backups)} / {quota},按时间倒序):"]
for i, b in enumerate(backups, 1):
label = b.get("user_label") or "(无标签)"
ts = b.get("created_at", 0)
when = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(ts)) if ts else "?"
age = _relative_age(ts) if ts else "?"
size = _human_size(int(b.get("size_bytes", 0)))
sections = b.get("sections") or []
bid = b.get("backup_id", "?")
lines.append("")
lines.append(f"[{i}] {bid}")
lines.append(f" 标签: {label}")
lines.append(f" 时间: {when} ({age})")
lines.append(f" 大小: {size}")
if sections:
lines.append(f" 内容: {', '.join(sections)}")
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--json", action="store_true",
help="emit raw storage JSON instead of a menu")
args = parser.parse_args()
data = _fetch()
if args.json:
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
print(_format_menu(data))
# Empty isn't an error — exit 0 and let the agent read stdout to decide
# what to say. Non-zero is reserved for genuine failures (network/auth).
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Deterministically assemble a backup bundle from a live Starchild agent.
Usage:
python3 pack.py \\
--api-dir $WORK/api/ \\
--out $WORK/bundle.tar.gz \\
[--mode default|full] \\
[--extra-exclude <rel-path>] [--extra-path <abs-path>] \\
[--label "升级前"] [--dry-run]
The "what to pack" model is a blacklist, not a whitelist:
--mode default walk /data/, pack everything EXCEPT logs, caches, derived
indexes (ChromaDB), transient runtime state, and this skill's
own scratch/markers. This is what you want 99% of the time.
--mode full walk /data/, pack literally everything. Only skip paths
that would create a self-reference loop (our own in-progress
bundle scratch, our own restore extract dirs).
Both modes always skip `__pycache__/` and `*.pyc` inside any copied directory.
Path arguments:
--extra-exclude REL additional path to skip, interpreted relative to
/data/ (e.g. "workspace/output" or "sessions").
Repeat for multiple.
--extra-path ABS extra absolute path to include, outside /data/
(e.g. some user-owned dir on another mount).
`--api-dir` must contain agent-supplied JSON written before pack.py runs:
api-dir/profile.json agent_profile(action="get") output
api-dir/settings.json user_settings(action="get") output
api-dir/scheduled_tasks.json scheduled_task(action="list"), normalized to
[{title, schedule, description, channels}]
Convention: the agent allocates a per-run workdir (e.g. `mktemp -d
/tmp/backup-XXXXXX`), creates `$WORK/api/`, writes the three JSONs there, then
calls `pack.py --api-dir $WORK/api --out $WORK/bundle.tar.gz`. After upload
succeeds it `rm -rf $WORK`. Each backup attempt stays isolated.
With --dry-run, nothing is written; a plan (JSON) is printed to stdout.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sys
import tarfile
import time
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Blacklists — the authoritative "what to skip" spec. Adding defaults here
# is the right place (keep in sync with SKILL.md's "what's excluded" table).
# ---------------------------------------------------------------------------
# Top-level children of /data/workspace/ we skip in DEFAULT mode.
# In FULL mode we keep ALL of these except the SELF_LOOP ones below.
DEFAULT_SKIP_WORKSPACE_TOP: set[str] = {
".active-upload.json", # client upload-resume state; transient
".restore.log", # restore progress marker; see SKILL.md Step 0
}
# Top-level children of /data/ we skip in DEFAULT mode.
# In FULL mode we keep ALL of these.
DEFAULT_SKIP_DATA_TOP: set[str] = {
# Logs
"logs",
"auto_update.log",
"skills-install.log",
# Transient runtime state (resets on restart anyway)
".bash_processes",
".startup-tasks",
"hibernation_state.json",
# Derived / cache / reconstructable
"memory", # ChromaDB + FTS indexes; rebuilds from workspace/memory/**
".npm-cache", # npm cache
".agents", # npx skills lock-file shadow; real registry is in workspace/skills/.skill-lock.json
}
# ALWAYS skipped (even in FULL mode) — avoids packaging the backup of itself.
ALWAYS_SKIP_WORKSPACE_TOP: set[str] = {
".backup", # our own pack scratch (where this run lives while running)
".restore", # our own restore extract dirs (per-backup_id subdirs)
}
# Files / dir names to prune INSIDE any copied directory at any depth.
# We exclude these in both default and full mode — they're universally noise.
UNIVERSAL_PRUNE: set[str] = {
"__pycache__",
}
UNIVERSAL_PRUNE_SUFFIXES: tuple[str, ...] = (".pyc",)
# API JSONs the agent must supply.
REQUIRED_API_FILES: list[str] = [
"profile.json",
"settings.json",
"scheduled_tasks.json",
]
# ---------------------------------------------------------------------------
# Plan construction
# ---------------------------------------------------------------------------
def _copy_ignore(src: str, names: list[str]) -> list[str]:
"""shutil.copytree ignore fn: prune __pycache__ and *.pyc at every depth."""
skip: list[str] = []
for n in names:
if n in UNIVERSAL_PRUNE:
skip.append(n)
continue
if any(n.endswith(suf) for suf in UNIVERSAL_PRUNE_SUFFIXES):
skip.append(n)
return skip
def _path_summary(p: Path) -> tuple[bool, int, int]:
"""Return (exists, size_bytes, file_count) with __pycache__/*.pyc ignored."""
if not p.exists():
return False, 0, 0
if p.is_file():
return True, p.stat().st_size, 1
total = 0
count = 0
for sub in p.rglob("*"):
if not sub.is_file():
continue
# Skip pruned dirs/files in the summary to match what we'll actually pack.
if UNIVERSAL_PRUNE.intersection(sub.parts):
continue
if any(sub.name.endswith(suf) for suf in UNIVERSAL_PRUNE_SUFFIXES):
continue
try:
total += sub.stat().st_size
count += 1
except OSError:
continue
return True, total, count
def build_plan(
workspace_dir: Path,
data_dir: Path,
api_dir: Path,
mode: str,
extra_excludes: set[str],
extra_paths: list[Path],
label: str,
) -> dict[str, Any]:
"""Produce a structured plan describing what will be packed.
Exclusions are resolved in this order (highest priority first):
1. ALWAYS_SKIP_WORKSPACE_TOP (self-loop guard — can't be overridden)
2. --extra-exclude user-supplied list (any path under /data/)
3. DEFAULT_SKIP_* lists (only applied when mode == 'default')
"""
if mode not in ("default", "full"):
raise ValueError(f"mode must be 'default' or 'full', got {mode!r}")
plan: dict[str, Any] = {
"label": label,
"mode": mode,
"workspace_dir": str(workspace_dir),
"data_dir": str(data_dir),
"created_at_unix": int(time.time()),
"items": [],
"api": [],
"extra_paths": [],
"warnings": [],
"total_files": 0,
"total_size_bytes": 0,
}
# Normalize extra-excludes to paths-relative-to-/data/.
# "sessions" → /data/sessions, "workspace/output" → /data/workspace/output
extra_excl_set: set[str] = set()
for e in extra_excludes:
e = e.strip().lstrip("/")
if e:
extra_excl_set.add(e)
def _include(src: Path, bundle_rel: str, rel_key: str, kind: str,
section: str) -> None:
exists, size, count = _path_summary(src)
status = "ok" if exists else "absent"
plan["items"].append({
"section": section,
"kind": kind,
"src": str(src),
"bundle": bundle_rel,
"rel": rel_key,
"status": status,
"size_bytes": size,
"files": count,
})
if exists:
plan["total_files"] += count
plan["total_size_bytes"] += size
def _skip(src: Path, bundle_rel: str, rel_key: str, kind: str,
section: str, reason: str) -> None:
plan["items"].append({
"section": section,
"kind": kind,
"src": str(src),
"bundle": bundle_rel,
"rel": rel_key,
"status": f"excluded_{reason}",
"size_bytes": 0,
"files": 0,
})
# ---------- /data/workspace/* ----------
if workspace_dir.exists():
for child in sorted(workspace_dir.iterdir()):
name = child.name
rel_key = f"workspace/{name}"
bundle_rel = f"files/workspace/{name}"
kind = "workspace_dir" if child.is_dir() else "workspace_file"
# 1. Always-skip (self-loop guard) — cannot be overridden even in full mode.
if name in ALWAYS_SKIP_WORKSPACE_TOP:
_skip(child, bundle_rel, rel_key, kind, "workspace", "self_loop")
continue
# 2. User override
if rel_key in extra_excl_set:
_skip(child, bundle_rel, rel_key, kind, "workspace", "user_rule")
continue
# 3. Default-mode skips
if mode == "default" and name in DEFAULT_SKIP_WORKSPACE_TOP:
_skip(child, bundle_rel, rel_key, kind, "workspace", "default_rule")
continue
_include(child, bundle_rel, rel_key, kind, "workspace")
else:
plan["warnings"].append(f"workspace_dir does not exist: {workspace_dir}")
# ---------- /data/* (everything NOT under workspace/) ----------
if data_dir.exists():
for child in sorted(data_dir.iterdir()):
name = child.name
if name == "workspace":
continue # handled above
rel_key = name
bundle_rel = f"files/data/{name}"
kind = "data_dir" if child.is_dir() else "data_file"
# 1. User override
if rel_key in extra_excl_set:
_skip(child, bundle_rel, rel_key, kind, "data", "user_rule")
continue
# 2. Default-mode skips
if mode == "default" and name in DEFAULT_SKIP_DATA_TOP:
_skip(child, bundle_rel, rel_key, kind, "data", "default_rule")
continue
_include(child, bundle_rel, rel_key, kind, "data")
else:
plan["warnings"].append(f"data_dir does not exist: {data_dir}")
# ---------- Extra user-specified paths (outside /data/) ----------
for p in extra_paths:
if not p.is_absolute():
plan["warnings"].append(f"--extra-path must be absolute: {p}")
continue
if p.is_relative_to(workspace_dir):
bundle_rel = f"files/workspace/{p.relative_to(workspace_dir)}"
elif p.is_relative_to(data_dir):
bundle_rel = f"files/data/{p.relative_to(data_dir)}"
else:
bundle_rel = f"files/extra/{p.name}"
exists, size, count = _path_summary(p)
kind = "workspace_dir" if p.is_dir() else "workspace_file"
plan["extra_paths"].append({
"src": str(p),
"bundle": bundle_rel,
"status": "ok" if exists else "missing",
"size_bytes": size,
"files": count,
})
if exists:
plan["total_files"] += count
plan["total_size_bytes"] += size
# ---------- API files ----------
for name in REQUIRED_API_FILES:
p = api_dir / name
if p.exists() and p.is_file():
plan["api"].append({
"file": name,
"status": "ok",
"size_bytes": p.stat().st_size,
})
plan["total_files"] += 1
plan["total_size_bytes"] += p.stat().st_size
else:
plan["api"].append({"file": name, "status": "missing", "size_bytes": 0})
plan["warnings"].append(
f"required api file missing: {p} — agent must write this "
"before pack.py runs"
)
return plan
# ---------------------------------------------------------------------------
# Plan execution (actual tar build)
# ---------------------------------------------------------------------------
def execute_plan(plan: dict[str, Any], api_dir: Path, out_path: Path) -> dict[str, Any]:
"""Copy everything the plan says 'ok' into a staging dir, write manifest
(with per-file sha256), tar.gz to out_path, cleanup.
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
staging = out_path.parent / f".{out_path.name}.staging.{os.getpid()}"
if staging.exists():
shutil.rmtree(staging)
root = staging / "backup"
root.mkdir(parents=True)
def _copy_into_bundle(src: Path, bundle_rel: str) -> None:
if not src.exists():
return
dst = root / bundle_rel
dst.parent.mkdir(parents=True, exist_ok=True)
if src.is_file():
shutil.copy2(src, dst)
else:
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(
src, dst,
symlinks=False,
ignore_dangling_symlinks=True,
ignore=_copy_ignore,
)
for item in plan["items"]:
if item["status"] != "ok":
continue
_copy_into_bundle(Path(item["src"]), item["bundle"])
for extra in plan["extra_paths"]:
if extra["status"] != "ok":
continue
_copy_into_bundle(Path(extra["src"]), extra["bundle"])
# API dir
api_bundle_dir = root / "api"
api_bundle_dir.mkdir(parents=True, exist_ok=True)
for entry in plan["api"]:
if entry["status"] != "ok":
continue
shutil.copy2(api_dir / entry["file"], api_bundle_dir / entry["file"])
# Per-file sha256 inventory.
contents_files: list[dict[str, Any]] = []
contents_api: list[dict[str, Any]] = []
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
rel = path.relative_to(root).as_posix()
if rel == "manifest.json":
continue
entry = {
"path": rel,
"size": path.stat().st_size,
"sha256": _sha256_file(path),
}
if rel.startswith("api/"):
contents_api.append(entry)
else:
contents_files.append(entry)
manifest = {
"version": "1.1",
"source": "starchild",
"mode": plan["mode"],
"created_at_unix": plan["created_at_unix"],
"created_at": time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(plan["created_at_unix"])
),
"label": plan["label"],
"sections": sorted({
item["bundle"].split("/")[0] + "/" + item["bundle"].split("/")[1]
for item in plan["items"]
if item["status"] == "ok" and "/" in item["bundle"]
}),
"contents": {
"files": contents_files,
"api": contents_api,
},
"exclusions": [
{"section": item["section"], "rel": item["rel"], "reason": item["status"]}
for item in plan["items"]
if item["status"].startswith("excluded_")
],
"plan_summary": {
"total_files": plan["total_files"],
"total_size_bytes": plan["total_size_bytes"],
"warnings": plan["warnings"],
},
}
(root / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
)
if out_path.exists():
out_path.unlink()
with tarfile.open(out_path, "w:gz") as tar:
tar.add(root, arcname=".")
shutil.rmtree(staging)
return {
"bundle_path": str(out_path),
"size_bytes": out_path.stat().st_size,
"manifest": manifest,
}
def _sha256_file(p: Path) -> str:
h = hashlib.sha256()
with open(p, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _default_out_path() -> Path:
ts = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
return Path(f"/tmp/backup-{ts}-{os.getpid()}/bundle.tar.gz")
def main() -> None:
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__,
)
parser.add_argument("--workspace-dir", type=Path,
default=Path(os.environ.get("WORKSPACE_DIR", "/data/workspace")))
parser.add_argument("--data-dir", type=Path, default=Path("/data"))
parser.add_argument("--api-dir", type=Path, required=True,
help="directory where the agent wrote profile.json, "
"settings.json, scheduled_tasks.json")
parser.add_argument("--out", type=Path, default=None,
help="output bundle path (default: /tmp/backup-{ts}-{pid}/bundle.tar.gz)")
parser.add_argument("--mode", choices=("default", "full"), default="default",
help="default (skip logs/caches/derived/scratch) or "
"full (pack everything except self-reference loops)")
parser.add_argument("--label", type=str, default="",
help="user-facing label (≤64 chars)")
parser.add_argument("--extra-exclude", action="append", default=[],
help="extra path to skip, relative to /data/ "
"(e.g. 'workspace/output' or 'sessions'). "
"Repeat for multiple.")
parser.add_argument("--extra-path", action="append", default=[],
help="absolute path to an extra dir/file to include "
"(outside /data/); repeat for multiple.")
parser.add_argument("--dry-run", action="store_true",
help="print the plan as JSON to stdout; don't build a bundle")
args = parser.parse_args()
if args.out is None:
args.out = _default_out_path()
# ── Early validation: fail loud and specific on the common pitfalls ──
# Agents sometimes split the flow across multiple bash tool calls; shell
# vars like $WORK don't persist between calls, so --api-dir can expand
# to something nonsensical. Detect this before building any plan.
if not args.api_dir.exists():
print(
f"ERROR: --api-dir does not exist: {args.api_dir}\n\n"
"This usually means one of:\n"
" (1) You passed a shell variable like $WORK that wasn't set in "
"this bash invocation — bash variables do NOT persist across "
"separate tool calls. Use a literal path string.\n"
" (2) You forgot to create the directory and write the three "
"API JSONs (profile.json, settings.json, scheduled_tasks.json) "
"before invoking pack.py.\n"
"\n"
"Fix: pick a literal timestamped path, create it, write the "
"JSONs, then pass that exact string to --api-dir. Example:\n"
" WORK=/tmp/backup-20260427T164500Z\n"
" mkdir -p $WORK/api\n"
" # (write the 3 JSONs to $WORK/api/ via your native file tools)\n"
" python3 skills/backup/scripts/pack.py \\\n"
f" --api-dir {args.api_dir} \\\n"
" --out $WORK/bundle.tar.gz \\\n"
" ...",
file=sys.stderr,
)
sys.exit(1)
if not args.api_dir.is_dir():
print(
f"ERROR: --api-dir exists but is not a directory: {args.api_dir}",
file=sys.stderr,
)
sys.exit(1)
# Out path: ensure parent is writable. We don't require --out to exist
# (pack.py creates it), but we DO need the parent directory to be
# writable — catching this early saves the agent from a confusing
# traceback halfway through plan building.
out_parent = args.out.parent
try:
out_parent.mkdir(parents=True, exist_ok=True)
except OSError as e:
print(
f"ERROR: cannot create --out parent directory {out_parent}: {e}",
file=sys.stderr,
)
sys.exit(1)
if not os.access(out_parent, os.W_OK):
print(
f"ERROR: --out parent is not writable: {out_parent}",
file=sys.stderr,
)
sys.exit(1)
extra_paths = [Path(p) for p in args.extra_path]
extra_excludes = set(args.extra_exclude)
plan = build_plan(
workspace_dir=args.workspace_dir.resolve(),
data_dir=args.data_dir.resolve(),
api_dir=args.api_dir.resolve(),
mode=args.mode,
extra_excludes=extra_excludes,
extra_paths=extra_paths,
label=args.label,
)
if args.dry_run:
print(json.dumps(plan, ensure_ascii=False, indent=2))
return
missing_api = [a["file"] for a in plan["api"] if a["status"] != "ok"]
if missing_api:
print(
"ERROR: required API files not found in --api-dir: "
f"{', '.join(missing_api)}.\nAgent must call profile / settings / "
"scheduled_tasks tools and write the results there before pack.py.",
file=sys.stderr,
)
sys.exit(1)
result = execute_plan(plan, args.api_dir.resolve(), args.out.resolve())
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Scan /data/workspace/ and /data/ top-level entries, classify each, and
emit a JSON proposal for the agent to discuss with the user.
Runs on the FIRST backup (when /data/workspace/config/backup_rules.md is
absent). The agent uses the output to show the user what's actually on disk,
discuss the ambiguous items, and compose the final rules file based on their
decisions.
Output JSON shape:
{
"workspace": [
{"name": ..., "path": ..., "is_dir": ..., "size_bytes": ...,
"file_count": ..., "category": ..., "reason": ...},
...
],
"data": [ ... same shape, path under /data/ ... ],
"summary": {"total_size_bytes": ..., "core_count": N, "skip_count": N,
"ask_count": N, "unknown_count": N}
}
Categories:
core — definitely back up (core user state; stock default includes)
skip — definitely skip (logs/cache/derived/runtime; stock default
already excludes; shown for transparency)
ask — default includes, user might want to skip (large or optional)
unknown — path not on any list; user decides
Exit codes:
0 ok, JSON on stdout
1 workspace or data dir missing / unreadable
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Classification sets — keep in sync with pack.py's DEFAULT_SKIP/ALWAYS_SKIP.
# The "reason" strings are what the agent surfaces to the user.
# ---------------------------------------------------------------------------
# Always-backup under workspace/
CORE_WORKSPACE: dict[str, str] = {
"memory": "agent memory (MEMORY.md + daily/ + topics/)",
"prompt": "agent identity (SOUL.md + USER.md + optionally AGENTS.md)",
"config": "per-agent config (agent.yaml / custom_models.yaml / backup_rules.md itself)",
"tasks": "scheduled task scripts",
"skills": "installed skills (including .skill-lock.json manifest)",
"setup.sh": "startup hook (apt/pip install steps)",
".env": "secrets (API keys, tokens, bot credentials)",
}
# Always-skip under workspace/ — stock default blacklist + self-loop guards
SKIP_WORKSPACE: dict[str, str] = {
".backup": "pack scratch — self-loop guard",
".restore": "restore extract — self-loop guard",
".active-upload.json": "upload resume state — transient",
".restore.log": "restore progress marker — transient",
}
# Always-backup directly under /data/
CORE_DATA: dict[str, str] = {
"scheduled_jobs.json": "scheduler registry (task definitions)",
"preview_history.json": "permanent preview service history",
"previews.json": "active preview registry",
"sessions": "chat history SQLite family (state.db + -wal + -shm)",
}
# Always-skip directly under /data/ — stock default blacklist
SKIP_DATA: dict[str, str] = {
"logs": "logs, regenerated",
"memory": "ChromaDB + FTS + embedding cache (derivable from workspace/memory/**)",
".npm-cache": "npm cache",
".bash_processes": "process registry — resets on container restart",
".startup-tasks": "startup transient status",
"hibernation_state.json": "container runtime state",
"auto_update.log": "logs",
"skills-install.log": "logs",
"workspace": "sub-tree handled separately", # placeholder; not actually skipped
}
# Default backs up, but user often wants to decide — "ask" category
ASK_WORKSPACE: dict[str, str] = {
"output": "agent-generated reports (could be large or sensitive)",
"scripts": "user-authored custom scripts",
}
ASK_DATA: dict[str, str] = {
"scheduled_jobs.db": "job execution history (definitions live in scheduled_jobs.json; this is the run log)",
"tasks.json": "subagent spawn history",
".user-packages": "pip/npm --user installs; often large; THEORETICALLY rebuildable via setup.sh",
".local": "typically user-installed binaries + app data; varies by agent",
".agents": "npx skills shadow lock-file (real registry is workspace/skills/.skill-lock.json)",
}
def _size_and_count(p: Path) -> tuple[int, int]:
"""Return (size_bytes, file_count) for a path (file or dir, recursive).
Skips __pycache__ and *.pyc to match what pack.py will actually pack.
"""
if p.is_file():
try:
return p.stat().st_size, 1
except OSError:
return 0, 0
total = 0
count = 0
for sub in p.rglob("*"):
if not sub.is_file():
continue
if "__pycache__" in sub.parts or sub.name.endswith(".pyc"):
continue
try:
total += sub.stat().st_size
count += 1
except OSError:
continue
return total, count
def classify_child(name: str, is_dir: bool, base: str) -> tuple[str, str]:
"""Return (category, reason) for a top-level child under workspace or data.
base is "workspace" or "data".
"""
if base == "workspace":
if name in CORE_WORKSPACE:
return "core", CORE_WORKSPACE[name]
if name in SKIP_WORKSPACE:
return "skip", SKIP_WORKSPACE[name]
if name in ASK_WORKSPACE:
return "ask", ASK_WORKSPACE[name]
else: # data
if name == "workspace":
return "core", "the workspace directory (enumerated separately above)"
if name in CORE_DATA:
return "core", CORE_DATA[name]
if name in SKIP_DATA:
return "skip", SKIP_DATA[name]
if name in ASK_DATA:
return "ask", ASK_DATA[name]
# Unrecognized — punt to user.
kind = "directory" if is_dir else "file"
return "unknown", f"unrecognized {kind} under {base}/; user must decide"
def _scan_children(base_dir: Path, label: str) -> list[dict]:
entries: list[dict] = []
for child in sorted(base_dir.iterdir()):
# Under /data/, skip 'workspace' — emitted as its own section.
if label == "data" and child.name == "workspace":
continue
size, count = _size_and_count(child)
cat, reason = classify_child(child.name, child.is_dir(), label)
entries.append({
"name": child.name,
"path": str(child),
"is_dir": child.is_dir(),
"size_bytes": size,
"file_count": count,
"category": cat,
"reason": reason,
})
return entries
def propose(workspace_dir: Path, data_dir: Path) -> dict:
if not workspace_dir.exists():
print(f"ERROR: workspace dir does not exist: {workspace_dir}",
file=sys.stderr)
sys.exit(1)
if not data_dir.exists():
print(f"ERROR: data dir does not exist: {data_dir}",
file=sys.stderr)
sys.exit(1)
ws = _scan_children(workspace_dir, "workspace")
dt = _scan_children(data_dir, "data")
counts = {"core": 0, "skip": 0, "ask": 0, "unknown": 0}
total = 0
for e in ws + dt:
counts[e["category"]] += 1
total += e["size_bytes"]
return {
"workspace": ws,
"data": dt,
"summary": {
"total_size_bytes": total,
"core_count": counts["core"],
"skip_count": counts["skip"],
"ask_count": counts["ask"],
"unknown_count": counts["unknown"],
},
}
def main() -> None:
workspace = Path(os.environ.get("WORKSPACE_DIR", "/data/workspace"))
data = Path(os.environ.get("DATA_DIR", "/data"))
print(json.dumps(propose(workspace, data), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()