Skip to content

feat: add capsula pull command - #1178

Merged
shunsuke-shimomura merged 3 commits into
mainfrom
feat/pull-command
Sep 9, 2026
Merged

shunsuke-shimomura merged 3 commits into
mainfrom
feat/pull-command

Conversation

@shunsuke-shimomura

Copy link
Copy Markdown
Member

Summary

Adds a capsula pull command that downloads a run from a Capsula server and restores it under the local .capsula directory, as the counterpart of capsula push. Implements the design decided in #1177.

Closes #1177.

capsula pull <RUN_ID> [--vault <NAME>] [--server <URL>] [--force]

How it works

  1. GET /api/v1/runs/{id} — extended in this PR to include a files array (path, size, hash; the server-internal storage_path is not exposed). The run's recorded vault is verified against --vault (default: the vault in capsula.toml).
  2. The run directory is reconstructed deterministically at {YYYY-MM-DD}/{HHMMSS}-{name} from the run's ULID (UTC timestamp) and name — the naming logic is extracted from Run::gen_run_dir into the public capsula_core::run::run_dir_relative_path, so it cannot drift.
  3. Captured files are downloaded one by one through the existing GET /api/v1/runs/{id}/files/{*path} endpoint and verified against the server-recorded SHA-256 hash; a mismatch fails the pull. Files without a stored hash are restored with a warning.
  4. _capsula/{metadata,pre-run,post-run,command}.json are reconstructed from the server's structured data (best-effort as decided in feat: add capsula pull command #1177 — not byte-identical: JSON formatting differs and duration is restored at millisecond precision). A _capsula/pulled.json marker records the origin server URL and pull time, so pulled runs can be told apart from locally produced ones (e.g. by a future push-side guard).
  5. Everything is downloaded into a temporary sibling directory and atomically renamed into place, so an interrupted pull never leaves a half-restored run. An existing run directory fails the pull unless --force is given, in which case it is replaced.

Path containment scope

Restoring writes server-provided file paths to the local disk, so this PR includes a minimal, syntactic containment check (sanitize_relative_path): only relative paths that stay inside the run directory are restored — absolute paths and paths containing .. (or any non-normal component) are rejected as errors. Within a pull, only regular files and directories are ever created, so symlink-based escapes cannot be introduced by the restore itself.

This intentionally stops short of full path-safety hardening. #1177 deferred sanitization entirely; this PR ships the cheap syntactic subset because writing unchecked server paths to disk is an obvious footgun. The full treatment should follow the approach of the in-flight path-safety PR stack (#1097 → #1098 → #1146 → #1147): once ResolvedProjectPath (#1098) and the containment semantics established for capture-file (#1146) land, the pull-side check should be migrated to that shared mechanism instead of growing its own.

Other notes

  • New client methods CapsulaClient::{get_run, download_file, base_url}. File paths are URL-encoded segment by segment with NON_ALPHANUMERIC (allowlist polarity — everything except [0-9A-Za-z] is percent-encoded, keeping / for the wildcard route), so no curated character list needs to be exhaustive. Spaces, URL-significant characters, and non-ASCII filenames all round-trip; server-side path normalization at upload time (e.g. \ → /) is untouched, and pull restores exactly what the server recorded.
  • Shared response types RunRecord / RunDetailFile / RunDetailResponse added to capsula-api-types, following the existing client/server type-sharing convention.
  • The files addition to GET /api/v1/runs/{id} is additive and reuses the exact query already used by the run detail page (no new SQLx offline metadata needed).
  • Runs pulled from a vault other than the configured one are placed in a sibling directory of the configured vault directory, named after their vault.
  • Docs: capsula pull section added to the CLI reference, including the restore semantics and the path/hash verification behavior.

Testing

  • 7 wiremock-based integration tests for pull_single_run: successful restore (files, metadata, hooks, marker), hash mismatch leaves no partial run, vault mismatch, unsafe path rejection, existing directory / --force replacement, not-found handling, and a regression test for URL-significant characters in file paths (spaces) that pins the percent-encoded wire format; plus unit tests for command parsing, path sanitization, and URL path encoding.
  • Server API test asserting GET /api/v1/runs/{id} returns files with path/size/hash and does not leak storage_path.
  • cargo test --workspace, cargo clippy --workspace --all-targets --all-features / --no-default-features, cargo fmt --check --all, cargo doc --workspace --no-deps all clean.
  • End-to-end against a real server + PostgreSQL: capsula run → push → delete local run → pull restored the captured files byte-identically at the exact original directory path, and the restored run works with capsula list / show; --force semantics verified live.

AI assistance

  • AI used: yes — implementation, tests, documentation, and this PR description were prepared with an AI coding agent (Claude Code) following the design decided in feat: add capsula pull command #1177
  • Human review of AI-assisted code: complete

Breaking changes

  • No breaking changes
  • Breaking changes; the breaking change label is applied

@codecov-commenter

codecov-commenter commented Aug 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.55056% with 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.49%. Comparing base (00b1735) to head (6542c96).

Files with missing lines Patch % Lines
crates/capsula-cli/src/main.rs 4.16% 23 Missing ⚠️
crates/capsula-orchestration/src/pull.rs 92.51% 17 Missing ⚠️
crates/capsula-client/src/lib.rs 81.39% 8 Missing ⚠️
crates/capsula-server/src/lib.rs 83.33% 4 Missing ⚠️
crates/capsula-orchestration/src/resolve.rs 75.00% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1178      +/-   ##
==========================================
+ Coverage   60.41%   62.49%   +2.07%     
==========================================
  Files          44       45       +1     
  Lines        4661     4994     +333     
==========================================
+ Hits         2816     3121     +305     
- Misses       1845     1873      +28     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@shunichironomura

Copy link
Copy Markdown
Member

Thank you for the PR. Below is the review of this PR by Claude Code Opus 5. I didn't verify each claim, but if they are true, I agree with the conclusion:

Merge blockers: F1, F2 — and F3 / F6, which share F1's fix.

Other findings are valid but don't need to be addressed in this PR.


Warning

This content was written by an AI agent and must be verified by a human developer. After human verification, this alert may be removed.

Threat model for this review

Per maintainer direction, this review assumes:

  • The server is trusted. No malicious server, no malicious client, no malicious user.
  • Users are fallible. Misconfiguration, typos, unintended CLI usage, and interrupted commands are all
    in scope and are the primary risk to design against.

Summary

The feature is well shaped. Extracting the run-directory naming into capsula_core::run::run_dir_relative_path
so push and pull cannot drift is exactly right; download-to-temp-then-rename is the correct instinct; hash
verification is present and the no-hash case is surfaced rather than silently trusted; the wiremock tests
pin the wire format usefully.

Under the stated trust model the dominant risk is destructive --force semantics: capsula pull --force
over a run that was produced locally replaces an authoritative copy with a strictly lossier reconstruction,
with no warning and no marker check. The second is interrupted pulls leaving debris at exactly the depth
the vault scanners walk
. Both are reachable through completely ordinary use.


Findings at a glance

Paths are relative to crates/. "Step" is the implementation-plan step that addresses the finding.

ID Sev Finding Primary location Step
F1 HIGH --force replaces an authoritative local run with a lossy reconstruction capsula-orchestration/src/pull.rs:77-87 1
F2 HIGH Temp directory sits at the depth the vault scanners walk capsula-orchestration/src/pull.rs:69-72 2
F3 MED pulled.json is never read; push --all re-uploads pulled runs capsula-orchestration/src/pull.rs:207-212 1
F4 MED pull accepts only a ULID while push accepts an ID or a name capsula-cli/src/main.rs:99-101 3
F5 MED New API types are not actually shared with the server capsula-api-types/src/lib.rs:127-178 5
F6 LOW-MED --force guard is checked long before it is used capsula-orchestration/src/pull.rs:53-58, :77-81 1
F7 LOW run.name used unvalidated as a path component capsula-orchestration/src/pull.rs:51 4
F8 LOW Vault name joined into a path unvalidated capsula-cli/src/main.rs:528-540 4
F9 LOW hex_encode duplicated three times capsula-orchestration/src/pull.rs:231-237 6
F10 LOW Two sanitize_relative_path functions with divergent contracts capsula-orchestration/src/pull.rs:138, capsula-server/src/lib.rs:1415 6
F11 LOW Stringly-typed status; untyped hook payloads capsula-api-types/src/lib.rs:158-178 5
F12 LOW Whole-file buffering caps artifact size capsula-client/src/lib.rs 6
F13 LOW encode_file_path over-encodes unreserved characters capsula-client/src/lib.rs 6
F14 LOW Captured-files query duplicated; selects discarded columns capsula-server/src/lib.rs:828-858 5
F15 LOW Silent numeric coercion; divergent numeric types capsula-orchestration/src/pull.rs:194 6
F16 LOW Undocumented round-trip fidelity gaps capsula-orchestration/src/pull.rs:177-188, :219-224 6
F17 LOW Test coverage gaps capsula-orchestration/tests/pull.rs 7

Merge blockers: F1, F2 — and F3 / F6, which share F1's fix.


Findings

Ordered by severity under the stated trust model.

F1 — [HIGH] --force silently replaces an authoritative local run with a lossy reconstruction

Location: crates/capsula-orchestration/src/pull.rs:77-87

--force is documented as "Replace the local run directory if it already exists". What it actually does is
remove_dir_all the existing directory and move a reconstruction into its place — with no check of whether
the existing run was produced locally (authoritative) or previously pulled (already a reconstruction).

A pushed run is a strict subset of the local run. Verified with a probe replicating push_single_run's
collection loop (push.rs:81-91) against a directory containing a regular file, a symlink, an empty
directory, and _capsula:

collected by push: ["real.txt"]

So push → pull --force loses, without warning:

Lost Why
Symlinks entry.file_type().is_file() is false for symlinks; follow_links defaults to false
Empty directories only files are collected
Original _capsula/*.json bytes _capsula is excluded from upload and reconstructed from DB columns
Sub-millisecond duration server stores duration_ms; restored as secs/nanos at ms precision (pull.rs:199-202)
Any file added after the push never uploaded

The realistic sequence needs no mistake beyond a reasonable reading of the flag: "I pulled this run, it
says the directory already exists, the error tells me to use --force, so I use --force."
The error at
pull.rs:54-57 actively suggests it.

_capsula/pulled.json already exists and is exactly the signal needed to distinguish the two cases — but
nothing reads it (F3).

Fix:

  • Refuse --force when the target lacks _capsula/pulled.json (i.e. it is locally produced), requiring a
    separate, explicit opt-in for that case. Reuse the existing marker rather than adding new state.
  • Reword the "already exists" error so it distinguishes the two cases instead of unconditionally
    recommending --force.
  • Document in docs/cli-reference.md that a restored run is a subset of the original, listing what is lost.

F2 — [HIGH] The temp directory sits at exactly the depth the vault scanners walk

Location: crates/capsula-orchestration/src/pull.rs:69-72

let temp = tempfile::Builder::new().prefix(".pull-tmp-").tempdir_in(date_dir)?;

TempDir cleans up on drop, which SIGINT/SIGKILL skips. Ctrl-C during a download — completely routine,
especially for large artifacts — therefore leaves .pull-tmp-XXXX at vault/<date>/.pull-tmp-XXXX. That is
precisely the vault/*/* shape every vault scanner walks:

  • list_runs — crates/capsula-orchestration/src/vault.rs:46-89
  • find_run_dir / find_run_dir_by_name — vault.rs:128, :173 (min_depth(2).max_depth(2))
  • push --all — crates/capsula-cli/src/main.rs:451-471

Two outcomes depending on when the interruption lands:

  1. During download (the common case): invisible garbage that accumulates silently across retries.
    Nothing surfaces it and nothing cleans it up.
  2. After write_capsula_dir (pull.rs:75) but before the rename (pull.rs:82): the leftover has a
    valid _capsula/metadata.json, so it becomes a phantom run — capsula list shows it,
    capsula show resolves to it, and push --all uploads it under a bogus path.

The PR's claim that "an interrupted pull never leaves a half-restored run" holds for the final path only,
not for the vault as a whole.

Fix: create the temp directory directly under target_vault_dir — same filesystem, so the rename stays
atomic, but one level above the depth-2 scan. Optionally sweep stale .pull-tmp-* at the start of a pull.
Filtering the prefix in the scanners is a weaker second line.


F3 — [MEDIUM] pulled.json is written but nothing reads it; push --all re-uploads pulled runs

Location: crates/capsula-orchestration/src/pull.rs:207-212

The marker is the right idea, but no code consumes it — so it currently reads as a safeguard while providing
none (it is also the missing ingredient for F1).

Meanwhile push --all (main.rs:451) pushes every depth-2 directory containing _capsula, which now
includes pulled runs. push --all is a habitual, whole-vault command, so this needs no mistake either.
The reconstruction is explicitly lossy, so the round trip degrades server state:

  • duration truncated to millisecond precision (pull.rs:199-202)
  • parse_command's shlex fallback (pull.rs:219-224) rewrites a legacy command string into a JSON array,
    changing what the server stores on re-push
  • __meta.hook_index is injected into the reconstructed hook JSON; run_outputs upserts with
    ON CONFLICT ... DO UPDATE (server/src/lib.rs:1224), so rows are rewritten from the reconstruction

POST /api/v1/runs returns 409 for an existing run, so the runs row itself is spared — hook rows and
captured files are not.

Fix: implement the guard now (skip pulled runs in push / push --all unless explicitly overridden).
It closes F1 and F3 together.


F4 — [MEDIUM] pull accepts only a ULID while push accepts an ID or a name

Location: crates/capsula-cli/src/main.rs:99-101 vs :88-92, crates/capsula-orchestration/src/pull.rs:49-50

capsula push [run-id-or-name] resolves either form via find_run_dir. capsula pull <RUN_ID> takes only a
ULID. Run names are the human-facing handle everywhere else — capsula list prints them, run-start emits
one, show and run-end take them — so capsula pull happy-river is the natural thing to type.

It fails with "Run 'happy-river' not found on server", which reads as "that run was never pushed" rather
than "wrong identifier kind". The Ulid::from_string check at pull.rs:49 never even runs, because the
server 404s on the name first.

Fix: at minimum, detect a non-ULID argument up front and say so explicitly. Better, accept names by
resolving them server-side (POST /api/v1/runs/search already filters by vault) so pull matches push.


F5 — [MEDIUM] The new API types are not actually shared with the server, so they can drift silently

Location: crates/capsula-api-types/src/lib.rs:127-178, crates/capsula-server/src/lib.rs:828-858, :924-930

(Unaffected by the trust model.)

The PR adds RunRecord / RunDetailFile / RunDetailResponse to capsula-api-types and describes this as
"following the existing client/server type-sharing convention" — but the server still hand-builds the response
with json!, and fetch_run_files_json hand-builds each file entry. capsula-server never references any
of the three types.

The existing convention is the opposite: VaultsResponse, VaultExistsResponse, and UploadResponse are
constructed by the server and serialized via json_response (server/src/lib.rs:473, :513, :1306).

Consequence: renaming a field in the server's json! is not a compile error. The only guard is the new
integration test, which requires live PostgreSQL and so does not run under a plain cargo test --workspace.

Fix: build RunDetailResponse / RunDetailFile in get_run and return them through json_response,
as the surrounding handlers already do.


F6 — [LOW-MEDIUM] --force guard is checked long before it is used, and the comment claims otherwise

Location: crates/capsula-orchestration/src/pull.rs:53-58, :77-81

if target_dir.exists() {
    // Only reachable with --force (checked above).
    std::fs::remove_dir_all(&target_dir)?;
}

The exists() guard is at line 53, before every network round trip. A directory created during the download
is therefore remove_dir_all'd without --force. Benign trigger: two concurrent capsula pull calls for
the same run, or a pull racing a long-running local operation. Narrow, but the comment asserts the opposite of
what holds and the failure mode is data loss.

Fix: if force && target_dir.exists(); if !force and it now exists, fail rather than clobber. One line,
and it also bounds the blast radius of F7.


F7 — [LOW under this trust model] run.name is used unvalidated as a path component

Location: crates/capsula-orchestration/src/pull.rs:51

run.name arrives from GET /api/v1/runs/{id} and is interpolated into a path component
(format!("{time_str}-{name}"), crates/capsula-core/src/run.rs:59) with no validation. run.id is gated by
Ulid::from_string; run.name has no equivalent.

Why this is low here: run names are generated in exactly one place —
Generator::default().next() in create_and_setup_run (orchestration/src/run.rs:23) — and there is no
user-facing way to set one. Under a trusted server with non-malicious clients, a name containing a path
separator cannot arise in practice.

Recorded for completeness, since the behaviour is confirmed rather than theoretical (throwaway probe,
name ../../../../escaped/evil):

result = Ok(".../test-vault/2016-07-30/235410-../../../../escaped/evil")
→ files written to proj/.capsula/escaped/evil/   (outside the vault)
→ with --force, remove_dir_all fires on the escaped path
→ a literal `235410-..` junk directory is left behind even on success

Recommendation: still add the guard, but as cheap robustness rather than a merge blocker — assert
run.name is a single Component::Normal, and assert the computed target_dir resolves under
target_vault_dir. ensure_within_vault (vault.rs:109) already implements the containment pattern.
If the trust model ever changes — a shared or multi-tenant server, since POST /api/v1/runs performs no
validation of name (server/src/models.rs:23) — this returns to high severity immediately.


F8 — [LOW under this trust model] Vault name joined into a path unvalidated

Location: crates/capsula-cli/src/main.rs:528-540

vault_dir.parent()?.join(&vault_name) — note that Path::join with an absolute argument discards the base
entirely, so --vault /tmp/x would target /tmp/x rather than a sibling.

Why this is low here: the pull only proceeds if vault_name equals the run's recorded vault
(pull.rs:40-47), and recorded vault names come from [vault] name in capsula.toml. A typo that is not a
real vault name fails the mismatch check first. The reachable case is therefore misconfiguration: a
[vault] name containing / (e.g. "team/project", which silently nests) or .. (which escapes).

Fix: validate vault_name as a single normal path component before joining — the same helper as F7.
Consider validating [vault] name at config-load time instead, which catches it earlier and for every command.


F9 — [LOW] hex_encode duplicated three times

Location: crates/capsula-orchestration/src/pull.rs:231-237, crates/capsula-orchestration/tests/pull.rs:23-31

capsula_core::util::hex_encode (crates/capsula-core/src/util.rs:7) already exists, is pub, and is
byte-for-byte the same implementation. capsula-orchestration already depends on capsula-core, and
pull.rs:3 already imports from it. The test helper sha256_hex is a third copy.

Fix: use capsula_core::util::hex_encode; in both; delete the local copies.


F10 — [LOW] Two sanitize_relative_path functions with the same name and different contracts

Location: crates/capsula-orchestration/src/pull.rs:138 vs crates/capsula-server/src/lib.rs:1415

server pull
Returns Result<String, &'static str> Result<PathBuf> (anyhow)
./a, a//b normalizes and accepts rejects
NUL byte explicit check none
C:\ prefix explicit check none (on Unix)

They compose safely today only because the server normalizes at upload time, so stored paths never contain the
forms the pull side rejects. The shared name over divergent semantics is a maintenance trap.

Fix: rename the pull-side one (e.g. checked_run_relative_path) or hoist a shared helper into
capsula_core::util next to resolve_relative.


F11 — [LOW] Stringly-typed status and untyped hook payloads

Location: crates/capsula-api-types/src/lib.rs:158-178, crates/capsula-orchestration/src/pull.rs:28-38

status: String matched via match detail.status.as_str() pushes a wire-format concern into a runtime string
compare. An internally-tagged enum expresses the real contract, makes the run: Option<_> / error: Option<_>
correlation type-level, and removes the "Server response is missing the run record" fallback at pull.rs:32:

#[serde(tag = "status", rename_all = "snake_case")]
pub enum RunDetailResponse {
    Ok { run: RunRecord, /* ... */ },
    NotFound { error: String },
    Error { error: String },
}

pre_run_hooks: Vec<serde_json::Value> is similarly untyped; models::HookOutputResponse already describes
the shape and could move into capsula-api-types alongside F5.


F12 — [LOW] Whole-file buffering caps artifact size

Location: crates/capsula-client/src/lib.rs (download_file), crates/capsula-orchestration/src/pull.rs:104-129

response.bytes()?.to_vec() buffers the entire body then copies it, roughly doubling peak memory; the caller
holds the same buffer to hash and write. Capsula captures build artifacts, so multi-hundred-MB files are
plausible — and a large download is also the case most likely to be Ctrl-C'd (F2).

Fix: stream into the destination through a hashing writer (response.copy_to(&mut writer) wrapping
Sha256), verifying the digest after the copy and discarding on mismatch.


F13 — [LOW] encode_file_path over-encodes unreserved characters

Location: crates/capsula-client/src/lib.rs (encode_file_path)

The allowlist-polarity reasoning is sound and worth keeping. But NON_ALPHANUMERIC also encodes -, .,
_, ~, which RFC 3986 §2.3 explicitly discourages: URIs differing only in the percent-encoding of unreserved
characters are equivalent and "should not be created". Practical costs: URLs and server logs read as
result%2Etxt, and some proxies normalize or reject %2E.

Fix: keep the polarity, subtract the unreserved set —
NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~'). This changes two client unit tests and
the integration mock paths (output/result%2Etxt → output/result.txt).


F14 — [LOW] fetch_run_files_json duplicates an existing query and selects columns it discards

Location: crates/capsula-server/src/lib.rs:828-858

Identical to the query at server/src/lib.rs:424 (HTML run-detail page). The new copy selects storage_path
and content_type purely to satisfy query_as!(models::CapturedFile, ...), then drops them.

Fix: one shared fetch_run_files(pool, id) used by both handlers; narrow the projection (or use a
dedicated row struct) if the extra columns are unwanted.


F15 — [LOW] Silent numeric coercion and unused/divergent numeric types

Location: crates/capsula-orchestration/src/pull.rs:194, crates/capsula-api-types/src/lib.rs

let duration_ms = u64::try_from(run.duration_ms.unwrap_or(0)).unwrap_or(0);

A negative duration_ms silently becomes 0. Per the repo's own guidance ("do not use None as an error
signal"), this deserves at least a warn!.

Also: RunDetailFile.size is deserialized but never used — the downloaded length is never compared against the
recorded size (harmless, since the hash covers integrity, but the field is then decorative). And
RunRecord.duration_ms: Option<i64> diverges from the server's Option<i32> (server/src/models.rs:14);
the widening direction is safe, but the mismatch is gratuitous.


F16 — [LOW] Undocumented round-trip fidelity gaps

Location: crates/capsula-orchestration/src/pull.rs:177-188, :219-224

  • A locally created run always writes pre-run.json and post-run.json (orchestration/src/run.rs:71,
    :98), even for an empty hook array. A pulled run omits them when the array is empty, so _capsula layouts
    differ by origin.
  • parse_command's shlex fallback rewrites legacy command strings into arrays — deliberate but irreversible
    (see F3).

The PR is upfront that reconstruction is best-effort; these two specifics just are not in the list, and they
belong in the F1 documentation.


F17 — [LOW] Test coverage gaps

The existing 7 integration + 6 unit tests are good. Missing, in trust-model priority order:

  • --force over a locally produced run — the F1 case; the highest-value missing test.
  • An abandoned .pull-tmp-* with a valid _capsula/metadata.json — asserts list_runs does not surface
    it (F2).
  • A run with no exit_code (created by run-start, never ended) — asserts command.json is absent.
  • A file entry with hash: null — exercises the warn! path at pull.rs:117.
  • --force when the target exists as a file, not a directory — remove_dir_all fails confusingly.
  • run.name / vault name containing separators — regression tests for F7 / F8.
  • The server-side test (api_tests.rs) needs live PostgreSQL, so the files wire contract is unverified in a
    default cargo test --workspace. Addressing F5 makes it compile-checked instead.

Implementation plan

Re-ordered for the "trusted server, fallible user" model. Steps 1–2 are the merge blockers.

Step 1 — Make --force non-destructive (F1, F3, F6) — blocking

These three share one mechanism, so do them together:

  1. Read _capsula/pulled.json in pull_single_run; refuse --force when the existing target lacks it,
    requiring a separate explicit opt-in to overwrite a locally produced run.
  2. Move the removal behind if force && target_dir.exists(); fail rather than clobber if it appeared during
    the download.
  3. Reword the "already exists" error so it distinguishes pulled-copy from local-original instead of
    unconditionally recommending --force.
  4. Have push / push --all skip runs carrying pulled.json unless explicitly overridden.
  5. Tests: --force over a local-origin run is refused; over a previously pulled run it succeeds;
    push --all skips pulled runs.

No dependencies. Highest value per line changed.

Step 2 — Move the temp directory out of the scanned depth (F2) — blocking

Create it under target_vault_dir rather than date_dir, keeping the rename on the same filesystem.
Optionally sweep stale .pull-tmp-* at pull start. Test: an abandoned temp dir with a valid
_capsula/metadata.json must not appear in list_runs.

Independent of Step 1.

Step 3 — Accept run names in pull (F4)

At minimum, detect a non-ULID argument before the request and produce a specific error. Preferably resolve
names server-side so pull matches push's [run-id-or-name].

Step 4 — Cheap path-component hardening (F7, F8)

One shared helper asserting a single Component::Normal, applied to run.name in pull_single_run and to
vault_name in main.rs, plus a containment assertion on target_dir reusing the ensure_within_vault
pattern. Consider validating [vault] name at config-load time so every command benefits.

Low severity under the current trust model, but a few lines. Revisit priority if the deployment model ever
becomes multi-tenant.

Step 5 — Make the API types genuinely shared (F5, F11, F14)

  1. Move HookOutputResponse / HookMetaResponse into capsula-api-types (or mirror them).
  2. Rewrite get_run to construct RunDetailResponse and return it via json_response.
  3. Fold fetch_run_files_json into a shared fetch_run_files used by both the HTML and JSON handlers.
  4. Convert status to an internally-tagged enum and simplify the match in pull.rs.

Do 5.1–5.2 together; 5.4 touches the client and should follow 5.2 so both sides move at once.

Step 6 — Quality cleanups (F9, F10, F12, F13, F15, F16)

Independent and individually small; batch into one follow-up commit:

  • Delete the duplicated hex_encodes in favour of capsula_core::util::hex_encode.
  • Rename or unify the two sanitize_relative_path functions.
  • Stream downloads through a hashing writer instead of buffering whole files.
  • Subtract the RFC 3986 unreserved set from the percent-encode set (updates 3 tests).
  • Warn rather than silently zero on a negative duration_ms; drop or use RunDetailFile.size; align
    duration_ms width with the server model.
  • Document the pre-run.json / post-run.json omission and the parse_command normalization as part of the
    F1 docs.

Step 7 — Fill the remaining test gaps (F17)

Whatever Steps 1–4 do not already cover.


What is good and should not change

  • Extracting run_dir_relative_path into capsula-core so push/pull naming cannot drift — exactly right, and
    the doc comment carries over the original rationale.
  • Download-to-temp-then-rename for atomicity of the final path (the flaw is where the temp lives, F2,
    not the technique).
  • SHA-256 verification against the server-recorded hash, with the no-hash case surfaced as a warning rather
    than silently trusted.
  • Allowlist-polarity percent-encoding with the reasoning written down, plus a regression test pinning the wire
    format (pull_restores_file_with_url_significant_characters_in_name). F13 only narrows the set.
  • Writing pulled.json at all — it is the right primitive, and Step 1 turns it into a real safeguard.
  • The server test asserting storage_path does not leak into the API response.
  • The PR description's honesty about what is best-effort and what is deferred.

shunsuke-shimomura added a commit that referenced this pull request Aug 5, 2026
Merge blockers:
- F1/F3/F6: --force now only replaces runs carrying the
  _capsula/pulled.json marker; locally produced runs are never replaced
  and the already-exists errors distinguish the two cases. push (and
  push --all) refuses/skips pulled runs so lossy reconstructions cannot
  degrade the server's copy. The existence check is re-done at
  replacement time, so a directory appearing during the download is no
  longer clobbered.
- F2: the download temp directory now lives directly under the vault
  root (same filesystem, atomic rename) instead of vault/<date>/, so an
  interrupted pull cannot leave a phantom run at the depth the vault
  scanners walk.

Non-blockers taken along:
- F4 (minimum): non-ULID pull arguments fail fast with a specific error
- F7/F8: run name and vault name are validated as single path
  components before being joined into paths
- F9: reuse capsula_core::util::hex_encode instead of local copies
- F10: rename the pull-side sanitizer to checked_run_relative_path to
  stop sharing a name with the server's normalizing variant
- F13: percent-encoding no longer escapes RFC 3986 unreserved chars
- F14: shared fetch_run_files used by both run-detail handlers
- F15: verify downloaded sizes; warn on negative duration_ms; align
  duration_ms width with the server model
- F16: document the round-trip fidelity gaps in the CLI reference
- F17: regression tests for all of the above (16 integration tests)
@shunsuke-shimomura

shunsuke-shimomura commented Aug 5, 2026 •

Copy link
Copy Markdown
Member Author

Thank you for the thorough review — the findings all checked out against the implementation. Addressed in 978ec85 (rebased onto latest main):

Merge blockers

  • F1: --force now only replaces run directories carrying the _capsula/pulled.json marker; locally produced runs are never replaced (not even with --force). The "already exists" errors distinguish the two cases, and the pulled-copy one is the only one that recommends --force. The subset nature of a restored run (symlinks, empty directories, original _capsula bytes, sub-ms duration, post-push files) is documented in the CLI reference.
  • F2: the temp directory now lives directly under the vault root — same filesystem, atomic rename preserved, but outside the vault/*/* depth. A regression test asserts an abandoned .pull-tmp-* with a valid metadata.json is invisible to list_runs / find_run_dir.
  • F3: push refuses pulled runs before any network access, and push --all skips them (reported separately in the summary line).
  • F6: existence + marker are re-checked at replacement time; a directory that appeared during the download fails the pull instead of being clobbered.

Non-blockers taken along: F4 (minimum: non-ULID arguments fail fast with a specific message), F7/F8 (run name and vault name validated as single path components), F9 (capsula_core::util::hex_encode reused), F10 (pull-side check renamed checked_run_relative_path), F13 (RFC 3986 unreserved characters no longer escaped), F14 (shared fetch_run_files for both run-detail handlers), F15 (size verification on download, warn! on negative duration_ms, duration_ms width aligned), F16 (fidelity gaps documented), F17 (integration tests grown from 7 to 16, covering all of the above).

Two deliberate deviations from the suggested fixes

  • Step 1.4's "unless explicitly overridden": there is currently no override flag — pushing a pulled run is refused unconditionally. An opt-in flag can be added when a real need appears.
  • Step 2's optional stale-.pull-tmp-* sweep at pull start is omitted: it would race a concurrent pull's live temp directory, and leftovers are inert and invisible to the scanners.

Deferred

Comment thread crates/capsula-cli/src/main.rs Outdated
Comment thread crates/capsula-orchestration/src/push.rs Outdated
shunsuke-shimomura added a commit that referenced this pull request Sep 8, 2026
Pull runs of a non-configured vault into the default vault location.

The configured `[vault] path` describes the configured vault only, so
restoring another vault's run next to it placed the run in a directory
that was never chosen for it (an external directory, for instance).
The default `.capsula/<name>` layout only existed as an inline format
string inside `VaultConfig`'s Deserialize impl, which is why `pull`
could not ask for it. Extract it as `DEFAULT_VAULT_ROOT` plus
`default_vault_path()` in capsula-config and use it from both the
config default and `pull`.

A run of another vault now defaults to `.capsula/<vault>/` under the
project root. An explicit `--vault-path` / `CAPSULA_VAULT_PATH`
override still wins, so `resolve_vault_path()` reports its source
through `VaultPathSource` and `LoadedConfig` carries it. The decision
lives in `resolve_pull_vault_dir()` so it is unit-testable.

Also centralize the pulled-run check: `is_pulled_run()` is now public
and replaces the open-coded `pulled.json` checks in `push_single_run()`
and in the `push --all` skip.
shunsuke-shimomura added a commit that referenced this pull request Sep 8, 2026
Merge blockers:
- F1/F3/F6: --force now only replaces runs carrying the
  _capsula/pulled.json marker; locally produced runs are never replaced
  and the already-exists errors distinguish the two cases. push (and
  push --all) refuses/skips pulled runs so lossy reconstructions cannot
  degrade the server's copy. The existence check is re-done at
  replacement time, so a directory appearing during the download is no
  longer clobbered.
- F2: the download temp directory now lives directly under the vault
  root (same filesystem, atomic rename) instead of vault/<date>/, so an
  interrupted pull cannot leave a phantom run at the depth the vault
  scanners walk.

Non-blockers taken along:
- F4 (minimum): non-ULID pull arguments fail fast with a specific error
- F7/F8: run name and vault name are validated as single path
  components before being joined into paths
- F9: reuse capsula_core::util::hex_encode instead of local copies
- F10: rename the pull-side sanitizer to checked_run_relative_path to
  stop sharing a name with the server's normalizing variant
- F13: percent-encoding no longer escapes RFC 3986 unreserved chars
- F14: shared fetch_run_files used by both run-detail handlers
- F15: verify downloaded sizes; warn on negative duration_ms; align
  duration_ms width with the server model
- F16: document the round-trip fidelity gaps in the CLI reference
- F17: regression tests for all of the above (16 integration tests)
shunsuke-shimomura added a commit that referenced this pull request Sep 8, 2026
Pull runs of a non-configured vault into the default vault location.

The configured `[vault] path` describes the configured vault only, so
restoring another vault's run next to it placed the run in a directory
that was never chosen for it (an external directory, for instance).
The default `.capsula/<name>` layout only existed as an inline format
string inside `VaultConfig`'s Deserialize impl, which is why `pull`
could not ask for it. Extract it as `DEFAULT_VAULT_ROOT` plus
`default_vault_path()` in capsula-config and use it from both the
config default and `pull`.

A run of another vault now defaults to `.capsula/<vault>/` under the
project root. An explicit `--vault-path` / `CAPSULA_VAULT_PATH`
override still wins, so `resolve_vault_path()` reports its source
through `VaultPathSource` and `LoadedConfig` carries it. The decision
lives in `resolve_pull_vault_dir()` so it is unit-testable.

Also centralize the pulled-run check: `is_pulled_run()` is now public
and replaces the open-coded `pulled.json` checks in `push_single_run()`
and in the `push --all` skip.
shunsuke-shimomura added a commit that referenced this pull request Sep 8, 2026
Merge blockers:
- F1/F3/F6: --force now only replaces runs carrying the
  _capsula/pulled.json marker; locally produced runs are never replaced
  and the already-exists errors distinguish the two cases. push (and
  push --all) refuses/skips pulled runs so lossy reconstructions cannot
  degrade the server's copy. The existence check is re-done at
  replacement time, so a directory appearing during the download is no
  longer clobbered.
- F2: the download temp directory now lives directly under the vault
  root (same filesystem, atomic rename) instead of vault/<date>/, so an
  interrupted pull cannot leave a phantom run at the depth the vault
  scanners walk.

Non-blockers taken along:
- F4 (minimum): non-ULID pull arguments fail fast with a specific error
- F7/F8: run name and vault name are validated as single path
  components before being joined into paths
- F9: reuse capsula_core::util::hex_encode instead of local copies
- F10: rename the pull-side sanitizer to checked_run_relative_path to
  stop sharing a name with the server's normalizing variant
- F13: percent-encoding no longer escapes RFC 3986 unreserved chars
- F14: shared fetch_run_files used by both run-detail handlers
- F15: verify downloaded sizes; warn on negative duration_ms; align
  duration_ms width with the server model
- F16: document the round-trip fidelity gaps in the CLI reference
- F17: regression tests for all of the above (16 integration tests)
shunsuke-shimomura added a commit that referenced this pull request Sep 8, 2026
Pull runs of a non-configured vault into the default vault location.

The configured `[vault] path` describes the configured vault only, so
restoring another vault's run next to it placed the run in a directory
that was never chosen for it (an external directory, for instance).
The default `.capsula/<name>` layout only existed as an inline format
string inside `VaultConfig`'s Deserialize impl, which is why `pull`
could not ask for it. Extract it as `DEFAULT_VAULT_ROOT` plus
`default_vault_path()` in capsula-config and use it from both the
config default and `pull`.

A run of another vault now defaults to `.capsula/<vault>/` under the
project root. An explicit `--vault-path` / `CAPSULA_VAULT_PATH`
override still wins, so `resolve_vault_path()` reports its source
through `VaultPathSource` and `LoadedConfig` carries it. The decision
lives in `resolve_pull_vault_dir()` so it is unit-testable.

Also centralize the pulled-run check: `is_pulled_run()` is now public
and replaces the open-coded `pulled.json` checks in `push_single_run()`
and in the `push --all` skip.
Add a pull command that downloads a run from a Capsula server and
restores it under the local vault, as the counterpart of capsula push
(#1177):

- Extend GET /api/v1/runs/{id} with a files array (path, size, hash);
  the server-internal storage_path is not exposed
- Reconstruct the run directory deterministically from the run's ULID
  and name via the new capsula_core::run::run_dir_relative_path (shared
  with local run creation)
- Verify downloaded files against server-recorded SHA-256 hashes;
  restore _capsula metadata best-effort from the server's structured
  data and write a _capsula/pulled.json origin marker
- Download into a temporary directory and rename atomically; existing
  run directories require --force to replace
- Restore only relative paths contained in the run directory (syntactic
  check); full path-safety hardening follows the ResolvedProjectPath
  work (#1098, #1146)
- Encode URL file paths segment-wise with NON_ALPHANUMERIC (allowlist)
  so arbitrary filenames round-trip without a curated character list
Merge blockers:
- F1/F3/F6: --force now only replaces runs carrying the
  _capsula/pulled.json marker; locally produced runs are never replaced
  and the already-exists errors distinguish the two cases. push (and
  push --all) refuses/skips pulled runs so lossy reconstructions cannot
  degrade the server's copy. The existence check is re-done at
  replacement time, so a directory appearing during the download is no
  longer clobbered.
- F2: the download temp directory now lives directly under the vault
  root (same filesystem, atomic rename) instead of vault/<date>/, so an
  interrupted pull cannot leave a phantom run at the depth the vault
  scanners walk.

Non-blockers taken along:
- F4 (minimum): non-ULID pull arguments fail fast with a specific error
- F7/F8: run name and vault name are validated as single path
  components before being joined into paths
- F9: reuse capsula_core::util::hex_encode instead of local copies
- F10: rename the pull-side sanitizer to checked_run_relative_path to
  stop sharing a name with the server's normalizing variant
- F13: percent-encoding no longer escapes RFC 3986 unreserved chars
- F14: shared fetch_run_files used by both run-detail handlers
- F15: verify downloaded sizes; warn on negative duration_ms; align
  duration_ms width with the server model
- F16: document the round-trip fidelity gaps in the CLI reference
- F17: regression tests for all of the above (16 integration tests)
Pull runs of a non-configured vault into the default vault location.

The configured `[vault] path` describes the configured vault only, so
restoring another vault's run next to it placed the run in a directory
that was never chosen for it (an external directory, for instance).
The default `.capsula/<name>` layout only existed as an inline format
string inside `VaultConfig`'s Deserialize impl, which is why `pull`
could not ask for it. Extract it as `DEFAULT_VAULT_ROOT` plus
`default_vault_path()` in capsula-config and use it from both the
config default and `pull`.

A run of another vault now defaults to `.capsula/<vault>/` under the
project root. An explicit `--vault-path` / `CAPSULA_VAULT_PATH`
override still wins, so `resolve_vault_path()` reports its source
through `VaultPathSource` and `LoadedConfig` carries it. The decision
lives in `resolve_pull_vault_dir()` so it is unit-testable.

Also centralize the pulled-run check: `is_pulled_run()` is now public
and replaces the open-coded `pulled.json` checks in `push_single_run()`
and in the `push --all` skip.
@shunsuke-shimomura
shunsuke-shimomura merged commit 1d26a2b into main Sep 9, 2026
9 checks passed
@shunsuke-shimomura
shunsuke-shimomura deleted the feat/pull-command branch September 9, 2026 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add capsula pull command

3 participants