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.

Failure rolls back

When any step fails, the kernel:

  1. stops the remaining steps;
  2. undoes the failing action's own side effects (a file written before its assertion failed must disappear);
  3. attempts to roll back the successful steps in reverse order, continuing if an individual undo fails;
  4. marks only successfully undone steps rolled-back in the journal;
  5. 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:

Permission boundaries

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.

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

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.