Undo and trust
The hard part of this project is not "can it operate the computer". It is "after it operates, would anyone dare use it again". Undo is the foundation of that.
What undo means
Undo is not "run something roughly opposite". It is restoring the exact state before the task started.
- move: move it back to the recorded source path;
- mkdir: remove the directory, only if empty;
- write: delete the file if it did not exist; otherwise write the previous content back. Remove empty parent directories created by the write, preserving existing directories and other files;
- missing/null
undoorirreversible: true: require explicit confirmation before apply. These actions cannot be restored.fs.deletewithout an undo is always rejected, including when confirmation is supplied.
Failure rolls back
When any step fails, the kernel:
- stops the remaining steps;
- undoes the failing action's own side effects (a file written before its assertion failed must disappear);
- attempts to roll back the successful steps in reverse order, continuing if an individual undo fails;
- marks only successfully undone steps
rolled-backin the journal; - returns the execution failure and any recovery failures.
Step 2 fixed a real bug in v0.1: originally only "previously successful steps" were rolled back, so "write file, then fail an assertion" left an orphan file — and that orphan then blocked removal of its parent directory with ENOTEMPTY.
Recovery returns the task to its starting point when all required undos succeed. Confirmed irreversible actions, missing undo records, or undo failures can leave changes behind; the report must expose that incomplete recovery instead of claiming success.
Watch it happen:
node bin/deskshell.ts story --fail
✓ 1-write-a
✓ 2-mkdir-sub
✗ 3-write-b-assert-must-fail post assertion failed: ...
3 steps rolled back (including the failing action's own side effects); leftovers: 0
Why undo lives in the kernel
If every skill implemented its own rollback, some would forget, some would get it wrong, and some would cheat.
In the kernel:
- skill authors only declare
undo, they don't write rollback code; - rollback logic exists once and is shared by every skill;
- the journal already records everything needed to undo — no extra instrumentation.
Permission boundaries
- capabilities must be declared, justified, and scoped; an empty declaration does not authorize any action;
- an ungranted capability rejects the whole task (not "skip that step");
- authorization is per task, not permanent.
The journal
Every run appends JSONL:
{"ts":1793201985000,"taskId":"task-1793201984999","actionId":"move:a","kind":"fs.move","summary":"move a.txt → .../docs/a.txt","status":"ok","undo":{"kind":"fs.move","from":".../docs/a.txt","to":".../a.txt"}}
It serves three audiences: users asking "what did it actually do", developers asking "which step broke", auditors asking "who changed what, and when".
Batch undo
deskshell undo --task <taskId> # one task
deskshell undo --last # the most recent task
deskshell undo --last 3 # the 3 most recent tasks
deskshell undo --since 30m # everything started in the last 30 minutes (30m / 2h / 1d)
Batch modes undo newest-first and print one line per task. If an undo fails — for example a file changed — that task stays un-rolled-back, the others still roll back, and the exit code is non-zero. Partial success is reported, never hidden.
An entry without an undo produces a failed step, contributes nothing to rolledBack, and keeps its original journal status. This also applies to older journals that lack an undo. Repeating undo returns zero steps only after all entries have actually been rolled back.
When the file changed since
Every fs.write step records the sha256 of what it wrote. Before restoring, undo compares that hash with the file on disk:
✗ write-note 文件在任务之后被修改,拒绝覆盖:…/note.txt(可用 deskshell undo --force 强制)
The refused step is not marked rolled-back in the journal — the audit trail never claims a rollback that did not happen. Restoring someone's newer content over their edit is the kind of silent data loss this guard exists to prevent.
Long-lived journals
The journal grows by roughly 400 bytes per step (measured: 387 KB for 1000 steps). When the file passes 5 MiB it is renamed to journal-<timestamp>.jsonl and a fresh one starts.
load()reads every rotated file plus the current one, sorted by time;- undo rewrites only the file that contains the affected entries, so statuses stay consistent without duplicating rows;
DESKSHELL_JOURNAL_MAX_BYTESoverrides the threshold (tests use 600 bytes).
Undo coverage, measured
scripts/undo-coverage-test.ts snapshots a directory (every path plus a sha256 per file, .deskshell/ excluded), applies a skill, undoes it, and compares the two snapshots byte for byte. Current result:
PASS downloads-cleanup 10 步 · 撤销 10 步 · 逐字节一致
PASS photo-sort 13 步 · 撤销 13 步 · 逐字节一致
PASS log-archive 5 步 · 撤销 5 步 · 逐字节一致
PASS rename-batch 3 步 · 撤销 3 步 · 逐字节一致
PASS first-skill 1 步 · 撤销 1 步 · 逐字节一致
SKIP browser-tabs needs a real browser window (GUI)
SKIP tile-windows needs real windows (GUI)
GUI skills are skipped with a reason in the output, never silently — a skipped test that pretends to pass is worse than no test.
Confirming capabilities one by one
deskshell run <skill> --ask asks before anything runs:
授予能力 fs.read(读取下载目录)?[y/N] y
授予能力 fs.write(移动与写入)?[y/N] y
Any n cancels the whole task with exit code 2 and no side effects — no journal entry, no file touched. Piped stdin works too (one y/n per line), which is how CI tests it; empty input counts as a refusal.
Known boundaries
- external side effects cannot be undone (an HTTP request already left); missing/null
undorequires explicit confirmation, and confirmed changes may remain after a later failure; fs.restorerejects overwriting a file whose recorded write hash no longer matches;- cross-volume moves are not atomic on every platform; the journal is what makes retries possible.
When a step cannot be undone
An action can declare irreversible: true (a pressed button, a sent email). Missing/null undo is also classified as irreversible. The kernel refuses the whole run before touching anything unless you say yes:
deskshell run submit-form --mode apply # → exit 2: 这一步不可逆,需要显式确认
deskshell run submit-form --mode apply --ask # interactive: asks once, then runs
deskshell run submit-form --mode apply --allow-irreversible # CI: explicit flag
preview never needs confirmation — it is read-only. MCP callers pass allowIrreversible: true instead.
Confirmation permits execution; it does not make the action reversible or bypass other policy checks. An explicit irreversible: true prevents an attached undo, including noop, from being recorded as a recovery operation. fs.delete with missing/null undo remains prohibited.
Why a hard gate instead of a warning: for the one class of action you cannot take back, "I clicked past a warning" is not consent.