From a29540b0945dd2828857e564a6ec22061076d412 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 7 Aug 2026 13:08:12 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20configurable=20share=20targets?= =?UTF-8?q?=20=E2=80=94=20S3,=20a=20folder,=20or=20Pathbase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `path share` uploaded to Pathbase and nowhere else. It can now be pointed at an S3 bucket or a plain folder once, and stay there. Designating a target: path auth default ~/Dropbox/toolpath-traces # no credentials needed path auth default s3://my-bucket/traces path auth default pathbase path auth default # what's in effect, and why The value lives in one place, `default_target` in `~/.toolpath/config.json`, so "where does my next share go?" has one answer rather than a precedence puzzle across each backend's own config. A scheme-less value is a local path; a bucket is spelled `s3://`. Folder targets persist as `file://` URLs so a stored default can't drift with the working directory, but display as plain paths so the printed location pastes straight into `path resume`. Resolution: `--to`, then `$TOOLPATH_SHARE_TARGET`, then the stored default, then Pathbase. Nothing is inferred from which credentials happen to exist — a share that silently changes destination is a data-egress bug, not a convenience. The one guard sits at the bottom of the order: with S3 credentials stored, no Pathbase session, and no default set, share refuses rather than falling through to the anonymous public endpoint. `path share --to ` overrides for one call. The Pathbase-only flags (`--anon`, `--repo`, `--public`, `--url`, `--name`) select Pathbase on their own, overriding an object default; pairing one with an explicit object `--to` is an error rather than a silent resolution. Target selection splits into a pure `plan_target` that runs before the harness scan and the picker, and an `open_target` that probes credentials before the picker fires — so neither a typo nor a bad credential costs a session pick. `path resume s3://bucket/key.json` is the inverse, caching downloads under an `s3--` id so `--force` and `--no-cache` behave as they do for Pathbase. A document shared to a folder resumes from its plain path, which already worked. `path auth s3 login` stores connection settings and credentials at `~/.toolpath/s3.json` (0600) — deliberately not a destination, so one stored credential serves any number of buckets and a folder target needs no `auth s3` at all. It merges rather than replaces, and prompts without echoing the secret. Stored values beat the AWS environment; with neither, the AWS credential chain still applies. Plumbing: `path p export object` / `path p import object`, both aliased `s3`. With no `--to`, export writes wherever `path share` would. Transport is the `object_store` crate, so one code path serves AWS S3, any S3-compatible endpoint (R2, MinIO, Ceph, B2), and `file://` for a folder. The folder backend is what the tests round-trip against, so share and resume are covered end-to-end with no network and no mock HTTP server. `crates/path-cli/src/store.rs` splits where-to-write (`Destination`, `ObjectUri` — pure URL parsing and key layout) from how-to-authenticate (`S3Settings`); `crates/path-cli/src/target.rs` sits above it and owns the Pathbase-vs-object choice. Config files under the config dir now share `config::write_private_json` / `read_private_json`, which own the 0600-file / 0700-parent story. --- CHANGELOG.md | 67 +++ CLAUDE.md | 69 ++- Cargo.lock | 257 +++++++++- Cargo.toml | 2 +- README.md | 14 + crates/path-cli/Cargo.toml | 9 +- crates/path-cli/src/cmd_auth.rs | 324 ++++++++++++- crates/path-cli/src/cmd_export.rs | 64 +++ crates/path-cli/src/cmd_import.rs | 25 + crates/path-cli/src/cmd_pathbase.rs | 34 +- crates/path-cli/src/cmd_resume.rs | 95 ++-- crates/path-cli/src/cmd_share.rs | 345 ++++++++++++-- crates/path-cli/src/config.rs | 46 ++ crates/path-cli/src/derive.rs | 22 + crates/path-cli/src/lib.rs | 10 +- crates/path-cli/src/store.rs | 633 +++++++++++++++++++++++++ crates/path-cli/src/target.rs | 475 +++++++++++++++++++ crates/path-cli/tests/resume.rs | 152 ++++++ crates/path-cli/tests/share_targets.rs | 453 ++++++++++++++++++ crates/toolpath-cli/Cargo.toml | 4 +- site/_data/crates.json | 6 +- 21 files changed, 2972 insertions(+), 134 deletions(-) create mode 100644 crates/path-cli/src/store.rs create mode 100644 crates/path-cli/src/target.rs create mode 100644 crates/path-cli/tests/share_targets.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f143f2d..34cc6efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,73 @@ All notable changes to the Toolpath workspace are documented here. +## Share and resume anywhere: S3, a folder, or Pathbase — 2026-08-07 + +**`path-cli`** (0.17.0) makes the share destination configurable. Bare +`path share` still goes to Pathbase by default, but it can now be +pointed at an S3 bucket or a plain folder once, and stay there. + +**Designating a target.** `path auth default ` sets it, and +`path auth default` with no argument prints what's in effect and why: + +``` +path auth default ~/Dropbox/toolpath-traces # a folder — no credentials needed +path auth default s3://my-bucket/traces +path auth default pathbase # switch back +path auth default --clear +``` + +The value is stored as `default_target` in `~/.toolpath/config.json` — +one place, so "where does my next share go?" has one answer. A +scheme-less value is a **local path**; spell a bucket `s3://…`. Folder +targets are stored as `file://` URLs, so a stored default can't drift +with the working directory, but they're displayed and printed as plain +paths. Resolution order: `--to`, then `$TOOLPATH_SHARE_TARGET`, then +the stored default, then Pathbase. + +Nothing is inferred from which credentials happen to exist: a share +that silently changes destination is a data-egress bug, not a +convenience. The one guard is at the bottom of the order — if S3 +credentials are stored, no Pathbase session exists, and no default is +set, `path share` refuses rather than falling through to the +*anonymous public* Pathbase endpoint. + +**Per-call override.** `path share --to ` takes the same three +forms. The Pathbase-only flags (`--anon`, `--repo`, `--public`, +`--url`, `--name`) select Pathbase on their own, overriding an object +default; combining one with an explicit object `--to` is an error +rather than a silent resolution. The target is resolved before the +harness scan and the picker, so a typo costs nothing to discover. + +**Resume.** `path resume s3://bucket/key.json` is the inverse — a new +input shape alongside Pathbase URLs, shorthands, file paths, and cache +ids, caching downloads under an `s3--` id so `--force` and +`--no-cache` behave exactly as they do for Pathbase. A document shared +to a folder is resumed with its plain path, which already worked. + +**S3 credentials.** `path auth s3 login` stores region, endpoint, +addressing style, and credentials at `~/.toolpath/s3.json` (0600) — +connection only, deliberately not a destination, so one stored +credential serves any number of buckets. It merges rather than +replaces, so `path auth s3 login --region eu-west-1` is a valid tweak; +run it bare in a terminal and it prompts, without echoing the secret. +`path auth s3 status` prints the settings in effect with secrets +redacted and environment-supplied values marked `(env)`. Stored values +win over the environment (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +`AWS_SESSION_TOKEN`, `AWS_REGION`, `AWS_ENDPOINT_URL_S3`); with +neither, the AWS credential chain still applies, so an EC2 instance +role needs no configuration at all. + +**Plumbing.** `path p export object --input [--to DEST]` and +`path p import object `, both aliased `s3`. With no `--to`, +export writes wherever `path share` would. + +Transport is the `object_store` crate, so one code path covers AWS S3, +any S3-compatible endpoint (R2, MinIO, Ceph, B2 — point `--endpoint` at +it), and `file://` for a folder. The folder backend is what the tests +round-trip against, so share and resume are exercised end-to-end +without a network or a mock HTTP server. + ## Projected Claude sessions are resumable again — 2026-07-30 Two fixes found by live-resuming a projected session against the real diff --git a/CLAUDE.md b/CLAUDE.md index 7fb2d744..b389e02c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,21 +112,30 @@ cargo run -p path-cli -- p import opencode --session ses_ cargo run -p path-cli -- p import cursor --session # IDE composer from state.vscdb cargo run -p path-cli -- p import pi --project /path/to/project cargo run -p path-cli -- p import pathbase +cargo run -p path-cli -- p import object s3://my-bucket/traces/claude-abc.json cargo run -p path-cli -- p import claude --project . --no-cache | path p render md --input - -# Share an agent session to Pathbase (interactive picker, single-shot) +# Share an agent session (interactive picker, single-shot). Bare `share` +# goes wherever `path auth default` points — Pathbase unless changed. cargo run -p path-cli -- share cargo run -p path-cli -- share --harness claude --session --project /path/to/project cargo run -p path-cli -- share --url https://my-pathbase.example +# Override the target for one call: a bucket, a folder, or Pathbase +cargo run -p path-cli -- share --to s3://my-bucket/traces +cargo run -p path-cli -- share --to ~/Dropbox/toolpath-traces +cargo run -p path-cli -- share --to pathbase # force Pathbase for one call + # Resume a Toolpath document into your coding agent of choice (interactive harness picker) -cargo run -p path-cli -- resume +cargo run -p path-cli -- resume +cargo run -p path-cli -- resume s3://my-bucket/traces/claude-abc.json cargo run -p path-cli -- resume --harness claude -C /path/to/project # Plumbing: export toolpath documents into external formats. is a # cache id or a file path. cargo run -p path-cli -- p export claude --input --project /tmp/sandbox cargo run -p path-cli -- p export claude --input --output conv.jsonl +cargo run -p path-cli -- p export object --input --to s3://my-bucket/traces cargo run -p path-cli -- p export cursor --input --project /tmp/workspace # writes composer rows into state.vscdb cargo run -p path-cli -- p export cursor --input --output composer.json cargo run -p path-cli -- p export pathbase --input @@ -168,6 +177,11 @@ cargo run -p path-cli -- auth login cargo run -p path-cli -- auth status cargo run -p path-cli -- auth whoami cargo run -p path-cli -- auth logout +cargo run -p path-cli -- auth default s3://my-bucket/traces # or a folder: ~/Dropbox/traces +cargo run -p path-cli -- auth default # print what's in effect, and why +cargo run -p path-cli -- auth s3 login --access-key-id AKIA... # credentials only +cargo run -p path-cli -- auth s3 status +cargo run -p path-cli -- auth s3 logout ``` **Breaking** (pre-1.0). The previous top-level commands `path import`, @@ -177,7 +191,7 @@ cargo run -p path-cli -- auth logout and no deprecation shim. They all now live exclusively under `path p …`. -The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. `p cache sync` fills the cache incrementally from the installed agent harnesses (see "Things to know") and always overwrites what it re-derives. +The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`), `s3-my-bucket-traces_claude-abc` (object downloads key on `-` with slashes flattened; `s3a://` folds into the same `s3` prefix so an alias can't fork the cache). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. `p cache sync` fills the cache incrementally from the installed agent harnesses (see "Things to know") and always overwrites what it re-derives. `path auth login` prints `/auth/cli`; the user opens it, logs in, and pastes the 8-character code back into the CLI. The CLI calls @@ -187,6 +201,46 @@ writes to `~/.toolpath/credentials.json` (0600, parent dir 0700) and sends as overrides the credentials directory. Server URL comes from `--url`, then `$PATHBASE_URL`, then `https://pathbase.dev`. +**Where shares go** is one setting in one place: `default_target` in +`~/.toolpath/config.json`, set by `path auth default ` and read by +`crate::target`. A target is the literal `pathbase`, or an object-storage +location — an S3 bucket (`s3://bucket/prefix`) or a plain folder +(`~/Dropbox/traces`, `/srv/traces`, `file:///srv/traces`). **A scheme-less +value is a local path, not a bucket**: bare strings are what people type +when designating a folder, and a bucket is unambiguous when spelled +`s3://`. Folder targets are persisted as `file://` URLs (so a stored +default can't be re-read relative to a different cwd) but displayed and +printed as plain paths (so the printed location pastes straight into +`path resume`, which already accepts file paths). Resolution order: +`--to` → `$TOOLPATH_SHARE_TARGET` → `default_target` → Pathbase. +`path auth default` with no argument prints the stored value, what is +actually in effect, and which of those four supplied it. + +Nothing is inferred from which credentials happen to exist — a share that +silently changes destination is a data-egress bug, not a convenience — with +one guard at the bottom of the order: if S3 credentials are stored, there's +no Pathbase session, and no default is set, share **refuses** rather than +falling through to the anonymous public Pathbase endpoint. Status commands +use `target::describe_effective`, which never inherits that refusal. + +`path auth s3 login` stores S3 *connection* settings at +`~/.toolpath/s3.json` (0600, parent dir 0700, same `$TOOLPATH_CONFIG_DIR` +root): region, endpoint, access key id, secret access key, session token, +and an optional virtual-hosted-style toggle. Deliberately **not** a +destination — that's the share target — so one stored credential serves any +number of buckets, and a folder target needs no `auth s3` at all. It +**merges** into what's already stored — only the fields you pass change — +so `path auth s3 login --region eu-west-1` is a valid tweak; run it bare in +a terminal with nothing stored yet and it prompts, reading the secret +without echoing it. Stored values take precedence over the environment +(`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, +`AWS_REGION`/`AWS_DEFAULT_REGION`, +`AWS_ENDPOINT_URL_S3`/`AWS_ENDPOINT_URL`); with neither, `object_store` +falls through to the AWS credential chain (instance metadata, web +identity), so an EC2 box with a role needs no configuration. +`path auth s3 status` prints the effective settings with secrets redacted +and env-supplied values marked `(env)`. + The CLI redeem endpoint (`POST /api/v1/auth/cli/redeem`) is real and works in production but is **not listed in `schema/pathbase-openapi.json`** — the OpenAPI spec only covers the documented surface. Don't be surprised that @@ -218,7 +272,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 358 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 380 unit + 135 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, share targets over a local-folder backend — `auth default` set/print/clear/env-precedence, `auth s3 login/status/logout`, `p export object` → `p import object` round trip, Pathbase-flag interaction, resume-from-object-storage with cache/`--no-cache`/missing-object cases — fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -285,9 +339,10 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. -- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. -- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. +- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. `--to ` overrides the destination for one call — `pathbase`, an S3 bucket, or a folder (see "Where shares go" above). The Pathbase-only flags select Pathbase on their own, overriding an object-storage default; combining one with an explicit object `--to` is an error rather than a silent resolution. Target selection is split in two: `cmd_share::plan_target` is pure (config read only, no network) and runs **first**, before the harness scan and the picker, so a typo'd `--to` or a flag conflict costs nothing to discover; `cmd_share::open_target` then probes credentials — S3 settings, or a Pathbase auth preflight — before the picker fires, so a credential failure doesn't waste the derive/cache work either. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. +- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an object-storage URL (`s3://bucket/key.json`, also `s3a://`/`file://`/`memory://`), an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching remote fetches under `~/.toolpath/documents/` unless `--no-cache`, via the shared `cmd_resume::fetch_cached` — every remote shape computes its cache id from the reference alone, so a cache hit costs no round trip); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive / peek-dir / scope-match — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--project-under` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--project-under ` on both `p cache sync` and `path query` restricts ingestion to sessions whose project directory (recorded cwd) is under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Object-storage transport: `crates/path-cli/src/store.rs` splits *where* from *how to authenticate* — `Destination`/`ObjectUri` (pure URL parsing and key layout, no credentials) and `S3Settings` (`~/.toolpath/s3.json`, env merge, `get`/`put`). `crates/path-cli/src/target.rs` sits above it and owns the Pathbase-vs-object choice plus the stored default. Transport is the `object_store` crate, so one code path serves AWS S3, any S3-compatible endpoint (R2, MinIO, Ceph, B2 — set `endpoint`), and `file://` for a local directory; `object_store` is async, so it tunnels through the same `cmd_pathbase::block_on` runtime the Pathbase client uses. `file://` is a first-class destination, not a toy — `path auth default ~/traces` is a complete setup needing no credentials — and it's what the tests round-trip against, so share and resume are covered end-to-end with no network and no mock HTTP server. Accepted schemes are deliberately narrower than what `object_store` parses (`s3`, `s3a`, `file`, `memory`) — `http`/`https` belong to Pathbase in the same dispatch, and `gs://`/`az://` would need feature flags we don't compile in. Object keys are `/.json`, so where a document lands is a pure function of the destination and the document's cache id. Config files under the config dir (Pathbase sessions, S3 settings, `config.json`) all go through `config::write_private_json` / `read_private_json`, which own the 0600-file / 0700-parent story. - Claude Code plugin: `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`, so commands are `/path:share` and `/path:query`). The plugin does **not** commit binaries — both commands invoke the CLI through `plugins/claude-code/scripts/ensure-path.sh`, which prefers an existing Toolpath `path` on PATH (identity-checked via `--help`), else `~/.local/bin/path`, else `~/.toolpath/bin/path`, else downloads the latest GitHub release (sha256-verified, same logic as `scripts/install.sh`) and installs globally to `~/.local/bin` — falling back to `~/.toolpath/bin` when a foreign binary named `path` claims the name. Two hard-won constraints baked into the command docs: slash-command inline `!` context commands and model-issued Bash must not contain `$PWD`/variables (Claude Code's permission checker rejects commands it can't statically analyze — hence the `sessions` and `current-session` helper modes, the latter reading `$CLAUDE_CODE_SESSION_ID` so no-arg `/path:share` shares exactly the running session), and `--project` must always be an absolute path (path-cli does not canonicalize relative `--project` values; `.` silently matches nothing). Tests: `scripts/test-plugin.sh` (manifest consistency + offline bootstrap tests against a stubbed curl/release), wired in as the `plugin` quality gate; plugin shell scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Future harness integrations go under `plugins//` (only Claude Code plugins are marketplace entries; other harnesses distribute their own way). Version bumps: keep `plugins/claude-code/.claude-plugin/plugin.json` and the matching entry in `.claude-plugin/marketplace.json` in lockstep (test-plugin.sh asserts this); the binary is unpinned (latest release) with `MIN_VERSION` in ensure-path.sh naming the oldest CLI the command docs support. -- `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. +- `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github, pathbase, and s3 are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.lock b/Cargo.lock index d9b355b7..2c130fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1685feee7d06d8813fe963f814c5c398d90392b9c3c41e656ac3100d5c334536" +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic" version = "0.6.1" @@ -269,6 +280,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -349,6 +369,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -543,6 +574,25 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -583,6 +633,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -781,8 +840,18 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -1036,7 +1105,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca1f7b0dce1c25254457f2edcfcf611bf207113564b96f90659a3f54506016b2" dependencies = [ - "itertools", + "itertools 0.14.0", "raw-cpuid", ] @@ -1189,6 +1258,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1334,6 +1404,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1661,6 +1746,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2065,6 +2159,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2338,6 +2442,48 @@ dependencies = [ "memchr", ] +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "chrono", + "crc-fast", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools 0.15.0", + "md-5", + "nix 0.31.3", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.2", + "reqwest", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2441,7 +2587,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "assert_cmd", @@ -2454,11 +2600,13 @@ dependencies = [ "jaq-json", "jaq-std", "jsonschema", + "object_store", "pathbase-client", "predicates", "rand 0.9.4", "regex", "reqwest", + "rpassword", "rusqlite", "serde", "serde_json", @@ -2480,6 +2628,7 @@ dependencies = [ "toolpath-md", "toolpath-opencode", "toolpath-pi", + "url", "uuid", ] @@ -2817,6 +2966,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2913,6 +3072,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2938,6 +3108,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -2962,7 +3138,7 @@ dependencies = [ "compact_str", "hashbrown 0.16.1", "indoc", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "strum", @@ -3014,7 +3190,7 @@ dependencies = [ "hashbrown 0.16.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "strum", @@ -3217,6 +3393,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3550,8 +3747,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3561,8 +3758,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3727,6 +3924,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3794,6 +3997,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4283,9 +4497,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4447,7 +4673,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -4963,6 +5189,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index ec3e2606..a7eef89f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.16.1", path = "crates/path-cli" } +path-cli = { version = "0.17.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/README.md b/README.md index d3a82eba..bf6fd83e 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,23 @@ path p export pathbase --input claude- # (full URL or bare `//` triple) path p import pathbase https://pathbase.dev/alex/pathstash/path-pr-42 +# Send shares somewhere else instead. Designate it once, and bare +# `path share` goes there from then on. +path auth default ~/Dropbox/toolpath-traces # a folder — no credentials needed +path auth default s3://my-bucket/traces # or a bucket +path auth s3 login # credentials, if the bucket needs them +path share # → wherever you pointed it +path auth default # what's in effect, and why + +# Override for one call, without changing the default +path share --to /tmp/scratch +path share --to pathbase + # Resume a Toolpath document into your coding agent of choice (interactive # harness picker; project the session and exec the harness's resume command) path resume https://pathbase.dev/alex/pathstash/path-pr-42 +path resume s3://my-bucket/traces/claude-.json +path resume ~/Dropbox/toolpath-traces/claude-.json path resume claude- --harness claude -C /path/to/project # Query the whole local cache with a jaq (jq) filter over wrapped steps diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 1ce394b6..d07f0afc 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.16.1" +version = "0.17.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" @@ -42,6 +42,13 @@ jaq-std = "3.0.1" jaq-json = "2.0.1" [target.'cfg(not(target_os = "emscripten"))'.dependencies] +# Share/resume over S3. `aws` covers every S3-compatible endpoint; +# the default `fs` feature gives `file://` for local destinations and +# for the round-trip tests. +object_store = { version = "0.14.1", features = ["aws"] } +url = "2" +# No-echo prompt for the S3 secret access key in `path auth s3 login`. +rpassword = "7" toolpath-claude = { workspace = true, features = ["watcher"] } toolpath-gemini = { workspace = true, features = ["watcher"] } toolpath-codex = { workspace = true } diff --git a/crates/path-cli/src/cmd_auth.rs b/crates/path-cli/src/cmd_auth.rs index 621dbc4a..7a2e375f 100644 --- a/crates/path-cli/src/cmd_auth.rs +++ b/crates/path-cli/src/cmd_auth.rs @@ -1,11 +1,14 @@ use anyhow::{Result, anyhow}; -use clap::Subcommand; +use clap::{Args, Subcommand}; +use std::io::IsTerminal; use std::path::Path; use crate::cmd_pathbase::{ StoredSession, api_logout, api_me, api_redeem, clear_session, credentials_path, load_session, prompt_line, resolve_url, store_session, }; +use crate::store::{self, S3Settings}; +use crate::target::{self, Target}; #[derive(Subcommand, Debug)] pub enum AuthOp { @@ -25,18 +28,145 @@ pub enum AuthOp { Status, /// Verify the stored session against the server and print the current user Whoami, + /// Set (or show) where `path share` uploads by default: `pathbase`, + /// an S3 bucket (`s3://bucket/prefix`), or a folder (`~/traces`) + Default { + /// The target to make default. Omit to print the current one. + #[arg(index = 1)] + target: Option, + + /// Forget the configured default, falling back to Pathbase + #[arg(long, conflicts_with = "target")] + clear: bool, + }, + /// Store S3 credentials once, so `s3://` share and resume targets + /// need none on the command line. A folder target needs no + /// credentials and so never needs this. + S3 { + #[command(subcommand)] + op: S3Op, + }, +} + +#[derive(Subcommand, Debug)] +pub enum S3Op { + /// Store S3 credentials and connection settings. + /// + /// Only the fields you pass are updated; the rest keep their stored + /// values, so `path auth s3 login --region eu-west-1` is a valid + /// tweak. Run interactively with no flags and it prompts, without + /// echoing the secret. + /// + /// This does not set *where* shares go — that's `path auth default` + /// — so one stored credential serves any number of buckets. + #[command(alias = "set")] + Login { + #[command(flatten)] + args: S3LoginArgs, + }, + /// Show the S3 settings in effect, with secrets redacted and + /// environment-supplied values marked + Status, + /// Forget the stored S3 settings + #[command(alias = "clear")] + Logout, +} + +#[derive(Args, Debug, Default)] +pub struct S3LoginArgs { + /// AWS region (default: us-east-1) + #[arg(long)] + pub region: Option, + + /// Endpoint URL for an S3-compatible service (R2, MinIO, Ceph). + /// Omit for real AWS S3. + #[arg(long)] + pub endpoint: Option, + + #[arg(long)] + pub access_key_id: Option, + + /// Secret access key. Prefer omitting this so it's prompted for + /// rather than landing in your shell history. + #[arg(long)] + pub secret_access_key: Option, + + /// Session token for temporary (STS / assumed-role) credentials + #[arg(long)] + pub session_token: Option, + + /// Address the bucket as `bucket.host/key` instead of `host/bucket/key` + #[arg(long)] + pub virtual_hosted_style: bool, } pub fn run(op: AuthOp) -> Result<()> { - let path = credentials_path()?; match op { - AuthOp::Login { url, code } => login(&path, url, code), - AuthOp::Logout => logout(&path), - AuthOp::Status => status(&path), - AuthOp::Whoami => whoami(&path), + AuthOp::S3 { op } => run_s3(op), + AuthOp::Default { target, clear } => default_target(target, clear), + other => { + let path = credentials_path()?; + match other { + AuthOp::Login { url, code } => login(&path, url, code), + AuthOp::Logout => logout(&path), + AuthOp::Status => status(&path), + AuthOp::Whoami => whoami(&path), + AuthOp::S3 { .. } | AuthOp::Default { .. } => unreachable!("handled above"), + } + } } } +// ── Default share target ──────────────────────────────────────────────── + +fn default_target(spec: Option, clear: bool) -> Result<()> { + if clear { + let path = target::clear_default()?; + println!("Default share target cleared ({}).", path.display()); + println!("`path share` now uploads to Pathbase."); + return Ok(()); + } + + let Some(spec) = spec else { + return print_default_target(); + }; + + let parsed = Target::parse(&spec)?; + let path = target::set_default(&parsed)?; + println!("Default share target set to {parsed}"); + println!(" stored in: {}", path.display()); + if let Target::Object(dest) = &parsed + && !dest.is_local() + && store::effective_settings()?.access_key_id.is_none() + { + println!( + " note: no S3 credentials stored yet — run `path auth s3 login`, \ + or rely on the AWS credential chain." + ); + } + Ok(()) +} + +/// Answer "where does my next share go?" in one command, including why. +fn print_default_target() -> Result<()> { + let (stored, path) = target::default_target()?; + match &stored { + Some(t) => println!("Default share target: {t} (from {})", path.display()), + None => println!("No default share target configured."), + } + // The stored value isn't the whole story — an env var or the + // built-in fallback may be what actually applies right now. + println!("In effect now: {}", target::describe_effective()?); + if stored.is_none() { + println!(); + println!("Set one with:"); + println!(" path auth default s3://my-bucket/traces"); + println!(" path auth default ~/Dropbox/toolpath # a folder needs no credentials"); + println!(" path auth default pathbase"); + } + Ok(()) +} + fn login(path: &Path, url: Option, code_arg: Option) -> Result<()> { let base_url = resolve_url(url); let auth_url = format!("{base_url}/auth/cli"); @@ -113,6 +243,188 @@ fn status(path: &Path) -> Result<()> { } } +// ── S3 ────────────────────────────────────────────────────────────────── + +fn run_s3(op: S3Op) -> Result<()> { + let path = store::config_path()?; + match op { + S3Op::Login { args } => s3_login(&path, args), + S3Op::Status => s3_status(&path), + S3Op::Logout => s3_logout(&path), + } +} + +/// Merge `args` into whatever is already stored, prompting for the +/// essentials when nothing was passed and we have a terminal. +/// +/// Merge rather than replace: partial updates are the common case +/// (rotating a key, switching endpoint), and a replace would silently +/// drop the fields the user didn't repeat. +fn s3_login(path: &Path, args: S3LoginArgs) -> Result<()> { + let mut cfg = store::load_stored(path)?.unwrap_or_default(); + let had_settings = cfg != S3Settings::default(); + + let set = |slot: &mut Option, value: Option| { + if let Some(v) = value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + *slot = Some(v); + } + }; + set(&mut cfg.region, args.region); + set(&mut cfg.endpoint, args.endpoint); + set(&mut cfg.access_key_id, args.access_key_id); + set(&mut cfg.secret_access_key, args.secret_access_key); + set(&mut cfg.session_token, args.session_token); + if args.virtual_hosted_style { + cfg.virtual_hosted_style = Some(true); + } + + if std::io::stdin().is_terminal() && !had_settings { + prompt_missing(&mut cfg)?; + } + + if cfg == S3Settings::default() { + anyhow::bail!( + "Nothing to store. Pass at least one setting (e.g. \ + `path auth s3 login --access-key-id AKIA… --secret-access-key …`), \ + or run this from a terminal to be prompted." + ); + } + + store::store(path, &cfg)?; + println!("S3 settings saved to {}", path.display()); + // Everything printed here was just written, so nothing is `(env)`. + print_settings(&cfg, &cfg); + suggest_default_target()?; + Ok(()) +} + +/// First-time interactive setup. Skipped when settings already exist, +/// so a targeted `--region` update doesn't re-interrogate the user +/// about credentials they already stored. +fn prompt_missing(cfg: &mut S3Settings) -> Result<()> { + println!("Store S3 credentials for `s3://` share and resume targets."); + println!("Leave a field blank to skip it (credentials can come from the AWS environment)."); + println!(); + + if cfg.region.is_none() { + let v = prompt_line(&format!("Region [{}]: ", store::DEFAULT_REGION))?; + cfg.region = Some(v).filter(|v| !v.is_empty()); + } + if cfg.endpoint.is_none() { + let v = prompt_line("Endpoint URL (blank for AWS): ")?; + cfg.endpoint = Some(v).filter(|v| !v.is_empty()); + } + if cfg.access_key_id.is_none() { + let v = prompt_line("Access key id (blank to use the AWS environment): ")?; + cfg.access_key_id = Some(v).filter(|v| !v.is_empty()); + } + if cfg.access_key_id.is_some() && cfg.secret_access_key.is_none() { + let v = rpassword::prompt_password("Secret access key: ")?; + cfg.secret_access_key = Some(v.trim().to_string()).filter(|v| !v.is_empty()); + } + Ok(()) +} + +/// Credentials alone don't make `path share` go to S3 — the target +/// does. Close that gap in the same breath rather than letting the user +/// discover it on their next share. +fn suggest_default_target() -> Result<()> { + if target::default_target()?.0.is_some() { + return Ok(()); + } + println!(); + println!("`path share` still uploads to Pathbase. To make S3 the default:"); + println!(" path auth default s3://my-bucket/traces"); + Ok(()) +} + +fn s3_status(path: &Path) -> Result<()> { + let stored = store::load_stored(path)?; + let effective = store::effective_settings()?; + + match &stored { + Some(_) => println!("S3 settings in {}", path.display()), + None => println!("No stored S3 settings ({} does not exist).", path.display()), + } + if effective == S3Settings::default() { + println!("Run `path auth s3 login` to store some."); + } else { + print_settings(&effective, &stored.unwrap_or_default()); + } + println!("Share target: {}", target::describe_effective()?); + Ok(()) +} + +fn s3_logout(path: &Path) -> Result<()> { + if store::load_stored(path)?.is_none() { + println!("No stored S3 settings."); + return Ok(()); + } + store::clear(path)?; + println!("S3 settings cleared."); + Ok(()) +} + +/// Print `effective`, tagging any field that `stored` didn't supply as +/// `(env)` — otherwise "where did this endpoint come from?" is a guess. +fn print_settings(effective: &S3Settings, stored: &S3Settings) { + let line = |label: &str, value: Option<&str>, from_store: bool| { + if let Some(v) = value { + let origin = if from_store { "" } else { " (env)" }; + // Width matches the longest label so the values line up. + println!(" {:<19}{v}{origin}", format!("{label}:")); + } + }; + line( + "region", + effective.region.as_deref(), + stored.region.is_some(), + ); + line( + "endpoint", + effective.endpoint.as_deref(), + stored.endpoint.is_some(), + ); + line( + "access key id", + effective.access_key_id.as_deref(), + stored.access_key_id.is_some(), + ); + line( + "secret access key", + effective + .secret_access_key + .as_deref() + .map(redact) + .as_deref(), + stored.secret_access_key.is_some(), + ); + line( + "session token", + effective.session_token.as_deref().map(redact).as_deref(), + stored.session_token.is_some(), + ); + if effective.access_key_id.is_none() { + println!(" credentials: none stored — falling back to the AWS credential chain"); + } +} + +/// Show enough of a secret to recognize which one it is, and no more. +fn redact(secret: &str) -> String { + let tail: String = secret + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("****{tail}") +} + fn whoami(path: &Path) -> Result<()> { let stored = load_session(path)?.ok_or_else(|| anyhow!("Not logged in. Run `path auth login`."))?; diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 42cd7e00..9c91a742 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -193,6 +193,25 @@ pub enum ExportTarget { #[arg(long)] public: bool, }, + /// Upload a toolpath document to object storage: an S3 bucket, an + /// S3-compatible endpoint, or a plain folder. + /// + /// S3 credentials come from `path auth s3 login`, with the AWS + /// environment as the fallback; a folder needs none. The object + /// lands at `/.json`, and the printed location is + /// what `path resume` takes. + #[command(alias = "s3")] + Object { + /// Input: cache id (e.g. `claude-abc`) or path to a toolpath JSON file + #[arg(short, long)] + input: String, + + /// Destination: `s3://bucket/prefix`, or a folder (`~/traces`, + /// `file:///srv/traces`). Defaults to the target from + /// `path auth default`. + #[arg(long, value_name = "DESTINATION")] + to: Option, + }, } /// `owner/name` pair for `--repo`. @@ -267,6 +286,7 @@ pub fn run(target: ExportTarget) -> Result<()> { name, public, }), + ExportTarget::Object { input, to } => run_object(input, to), } } @@ -1854,6 +1874,50 @@ fn write_cursor_to_stdout(session: &toolpath_cursor::CursorSession) -> Result<() Ok(()) } +// ── Object storage ──────────────────────────────────────────────────── + +fn run_object(input: String, to: Option) -> Result<()> { + #[cfg(target_os = "emscripten")] + { + let _ = (input, to); + anyhow::bail!("'path p export object' requires a native environment with network access"); + } + + #[cfg(not(target_os = "emscripten"))] + { + let file = cache_ref(&input)?; + let body = std::fs::read_to_string(&file) + .with_context(|| format!("Failed to read {}", file.display()))?; + + // Share the porcelain's target resolution so `p export object` + // with no `--to` writes where `path share` would. + let dest = match crate::target::resolve(to.as_deref())?.0 { + crate::target::Target::Object(d) => d, + crate::target::Target::Pathbase => anyhow::bail!( + "the configured share target is Pathbase, not object storage. \ + Pass `--to s3://bucket/prefix` (or a folder), or use \ + `path p export pathbase`." + ), + }; + let settings = crate::store::effective_settings()?; + + // The object is named for the cache id, not the input string — + // `--input ./some/file.json` and `--input claude-abc` naming the + // same document should land on the same key. + let cache_id = file + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| input.clone()); + + let uri = dest.uri_for(&cache_id); + uri.put(&settings, body.as_bytes())?; + println!("{uri}"); + eprintln!("Uploaded {} bytes → {uri}", body.len()); + eprintln!("Resume it with: path resume {uri}"); + Ok(()) + } +} + // ── Pathbase ────────────────────────────────────────────────────────── fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 7d210beb..0534cbc0 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -183,6 +183,17 @@ pub enum ImportSource { #[arg(long)] url: Option, }, + /// Import from object storage — an S3 bucket, an S3-compatible + /// endpoint, or a folder (the inverse of `path share --to`). + /// S3 credentials come from `path auth s3 login` or the AWS + /// environment; a folder needs none. + #[command(alias = "s3")] + Object { + /// Object URL: `s3://bucket/key.json` (also `s3a://`, and + /// `file:///dir/key.json` for a local folder) + #[arg(index = 1)] + target: String, + }, } #[derive(clap::Args, Debug)] @@ -301,6 +312,7 @@ fn derive(source: ImportSource) -> Result> { base, } => derive_pi(project, session, all, base), ImportSource::Pathbase { target, url } => derive_pathbase(target, url), + ImportSource::Object { target } => derive_object(target), } } @@ -1531,6 +1543,19 @@ fn derive_pathbase(target: String, url_flag: Option) -> Result Result> { + #[cfg(target_os = "emscripten")] + { + let _ = target; + anyhow::bail!("'path p import object' requires a native environment with network access"); + } + + #[cfg(not(target_os = "emscripten"))] + { + Ok(vec![crate::derive::object_fetch_to_doc(&target)?]) + } +} + #[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; diff --git a/crates/path-cli/src/cmd_pathbase.rs b/crates/path-cli/src/cmd_pathbase.rs index 020a0078..63cd6594 100644 --- a/crates/path-cli/src/cmd_pathbase.rs +++ b/crates/path-cli/src/cmd_pathbase.rs @@ -333,7 +333,10 @@ fn short_body(body: &str) -> String { // hand-rolled only because the redeem endpoint isn't in the OpenAPI // spec, not because of any HTTP-stack difference. -fn block_on(f: F) -> F::Output { +/// Shared with the `s3` module, which drives the equally-async +/// `object_store` client: one runtime for every async client the CLI +/// tunnels into. +pub(crate) fn block_on(f: F) -> F::Output { use std::sync::OnceLock; static RT: OnceLock = OnceLock::new(); let rt = RT.get_or_init(|| { @@ -659,36 +662,11 @@ pub(crate) fn credentials_path() -> Result { } pub(crate) fn store_session(path: &Path, s: &StoredSession) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("credentials path has no parent: {}", path.display()))?; - std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); - } - - let payload = serde_json::to_string_pretty(s)?; - std::fs::write(path, payload).with_context(|| format!("write {}", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("chmod 0600 {}", path.display()))?; - } - Ok(()) + crate::config::write_private_json(path, s) } pub(crate) fn load_session(path: &Path) -> Result> { - match std::fs::read_to_string(path) { - Ok(s) if s.trim().is_empty() => Ok(None), - Ok(s) => Ok(Some(serde_json::from_str(&s).with_context(|| { - format!("decode credentials at {}", path.display()) - })?)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(anyhow!("read {}: {e}", path.display())), - } + crate::config::read_private_json(path) } pub(crate) fn clear_session(path: &Path) -> Result<()> { diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 163634fe..ba199c3d 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -7,9 +7,13 @@ //! `` is resolved in this order: //! 1. `https://` / `http://` URL → fetched via `pathbase-client`, //! cached unless `--no-cache`. -//! 2. `owner/repo/slug` shorthand → same Pathbase fetch flow. -//! 3. Existing file path → read directly. -//! 4. Otherwise treated as a cache id under `~/.toolpath/documents/`. +//! 2. `s3://` / `s3a://` / `file://` / `memory://` URL → fetched from +//! object storage (the inverse of `path share --to`), cached the same +//! way. A plain path to a shared folder lands in case 4 and is read +//! directly, which is the same document either way. +//! 3. `owner/repo/slug` shorthand → same Pathbase fetch flow. +//! 4. Existing file path → read directly. +//! 5. Otherwise treated as a cache id under `~/.toolpath/documents/`. //! //! ## Harness selection //! @@ -49,9 +53,10 @@ use crate::harness::Harness; #[derive(Args, Debug)] pub struct ResumeArgs { /// Toolpath document to resume from. Accepted shapes: a Pathbase - /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand - /// (`owner/repo/slug`), a path to a local toolpath JSON file, or a - /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). + /// URL (`https://host/owner/repo/slug`), an object-storage URL + /// (`s3://bucket/key.json`, `file:///dir/key.json`), a bare Pathbase + /// shorthand (`owner/repo/slug`), a path to a local toolpath JSON + /// file, or a cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). pub input: String, /// Working directory to run the resumed harness from. Defaults to @@ -193,6 +198,42 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { Ok(path) } +/// Fetch a remote document, preferring an existing cache entry. +/// +/// `--force` skips the probe and re-fetches; `--no-cache` skips both the +/// probe AND the post-fetch write (still useful for ephemeral +/// environments). Callers supply the cache id up front because every +/// remote shape can compute it from the reference alone — that's what +/// makes the cache hit free of a round trip. +fn fetch_cached( + raw: &str, + cache_id: &str, + args: &ResumeArgs, + fetch: impl FnOnce() -> Result, +) -> Result { + if !args.force + && !args.no_cache + && let Ok(cache_path) = crate::cache::cache_path(cache_id) + && cache_path.exists() + { + let json = std::fs::read_to_string(&cache_path) + .with_context(|| format!("read {}", cache_path.display()))?; + eprintln!("Resolved {raw} → {cache_id} (cached)"); + return Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e)); + } + + let derived = fetch()?; + if !args.no_cache { + // force=true here: we either short-circuited above (cache miss) + // or the user explicitly passed --force, and either way we want + // the new bytes to land. + crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; + eprintln!("Resolved {raw} → {}", derived.cache_id); + } + Ok(derived.doc) +} + /// Resolve the user-supplied `` argument into a parsed `Graph` /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. @@ -202,12 +243,15 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option enum Shape<'a> { PathbaseUrl(&'a str), PathbaseShorthand(&'a str), + ObjectStore(&'a str), FilePath(&'a str), CacheId(&'a str), } let shape = if raw.starts_with("http://") || raw.starts_with("https://") { Shape::PathbaseUrl(raw) + } else if crate::store::looks_like_object_uri(raw) { + Shape::ObjectStore(raw) } else if looks_like_pathbase_shorthand(raw) { Shape::PathbaseShorthand(raw) } else if std::path::Path::new(raw).is_file() { @@ -218,34 +262,21 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option let graph: Graph = match shape { Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { - // Probe the local cache before going to the network. The cache - // id is purely a function of the parsed (owner, repo, id), so - // we can compute it without fetching. `--force` skips the probe - // and re-fetches; `--no-cache` skips both the probe AND the - // post-fetch write (still useful for ephemeral environments). + // The cache id is purely a function of the parsed (owner, + // repo, id), so we can probe the cache without fetching. let (_, ref_) = crate::derive::parse_pathbase_ref(u, args.url.as_deref())?; let cache_id = crate::cache::pathbase_cache_id(&ref_.owner, &ref_.repo, &ref_.id); - if !args.force - && !args.no_cache - && let Ok(cache_path) = crate::cache::cache_path(&cache_id) - && cache_path.exists() - { - let json = std::fs::read_to_string(&cache_path) - .with_context(|| format!("read {}", cache_path.display()))?; - eprintln!("Resolved {} → {} (cached)", raw, cache_id); - Graph::from_json(&json) - .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))? - } else { - let derived = crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref())?; - if !args.no_cache { - // force=true here: we either short-circuited above - // (cache miss) or the user explicitly passed --force, - // and either way we want the new bytes to land. - crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; - eprintln!("Resolved {} → {}", raw, derived.cache_id); - } - derived.doc - } + fetch_cached(raw, &cache_id, args, || { + crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref()) + })? + } + Shape::ObjectStore(u) => { + // Same shape as Pathbase: the cache id falls out of the URI + // alone, so an already-downloaded object costs no request. + let cache_id = crate::store::ObjectUri::parse(u)?.cache_id(); + fetch_cached(raw, &cache_id, args, || { + crate::derive::object_fetch_to_doc(u) + })? } Shape::FilePath(p) => { let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?; diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index d85b780f..80981054 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -54,11 +54,135 @@ pub struct ShareArgs { #[arg(long)] pub project: Option, + /// Where to upload, overriding the configured default. + /// + /// One of: `pathbase`; an S3 bucket (`s3://bucket/prefix`, or an + /// S3-compatible endpoint configured via `path auth s3 login`); or + /// a folder (`~/traces`, `/srv/traces`, `file:///srv/traces`), + /// which needs no credentials at all. A value with no scheme is a + /// local path — spell a bucket `s3://…`. + /// + /// Without this flag the target comes from `$TOOLPATH_SHARE_TARGET`, + /// then `path auth default`, then Pathbase. For object storage the + /// document lands at `/.json`, and the printed + /// location is what `path resume` takes. + #[arg(long, value_name = "TARGET")] + pub to: Option, + /// Skip writing the cache; derive in-memory only #[arg(long)] pub no_cache: bool, } +/// Where a share uploads to, with everything needed to get it there. +/// +/// Resolved once in [`run`] — before the picker and before any derive — +/// so a missing credential or an unparseable destination fails in +/// milliseconds instead of after the user has picked a session and paid +/// for a derivation. +enum ShareTarget { + Pathbase { + auth: crate::cmd_pathbase::AuthMode, + base_url: String, + upload: crate::cmd_export::PathbaseUploadArgs, + }, + Object { + dest: crate::store::Destination, + settings: crate::store::S3Settings, + }, +} + +impl ShareTarget { + /// Human-readable destination, for the picker header. + fn describe(&self) -> String { + match self { + ShareTarget::Pathbase { base_url, .. } => base_url.clone(), + ShareTarget::Object { dest, .. } => dest.to_string(), + } + } + + /// Send `body` (the document JSON for `cache_id`) to the target. + fn upload(self, body: &str, cache_id: &str, summary: &str) -> Result<()> { + match self { + ShareTarget::Pathbase { + auth, + base_url, + upload, + } => crate::cmd_export::run_pathbase_inner(auth, base_url, upload, body, summary), + ShareTarget::Object { dest, settings } => { + let uri = dest.uri_for(cache_id); + uri.put(&settings, body.as_bytes())?; + println!("{uri}"); + eprintln!("Uploaded {summary} → {uri}"); + eprintln!("Resume it with: path resume {uri}"); + Ok(()) + } + } + } +} + +/// Decide *where* this share goes. Pure: no network, no credential +/// probe, no filesystem beyond reading the config — so it can run +/// first, before the harness scan and the picker, and a typo'd target +/// fails instantly instead of after the user has picked a session. +fn plan_target(args: &ShareArgs) -> Result<(crate::target::Target, crate::target::Origin)> { + // The Pathbase-only flags are a statement of intent strong enough + // to override an object-storage default — and to be an error when + // paired with an explicit object `--to`. + let pathbase_flags = args.anon + || args.repo.is_some() + || args.public + || args.url.is_some() + || args.name.is_some(); + let resolved = crate::target::resolve(args.to.as_deref())?; + crate::target::apply_pathbase_flags(resolved, pathbase_flags) +} + +/// Turn a planned target into one we can upload through: load S3 +/// settings, or probe Pathbase credentials. This is the part that can +/// touch the network, so it runs only once we know there's something to +/// upload. +fn open_target( + args: &ShareArgs, + planned: (crate::target::Target, crate::target::Origin), +) -> Result { + let (target, origin) = planned; + match target { + crate::target::Target::Object(dest) => { + let settings = crate::store::effective_settings()?; + crate::target::check_reachable( + &crate::target::Target::Object(dest.clone()), + &settings, + )?; + if origin != crate::target::Origin::Flag { + // The destination wasn't typed on this command line, so + // say where it came from before uploading to it. + eprintln!("Sharing to {dest} ({})", origin.describe()); + } + Ok(ShareTarget::Object { dest, settings }) + } + crate::target::Target::Pathbase => { + let upload = crate::cmd_export::PathbaseUploadArgs { + url: args.url.clone(), + anon: args.anon, + repo: args.repo.clone(), + name: args.name.clone(), + public: args.public, + }; + let base_url = crate::cmd_export::resolve_upload_base_url(&upload); + // `needs_auth` decides whether preflight can fall back to + // anon on credential failure. + let needs_auth = upload.repo.is_some() || upload.public || upload.name.is_some(); + let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload.anon, needs_auth)?; + Ok(ShareTarget::Pathbase { + auth, + base_url, + upload, + }) + } + } +} + /// One artifact surfaced by a provider — today always an agent session. /// Rows feed both the unified `share` picker and `p cache sync`. #[derive(Debug, Clone)] @@ -488,24 +612,17 @@ pub fn run(args: ShareArgs) -> Result<()> { anyhow::bail!("--session requires --harness"); } - // Build upload args + base URL once and reuse for both the explicit - // path and the picker path. `needs_auth` decides whether preflight - // can fall back to anon on credential failure. - let upload_args = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: args.repo.clone(), - name: args.name.clone(), - public: args.public, - }; - let base_url = crate::cmd_export::resolve_upload_base_url(&upload_args); - let needs_auth = upload_args.repo.is_some() || upload_args.public || upload_args.name.is_some(); + // Decide where this is going before doing anything else. A bad + // `--to`, or Pathbase flags aimed at an object target, should cost + // nothing to discover — not a harness scan, and certainly not a + // session pick. + let planned = plan_target(&args)?; if let (Some(h), Some(session)) = (harness, &args.session) { - // Explicit-args: validate creds before derive so a credential - // failure doesn't waste the derive/cache work. - let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?; - return share_explicit(h, session.as_str(), &args, auth, base_url); + // Explicit-args: probe credentials before derive so a + // credential failure doesn't waste the derive/cache work. + let target = open_target(&args, planned)?; + return share_explicit(h, session.as_str(), &args, target); } let cwd = std::env::current_dir()?; @@ -528,14 +645,18 @@ pub fn run(args: ShareArgs) -> Result<()> { anyhow::bail!("fzf unavailable; run `path import ` then `path export pathbase`"); } - // We have rows AND fzf available — now validate credentials before - // making the user pick a session. If preflight returns Anon (either - // explicit --anon, no creds + no auth flags, or auth probe failed - // and fell back), the picker still fires with that knowledge baked in. - let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?; + // We have rows AND fzf available — now probe credentials, before + // making the user pick a session. For Pathbase, if preflight + // returns Anon (either explicit --anon, no creds + no auth flags, or + // auth probe failed and fell back), the picker still fires with that + // knowledge baked in. + let target = open_target(&args, planned)?; let lines: Vec = rows.iter().map(format_picker_row).collect(); - let header = format!("share an agent session (Enter = upload to {base_url})"); + let header = format!( + "share an agent session (Enter = upload to {})", + target.describe() + ); let opts = crate::fuzzy::PickOptions { with_nth: "4", prompt: "share> ", @@ -578,13 +699,14 @@ pub fn run(args: ShareArgs) -> Result<()> { } else { None }, + to: args.to.clone(), no_cache: args.no_cache, }; // Show the conversation title in the confirmation line; the session id // is opaque and doesn't help the user verify they picked the right // thing. `{:?}` adds the surrounding quotes per the spec. eprintln!("Picked {} session {:?}", h.name(), title); - share_explicit(h, &session, &explicit, auth, base_url) + share_explicit(h, &session, &explicit, target) } fn bail_no_sessions( @@ -789,8 +911,7 @@ fn share_explicit( harness: ArtifactType, session: &str, args: &ShareArgs, - auth: crate::cmd_pathbase::AuthMode, - base_url: String, + target: ShareTarget, ) -> Result<()> { let project = match (harness.path_keyed(), args.project.as_ref()) { (true, Some(p)) => Some(p.to_string_lossy().into_owned()), @@ -820,14 +941,7 @@ fn share_explicit( harness.name() ); let summary = format!("{} session {}", harness.name(), cache_id); - let upload = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: args.repo.clone(), - name: args.name.clone(), - public: args.public, - }; - return crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary); + return target.upload(&body, &cache_id, &summary); } let derived = derive_session(harness, project.as_deref(), session)?; @@ -856,14 +970,7 @@ fn share_explicit( } let body = derived.doc.to_json()?; - let upload = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: args.repo.clone(), - name: args.name.clone(), - public: args.public, - }; - crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary) + target.upload(&body, &derived.cache_id, &summary) } /// Build the TSV line fed to the picker. Three hidden parser-only @@ -1367,4 +1474,160 @@ mod tests { assert!(!status.exists); } } + + // ── Share target ───────────────────────────────────────────────── + + fn share_args_to(dest: Option<&str>) -> ShareArgs { + ShareArgs { + url: None, + anon: false, + repo: None, + name: None, + public: false, + harness: None, + session: None, + project: None, + to: dest.map(str::to_string), + no_cache: false, + } + } + + /// Pin `$TOOLPATH_CONFIG_DIR` at an empty tempdir and clear the + /// share-target env var, so resolution can't pick up the + /// developer's real configuration. + fn with_empty_config(f: impl FnOnce(&Path) -> R) -> R { + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + let temp = TempDir::new().unwrap(); + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_dir = std::env::var_os(CONFIG_DIR_ENV); + let prev_target = std::env::var_os(crate::target::TARGET_ENV); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path()); + std::env::remove_var(crate::target::TARGET_ENV); + } + let out = f(temp.path()); + unsafe { + match prev_dir { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + match prev_target { + Some(v) => std::env::set_var(crate::target::TARGET_ENV, v), + None => std::env::remove_var(crate::target::TARGET_ENV), + } + } + out + } + + #[test] + fn a_folder_to_flag_routes_the_share_to_object_storage() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + open_target( + &share_args_to(Some(&dest)), + plan_target(&share_args_to(Some(&dest))).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Object { .. })); + assert_eq!(target.describe(), dest); + } + + #[test] + fn an_object_target_uploads_the_body_under_the_cache_id() { + let folder = TempDir::new().unwrap(); + let dest = format!("{}/traces", folder.path().display()); + let target = with_empty_config(|_| { + open_target( + &share_args_to(Some(&dest)), + plan_target(&share_args_to(Some(&dest))).unwrap(), + ) + .unwrap() + }); + + let body = r#"{"graph":{"id":"g"},"paths":[]}"#; + target.upload(body, "claude-abc", "claude session").unwrap(); + + let written = folder.path().join("traces/claude-abc.json"); + assert_eq!(std::fs::read_to_string(written).unwrap(), body); + } + + #[test] + fn no_flag_and_no_default_still_means_pathbase() { + let target = with_empty_config(|_| { + open_target( + &share_args_to(None), + plan_target(&share_args_to(None)).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Pathbase { .. })); + } + + #[test] + fn a_configured_default_routes_a_bare_share() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + crate::target::set_default(&crate::target::Target::parse(&dest).unwrap()).unwrap(); + open_target( + &share_args_to(None), + plan_target(&share_args_to(None)).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Object { .. })); + assert_eq!(target.describe(), dest); + } + + #[test] + fn pathbase_flags_beat_an_object_default() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + crate::target::set_default(&crate::target::Target::parse(&dest).unwrap()).unwrap(); + let args = ShareArgs { + anon: true, + ..share_args_to(None) + }; + open_target(&args, plan_target(&args).unwrap()).unwrap() + }); + assert!( + matches!(target, ShareTarget::Pathbase { .. }), + "--anon is a Pathbase-only option and must win over an S3 default" + ); + } + + #[test] + fn pathbase_flags_with_an_explicit_object_to_is_an_error() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let err = with_empty_config(|_| { + let args = ShareArgs { + public: true, + ..share_args_to(Some(&dest)) + }; + match plan_target(&args) { + Err(e) => e.to_string(), + Ok(_) => panic!("--public with an object --to must be rejected"), + } + }); + assert!(err.contains("--to pathbase"), "{err}"); + } + + #[test] + fn to_pathbase_forces_pathbase_over_an_object_default() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + crate::target::set_default(&crate::target::Target::parse(&dest).unwrap()).unwrap(); + open_target( + &share_args_to(Some("pathbase")), + plan_target(&share_args_to(Some("pathbase"))).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Pathbase { .. })); + } } diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 86a923f8..7b1c596d 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -29,6 +29,52 @@ pub(crate) fn config_dir() -> Result { Ok(PathBuf::from(home).join(CONFIG_DIR_NAME)) } +/// Write `value` as pretty JSON to a user-private file: the parent +/// directory is created `0700` and the file itself `0600`. +/// +/// Every credential-bearing blob under the config dir goes through +/// here (Pathbase sessions, S3 settings) so the permissions story is +/// stated once instead of re-derived per call site. +pub(crate) fn write_private_json( + path: &std::path::Path, + value: &T, +) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("config path has no parent: {}", path.display()))?; + std::fs::create_dir_all(parent).map_err(|e| anyhow!("create {}: {e}", parent.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); + } + + let payload = serde_json::to_string_pretty(value)?; + std::fs::write(path, payload).map_err(|e| anyhow!("write {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow!("chmod 0600 {}: {e}", path.display()))?; + } + Ok(()) +} + +/// Read a JSON blob written by [`write_private_json`]. A missing or +/// empty file is `Ok(None)` — "not configured", not an error. +pub(crate) fn read_private_json( + path: &std::path::Path, +) -> Result> { + match std::fs::read_to_string(path) { + Ok(s) if s.trim().is_empty() => Ok(None), + Ok(s) => Ok(Some( + serde_json::from_str(&s).map_err(|e| anyhow!("decode {}: {e}", path.display()))?, + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow!("read {}: {e}", path.display())), + } +} + /// Shared lock for tests that manipulate `$TOOLPATH_CONFIG_DIR`. Every /// test module that calls `set_var` / `remove_var` on this env var should /// grab this lock first, otherwise parallel tests race and clobber each diff --git a/crates/path-cli/src/derive.rs b/crates/path-cli/src/derive.rs index d9b09e89..5f767846 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -352,6 +352,28 @@ pub(crate) fn derive_pi_session_with( }) } +/// Download a toolpath document from object storage (`s3://bucket/key`, +/// `file:///dir/doc.json`) and parse it. The counterpart to +/// `path share --s3`; used by `path p import s3` and `path resume +/// s3://…`. +/// +/// `provenance` is `None` for the same reason Pathbase downloads leave +/// it unset: the sync manifest tracks local artifact sources, and a +/// remote object isn't one. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn object_fetch_to_doc(target: &str) -> Result { + let uri = crate::store::ObjectUri::parse(target)?; + let cfg = crate::store::effective_settings()?; + let body = uri.get(&cfg)?; + let doc = Graph::from_json(&body) + .map_err(|e| anyhow::anyhow!("{uri} is not a toolpath document: {e}"))?; + Ok(DerivedDoc { + cache_id: uri.cache_id(), + doc, + provenance: None, + }) +} + /// Fetch a Pathbase ref (`https://host/u/owner/repos/repo/graphs/` /// URL or bare `owner/repo/` triple) and parse it as a toolpath /// document. Used by `path import pathbase` and `path resume `. diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 14ed9bba..f6b57009 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -38,7 +38,11 @@ mod query; mod schema; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; +#[cfg(not(target_os = "emscripten"))] +mod store; mod sync; +#[cfg(not(target_os = "emscripten"))] +mod target; mod term; use anyhow::Result; @@ -79,7 +83,8 @@ enum Commands { #[arg(long)] ansi: bool, }, - /// Share an agent session to Pathbase via an interactive picker + /// Share an agent session via an interactive picker — to Pathbase, + /// an S3 bucket, or a folder (see `path auth default`) #[cfg(not(target_os = "emscripten"))] Share { #[command(flatten)] @@ -104,7 +109,8 @@ enum Commands { #[command(flatten)] args: cmd_kind::KindArgs, }, - /// Manage Pathbase credentials for trace uploads + /// Manage where shares go (`auth default`) and the credentials to + /// get there: Pathbase, or S3 under `auth s3` #[cfg(not(target_os = "emscripten"))] Auth { #[command(subcommand)] diff --git a/crates/path-cli/src/store.rs b/crates/path-cli/src/store.rs new file mode 100644 index 00000000..877fd6b9 --- /dev/null +++ b/crates/path-cli/src/store.rs @@ -0,0 +1,633 @@ +//! Object-storage destinations for share and resume. +//! +//! Transport is [`object_store`], so one code path covers real AWS S3, +//! any S3-compatible endpoint (Cloudflare R2, MinIO, Ceph, Backblaze +//! B2), and a plain local directory via `file://`. A folder is a +//! first-class destination, not a testing affordance: `path auth +//! default ~/Dropbox/traces` is a complete setup, needing no +//! credentials at all. It is also what the tests round-trip against, +//! so share and resume are exercised end-to-end without a network. +//! +//! The module owns two separable things: +//! +//! 1. [`Destination`] / [`ObjectUri`] — *where* a document goes. Pure +//! URL parsing and key layout; no credentials involved. Where a +//! document lands is a function of the destination and the +//! document's cache id, nothing else. +//! 2. [`S3Settings`] — *how to reach* an `s3://` destination: region, +//! endpoint, addressing style, and credentials, persisted at +//! `~/.toolpath/s3.json` by `path auth s3 login`. +//! +//! Keeping those apart is what lets `--to ~/traces` skip the whole +//! credential story, and lets one stored credential serve any number +//! of buckets. +//! +//! Credentials are handed to `object_store` as config options rather +//! than resolved here. When none are configured, the AWS credential +//! chain (env, EC2/ECS instance metadata, web identity) still applies — +//! so an EC2 box with an instance role needs no `path auth s3 login` +//! at all. + +use anyhow::{Context, Result, anyhow, bail}; +use object_store::{ObjectStore, ObjectStoreExt}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use url::Url; + +use crate::config::config_dir; + +pub(crate) const S3_CONFIG_FILE: &str = "s3.json"; +pub(crate) const DEFAULT_REGION: &str = "us-east-1"; + +/// URL schemes routed to object storage. Deliberately narrower than +/// what `object_store` can parse: `http`/`https` belong to Pathbase in +/// every command that shares this dispatch, and `gs://` / `az://` would +/// need feature flags we don't compile in. +const SCHEMES: [&str; 4] = ["s3", "s3a", "file", "memory"]; + +// ── S3 connection settings ────────────────────────────────────────────── + +/// The blob persisted at `~/.toolpath/s3.json` (0600). +/// +/// Connection and credentials only — deliberately *not* a destination. +/// The bucket and prefix live in the share target +/// ([`crate::target`]), so there is exactly one answer to +/// "where does my next share go?", and so one stored credential can +/// serve `--to s3://a/x` and `--to s3://b/y` alike. +/// +/// Every field is optional so a partial configuration is legal: a user +/// whose credentials come from the environment (CI, an EC2 instance +/// role) may store only `region`/`endpoint`, or nothing at all. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct S3Settings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + /// Custom endpoint (`https://…`) for S3-compatible services such as + /// Cloudflare R2 or MinIO. Absent means real AWS S3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_key_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret_access_key: Option, + /// Temporary-credential token (STS / assumed role). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_token: Option, + /// Use virtual-hosted addressing (`bucket.host/key`) instead of + /// path style (`host/bucket/key`). Unset lets `object_store` pick: + /// path style, which every S3-compatible endpoint accepts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub virtual_hosted_style: Option, +} + +pub(crate) fn config_path() -> Result { + Ok(config_dir()?.join(S3_CONFIG_FILE)) +} + +pub(crate) fn load_stored(path: &std::path::Path) -> Result> { + crate::config::read_private_json(path) +} + +pub(crate) fn store(path: &std::path::Path, cfg: &S3Settings) -> Result<()> { + crate::config::write_private_json(path, cfg) +} + +pub(crate) fn clear(path: &std::path::Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow!("remove {}: {e}", path.display())), + } +} + +/// The stored settings with environment variables filling any gap. +/// +/// Precedence is stored-then-env, not env-then-stored: the point of +/// `path auth s3 login` is that what you configured is what you get. +/// Env vars are the fallback for environments that never ran `login` +/// (CI, containers), and they use the conventional AWS names so an +/// already-configured shell just works. +/// +/// Recognized: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +/// `AWS_SESSION_TOKEN`, `AWS_REGION` (then `AWS_DEFAULT_REGION`), and +/// `AWS_ENDPOINT_URL_S3` (then `AWS_ENDPOINT_URL`). +pub(crate) fn effective_settings() -> Result { + let stored = load_stored(&config_path()?)?.unwrap_or_default(); + Ok(merge_env(stored, |k| std::env::var(k).ok())) +} + +/// [`effective_settings`] with the environment injected, so tests don't +/// have to mutate process-global state. +pub(crate) fn merge_env Option>(mut cfg: S3Settings, env: F) -> S3Settings { + let first = |keys: &[&str]| -> Option { + keys.iter() + .find_map(|k| env(k).filter(|v| !v.trim().is_empty())) + }; + cfg.access_key_id = cfg.access_key_id.or_else(|| first(&["AWS_ACCESS_KEY_ID"])); + cfg.secret_access_key = cfg + .secret_access_key + .or_else(|| first(&["AWS_SECRET_ACCESS_KEY"])); + cfg.session_token = cfg.session_token.or_else(|| first(&["AWS_SESSION_TOKEN"])); + cfg.region = cfg + .region + .or_else(|| first(&["AWS_REGION", "AWS_DEFAULT_REGION"])); + cfg.endpoint = cfg + .endpoint + .or_else(|| first(&["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL"])); + cfg +} + +/// Settings as `object_store` key/value options. Unrecognized keys are +/// ignored by `parse_url_opts`, so the same list is safe to pass for a +/// `file://` URL as for `s3://`. +fn store_options(cfg: &S3Settings) -> Vec<(&'static str, String)> { + let mut opts: Vec<(&'static str, String)> = Vec::new(); + let mut push = |k: &'static str, v: &Option| { + if let Some(v) = v.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + opts.push((k, v.to_string())); + } + }; + push("aws_access_key_id", &cfg.access_key_id); + push("aws_secret_access_key", &cfg.secret_access_key); + push("aws_session_token", &cfg.session_token); + push("aws_endpoint", &cfg.endpoint); + push( + "aws_region", + &Some( + cfg.region + .clone() + .unwrap_or_else(|| DEFAULT_REGION.to_string()), + ), + ); + if let Some(v) = cfg.virtual_hosted_style { + opts.push(("aws_virtual_hosted_style_request", v.to_string())); + } + // A plaintext endpoint is a deliberate choice (MinIO on localhost, + // a test fixture); object_store refuses http:// unless told. + if cfg + .endpoint + .as_deref() + .is_some_and(|e| e.starts_with("http://")) + { + opts.push(("aws_allow_http", "true".to_string())); + } + opts +} + +// ── Locations ─────────────────────────────────────────────────────────── + +/// A single object in object storage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObjectUri { + url: Url, +} + +impl std::fmt::Display for ObjectUri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&friendly(&self.url)) + } +} + +/// True for anything `path resume` / `p import` should route to object +/// storage rather than to Pathbase or the local cache. +pub(crate) fn looks_like_object_uri(s: &str) -> bool { + SCHEMES.iter().any(|p| s.starts_with(&format!("{p}://"))) +} + +impl ObjectUri { + /// Parse a full object reference. A container with no key names a + /// place, not a document, so it's rejected here — the share side + /// goes through [`Destination`] instead. + pub(crate) fn parse(raw: &str) -> Result { + let url = parse_location(raw)?; + if url.path().trim_matches('/').is_empty() { + bail!( + "`{raw}` names a location but no object key \ + (expected s3://bucket/path/to/doc.json)" + ); + } + Ok(ObjectUri { url }) + } + + /// The cache id a download of this object lands at, e.g. + /// `s3-my-bucket-traces_claude-abc`. + pub(crate) fn cache_id(&self) -> String { + let source = match self.url.scheme() { + "s3a" => "s3", + other => other, + }; + let host = self.url.host_str().unwrap_or_default(); + let key = self.url.path().trim_matches('/'); + let inner = if host.is_empty() { + key.to_string() + } else { + format!("{host}-{key}") + }; + crate::cache::make_id(source, &inner) + } + + fn open(&self, cfg: &S3Settings) -> Result<(Box, object_store::path::Path)> { + object_store::parse_url_opts(&self.url, store_options(cfg)) + .with_context(|| format!("open {self}")) + } + + /// Download the object as UTF-8 text. + pub(crate) fn get(&self, cfg: &S3Settings) -> Result { + let (store, path) = self.open(cfg)?; + let bytes = block_on(async { + let result = store.get(&path).await?; + result.bytes().await + }) + .map_err(|e| explain(e, "read", self))?; + String::from_utf8(bytes.to_vec()).with_context(|| format!("{self} is not valid UTF-8")) + } + + /// Upload `body` to the object, overwriting any existing one. + pub(crate) fn put(&self, cfg: &S3Settings, body: &[u8]) -> Result<()> { + let (store, path) = self.open(cfg)?; + let payload = object_store::PutPayload::from(body.to_vec()); + block_on(store.put(&path, payload)) + .map(|_| ()) + .map_err(|e| explain(e, "write", self)) + } +} + +/// Where `path share` writes when the target is object storage: a +/// bucket-or-folder base that object keys hang off. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Destination { + base: Url, +} + +impl std::fmt::Display for Destination { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(friendly(&self.base).trim_end_matches('/')) + } +} + +impl Destination { + /// Parse a user-supplied destination. See [`parse_location`] for + /// how a scheme-less value is read. + pub(crate) fn parse(raw: &str) -> Result { + Ok(Destination { + base: parse_location(raw)?, + }) + } + + /// The canonical form to persist: always a URL, never a bare path, + /// so a stored default can't be re-read relative to a different cwd. + pub(crate) fn as_url(&self) -> &str { + self.base.as_str() + } + + /// True for a plain local folder — the case that needs no + /// credentials, and so no `path auth s3 login`. + pub(crate) fn is_local(&self) -> bool { + self.base.scheme() == "file" + } + + /// The object a document with this cache id lands at. + pub(crate) fn uri_for(&self, cache_id: &str) -> ObjectUri { + let mut url = self.base.clone(); + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}/{cache_id}.json")); + ObjectUri { url } + } +} + +/// Parse a user-supplied location into a URL. +/// +/// A value carrying a scheme is taken at its word. A scheme-less value +/// is a **local filesystem path** — `~/traces`, `./out`, `/srv/traces` +/// — expanded, made absolute, and turned into a `file://` URL. Bare +/// strings mean folders rather than buckets because that is what people +/// type when designating a directory; an S3 bucket is named with an +/// explicit `s3://`, which is unambiguous and self-documenting. +fn parse_location(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + bail!("empty location"); + } + + if raw.contains("://") { + let url = Url::parse(raw).with_context(|| format!("`{raw}` is not a valid URL"))?; + if !SCHEMES.contains(&url.scheme()) { + bail!( + "unsupported location scheme `{}://` (expected one of: {})", + url.scheme(), + SCHEMES + .iter() + .map(|s| format!("{s}://")) + .collect::>() + .join(", ") + ); + } + if matches!(url.scheme(), "s3" | "s3a") && url.host_str().unwrap_or_default().is_empty() { + bail!("`{raw}` has no bucket (expected s3://bucket/prefix)"); + } + return Ok(url); + } + + let expanded = expand_tilde(raw); + let absolute = std::path::absolute(&expanded) + .with_context(|| format!("resolve `{}` to an absolute path", expanded.display()))?; + Url::from_directory_path(&absolute).map_err(|()| { + anyhow!( + "`{raw}` is neither a URL nor a usable filesystem path \ + (for an S3 bucket, write it as s3://{raw})" + ) + }) +} + +/// Expand a leading `~/`. Shells normally do this, but a quoted or +/// config-file value arrives literal, and `object_store` would happily +/// create a directory actually named `~`. +fn expand_tilde(raw: &str) -> PathBuf { + let Some(rest) = raw.strip_prefix("~/") else { + return PathBuf::from(raw); + }; + match std::env::var_os("HOME") { + Some(home) => PathBuf::from(home).join(rest), + None => PathBuf::from(raw), + } +} + +/// Render a location for humans: a `file://` URL shows as the plain +/// path it names, which is both shorter and directly pasteable into +/// `path resume`. Everything else shows as its URL. +fn friendly(url: &Url) -> String { + if url.scheme() == "file" + && let Ok(p) = url.to_file_path() + { + return p.to_string_lossy().into_owned(); + } + url.as_str().to_string() +} + +/// Turn an `object_store` error into something a user can act on. Its +/// `NotFound` and `Unauthenticated` variants are the two that matter: +/// the first usually means a typo'd key, the second an unconfigured or +/// stale credential. +fn explain(err: object_store::Error, verb: &str, uri: &ObjectUri) -> anyhow::Error { + match err { + object_store::Error::NotFound { .. } => anyhow!("{uri} not found"), + object_store::Error::Unauthenticated { .. } + | object_store::Error::PermissionDenied { .. } => { + anyhow!( + "not authorized to {verb} {uri}. Run `path auth s3 login` to store \ + credentials, or check the bucket policy for the ones you have." + ) + } + e => anyhow!(e).context(format!("failed to {verb} {uri}")), + } +} + +/// `object_store` is async; the rest of path-cli is sync. Same tunnel +/// the Pathbase client uses, so both share one runtime. +fn block_on(f: F) -> F::Output { + crate::cmd_pathbase::block_on(f) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_s3_uris() { + let u = ObjectUri::parse("s3://my-bucket/traces/claude-abc.json").unwrap(); + assert_eq!(u.to_string(), "s3://my-bucket/traces/claude-abc.json"); + } + + #[test] + fn container_without_a_key_is_not_an_object() { + let err = ObjectUri::parse("s3://my-bucket").unwrap_err().to_string(); + assert!(err.contains("no object key"), "{err}"); + } + + #[test] + fn looks_like_object_uri_only_matches_known_schemes() { + assert!(looks_like_object_uri("s3://b/k")); + assert!(looks_like_object_uri("s3a://b/k")); + assert!(looks_like_object_uri("file:///tmp/k.json")); + // https belongs to Pathbase; a bare id belongs to the cache. + assert!(!looks_like_object_uri("https://pathbase.dev/a/b/c")); + assert!(!looks_like_object_uri("claude-abc")); + } + + #[test] + fn unsupported_scheme_lists_the_supported_ones() { + let err = ObjectUri::parse("gs://bucket/key.json") + .unwrap_err() + .to_string(); + assert!(err.contains("s3://"), "{err}"); + } + + // ── Destinations ───────────────────────────────────────────────── + + #[test] + fn s3_destination_keys_on_the_cache_id() { + let d = Destination::parse("s3://bkt/pre/fix").unwrap(); + assert!(!d.is_local()); + assert_eq!( + d.uri_for("claude-abc").to_string(), + "s3://bkt/pre/fix/claude-abc.json" + ); + } + + #[test] + fn s3_destination_without_a_prefix_writes_at_the_bucket_root() { + let d = Destination::parse("s3://bkt").unwrap(); + assert_eq!( + d.uri_for("claude-abc").to_string(), + "s3://bkt/claude-abc.json" + ); + } + + #[test] + fn a_bare_path_is_a_local_folder_not_a_bucket() { + let d = Destination::parse("/srv/traces").unwrap(); + assert!(d.is_local()); + assert_eq!(d.to_string(), "/srv/traces"); + assert_eq!(d.as_url(), "file:///srv/traces/"); + assert_eq!( + d.uri_for("claude-abc").to_string(), + "/srv/traces/claude-abc.json" + ); + } + + #[test] + fn a_relative_path_is_made_absolute_so_a_stored_default_is_stable() { + let d = Destination::parse("./out").unwrap(); + let expected = std::path::absolute("./out").unwrap(); + assert_eq!(d.to_string(), expected.to_string_lossy()); + } + + #[test] + fn a_tilde_path_expands_against_home() { + let home = std::env::var("HOME").unwrap(); + let d = Destination::parse("~/traces").unwrap(); + assert_eq!(d.to_string(), format!("{home}/traces")); + } + + #[test] + fn a_file_url_and_the_equivalent_bare_path_agree() { + let from_path = Destination::parse("/srv/traces").unwrap(); + let from_url = Destination::parse("file:///srv/traces").unwrap(); + assert_eq!( + from_path.uri_for("x").to_string(), + from_url.uri_for("x").to_string() + ); + } + + #[test] + fn local_destinations_display_as_plain_paths() { + // The printed form is what a user pastes into `path resume`, + // and `path resume /abs/path.json` already works. + let uri = Destination::parse("/srv/traces") + .unwrap() + .uri_for("claude-abc"); + assert_eq!(uri.to_string(), "/srv/traces/claude-abc.json"); + } + + #[test] + fn cache_id_flattens_the_key() { + let uri = ObjectUri::parse("s3://bkt/traces/claude-abc.json").unwrap(); + assert_eq!(uri.cache_id(), "s3-bkt-traces_claude-abc"); + // s3a is the same store under a different scheme spelling, so + // it must not fork the cache. + let alias = ObjectUri::parse("s3a://bkt/traces/claude-abc.json").unwrap(); + assert_eq!(alias.cache_id(), uri.cache_id()); + } + + // ── S3 settings ────────────────────────────────────────────────── + + #[test] + fn env_fills_only_the_gaps() { + let stored = S3Settings { + region: Some("eu-west-1".to_string()), + ..Default::default() + }; + let merged = merge_env(stored, |k| match k { + "AWS_ACCESS_KEY_ID" => Some("AK".to_string()), + "AWS_SECRET_ACCESS_KEY" => Some("SK".to_string()), + "AWS_REGION" => Some("us-west-2".to_string()), + _ => None, + }); + assert_eq!(merged.access_key_id.as_deref(), Some("AK")); + // Stored wins over env. + assert_eq!(merged.region.as_deref(), Some("eu-west-1")); + } + + #[test] + fn blank_env_values_are_ignored() { + let merged = merge_env(S3Settings::default(), |k| match k { + "AWS_ACCESS_KEY_ID" => Some(" ".to_string()), + _ => None, + }); + assert!(merged.access_key_id.is_none()); + } + + #[test] + fn store_options_carry_credentials_and_endpoint() { + let opts = store_options(&S3Settings { + access_key_id: Some("AK".to_string()), + secret_access_key: Some("SK".to_string()), + endpoint: Some("http://127.0.0.1:9000".to_string()), + ..Default::default() + }); + let get = |k: &str| { + opts.iter() + .find(|(key, _)| *key == k) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(get("aws_access_key_id"), Some("AK")); + assert_eq!(get("aws_secret_access_key"), Some("SK")); + assert_eq!(get("aws_endpoint"), Some("http://127.0.0.1:9000")); + // Plaintext endpoints have to be opted into explicitly. + assert_eq!(get("aws_allow_http"), Some("true")); + assert_eq!(get("aws_region"), Some(DEFAULT_REGION)); + } + + #[test] + fn https_endpoint_does_not_allow_http() { + let opts = store_options(&S3Settings { + endpoint: Some("https://minio.example".to_string()), + ..Default::default() + }); + assert!(!opts.iter().any(|(k, _)| *k == "aws_allow_http")); + } + + #[test] + fn stored_settings_round_trip_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s3.json"); + let cfg = S3Settings { + region: Some("us-east-2".to_string()), + access_key_id: Some("AK".to_string()), + secret_access_key: Some("SK".to_string()), + ..Default::default() + }; + store(&path, &cfg).unwrap(); + assert_eq!(load_stored(&path).unwrap().unwrap(), cfg); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + clear(&path).unwrap(); + assert!(load_stored(&path).unwrap().is_none()); + // Clearing settings that aren't there is not an error. + clear(&path).unwrap(); + } + + // ── Round trips against a local folder ─────────────────────────── + + #[test] + fn put_then_get_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + let uri = dest.uri_for("claude-abc"); + let body = br#"{"graph":{"id":"g"},"paths":[]}"#; + + uri.put(&cfg, body).unwrap(); + assert_eq!(uri.get(&cfg).unwrap(), String::from_utf8_lossy(body)); + assert!(dir.path().join("claude-abc.json").is_file()); + } + + #[test] + fn put_overwrites_an_existing_object() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + let uri = dest.uri_for("claude-abc"); + + uri.put(&cfg, b"{\"v\":1}").unwrap(); + uri.put(&cfg, b"{\"v\":2}").unwrap(); + assert_eq!(uri.get(&cfg).unwrap(), "{\"v\":2}"); + } + + #[test] + fn nested_prefixes_are_created_on_write() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&format!("{}/a/b/c", dir.path().display())).unwrap(); + dest.uri_for("claude-abc") + .put(&S3Settings::default(), b"{}") + .unwrap(); + assert!(dir.path().join("a/b/c/claude-abc.json").is_file()); + } + + #[test] + fn missing_object_says_not_found() { + let dir = tempfile::tempdir().unwrap(); + let uri = Destination::parse(&dir.path().to_string_lossy()) + .unwrap() + .uri_for("nope"); + let err = uri.get(&S3Settings::default()).unwrap_err().to_string(); + assert!(err.contains("not found"), "{err}"); + assert!(err.contains("nope.json"), "{err}"); + } +} diff --git a/crates/path-cli/src/target.rs b/crates/path-cli/src/target.rs new file mode 100644 index 00000000..9e1bf323 --- /dev/null +++ b/crates/path-cli/src/target.rs @@ -0,0 +1,475 @@ +//! Where `path share` uploads to, and how that gets configured once. +//! +//! A share target is either Pathbase — the hosted service, with its own +//! auth, repos, and visibility — or an object-storage +//! [`Destination`](crate::store::Destination): an S3 bucket, an +//! S3-compatible endpoint, or a plain local folder. +//! +//! The default lives in **one** place, `~/.toolpath/config.json`, and +//! not inside either credential file. "Where does my next share go?" +//! must have a single answer; if each backend's own config could claim +//! to be the default, that answer becomes a precedence puzzle the day a +//! third backend appears. +//! +//! Resolution order, highest first: +//! +//! 1. `--to ` on the command +//! 2. `$TOOLPATH_SHARE_TARGET` +//! 3. `default_target` in `config.json` (`path auth default `) +//! 4. Pathbase +//! +//! Nothing is inferred from which credentials happen to exist. A share +//! that silently changes destination is a data-egress bug, not a +//! convenience — so the default only ever moves because someone moved +//! it. The one concession is a guard at the bottom of the order: see +//! [`resolve`]. + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::config::config_dir; +use crate::store::Destination; + +pub(crate) const CONFIG_FILE: &str = "config.json"; +pub(crate) const TARGET_ENV: &str = "TOOLPATH_SHARE_TARGET"; + +/// The literal that selects Pathbase, for `--to` and for the stored +/// default. Everything else is parsed as an object-storage location. +pub(crate) const PATHBASE: &str = "pathbase"; + +/// General CLI settings at `~/.toolpath/config.json`. +/// +/// Not credentials — this file is preferences, and is the one place a +/// future non-credential setting should land rather than growing a +/// fourth file. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Settings { + /// `pathbase`, or an object-storage URL. Stored canonicalized (a + /// folder is written as its `file://` URL) so re-reading it from a + /// different working directory can't change where it points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_target: Option, +} + +pub(crate) fn settings_path() -> Result { + Ok(config_dir()?.join(CONFIG_FILE)) +} + +pub(crate) fn load_settings(path: &Path) -> Result { + Ok(crate::config::read_private_json(path)?.unwrap_or_default()) +} + +pub(crate) fn store_settings(path: &Path, s: &Settings) -> Result<()> { + crate::config::write_private_json(path, s) +} + +/// Where a share goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Target { + Pathbase, + Object(Destination), +} + +impl std::fmt::Display for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Target::Pathbase => f.write_str(PATHBASE), + Target::Object(d) => write!(f, "{d}"), + } + } +} + +impl Target { + /// Parse a `--to` value or a stored default. + pub(crate) fn parse(raw: &str) -> Result { + let raw = raw.trim(); + if raw.eq_ignore_ascii_case(PATHBASE) { + return Ok(Target::Pathbase); + } + Ok(Target::Object(Destination::parse(raw)?)) + } + + /// The canonical string to persist. Object destinations round-trip + /// as URLs so a bare relative path can't be stored. + pub(crate) fn as_stored(&self) -> String { + match self { + Target::Pathbase => PATHBASE.to_string(), + Target::Object(d) => d.as_url().to_string(), + } + } +} + +/// Where a resolved target came from, so `path auth default` and the +/// share confirmation can say *why* this is the destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Origin { + Flag, + Env, + Config, + Builtin, +} + +impl Origin { + pub(crate) fn describe(self) -> &'static str { + match self { + Origin::Flag => "--to", + Origin::Env => TARGET_ENV, + Origin::Config => "configured default", + Origin::Builtin => "built-in default", + } + } +} + +/// The explicitly-chosen target, if there is one: flag, then env, then +/// the stored default. `None` means nothing has been configured and the +/// caller decides what the absence means. +fn lookup(flag: Option<&str>) -> Result> { + if let Some(raw) = flag.map(str::trim).filter(|s| !s.is_empty()) { + return Ok(Some(( + Target::parse(raw).with_context(|| format!("--to {raw}"))?, + Origin::Flag, + ))); + } + if let Some(raw) = std::env::var(TARGET_ENV) + .ok() + .filter(|v| !v.trim().is_empty()) + { + return Ok(Some(( + Target::parse(&raw).with_context(|| format!("${TARGET_ENV}"))?, + Origin::Env, + ))); + } + let settings_path = settings_path()?; + if let Some(raw) = load_settings(&settings_path)?.default_target { + return Ok(Some(( + Target::parse(&raw) + .with_context(|| format!("default_target in {}", settings_path.display()))?, + Origin::Config, + ))); + } + Ok(None) +} + +/// True when falling through to Pathbase would be a bad guess: S3 +/// credentials exist, no Pathbase session does, and nothing has been +/// designated. Uploading anyway would hit the *anonymous public* +/// endpoint — a surprising place for someone who has only ever +/// configured S3. +fn fallback_is_a_bad_guess() -> Result { + Ok(s3_configured()? && !pathbase_logged_in()?) +} + +/// Resolve the share target for an upload. Errors rather than guessing +/// when the fallback would be surprising — see +/// [`fallback_is_a_bad_guess`]. +pub(crate) fn resolve(flag: Option<&str>) -> Result<(Target, Origin)> { + if let Some(hit) = lookup(flag)? { + return Ok(hit); + } + if fallback_is_a_bad_guess()? { + bail!( + "No share target configured. S3 credentials are stored but there's no \ + default target and no Pathbase login, and defaulting to Pathbase here \ + would publish anonymously.\n\ + \n \ + path auth default s3://my-bucket/traces # or a folder: path auth default ~/traces\n \ + path share --to pathbase # to publish to Pathbase this once" + ); + } + Ok((Target::Pathbase, Origin::Builtin)) +} + +/// What [`resolve`] would do, as a line of prose that never fails. +/// +/// Status commands report the situation; they must not inherit an +/// upload-time refusal, or `path auth s3 status` would break precisely +/// when the user most needs it to explain itself. +pub(crate) fn describe_effective() -> Result { + if let Some((target, origin)) = lookup(None)? { + return Ok(format!("{target} ({})", origin.describe())); + } + if fallback_is_a_bad_guess()? { + return Ok( + "not configured — S3 credentials are stored but no default target is set \ + (run `path auth default`)" + .to_string(), + ); + } + Ok(format!( + "{} ({})", + Target::Pathbase, + Origin::Builtin.describe() + )) +} + +/// The stored default, if any, plus where it is stored. +pub(crate) fn default_target() -> Result<(Option, PathBuf)> { + let path = settings_path()?; + let stored = load_settings(&path)?.default_target; + let parsed = stored.as_deref().map(Target::parse).transpose()?; + Ok((parsed, path)) +} + +pub(crate) fn set_default(target: &Target) -> Result { + let path = settings_path()?; + let mut settings = load_settings(&path)?; + settings.default_target = Some(target.as_stored()); + store_settings(&path, &settings)?; + Ok(path) +} + +pub(crate) fn clear_default() -> Result { + let path = settings_path()?; + let mut settings = load_settings(&path)?; + settings.default_target = None; + store_settings(&path, &settings)?; + Ok(path) +} + +fn s3_configured() -> Result { + Ok(crate::store::load_stored(&crate::store::config_path()?)?.is_some()) +} + +fn pathbase_logged_in() -> Result { + Ok(crate::cmd_pathbase::load_session(&crate::cmd_pathbase::credentials_path()?)?.is_some()) +} + +/// Reject the Pathbase-only flags when the target isn't Pathbase, and +/// treat their presence as selecting Pathbase when no `--to` was given. +/// +/// `--repo` / `--public` / `--anon` / `--url` / `--name` are meaningless +/// for object storage, so using one is a clear statement of intent — +/// clear enough to override a configured S3 default, and clear enough +/// that combining it with an explicit `--to s3://…` is a mistake worth +/// naming rather than silently resolving. +pub(crate) fn apply_pathbase_flags( + resolved: (Target, Origin), + pathbase_flags: bool, +) -> Result<(Target, Origin)> { + let (target, origin) = resolved; + if !pathbase_flags { + return Ok((target, origin)); + } + match (&target, origin) { + (Target::Pathbase, _) => Ok((target, origin)), + (Target::Object(_), Origin::Flag) => bail!( + "--repo / --public / --anon / --url / --name are Pathbase options, \ + but --to names object storage. Drop the Pathbase flags, or use \ + `--to pathbase`." + ), + // The default said object storage, but this invocation asked + // for something only Pathbase can do. Honor the request. + (Target::Object(_), _) => Ok((Target::Pathbase, Origin::Flag)), + } +} + +/// Guard against a destination that can't work: an `s3://` target with +/// no credentials anywhere. A local folder never needs any, which is +/// the whole appeal of `path auth default ~/traces`. +pub(crate) fn check_reachable(target: &Target, settings: &crate::store::S3Settings) -> Result<()> { + let Target::Object(dest) = target else { + return Ok(()); + }; + if dest.is_local() { + return Ok(()); + } + if settings.access_key_id.is_some() { + return Ok(()); + } + // No explicit credentials — object_store will still try the AWS + // credential chain (instance role, web identity), so this is a + // note, not a failure. + eprintln!( + "note: no S3 credentials configured; falling back to the AWS credential chain. \ + Run `path auth s3 login` if the upload fails." + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + + /// Pin `$TOOLPATH_CONFIG_DIR` at an empty tempdir and clear + /// `$TOOLPATH_SHARE_TARGET`, so resolution can't see the + /// developer's real configuration. + fn with_sandbox(f: impl FnOnce(&Path) -> R) -> R { + let temp = tempfile::tempdir().unwrap(); + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_dir = std::env::var_os(CONFIG_DIR_ENV); + let prev_target = std::env::var_os(TARGET_ENV); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path()); + std::env::remove_var(TARGET_ENV); + } + let out = f(temp.path()); + unsafe { + match prev_dir { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + match prev_target { + Some(v) => std::env::set_var(TARGET_ENV, v), + None => std::env::remove_var(TARGET_ENV), + } + } + out + } + + #[test] + fn pathbase_is_spelled_by_name() { + assert_eq!(Target::parse("pathbase").unwrap(), Target::Pathbase); + assert_eq!(Target::parse("PathBase").unwrap(), Target::Pathbase); + assert_eq!(Target::parse("pathbase").unwrap().as_stored(), "pathbase"); + } + + #[test] + fn a_bucket_url_is_an_object_target() { + let t = Target::parse("s3://bkt/traces").unwrap(); + assert_eq!(t.to_string(), "s3://bkt/traces"); + assert_eq!(t.as_stored(), "s3://bkt/traces"); + } + + #[test] + fn a_folder_is_an_object_target_stored_as_a_url() { + let t = Target::parse("/srv/traces").unwrap(); + // Displayed as a path… + assert_eq!(t.to_string(), "/srv/traces"); + // …but persisted as a URL, so it can't drift with cwd. + assert_eq!(t.as_stored(), "file:///srv/traces/"); + } + + #[test] + fn nothing_configured_resolves_to_pathbase() { + with_sandbox(|_| { + let (target, origin) = resolve(None).unwrap(); + assert_eq!(target, Target::Pathbase); + assert_eq!(origin, Origin::Builtin); + }); + } + + #[test] + fn the_flag_beats_everything() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + let (target, origin) = resolve(Some("s3://flagged/y")).unwrap(); + assert_eq!(target.to_string(), "s3://flagged/y"); + assert_eq!(origin, Origin::Flag); + }); + } + + #[test] + fn the_env_beats_the_stored_default() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + unsafe { std::env::set_var(TARGET_ENV, "s3://from-env/z") }; + let (target, origin) = resolve(None).unwrap(); + unsafe { std::env::remove_var(TARGET_ENV) }; + assert_eq!(target.to_string(), "s3://from-env/z"); + assert_eq!(origin, Origin::Env); + }); + } + + #[test] + fn the_stored_default_is_used_when_no_flag_or_env() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + let (target, origin) = resolve(None).unwrap(); + assert_eq!(target.to_string(), "s3://configured/x"); + assert_eq!(origin, Origin::Config); + }); + } + + #[test] + fn a_stored_folder_default_survives_a_round_trip() { + with_sandbox(|_| { + set_default(&Target::parse("/srv/traces").unwrap()).unwrap(); + let (target, _) = resolve(None).unwrap(); + assert_eq!(target.to_string(), "/srv/traces"); + }); + } + + #[test] + fn clearing_the_default_falls_back_to_pathbase() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + clear_default().unwrap(); + assert_eq!(resolve(None).unwrap().1, Origin::Builtin); + }); + } + + #[test] + fn s3_credentials_without_a_default_refuse_to_publish_anonymously() { + with_sandbox(|dir| { + crate::store::store( + &dir.join(crate::store::S3_CONFIG_FILE), + &crate::store::S3Settings { + access_key_id: Some("AK".to_string()), + ..Default::default() + }, + ) + .unwrap(); + let err = match resolve(None) { + Err(e) => e.to_string(), + Ok(t) => panic!("expected a refusal, got {t:?}"), + }; + assert!(err.contains("path auth default"), "{err}"); + }); + } + + #[test] + fn an_invalid_stored_default_names_the_file() { + with_sandbox(|dir| { + store_settings( + &dir.join(CONFIG_FILE), + &Settings { + default_target: Some("gs://nope/x".to_string()), + }, + ) + .unwrap(); + let err = match resolve(None) { + Err(e) => format!("{e:#}"), + Ok(t) => panic!("expected a parse failure, got {t:?}"), + }; + assert!(err.contains("default_target"), "{err}"); + assert!(err.contains(CONFIG_FILE), "{err}"); + }); + } + + // ── Pathbase-flag interaction ──────────────────────────────────── + + #[test] + fn pathbase_flags_override_an_object_default() { + let resolved = (Target::parse("s3://bkt/x").unwrap(), Origin::Config); + let (target, origin) = apply_pathbase_flags(resolved, true).unwrap(); + assert_eq!(target, Target::Pathbase); + assert_eq!(origin, Origin::Flag); + } + + #[test] + fn pathbase_flags_with_an_explicit_object_to_is_an_error() { + let resolved = (Target::parse("s3://bkt/x").unwrap(), Origin::Flag); + let err = match apply_pathbase_flags(resolved, true) { + Err(e) => e.to_string(), + Ok(t) => panic!("expected a conflict, got {t:?}"), + }; + assert!(err.contains("--to pathbase"), "{err}"); + } + + #[test] + fn pathbase_flags_are_a_no_op_when_the_target_is_already_pathbase() { + let resolved = (Target::Pathbase, Origin::Builtin); + assert_eq!( + apply_pathbase_flags(resolved.clone(), true).unwrap(), + resolved + ); + } + + #[test] + fn a_local_folder_needs_no_credentials() { + let target = Target::parse("/srv/traces").unwrap(); + check_reachable(&target, &crate::store::S3Settings::default()).unwrap(); + } +} diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..408e1f77 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -275,6 +275,158 @@ fn cache_id_input_loads_and_projects() { assert_eq!(cap.args[0], "-r"); } +// ── Object-storage input ──────────────────────────────────────────── +// +// `file://` exercises the same `object_store` path an `s3://` bucket +// takes, so these cover the S3 resume flow without a network. + +/// Write `graph` into `bucket` as `.json` and return its URL. +fn seed_object(bucket: &std::path::Path, name: &str, graph: &toolpath::v1::Graph) -> String { + std::fs::write( + bucket.join(format!("{name}.json")), + graph.to_json().unwrap(), + ) + .unwrap(); + format!("file://{}/{name}.json", bucket.display()) +} + +#[test] +fn object_store_input_downloads_projects_and_caches() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-s3-int", + )); + let uri = seed_object(bucket.path(), "claude-abc", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + input: uri, + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + + // The download is cached, so a second resume costs no fetch. + let documents = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()) + .join("documents"); + let cached: Vec = std::fs::read_dir(&documents) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(cached.len(), 1, "expected one cached doc, got {cached:?}"); + assert!( + cached[0].ends_with("claude-abc.json"), + "unexpected cache id: {cached:?}" + ); +} + +#[test] +fn object_store_input_with_no_cache_leaves_the_cache_empty() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-s3-nocache", + )); + let uri = seed_object(bucket.path(), "claude-nocache", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + input: uri, + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: true, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + assert_eq!(recorder.captured().binary, "claude"); + let documents = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()) + .join("documents"); + assert!( + !documents.exists() || std::fs::read_dir(&documents).unwrap().next().is_none(), + "--no-cache must not write the cache" + ); +} + +#[test] +fn object_store_input_prefers_the_cache_over_a_refetch() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-s3-cached", + )); + let uri = seed_object(bucket.path(), "claude-cached", &graph); + + let args = || ResumeArgs { + input: uri.clone(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }; + run_with_strategy(args(), &RecordingExec::default()).unwrap(); + + // Delete the object: a cache hit must not need it any more. + std::fs::remove_file(bucket.path().join("claude-cached.json")).unwrap(); + let recorder = RecordingExec::default(); + run_with_strategy(args(), &recorder).unwrap(); + assert_eq!(recorder.captured().binary, "claude"); +} + +#[test] +fn missing_object_reports_not_found() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let err = run_with_strategy( + ResumeArgs { + input: format!("file://{}/absent.json", bucket.path().display()), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &RecordingExec::default(), + ) + .unwrap_err(); + assert!(err.to_string().contains("not found"), "actual: {err}"); +} + // ── Rejection cases ───────────────────────────────────────────────── #[test] diff --git a/crates/path-cli/tests/share_targets.rs b/crates/path-cli/tests/share_targets.rs new file mode 100644 index 00000000..ed73151b --- /dev/null +++ b/crates/path-cli/tests/share_targets.rs @@ -0,0 +1,453 @@ +//! Integration tests for share targets: the configured default, and +//! object storage (S3 or a folder). +//! +//! Every test runs against a local folder — the same `object_store` +//! code path an `s3://` bucket takes, minus the network. That's the +//! point of making folders first-class: the plumbing under test +//! (target resolution, destination parsing, key layout, upload, +//! download, cache landing) is backend-independent, so exercising it +//! locally covers the S3 case without credentials or a mock endpoint. + +#![cfg(not(target_os = "emscripten"))] + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::Path; + +fn cmd(config_dir: &Path) -> Command { + let mut c = Command::cargo_bin("path").unwrap(); + c.env("TOOLPATH_CONFIG_DIR", config_dir); + // Keep an ambient developer AWS profile or share target out of the + // test's way. + for k in [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL", + "AWS_ENDPOINT_URL_S3", + "TOOLPATH_SHARE_TARGET", + ] { + c.env_remove(k); + } + c +} + +/// A minimal single-step agent document, written to `dir/doc.json`. +fn write_doc(dir: &Path) -> std::path::PathBuf { + let body = serde_json::json!({ + "graph": { "id": "g1" }, + "paths": [{ + "path": { "id": "p1", "head": "s1" }, + "steps": [{ + "step": { + "id": "s1", + "parents": [], + "actor": "agent:claude-code", + "timestamp": "2026-01-01T00:00:00Z" + }, + "change": { + "claude-code://share-target-int": { + "structural": { + "type": "conversation.append", + "role": "user", + "text": "hello" + } + } + } + }] + }] + }); + let p = dir.join("doc.json"); + std::fs::write(&p, serde_json::to_string(&body).unwrap()).unwrap(); + p +} + +// ── path auth default ─────────────────────────────────────────────── + +#[test] +fn a_folder_can_be_designated_as_the_share_target() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let folder_str = folder.path().to_string_lossy().into_owned(); + + cmd(config.path()) + .args(["auth", "default", &folder_str]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Default share target set to {folder_str}" + ))) + // A folder needs no credentials, so nothing should nag about them. + .stdout(predicate::str::contains("credentials").not()); + + // Stored as a URL so it can't be re-read relative to another cwd… + let raw = std::fs::read_to_string(config.path().join("config.json")).unwrap(); + assert!(raw.contains("file://"), "{raw}"); + // …but reported back as the path the user typed. + cmd(config.path()) + .args(["auth", "default"]) + .assert() + .success() + .stdout(predicate::str::contains(&folder_str)) + .stdout(predicate::str::contains("configured default")); +} + +#[test] +fn a_bucket_url_can_be_designated_as_the_share_target() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "default", "s3://my-bucket/traces"]) + .assert() + .success() + .stdout(predicate::str::contains("s3://my-bucket/traces")) + // No credentials stored yet, and S3 needs them — say so. + .stdout(predicate::str::contains("path auth s3 login")); +} + +#[test] +fn auth_default_with_no_argument_explains_the_options() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "default"]) + .assert() + .success() + .stdout(predicate::str::contains("No default share target")) + .stdout(predicate::str::contains("In effect now: pathbase")) + .stdout(predicate::str::contains("path auth default ~/")); +} + +#[test] +fn auth_default_clear_falls_back_to_pathbase() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "default", "s3://my-bucket/traces"]) + .assert() + .success(); + cmd(config.path()) + .args(["auth", "default", "--clear"]) + .assert() + .success() + .stdout(predicate::str::contains("uploads to Pathbase")); + cmd(config.path()) + .args(["auth", "default"]) + .assert() + .success() + .stdout(predicate::str::contains("In effect now: pathbase")); +} + +#[test] +fn the_env_var_overrides_the_stored_default() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "default", "s3://stored/x"]) + .assert() + .success(); + cmd(config.path()) + .args(["auth", "default"]) + .env("TOOLPATH_SHARE_TARGET", "s3://from-env/y") + .assert() + .success() + // The stored value is still reported as stored… + .stdout(predicate::str::contains("s3://stored/x")) + // …but the env var is what would actually be used. + .stdout(predicate::str::contains( + "In effect now: s3://from-env/y (TOOLPATH_SHARE_TARGET)", + )); +} + +#[test] +fn a_scheme_less_target_is_a_folder_not_a_bucket() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "default", &folder.path().to_string_lossy()]) + .assert() + .success(); + let raw = std::fs::read_to_string(config.path().join("config.json")).unwrap(); + assert!( + raw.contains("file://"), + "a bare path must mean a folder: {raw}" + ); + assert!(!raw.contains("s3://"), "{raw}"); +} + +#[test] +fn an_unsupported_scheme_is_rejected() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "default", "gs://bucket/x"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://")); +} + +// ── path auth s3 ──────────────────────────────────────────────────── + +#[test] +fn auth_s3_login_stores_status_shows_and_logout_clears() { + let config = tempfile::tempdir().unwrap(); + + cmd(config.path()) + .args([ + "auth", + "s3", + "login", + "--region", + "eu-west-1", + "--access-key-id", + "AKIAEXAMPLE", + "--secret-access-key", + "supersecretvalue", + ]) + .assert() + .success() + .stdout(predicate::str::contains("S3 settings saved")) + // Credentials alone don't redirect `path share` — say so. + .stdout(predicate::str::contains("path auth default")); + + let stored = config.path().join("s3.json"); + assert!(stored.is_file(), "s3.json not written"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&stored).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "credentials must not be world-readable" + ); + } + + cmd(config.path()) + .args(["auth", "s3", "status"]) + .assert() + .success() + .stdout(predicate::str::contains("eu-west-1")) + .stdout(predicate::str::contains("AKIAEXAMPLE")) + // The secret is stored but never printed back in full. + .stdout(predicate::str::contains("supersecretvalue").not()) + .stdout(predicate::str::contains("****alue")); + + cmd(config.path()) + .args(["auth", "s3", "logout"]) + .assert() + .success() + .stdout(predicate::str::contains("cleared")); + assert!(!stored.exists()); +} + +#[test] +fn auth_s3_login_merges_into_the_existing_settings() { + let config = tempfile::tempdir().unwrap(); + + cmd(config.path()) + .args(["auth", "s3", "login", "--access-key-id", "AKIAEXAMPLE"]) + .assert() + .success(); + // A later, narrower call must not wipe the key. + cmd(config.path()) + .args(["auth", "s3", "login", "--region", "us-west-2"]) + .assert() + .success(); + + let raw = std::fs::read_to_string(config.path().join("s3.json")).unwrap(); + assert!(raw.contains("AKIAEXAMPLE"), "{raw}"); + assert!(raw.contains("us-west-2"), "{raw}"); +} + +#[test] +fn auth_s3_status_marks_env_supplied_values() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "s3", "status"]) + .env("AWS_REGION", "ap-south-1") + .assert() + .success() + .stdout(predicate::str::contains("ap-south-1 (env)")); +} + +#[test] +fn auth_s3_login_without_a_terminal_or_flags_is_an_error() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "s3", "login"]) + .assert() + .failure() + .stderr(predicate::str::contains("Nothing to store")); +} + +#[test] +fn s3_credentials_without_a_default_refuse_to_publish_anonymously() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args(["auth", "s3", "login", "--access-key-id", "AKIAEXAMPLE"]) + .assert() + .success(); + + // Not logged into Pathbase, S3 credentials present, no default: + // silently uploading to the anonymous public endpoint would be the + // worst possible guess. + cmd(config.path()) + .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("path auth default")); +} + +// ── p export object / p import object ─────────────────────────────── + +#[test] +fn export_then_import_round_trips_through_a_folder() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + let out = cmd(config.path()) + .args(["p", "export", "object"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", &folder.path().to_string_lossy()]) + .assert() + .success(); + let uri = String::from_utf8(out.get_output().stdout.clone()) + .unwrap() + .trim() + .to_string(); + assert!(uri.ends_with("/doc.json"), "unexpected location: {uri}"); + assert!(folder.path().join("doc.json").is_file()); + + cmd(config.path()) + .args(["p", "import", "object", &format!("file://{uri}")]) + .assert() + .success(); + + let docs = config.path().join("documents"); + let ids: Vec = std::fs::read_dir(&docs) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(ids.len(), 1, "expected one cached doc, got {ids:?}"); + assert!(ids[0].starts_with("file-"), "unexpected cache id: {ids:?}"); +} + +#[test] +fn the_s3_subcommand_alias_still_works() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args(["p", "export", "s3"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", &folder.path().to_string_lossy()]) + .assert() + .success(); + assert!(folder.path().join("doc.json").is_file()); +} + +#[test] +fn export_object_uses_the_configured_default_target() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args([ + "auth", + "default", + &format!("{}/traces", folder.path().display()), + ]) + .assert() + .success(); + + cmd(config.path()) + .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains("/traces/doc.json")); + + assert!(folder.path().join("traces/doc.json").is_file()); +} + +#[test] +fn export_object_with_a_pathbase_default_says_so() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args(["auth", "default", "pathbase"]) + .assert() + .success(); + + cmd(config.path()) + .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("p export pathbase")); +} + +#[test] +fn import_object_reports_a_missing_object_clearly() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + + cmd(config.path()) + .args([ + "p", + "import", + "object", + &format!("file://{}/nope.json", folder.path().display()), + ]) + .assert() + .failure() + .stderr(predicate::str::contains("not found")); +} + +#[test] +fn import_object_rejects_a_non_toolpath_object() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + std::fs::write(folder.path().join("junk.json"), "{\"hello\":1}").unwrap(); + + cmd(config.path()) + .args([ + "p", + "import", + "object", + &format!("file://{}/junk.json", folder.path().display()), + ]) + .assert() + .failure() + .stderr(predicate::str::contains("not a toolpath document")); +} + +// ── path share ────────────────────────────────────────────────────── + +#[test] +fn share_rejects_pathbase_flags_alongside_an_object_target() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["share", "--to", "s3://b/p", "--public"]) + .assert() + .failure() + .stderr(predicate::str::contains("--to pathbase")); +} + +#[test] +fn share_rejects_an_unparseable_target_before_doing_any_work() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["share", "--to", "gs://bucket/x"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://")); +} diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 9b38ca37..5fd5d044 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.16.1" +version = "0.17.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.16.1" } +path-cli = { path = "../path-cli", version = "0.17.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index 4d282a97..d7982a6f 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,15 +113,15 @@ }, { "name": "path-cli", - "version": "0.16.1", + "version": "0.17.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", - "role": "One binary called `path` that ties everything together. Porcelain at the top level (share, resume, query, show, track, auth); plumbing under `path p \u2026` (import, export, cache, list, render, merge, validate). Pathbase round-trip via `p import pathbase` / `p export pathbase` (authed default \u2192 secret pathstash; anon fallback when not logged in)." + "role": "One binary called `path` that ties everything together. Porcelain at the top level (share, resume, query, show, track, auth); plumbing under `path p \u2026` (import, export, cache, list, render, merge, validate). Pathbase round-trip via `p import pathbase` / `p export pathbase` (authed default \u2192 secret pathstash; anon fallback when not logged in). Share targets are configurable: `path auth default ` points `share` at an S3 bucket, an S3-compatible endpoint, or a plain folder; `resume` takes the location back." }, { "name": "toolpath-cli", - "version": "0.16.1", + "version": "0.17.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", From 6553004b37e49c4faf5ec2a56feda41a81f7be9e Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 11 Aug 2026 11:04:48 -0400 Subject: [PATCH 2/3] feat(cli): browsable share destinations, legible names, verified targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing to a destination was a one-way trip, and pointing at one was an act of faith. Three changes, in the order they bite. ## Destinations you can read back out of `share` had a picker across every harness; resuming from where you shared to had nothing, and the object was named `.json` — an opaque uuid. Twenty shares later a folder was twenty blobs you could only read by `cat`-ing JSON. A shared document now lands at `--.json`, e.g. `2026-08-07-add-s3-support-to-share-claude-6f2a1c9e.json`. Every component is a pure function of the document, so a re-share overwrites its own object rather than leaving near-duplicates; the date is the session's *first* step, so it doesn't move as the conversation grows. `path resume ` lists what is there and offers a picker — the counterpart to `share`'s. Rows come from object names alone, so browsing a hundred sessions costs one list request and zero downloads; that is what legible names buy. A destination holding one document skips the picker, and anything ending in `.json` is still fetched directly. ## Targets checked when they're set Setting a target stored it and hoped. A typo'd bucket or a missing permission surfaced in the middle of a share — after a session pick and a derivation, at the moment the user wanted a result rather than a setup step. A target is set once and used many times, so configuration time is where it should fail. `path target ` now verifies before storing, by doing what a share does: write a small `.toolpath-access-check` object, then remove it. A listing would only prove `s3:ListBucket`, which isn't the permission a share needs; only a write answers the question actually being asked. Verification failure means nothing is stored. `--no-verify` is the escape hatch for a bucket that doesn't exist yet or a laptop that's offline, and it says `(not verified)` when it stores one. Folders take the same path, which also creates them; a credential that can write but not delete leaves the probe behind and gets a note rather than a failure. Both preflights bound their own runtime. Per-request timeouts alone don't: object_store's default ten retries multiply them, turning a 5s connect timeout into a ~50s stall on a hanging endpoint. `open_for_check` builds the S3 store directly to cap retries at 2, since `parse_url_opts` has no key for retry policy. Its retry epilogue ("after 10 retries, max_retries: 10, retry_timeout: 180s") is stripped from user-facing errors, and an unreachable endpoint now says so instead of quoting a transport trace. Share keeps a weaker list-based probe: the upload is about to happen and will report its own failure, so all that check needs to buy is not wasting a derivation. ## Naming and guards `path target` replaces `path auth default`. "Auth" means credentials; where shares go is a persistent setting, not authentication, and nobody would look for it under `auth`. `path auth s3` keeps the credentials. A bare relative value (`my-bucket/traces`) is rejected instead of silently creating `./my-bucket/traces` and reporting success. It is overwhelmingly a bucket name typed from memory; `./my-bucket/traces` opts in explicitly. `memory://` is no longer accepted: a fresh per-process store, so anything "shared" there is gone before the command exits. Plumbing renamed to match: `p export object` / `p import object`, both aliased `s3`. --- CHANGELOG.md | 99 +++- CLAUDE.md | 34 +- README.md | 30 +- crates/path-cli/src/cmd_auth.rs | 70 +-- crates/path-cli/src/cmd_export.rs | 4 +- crates/path-cli/src/cmd_resume.rs | 156 +++++- crates/path-cli/src/cmd_share.rs | 6 +- crates/path-cli/src/cmd_target.rs | 91 ++++ crates/path-cli/src/lib.rs | 16 +- crates/path-cli/src/store.rs | 719 +++++++++++++++++++++++-- crates/path-cli/src/target.rs | 58 +- crates/path-cli/tests/resume.rs | 94 ++++ crates/path-cli/tests/share_targets.rs | 211 ++++++-- 13 files changed, 1362 insertions(+), 226 deletions(-) create mode 100644 crates/path-cli/src/cmd_target.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cc6efd..1b871502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,47 +2,88 @@ All notable changes to the Toolpath workspace are documented here. -## Share and resume anywhere: S3, a folder, or Pathbase — 2026-08-07 +## Share and resume anywhere: S3, a folder, or Pathbase — 2026-08-10 **`path-cli`** (0.17.0) makes the share destination configurable. Bare `path share` still goes to Pathbase by default, but it can now be -pointed at an S3 bucket or a plain folder once, and stay there. +pointed at an S3 bucket or a plain folder once, and stay there — and +what you share to is somewhere you can browse and resume out of, not a +write-only hole. -**Designating a target.** `path auth default ` sets it, and -`path auth default` with no argument prints what's in effect and why: +**Designating a target.** `path target ` sets it, and +`path target` with no argument prints what's in effect and why: ``` -path auth default ~/Dropbox/toolpath-traces # a folder — no credentials needed -path auth default s3://my-bucket/traces -path auth default pathbase # switch back -path auth default --clear +path target ~/Dropbox/toolpath-traces # a folder — no credentials needed +path target s3://my-bucket/traces +path target pathbase # switch back +path target --clear ``` +Setting a target **verifies** it, by doing what a share does: writing a +small object and removing it. That's the whole point of checking at +configuration time — a target is set once and used many times, so a +wrong one that survives costs a session pick and a derivation later, at +the moment the user wanted a result rather than a setup step. A listing +would only tell you about `s3:ListBucket`, which isn't the permission a +share needs; only a write answers the question actually being asked. +Folders go through the same path, which also creates them. `--no-verify` +stores a target unchecked, for a bucket that doesn't exist yet or a +laptop that's offline. + +It's a top-level verb rather than a flag on `share` or a subcommand of +`auth`, because it's a persistent setting and it is not authentication. The value is stored as `default_target` in `~/.toolpath/config.json` — -one place, so "where does my next share go?" has one answer. A -scheme-less value is a **local path**; spell a bucket `s3://…`. Folder -targets are stored as `file://` URLs, so a stored default can't drift -with the working directory, but they're displayed and printed as plain -paths. Resolution order: `--to`, then `$TOOLPATH_SHARE_TARGET`, then -the stored default, then Pathbase. +one place, so "where does my next share go?" has one answer. + +A scheme-less value is a **local path**; spell a bucket `s3://…`. A +*bare relative* value (`my-bucket/traces`) is rejected outright rather +than quietly creating `./my-bucket/traces` and reporting success — +`./my-bucket/traces` says you meant it. Folder targets are stored as +`file://` URLs, so a stored default can't drift with the working +directory, but they're displayed and printed as plain paths. +Resolution: `--to`, then `$TOOLPATH_SHARE_TARGET`, then the stored +default, then Pathbase. Nothing is inferred from which credentials happen to exist: a share that silently changes destination is a data-egress bug, not a convenience. The one guard is at the bottom of the order — if S3 -credentials are stored, no Pathbase session exists, and no default is +credentials are stored, no Pathbase session exists, and no target is set, `path share` refuses rather than falling through to the *anonymous public* Pathbase endpoint. -**Per-call override.** `path share --to ` takes the same three -forms. The Pathbase-only flags (`--anon`, `--repo`, `--public`, -`--url`, `--name`) select Pathbase on their own, overriding an object -default; combining one with an explicit object `--to` is an error -rather than a silent resolution. The target is resolved before the -harness scan and the picker, so a typo costs nothing to discover. - -**Resume.** `path resume s3://bucket/key.json` is the inverse — a new -input shape alongside Pathbase URLs, shorthands, file paths, and cache -ids, caching downloads under an `s3--` id so `--force` and +**Legible object names.** A shared document lands at +`--.json` — e.g. +`2026-08-07-add-s3-support-to-share-claude-6f2a1c9e.json`. Every +component is a pure function of the document, so re-sharing a session +that has grown overwrites its own object instead of leaving a trail of +near-duplicates; the date comes from the session's *first* step, so it +doesn't move as the conversation continues. The point is that a +destination is a folder someone will open or a bucket someone will page +through, and it also makes listing cheap enough to build a picker on. + +**Resuming from a destination.** `path resume ` — a +bucket, a prefix, or a folder — lists what's there and offers a picker, +the counterpart to `path share`'s picker across every harness. Rows are +built from object names alone, so browsing a hundred shared sessions +costs one list request and zero downloads. A destination holding a +single document skips the picker. Anything ending in `.json` is still +treated as a document and fetched directly. + +**Per-call override.** `path share --to ` takes the same forms. +The Pathbase-only flags (`--anon`, `--repo`, `--public`, `--url`, +`--name`) select Pathbase on their own, overriding an object target; +combining one with an explicit object `--to` is an error rather than a +silent resolution. The target is resolved before the harness scan and +the picker, and an `s3://` target is probed for reachability before the +picker too — a weaker check than `path target`'s, since the upload is +about to happen and will report its own failure; all it needs to buy is +not wasting a derivation on a typo'd bucket. Both checks bound their own +runtime (short timeouts *and* a retry cap, since the default ten retries +would otherwise multiply the timeout). + +**Resume by location.** `path resume s3://bucket/key.json` caches +downloads under an `s3--` id, so `--force` and `--no-cache` behave exactly as they do for Pathbase. A document shared to a folder is resumed with its plain path, which already worked. @@ -65,9 +106,11 @@ export writes wherever `path share` would. Transport is the `object_store` crate, so one code path covers AWS S3, any S3-compatible endpoint (R2, MinIO, Ceph, B2 — point `--endpoint` at -it), and `file://` for a folder. The folder backend is what the tests -round-trip against, so share and resume are exercised end-to-end -without a network or a mock HTTP server. +it), and `file://` for a folder. `memory://` is rejected: it's a fresh +per-process store, so anything "shared" there is gone before the +command exits. The folder backend is what the tests round-trip against, +so share and resume are exercised end-to-end without a network or a +mock HTTP server. ## Projected Claude sessions are resumable again — 2026-07-30 diff --git a/CLAUDE.md b/CLAUDE.md index b389e02c..6f1394f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ cargo run -p path-cli -- p import object s3://my-bucket/traces/claude-abc.json cargo run -p path-cli -- p import claude --project . --no-cache | path p render md --input - # Share an agent session (interactive picker, single-shot). Bare `share` -# goes wherever `path auth default` points — Pathbase unless changed. +# goes wherever `path target` points — Pathbase unless changed. cargo run -p path-cli -- share cargo run -p path-cli -- share --harness claude --session --project /path/to/project cargo run -p path-cli -- share --url https://my-pathbase.example @@ -128,7 +128,9 @@ cargo run -p path-cli -- share --to pathbase # force Pathbase for one call # Resume a Toolpath document into your coding agent of choice (interactive harness picker) cargo run -p path-cli -- resume -cargo run -p path-cli -- resume s3://my-bucket/traces/claude-abc.json +cargo run -p path-cli -- resume s3://my-bucket/traces/2026-08-07-add-s3-support-claude-abc.json +cargo run -p path-cli -- resume s3://my-bucket/traces # a destination: lists it, picker +cargo run -p path-cli -- resume ~/Dropbox/toolpath-traces cargo run -p path-cli -- resume --harness claude -C /path/to/project # Plumbing: export toolpath documents into external formats. is a @@ -177,8 +179,9 @@ cargo run -p path-cli -- auth login cargo run -p path-cli -- auth status cargo run -p path-cli -- auth whoami cargo run -p path-cli -- auth logout -cargo run -p path-cli -- auth default s3://my-bucket/traces # or a folder: ~/Dropbox/traces -cargo run -p path-cli -- auth default # print what's in effect, and why +cargo run -p path-cli -- target s3://my-bucket/traces # verified before it's stored +cargo run -p path-cli -- target ~/Dropbox/traces # a folder needs no credentials +cargo run -p path-cli -- target # print what's in effect, and why cargo run -p path-cli -- auth s3 login --access-key-id AKIA... # credentials only cargo run -p path-cli -- auth s3 status cargo run -p path-cli -- auth s3 logout @@ -202,18 +205,25 @@ overrides the credentials directory. Server URL comes from `--url`, then `$PATHBASE_URL`, then `https://pathbase.dev`. **Where shares go** is one setting in one place: `default_target` in -`~/.toolpath/config.json`, set by `path auth default ` and read by -`crate::target`. A target is the literal `pathbase`, or an object-storage +`~/.toolpath/config.json`, set by `path target ` (a top-level verb, +not a flag on `share` and not under `auth` — it's a persistent setting and +it isn't authentication) and read by `crate::target`. A target is the literal `pathbase`, or an object-storage location — an S3 bucket (`s3://bucket/prefix`) or a plain folder (`~/Dropbox/traces`, `/srv/traces`, `file:///srv/traces`). **A scheme-less value is a local path, not a bucket**: bare strings are what people type when designating a folder, and a bucket is unambiguous when spelled -`s3://`. Folder targets are persisted as `file://` URLs (so a stored +`s3://`. A *bare relative* value (`my-bucket/traces`) is **rejected** — it's +overwhelmingly a bucket name typed from memory, and resolving it against the +cwd would create `./my-bucket/traces` and report success; `./my-bucket/traces` +opts in explicitly. A *bare relative* value (`my-bucket/traces`) is **rejected** — +it's overwhelmingly a bucket name typed from memory, and resolving it +against the cwd would create `./my-bucket/traces` and report success; +`./my-bucket/traces` opts in explicitly. Folder targets are persisted as `file://` URLs (so a stored default can't be re-read relative to a different cwd) but displayed and printed as plain paths (so the printed location pastes straight into `path resume`, which already accepts file paths). Resolution order: `--to` → `$TOOLPATH_SHARE_TARGET` → `default_target` → Pathbase. -`path auth default` with no argument prints the stored value, what is +Setting a target **verifies** it first (`target::verify` → `store::verify`): a real write of a `.toolpath-access-check` object, then a delete. Configuration time is the right time to fail — a target is set once and used many times, and a listing would only prove `s3:ListBucket`, not the `s3:PutObject` a share needs. Folders go through the same path, which also creates them; a credential that can write but not delete leaves the probe behind and gets a note, not a failure. `--no-verify` stores unchecked (bucket not created yet, offline). `path target` with no argument prints the stored value, what is actually in effect, and which of those four supplied it. Nothing is inferred from which credentials happen to exist — a share that @@ -272,7 +282,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 380 unit + 135 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, share targets over a local-folder backend — `auth default` set/print/clear/env-precedence, `auth s3 login/status/logout`, `p export object` → `p import object` round trip, Pathbase-flag interaction, resume-from-object-storage with cache/`--no-cache`/missing-object cases — fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 413 unit + 154 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, share targets over a local-folder backend — `path target` set/print/clear/env-precedence + the bare-relative rejection + write-verification at configuration time (unreachable bucket refused and not stored, `--no-verify` escape hatch, folder created with the probe cleaned up, unwritable folder refused), legible-name stability across re-shares, `auth s3 login/status/logout`, `p export object` → `p import object` round trip, Pathbase-flag interaction, resume-from-object-storage with cache/`--no-cache`/missing-object cases, and resume-from-a-destination (single-document short-circuit, empty-destination guidance, container-URL detection) — fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -339,10 +349,10 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change. - Interactive session selection: `path p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. -- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. `--to ` overrides the destination for one call — `pathbase`, an S3 bucket, or a folder (see "Where shares go" above). The Pathbase-only flags select Pathbase on their own, overriding an object-storage default; combining one with an explicit object `--to` is an error rather than a silent resolution. Target selection is split in two: `cmd_share::plan_target` is pure (config read only, no network) and runs **first**, before the harness scan and the picker, so a typo'd `--to` or a flag conflict costs nothing to discover; `cmd_share::open_target` then probes credentials — S3 settings, or a Pathbase auth preflight — before the picker fires, so a credential failure doesn't waste the derive/cache work either. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. -- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an object-storage URL (`s3://bucket/key.json`, also `s3a://`/`file://`/`memory://`), an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching remote fetches under `~/.toolpath/documents/` unless `--no-cache`, via the shared `cmd_resume::fetch_cached` — every remote shape computes its cache id from the reference alone, so a cache hit costs no round trip); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. +- `path share` is the one-shot equivalent of `path p import | path p export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. `--to ` overrides the destination for one call — `pathbase`, an S3 bucket, or a folder (see "Where shares go" above). The Pathbase-only flags select Pathbase on their own, overriding an object-storage default; combining one with an explicit object `--to` is an error rather than a silent resolution. Target selection is split in two: `cmd_share::plan_target` is pure (config read only, no network) and runs **first**, before the harness scan and the picker, so a typo'd `--to` or a flag conflict costs nothing to discover; `cmd_share::open_target` then probes — a Pathbase auth preflight, or for `s3://` a `list_with_delimiter` reachability check via `Destination::probe` — before the picker fires, so neither a bad credential nor a typo'd bucket wastes the derive/cache work. Share's probe is deliberately weaker than `path target`'s write check: the upload is about to happen and will report its own failure, so all it needs to buy is not wasting a derivation, and a write-only credential legitimately can't list. Both checks go through `store::open_for_check`, which caps timeouts *and* retries — `object_store`'s default ten retries would otherwise multiply a 5s connect timeout into a ~50s stall, and its retry epilogue ("after 10 retries, max_retries: …") is stripped from user-facing errors by `store::terse`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. +- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an object-storage URL (`s3://bucket/key.json`, also `s3a://`/`file://`), a **share destination** — a bucket, prefix, or folder, which it lists and offers in an fzf picker built from object names alone (no downloads; a single-document destination skips the picker) — an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; a shared document is always `.json`, which is how a document is told from a place to browse; resolves it (caching remote fetches under `~/.toolpath/documents/` unless `--no-cache`, via the shared `cmd_resume::fetch_cached` — every remote shape computes its cache id from the reference alone, so a cache hit costs no round trip); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive / peek-dir / scope-match — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--project-under` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--project-under ` on both `p cache sync` and `path query` restricts ingestion to sessions whose project directory (recorded cwd) is under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. -- Object-storage transport: `crates/path-cli/src/store.rs` splits *where* from *how to authenticate* — `Destination`/`ObjectUri` (pure URL parsing and key layout, no credentials) and `S3Settings` (`~/.toolpath/s3.json`, env merge, `get`/`put`). `crates/path-cli/src/target.rs` sits above it and owns the Pathbase-vs-object choice plus the stored default. Transport is the `object_store` crate, so one code path serves AWS S3, any S3-compatible endpoint (R2, MinIO, Ceph, B2 — set `endpoint`), and `file://` for a local directory; `object_store` is async, so it tunnels through the same `cmd_pathbase::block_on` runtime the Pathbase client uses. `file://` is a first-class destination, not a toy — `path auth default ~/traces` is a complete setup needing no credentials — and it's what the tests round-trip against, so share and resume are covered end-to-end with no network and no mock HTTP server. Accepted schemes are deliberately narrower than what `object_store` parses (`s3`, `s3a`, `file`, `memory`) — `http`/`https` belong to Pathbase in the same dispatch, and `gs://`/`az://` would need feature flags we don't compile in. Object keys are `/.json`, so where a document lands is a pure function of the destination and the document's cache id. Config files under the config dir (Pathbase sessions, S3 settings, `config.json`) all go through `config::write_private_json` / `read_private_json`, which own the 0600-file / 0700-parent story. +- Object-storage transport: `crates/path-cli/src/store.rs` splits *where* from *how to authenticate* — `Destination`/`ObjectUri` (pure URL parsing and key layout, no credentials) and `S3Settings` (`~/.toolpath/s3.json`, env merge, `get`/`put`). `crates/path-cli/src/target.rs` sits above it and owns the Pathbase-vs-object choice plus the stored default. Transport is the `object_store` crate, so one code path serves AWS S3, any S3-compatible endpoint (R2, MinIO, Ceph, B2 — set `endpoint`), and `file://` for a local directory; `object_store` is async, so it tunnels through the same `cmd_pathbase::block_on` runtime the Pathbase client uses. `file://` is a first-class destination, not a toy — `path target ~/traces` is a complete setup needing no credentials — and it's what the tests round-trip against, so share and resume are covered end-to-end with no network and no mock HTTP server. Accepted schemes are deliberately narrower than what `object_store` parses (`s3`, `s3a`, `file`) — `http`/`https` belong to Pathbase in the same dispatch, `gs://`/`az://` would need feature flags we don't compile in, and `memory://` is a fresh per-process store whose contents vanish before the command exits. Object names are `--.json` (`store::name_for`): every component is a pure function of the document, so a re-share overwrites its own object rather than leaving near-duplicates (the date is the *earliest* step's, so it doesn't move as the session grows), and the name is legible enough that `Destination::list` + `cmd_resume::pick_from_destination` can build picker rows without downloading anything. Config files under the config dir (Pathbase sessions, S3 settings, `config.json`) all go through `config::write_private_json` / `read_private_json`, which own the 0600-file / 0700-parent story. - Claude Code plugin: `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`, so commands are `/path:share` and `/path:query`). The plugin does **not** commit binaries — both commands invoke the CLI through `plugins/claude-code/scripts/ensure-path.sh`, which prefers an existing Toolpath `path` on PATH (identity-checked via `--help`), else `~/.local/bin/path`, else `~/.toolpath/bin/path`, else downloads the latest GitHub release (sha256-verified, same logic as `scripts/install.sh`) and installs globally to `~/.local/bin` — falling back to `~/.toolpath/bin` when a foreign binary named `path` claims the name. Two hard-won constraints baked into the command docs: slash-command inline `!` context commands and model-issued Bash must not contain `$PWD`/variables (Claude Code's permission checker rejects commands it can't statically analyze — hence the `sessions` and `current-session` helper modes, the latter reading `$CLAUDE_CODE_SESSION_ID` so no-arg `/path:share` shares exactly the running session), and `--project` must always be an absolute path (path-cli does not canonicalize relative `--project` values; `.` silently matches nothing). Tests: `scripts/test-plugin.sh` (manifest consistency + offline bootstrap tests against a stubbed curl/release), wired in as the `plugin` quality gate; plugin shell scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Future harness integrations go under `plugins//` (only Claude Code plugins are marketplace entries; other harnesses distribute their own way). Version bumps: keep `plugins/claude-code/.claude-plugin/plugin.json` and the matching entry in `.claude-plugin/marketplace.json` in lockstep (test-plugin.sh asserts this); the binary is unpinned (latest release) with `MIN_VERSION` in ensure-path.sh naming the oldest CLI the command docs support. - `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github, pathbase, and s3 are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/README.md b/README.md index bf6fd83e..dee5b2b4 100644 --- a/README.md +++ b/README.md @@ -127,11 +127,11 @@ path p import pathbase https://pathbase.dev/alex/pathstash/path-pr-42 # Send shares somewhere else instead. Designate it once, and bare # `path share` goes there from then on. -path auth default ~/Dropbox/toolpath-traces # a folder — no credentials needed -path auth default s3://my-bucket/traces # or a bucket -path auth s3 login # credentials, if the bucket needs them -path share # → wherever you pointed it -path auth default # what's in effect, and why +path auth s3 login # credentials, if you're using a bucket +path target ~/Dropbox/toolpath-traces # a folder — no credentials needed +path target s3://my-bucket/traces # or a bucket; checked before it's stored +path share # → wherever you pointed it +path target # what's in effect, and why # Override for one call, without changing the default path share --to /tmp/scratch @@ -140,8 +140,8 @@ path share --to pathbase # Resume a Toolpath document into your coding agent of choice (interactive # harness picker; project the session and exec the harness's resume command) path resume https://pathbase.dev/alex/pathstash/path-pr-42 -path resume s3://my-bucket/traces/claude-.json -path resume ~/Dropbox/toolpath-traces/claude-.json +path resume ~/Dropbox/toolpath-traces # lists what you've shared, pick one +path resume s3://my-bucket/traces/2026-08-07-fix-the-parser-claude-abc.json path resume claude- --harness claude -C /path/to/project # Query the whole local cache with a jaq (jq) filter over wrapped steps @@ -174,14 +174,26 @@ path codex --session ID opencode --session ID pi --project PATH --session ID [--base DIR] - share # one-shot interactive picker + Pathbase upload + share # one-shot interactive picker + upload to the share target + [--to pathbase|s3://BUCKET/PREFIX|FOLDER] + [--harness NAME] [--session ID] [--project PATH] [--no-cache] + [--url URL] [--anon] [--repo OWNER/NAME] [--name TEXT] [--public] resume # project a doc into a coding agent and exec --resume + # INPUT: pathbase URL | s3://…/doc.json | a destination to + # browse | owner/repo/slug | file | cache id query # jaq (jq) filter over cached steps FILTER [--source NAME] [--id CACHE-ID] [--input FILE] [--project PATH] [--kind SELECTOR] [-c] [-r] kind # list bundled kinds, or print a kind's schema [KIND[/VERSION]] auth login | status | whoami | logout [--url URL] + s3 login [--region R] [--endpoint URL] [--access-key-id ID] + [--secret-access-key KEY] [--session-token TOK] + [--virtual-hosted-style] + s3 status | s3 logout + target # where `path share` uploads; no argument prints it. + # Setting one writes a probe object to prove it works. + [pathbase | s3://BUCKET/PREFIX | FOLDER] [--clear] [--no-verify] p # plumbing: lower-level building blocks query ancestors --input FILE --step-id ID @@ -202,10 +214,12 @@ path opencode [--session ID] [--all] [--project ID] [--no-snapshot-diffs] pi [--project PATH] [--session ID] [--all] [--base DIR] pathbase TRACE-ID-OR-URL [--url URL] + object URL # s3:// or file://; alias: s3 # global: [--force] [--no-cache] export claude --input REF [--project DIR | --output FILE] pathbase --input REF [--url URL] + object --input REF [--to DEST] # alias: s3 cache ls | rm CACHE-ID render diff --git a/crates/path-cli/src/cmd_auth.rs b/crates/path-cli/src/cmd_auth.rs index 7a2e375f..8cde387f 100644 --- a/crates/path-cli/src/cmd_auth.rs +++ b/crates/path-cli/src/cmd_auth.rs @@ -8,7 +8,7 @@ use crate::cmd_pathbase::{ prompt_line, resolve_url, store_session, }; use crate::store::{self, S3Settings}; -use crate::target::{self, Target}; +use crate::target; #[derive(Subcommand, Debug)] pub enum AuthOp { @@ -28,17 +28,6 @@ pub enum AuthOp { Status, /// Verify the stored session against the server and print the current user Whoami, - /// Set (or show) where `path share` uploads by default: `pathbase`, - /// an S3 bucket (`s3://bucket/prefix`), or a folder (`~/traces`) - Default { - /// The target to make default. Omit to print the current one. - #[arg(index = 1)] - target: Option, - - /// Forget the configured default, falling back to Pathbase - #[arg(long, conflicts_with = "target")] - clear: bool, - }, /// Store S3 credentials once, so `s3://` share and resume targets /// need none on the command line. A folder target needs no /// credentials and so never needs this. @@ -57,7 +46,7 @@ pub enum S3Op { /// tweak. Run interactively with no flags and it prompts, without /// echoing the secret. /// - /// This does not set *where* shares go — that's `path auth default` + /// This does not set *where* shares go — that's `path target` /// — so one stored credential serves any number of buckets. #[command(alias = "set")] Login { @@ -103,7 +92,6 @@ pub struct S3LoginArgs { pub fn run(op: AuthOp) -> Result<()> { match op { AuthOp::S3 { op } => run_s3(op), - AuthOp::Default { target, clear } => default_target(target, clear), other => { let path = credentials_path()?; match other { @@ -111,62 +99,12 @@ pub fn run(op: AuthOp) -> Result<()> { AuthOp::Logout => logout(&path), AuthOp::Status => status(&path), AuthOp::Whoami => whoami(&path), - AuthOp::S3 { .. } | AuthOp::Default { .. } => unreachable!("handled above"), + AuthOp::S3 { .. } => unreachable!("handled above"), } } } } -// ── Default share target ──────────────────────────────────────────────── - -fn default_target(spec: Option, clear: bool) -> Result<()> { - if clear { - let path = target::clear_default()?; - println!("Default share target cleared ({}).", path.display()); - println!("`path share` now uploads to Pathbase."); - return Ok(()); - } - - let Some(spec) = spec else { - return print_default_target(); - }; - - let parsed = Target::parse(&spec)?; - let path = target::set_default(&parsed)?; - println!("Default share target set to {parsed}"); - println!(" stored in: {}", path.display()); - if let Target::Object(dest) = &parsed - && !dest.is_local() - && store::effective_settings()?.access_key_id.is_none() - { - println!( - " note: no S3 credentials stored yet — run `path auth s3 login`, \ - or rely on the AWS credential chain." - ); - } - Ok(()) -} - -/// Answer "where does my next share go?" in one command, including why. -fn print_default_target() -> Result<()> { - let (stored, path) = target::default_target()?; - match &stored { - Some(t) => println!("Default share target: {t} (from {})", path.display()), - None => println!("No default share target configured."), - } - // The stored value isn't the whole story — an env var or the - // built-in fallback may be what actually applies right now. - println!("In effect now: {}", target::describe_effective()?); - if stored.is_none() { - println!(); - println!("Set one with:"); - println!(" path auth default s3://my-bucket/traces"); - println!(" path auth default ~/Dropbox/toolpath # a folder needs no credentials"); - println!(" path auth default pathbase"); - } - Ok(()) -} - fn login(path: &Path, url: Option, code_arg: Option) -> Result<()> { let base_url = resolve_url(url); let auth_url = format!("{base_url}/auth/cli"); @@ -337,7 +275,7 @@ fn suggest_default_target() -> Result<()> { } println!(); println!("`path share` still uploads to Pathbase. To make S3 the default:"); - println!(" path auth default s3://my-bucket/traces"); + println!(" path target s3://my-bucket/traces"); Ok(()) } diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 9c91a742..1c452242 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -208,7 +208,7 @@ pub enum ExportTarget { /// Destination: `s3://bucket/prefix`, or a folder (`~/traces`, /// `file:///srv/traces`). Defaults to the target from - /// `path auth default`. + /// `path target`. #[arg(long, value_name = "DESTINATION")] to: Option, }, @@ -1909,7 +1909,7 @@ fn run_object(input: String, to: Option) -> Result<()> { .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| input.clone()); - let uri = dest.uri_for(&cache_id); + let uri = dest.uri_for(&crate::store::name_for_body(&body, &cache_id)); uri.put(&settings, body.as_bytes())?; println!("{uri}"); eprintln!("Uploaded {} bytes → {uri}", body.len()); diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index ba199c3d..df64f011 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -7,13 +7,16 @@ //! `` is resolved in this order: //! 1. `https://` / `http://` URL → fetched via `pathbase-client`, //! cached unless `--no-cache`. -//! 2. `s3://` / `s3a://` / `file://` / `memory://` URL → fetched from -//! object storage (the inverse of `path share --to`), cached the same -//! way. A plain path to a shared folder lands in case 4 and is read -//! directly, which is the same document either way. -//! 3. `owner/repo/slug` shorthand → same Pathbase fetch flow. -//! 4. Existing file path → read directly. -//! 5. Otherwise treated as a cache id under `~/.toolpath/documents/`. +//! 2. `s3://` / `s3a://` / `file://` URL ending in `.json` → fetched +//! from object storage (the inverse of `path share --to`), cached +//! the same way. +//! 3. A **share destination** rather than a document — a bucket, a +//! prefix, or a directory — is listed and offered in a picker. A +//! shared document is always `.json`, so anything else naming +//! a place is somewhere to browse. +//! 4. `owner/repo/slug` shorthand → same Pathbase fetch flow. +//! 5. Existing file path → read directly. +//! 6. Otherwise treated as a cache id under `~/.toolpath/documents/`. //! //! ## Harness selection //! @@ -52,11 +55,15 @@ use crate::harness::Harness; #[derive(Args, Debug)] pub struct ResumeArgs { - /// Toolpath document to resume from. Accepted shapes: a Pathbase - /// URL (`https://host/owner/repo/slug`), an object-storage URL - /// (`s3://bucket/key.json`, `file:///dir/key.json`), a bare Pathbase - /// shorthand (`owner/repo/slug`), a path to a local toolpath JSON - /// file, or a cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). + /// Toolpath document to resume from, or a share destination to pick + /// one out of. + /// + /// Accepted shapes: a Pathbase URL + /// (`https://host/owner/repo/slug`); an object-storage URL + /// (`s3://bucket/key.json`); a share destination — a bucket, a + /// prefix, or a folder — which is listed in a picker; a bare + /// Pathbase shorthand (`owner/repo/slug`); a path to a local + /// toolpath JSON file; or a cache id (e.g. `claude-abc`). pub input: String, /// Working directory to run the resumed harness from. Defaults to @@ -244,16 +251,30 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option PathbaseUrl(&'a str), PathbaseShorthand(&'a str), ObjectStore(&'a str), + /// A share destination rather than a single document: list it + /// and let the user pick. + ObjectContainer(&'a str), FilePath(&'a str), CacheId(&'a str), } + // A shared document is always `.json`, so anything else that + // names a place — a bucket, a prefix, a directory — is a container + // to browse rather than a document to load. + let names_a_document = raw.trim_end_matches('/').ends_with(".json"); + let shape = if raw.starts_with("http://") || raw.starts_with("https://") { Shape::PathbaseUrl(raw) } else if crate::store::looks_like_object_uri(raw) { - Shape::ObjectStore(raw) + if names_a_document { + Shape::ObjectStore(raw) + } else { + Shape::ObjectContainer(raw) + } } else if looks_like_pathbase_shorthand(raw) { Shape::PathbaseShorthand(raw) + } else if std::path::Path::new(raw).is_dir() { + Shape::ObjectContainer(raw) } else if std::path::Path::new(raw).is_file() { Shape::FilePath(raw) } else { @@ -278,6 +299,13 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option crate::derive::object_fetch_to_doc(u) })? } + Shape::ObjectContainer(c) => { + let picked = pick_from_destination(c)?; + let uri = picked.to_string(); + fetch_cached(&uri, &picked.cache_id(), args, || { + crate::derive::object_fetch_to_doc(&uri) + })? + } Shape::FilePath(p) => { let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?; Graph::from_json(&json) @@ -302,6 +330,108 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option Ok((graph, harness)) } +/// List a share destination and let the user pick one of its documents. +/// +/// This is the other half of `path share`: sharing has a picker across +/// every harness, so resuming from where you shared to needs one too, +/// or a destination is a write-only hole you can only read out of by +/// already knowing a filename. +/// +/// Rows are built from object names alone — no downloads — which is +/// what legible names buy: browsing a hundred shared sessions costs one +/// list request. +fn pick_from_destination(raw: &str) -> Result { + let dest = crate::store::Destination::parse(raw)?; + let settings = crate::store::effective_settings()?; + let entries = dest.list(&settings)?; + + if entries.is_empty() { + anyhow::bail!("no shared documents in {dest}. `path share --to {dest}` puts one there."); + } + + // One document is not a choice worth making the user confirm. + if entries.len() == 1 { + let only = &entries[0]; + eprintln!("Resuming the only document in {dest}: {}", only.stem); + return Ok(only.uri.clone()); + } + + if !crate::fuzzy::available() { + eprintln!("{} documents in {dest}:", entries.len()); + for e in entries.iter().take(20) { + eprintln!(" {}", e.uri); + } + if entries.len() > 20 { + eprintln!(" … and {} more", entries.len() - 20); + } + anyhow::bail!("picking needs `fzf` on PATH and a TTY; pass a full location instead"); + } + + let lines: Vec = entries + .iter() + .enumerate() + .map(|(i, e)| format_object_row(i, e)) + .collect(); + let header = format!("resume a shared session from {dest}"); + let opts = crate::fuzzy::PickOptions { + with_nth: "2..", + prompt: "resume> ", + preview: None, + preview_window: "up:60%:wrap-word", + header: Some(&header), + tiebreak: "index", + multi: false, + }; + let line = match crate::fuzzy::pick(&lines, &opts)? { + crate::fuzzy::PickResult::Selected(v) => match v.into_iter().next() { + Some(l) => l, + None => std::process::exit(130), + }, + crate::fuzzy::PickResult::NoMatch => std::process::exit(1), + // Esc / Ctrl-C: deliberate cancel, same exit code `share` uses. + crate::fuzzy::PickResult::Cancelled => std::process::exit(130), + }; + let idx: usize = line + .split('\t') + .next() + .and_then(|i| i.parse().ok()) + .ok_or_else(|| anyhow::anyhow!("internal: failed to parse picker row"))?; + entries + .get(idx) + .map(|e| e.uri.clone()) + .ok_or_else(|| anyhow::anyhow!("internal: picker returned an out-of-range row")) +} + +/// `\t ` — a hidden index column so the +/// selection maps back to its entry without reparsing the display, and +/// a display built only from what listing already told us. +fn format_object_row(index: usize, entry: &crate::store::ObjectEntry) -> String { + let when = entry + .modified + .map(|t| t.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| " ".to_string()); + format!( + "{index}\t{when} {:>8} {}", + human_size(entry.size), + entry.stem + ) +} + +fn human_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit + 1 < UNITS.len() { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{size:.1} {}", UNITS[unit]) + } +} + /// Probe `$PATH` (or `path_override`, for tests) for a given binary name. /// Cross-platform: on Windows, also tries `.exe`. pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool { diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 80981054..5a3d6072 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -63,8 +63,8 @@ pub struct ShareArgs { /// local path — spell a bucket `s3://…`. /// /// Without this flag the target comes from `$TOOLPATH_SHARE_TARGET`, - /// then `path auth default`, then Pathbase. For object storage the - /// document lands at `/.json`, and the printed + /// then `path target`, then Pathbase. For object storage the + /// document is named `--.json`, and the printed /// location is what `path resume` takes. #[arg(long, value_name = "TARGET")] pub to: Option, @@ -110,7 +110,7 @@ impl ShareTarget { upload, } => crate::cmd_export::run_pathbase_inner(auth, base_url, upload, body, summary), ShareTarget::Object { dest, settings } => { - let uri = dest.uri_for(cache_id); + let uri = dest.uri_for(&crate::store::name_for_body(body, cache_id)); uri.put(&settings, body.as_bytes())?; println!("{uri}"); eprintln!("Uploaded {summary} → {uri}"); diff --git a/crates/path-cli/src/cmd_target.rs b/crates/path-cli/src/cmd_target.rs new file mode 100644 index 00000000..7210d232 --- /dev/null +++ b/crates/path-cli/src/cmd_target.rs @@ -0,0 +1,91 @@ +//! `path target` — read or set where `path share` uploads. +//! +//! A top-level verb rather than a flag on `share` or a subcommand of +//! `auth`: it's a persistent setting, not a per-call option, and it is +//! not authentication. "Where do my shares go?" is a question people +//! ask without knowing which command owns the answer, so the command is +//! named after the thing itself. +//! +//! The setting and its resolution live in [`crate::target`]; this +//! module is only the CLI surface over them. + +#![cfg(not(target_os = "emscripten"))] + +use anyhow::Result; +use clap::Args; + +use crate::store; +use crate::target::{self, Target}; + +#[derive(Args, Debug)] +pub struct TargetArgs { + /// Where `path share` should upload: `pathbase`, an S3 bucket + /// (`s3://bucket/prefix`), or a folder (`~/Dropbox/traces`, + /// `/srv/traces`). Omit to print the current target and where it + /// came from. + #[arg(index = 1, value_name = "TARGET")] + pub target: Option, + + /// Forget the configured target, falling back to Pathbase + #[arg(long, conflicts_with = "target")] + pub clear: bool, + + /// Store the target without checking that it works. For a bucket + /// you haven't created yet, or when you're offline. + #[arg(long, conflicts_with = "clear")] + pub no_verify: bool, +} + +pub fn run(args: TargetArgs) -> Result<()> { + if args.clear { + let path = target::clear_default()?; + println!("Share target cleared ({}).", path.display()); + println!("`path share` now uploads to Pathbase."); + return Ok(()); + } + + let Some(spec) = args.target else { + return print_current(); + }; + + let parsed = Target::parse(&spec)?; + + // Prove it works before storing it. A target set once and used many + // times is exactly the thing worth checking at the moment it's + // chosen — storing an unusable one just defers the failure to the + // middle of a share, where it costs a session pick and a + // derivation. `--no-verify` is the escape hatch for the cases where + // the user genuinely knows better. + if !args.no_verify { + let settings = store::effective_settings()?; + target::verify(&parsed, &settings)?; + } + + let path = target::set_default(&parsed)?; + println!("Share target set to {parsed}"); + println!(" stored in: {}", path.display()); + if args.no_verify { + println!(" (not verified)"); + } + Ok(()) +} + +/// Answer "where does my next share go?" in one command, including why. +fn print_current() -> Result<()> { + let (stored, path) = target::default_target()?; + match &stored { + Some(t) => println!("Share target: {t} (from {})", path.display()), + None => println!("No share target configured."), + } + // The stored value isn't the whole story — an env var or the + // built-in fallback may be what actually applies right now. + println!("In effect now: {}", target::describe_effective()?); + if stored.is_none() { + println!(); + println!("Set one with:"); + println!(" path target s3://my-bucket/traces"); + println!(" path target ~/Dropbox/toolpath # a folder needs no credentials"); + println!(" path target pathbase"); + } + Ok(()) +} diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index f6b57009..6e912bec 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -24,6 +24,8 @@ pub mod cmd_resume; mod cmd_share; #[cfg(not(target_os = "emscripten"))] mod cmd_show; +#[cfg(not(target_os = "emscripten"))] +mod cmd_target; mod cmd_track; mod cmd_validate; mod config; @@ -84,7 +86,7 @@ enum Commands { ansi: bool, }, /// Share an agent session via an interactive picker — to Pathbase, - /// an S3 bucket, or a folder (see `path auth default`) + /// an S3 bucket, or a folder (see `path target`) #[cfg(not(target_os = "emscripten"))] Share { #[command(flatten)] @@ -109,13 +111,19 @@ enum Commands { #[command(flatten)] args: cmd_kind::KindArgs, }, - /// Manage where shares go (`auth default`) and the credentials to - /// get there: Pathbase, or S3 under `auth s3` + /// Manage upload credentials: Pathbase, or S3 under `auth s3` #[cfg(not(target_os = "emscripten"))] Auth { #[command(subcommand)] op: cmd_auth::AuthOp, }, + /// Show or set where `path share` uploads: Pathbase, an S3 bucket, + /// or a folder + #[cfg(not(target_os = "emscripten"))] + Target { + #[command(flatten)] + args: cmd_target::TargetArgs, + }, /// Plumbing: lower-level operations on documents and sources /// (import, export, cache, list, render, merge, validate, derive, /// project, incept, track, query) @@ -148,6 +156,8 @@ pub fn run() -> Result<()> { Commands::Kind { args } => cmd_kind::run(args), #[cfg(not(target_os = "emscripten"))] Commands::Auth { op } => cmd_auth::run(op), + #[cfg(not(target_os = "emscripten"))] + Commands::Target { args } => cmd_target::run(args), Commands::P { command } => cmd_p::run(command, cli.pretty), } } diff --git a/crates/path-cli/src/store.rs b/crates/path-cli/src/store.rs index 877fd6b9..3ee11881 100644 --- a/crates/path-cli/src/store.rs +++ b/crates/path-cli/src/store.rs @@ -3,17 +3,17 @@ //! Transport is [`object_store`], so one code path covers real AWS S3, //! any S3-compatible endpoint (Cloudflare R2, MinIO, Ceph, Backblaze //! B2), and a plain local directory via `file://`. A folder is a -//! first-class destination, not a testing affordance: `path auth -//! default ~/Dropbox/traces` is a complete setup, needing no -//! credentials at all. It is also what the tests round-trip against, -//! so share and resume are exercised end-to-end without a network. +//! first-class destination, not a testing affordance: `path target +//! ~/Dropbox/traces` is a complete setup, needing no credentials at +//! all. It is also what the tests round-trip against, so share and +//! resume are exercised end-to-end without a network. //! //! The module owns two separable things: //! -//! 1. [`Destination`] / [`ObjectUri`] — *where* a document goes. Pure -//! URL parsing and key layout; no credentials involved. Where a -//! document lands is a function of the destination and the -//! document's cache id, nothing else. +//! 1. [`Destination`] / [`ObjectUri`] / [`ObjectName`] — *where* a +//! document goes and what it's called. Pure URL parsing and naming; +//! no credentials involved. Where a document lands is a function of +//! the destination and the document itself, nothing else. //! 2. [`S3Settings`] — *how to reach* an `s3://` destination: region, //! endpoint, addressing style, and credentials, persisted at //! `~/.toolpath/s3.json` by `path auth s3 login`. @@ -41,9 +41,11 @@ pub(crate) const DEFAULT_REGION: &str = "us-east-1"; /// URL schemes routed to object storage. Deliberately narrower than /// what `object_store` can parse: `http`/`https` belong to Pathbase in -/// every command that shares this dispatch, and `gs://` / `az://` would -/// need feature flags we don't compile in. -const SCHEMES: [&str; 4] = ["s3", "s3a", "file", "memory"]; +/// every command that shares this dispatch, `gs://` / `az://` would +/// need feature flags we don't compile in, and `memory://` is a fresh +/// per-process store — anything "shared" there is gone before the +/// command exits, so accepting it would only waste someone's afternoon. +const SCHEMES: [&str; 3] = ["s3", "s3a", "file"]; // ── S3 connection settings ────────────────────────────────────────────── @@ -226,32 +228,143 @@ impl ObjectUri { crate::cache::make_id(source, &inner) } - fn open(&self, cfg: &S3Settings) -> Result<(Box, object_store::path::Path)> { - object_store::parse_url_opts(&self.url, store_options(cfg)) - .with_context(|| format!("open {self}")) - } - /// Download the object as UTF-8 text. pub(crate) fn get(&self, cfg: &S3Settings) -> Result { - let (store, path) = self.open(cfg)?; + let (store, path) = open(&self.url, cfg)?; let bytes = block_on(async { let result = store.get(&path).await?; result.bytes().await }) - .map_err(|e| explain(e, "read", self))?; + .map_err(|e| explain_location(e, "read", &self.to_string()))?; String::from_utf8(bytes.to_vec()).with_context(|| format!("{self} is not valid UTF-8")) } /// Upload `body` to the object, overwriting any existing one. + /// + /// Overwrite is intentional: the object name is a pure function of + /// the document, so re-sharing a session that has grown replaces + /// its own object rather than accumulating near-duplicates. pub(crate) fn put(&self, cfg: &S3Settings, body: &[u8]) -> Result<()> { - let (store, path) = self.open(cfg)?; + let (store, path) = open(&self.url, cfg)?; let payload = object_store::PutPayload::from(body.to_vec()); block_on(store.put(&path, payload)) .map(|_| ()) - .map_err(|e| explain(e, "write", self)) + .map_err(|e| explain_location(e, "write", &self.to_string())) } } +fn open(url: &Url, cfg: &S3Settings) -> Result<(Box, object_store::path::Path)> { + open_with(url, cfg, Vec::new()) +} + +fn open_with( + url: &Url, + cfg: &S3Settings, + extra: Vec<(&'static str, String)>, +) -> Result<(Box, object_store::path::Path)> { + let mut opts = store_options(cfg); + opts.extend(extra); + object_store::parse_url_opts(url, opts).with_context(|| format!("open {}", friendly(url))) +} + +/// Open a store for a preflight check rather than for real work. +/// +/// Uploads keep `object_store`'s defaults — a big session over a slow +/// link is not a failure — but a check exists to give a fast answer. +/// Per-request timeouts alone don't bound it: the default ten retries +/// multiply them, so a hanging endpoint could stall a command whose +/// whole job is to record a preference. Cap the retries too. +fn open_for_check( + url: &Url, + cfg: &S3Settings, +) -> Result<(Box, object_store::path::Path)> { + let timeouts = vec![ + ("aws_connect_timeout", "5s".to_string()), + ("aws_timeout", "15s".to_string()), + ]; + if !matches!(url.scheme(), "s3" | "s3a") { + // Local has neither retries nor a network to wait on. + return open_with(url, cfg, timeouts); + } + + // `parse_url_opts` has no key for retry policy, so build the S3 + // store directly. `with_url` does the same bucket/region parsing + // `parse_url_opts` would. + let mut builder = object_store::aws::AmazonS3Builder::new().with_url(url.as_str()); + for (key, value) in store_options(cfg).into_iter().chain(timeouts) { + if let Ok(parsed) = key.parse() { + builder = builder.with_config(parsed, value); + } + } + let store = builder + .with_retry(object_store::RetryConfig { + max_retries: 2, + retry_timeout: std::time::Duration::from_secs(20), + ..Default::default() + }) + .build() + .with_context(|| format!("open {}", friendly(url)))?; + let path = object_store::path::Path::parse(url.path()) + .with_context(|| format!("parse {}", friendly(url)))?; + Ok((Box::new(store), path)) +} + +/// Explain a failed [`Destination::verify`] in terms of what the user +/// was trying to do — designate a place to share to — and how to get +/// past it if they know better than we do. +fn explain_verify(err: object_store::Error, dest: &Destination) -> anyhow::Error { + let detail = match &err { + e if is_unreachable(e) => { + "couldn't reach the endpoint. Check the URL and your network.".to_string() + } + e if is_missing_container(e) => format!( + "no such bucket: {}. Check the name, or the endpoint if this isn't AWS.", + dest.base.host_str().unwrap_or_default() + ), + object_store::Error::Unauthenticated { .. } => { + "S3 rejected the credentials. Run `path auth s3 login` to store working ones." + .to_string() + } + object_store::Error::PermissionDenied { .. } => { + "the credentials can reach the bucket but aren't allowed to write to it. \ + Check the bucket policy for `s3:PutObject` on this prefix." + .to_string() + } + e => terse(e), + }; + anyhow!( + "can't write to {dest}: {detail}\n\ + Pass --no-verify to store it anyway (e.g. the bucket doesn't exist yet, \ + or you're offline)." + ) +} + +/// True when the request never reached a server: DNS, refused +/// connection, timeout. Distinguished from an S3-level rejection +/// because the fix is completely different — check the URL, not the +/// policy. +fn is_unreachable(err: &object_store::Error) -> bool { + let msg = err.to_string(); + msg.contains("error sending request") + || msg.contains("Connection refused") + || msg.contains("operation timed out") + || msg.contains("dns error") +} + +/// Strip `object_store`'s internals out of an error message. +/// +/// Its transport errors carry a retry epilogue — "after 10 retries, +/// max_retries: 10, retry_timeout: 180s" — plus a `Generic S3 error:` +/// prefix. Neither tells a user anything actionable, and both bury the +/// part that does. +fn terse(err: &object_store::Error) -> String { + let msg = err.to_string(); + let msg = msg.split(", after ").next().unwrap_or(&msg); + msg.trim_start_matches("Generic S3 error: ") + .trim_end_matches([' ', '-']) + .to_string() +} + /// Where `path share` writes when the target is object storage: a /// bucket-or-folder base that object keys hang off. #[derive(Debug, Clone, PartialEq, Eq)] @@ -265,6 +378,17 @@ impl std::fmt::Display for Destination { } } +/// One object found by [`Destination::list`]. +#[derive(Debug, Clone)] +pub(crate) struct ObjectEntry { + pub uri: ObjectUri, + /// Filename without the `.json` extension — for legible names this + /// is `--`, which is the whole point. + pub stem: String, + pub size: u64, + pub modified: Option>, +} + impl Destination { /// Parse a user-supplied destination. See [`parse_location`] for /// how a scheme-less value is read. @@ -274,6 +398,119 @@ impl Destination { }) } + /// The `.json` objects sitting directly under this destination, + /// newest first. + /// + /// Deliberately non-recursive: a destination is a place you share + /// *to*, so its immediate contents are what a picker should offer. + /// Nothing is downloaded — legible object names carry enough for a + /// picker row, which is exactly why they're worth the length. + pub(crate) fn list(&self, cfg: &S3Settings) -> Result> { + let (store, prefix) = open(&self.base, cfg)?; + let listed = block_on(store.list_with_delimiter(Some(&prefix))) + .map_err(|e| explain_location(e, "list", &friendly(&self.base)))?; + + let mut out: Vec = listed + .objects + .into_iter() + .filter(|m| m.location.as_ref().ends_with(".json")) + .map(|m| { + let name = m + .location + .filename() + .unwrap_or_default() + .trim_end_matches(".json") + .to_string(); + let mut url = self.base.clone(); + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}/{name}.json")); + ObjectEntry { + uri: ObjectUri { url }, + stem: name, + size: m.size, + modified: Some(m.last_modified), + } + }) + .collect(); + // Newest first: the session you want is nearly always recent. + out.sort_by(|a, b| b.modified.cmp(&a.modified).then(a.stem.cmp(&b.stem))); + Ok(out) + } + + /// Prove a share to this destination will actually work, by doing + /// what a share does: write a small object, then remove it. + /// + /// This is the *configuration-time* check, and it is deliberately a + /// real write. A listing tells you about `s3:ListBucket`, which is + /// not the permission a share needs; a credential probe tells you + /// the keys parse. Only a write tells you the thing the user is + /// actually asking — "can I send my sessions here?" — and the + /// moment they designate a destination is the cheapest possible + /// time to answer it. Anything less is theater that defers the + /// failure to the middle of a share. + /// + /// The probe object is named to be obviously ours, and removed + /// afterwards. A credential that can write but not delete leaves it + /// behind; that's reported, not fatal, because writing was the + /// thing being tested. + pub(crate) fn verify(&self, cfg: &S3Settings) -> Result<()> { + const PROBE: &str = ".toolpath-access-check"; + const BODY: &[u8] = b"toolpath access check\n"; + + let mut url = self.base.clone(); + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}/{PROBE}")); + + // Bound the wait: a wrong endpoint should fail in seconds, not + // hang a command whose whole job is to record a preference. + let (store, path) = open_for_check(&url, cfg)?; + block_on(store.put(&path, object_store::PutPayload::from(BODY.to_vec()))) + .map_err(|e| explain_verify(e, self))?; + + if let Err(e) = block_on(store.delete(&path)) { + eprintln!( + "note: wrote and left behind {}/{PROBE} — the credentials can write \ + but not delete ({e})", + self + ); + } + Ok(()) + } + + /// Cheap reachability check for the moment *before* an upload, when + /// a real write would be redundant — the upload itself is about to + /// happen and will report its own failure. + /// + /// Only *conclusive* failures are errors. A credential that can + /// write but not list is normal, and so is an empty or missing + /// prefix. This exists to catch the one case worth catching before + /// a derivation: a typo'd bucket or endpoint. + pub(crate) fn probe(&self, cfg: &S3Settings) -> Result<()> { + // Local destinations create themselves on write, and a bad path + // fails with a clear OS error. Nothing to preflight. + if self.is_local() { + return Ok(()); + } + let (store, prefix) = open_for_check(&self.base, cfg)?; + match block_on(store.list_with_delimiter(Some(&prefix))) { + Ok(_) | Err(object_store::Error::NotFound { .. }) => Ok(()), + // A write-only credential can't list. Not a problem. + Err(object_store::Error::PermissionDenied { .. }) => Ok(()), + Err(e) if is_missing_container(&e) => Err(anyhow!( + "no such bucket: {}. Check the name, or the endpoint if this isn't AWS.", + self.base.host_str().unwrap_or_default() + )), + Err(e @ object_store::Error::Unauthenticated { .. }) => Err(anyhow!( + "S3 rejected the stored credentials for {self}: {e}. \ + Run `path auth s3 login` to replace them." + )), + // Anything else — a transient network blip, an unusual + // policy — is not worth blocking a share over. The upload + // itself will report it properly if it's real. + Err(_) => Ok(()), + } + } + /// The canonical form to persist: always a URL, never a bare path, /// so a stored default can't be re-read relative to a different cwd. pub(crate) fn as_url(&self) -> &str { @@ -286,15 +523,184 @@ impl Destination { self.base.scheme() == "file" } - /// The object a document with this cache id lands at. - pub(crate) fn uri_for(&self, cache_id: &str) -> ObjectUri { + /// The object a document with this name lands at. + pub(crate) fn uri_for(&self, name: &ObjectName) -> ObjectUri { let mut url = self.base.clone(); let base_path = url.path().trim_end_matches('/').to_string(); - url.set_path(&format!("{base_path}/{cache_id}.json")); + url.set_path(&format!("{base_path}/{}.json", name.0)); ObjectUri { url } } } +// ── Naming ────────────────────────────────────────────────────────────── + +/// What a shared document is called in the destination. +/// +/// `--`, e.g. +/// `2026-08-07-add-s3-support-to-share-claude-6f2a1c9e`. +/// +/// Two requirements pull in opposite directions and both are load-bearing: +/// +/// - **Stable.** Every component is a pure function of the document, so +/// re-sharing a session that has grown overwrites its own object +/// instead of leaving a trail of near-duplicates. +/// - **Legible.** A destination is a folder someone will open, or a +/// bucket someone will page through. `claude-6f2a1c9e.json` tells +/// them nothing; the date sorts chronologically under a plain +/// lexicographic listing, and the slug says which session it is. +/// +/// Legibility also buys the picker: `path resume ` builds +/// its rows from names alone, so browsing a hundred shared sessions +/// costs one list request and zero downloads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObjectName(String); + +impl std::fmt::Display for ObjectName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl ObjectName { + /// Longest slug we'll put in a name. Long enough to recognize a + /// session, short enough that the cache id stays visible in a + /// terminal-width listing. + const SLUG_MAX: usize = 48; + + pub(crate) fn new(cache_id: &str, date: Option<&str>, title: Option<&str>) -> Self { + let mut parts: Vec = Vec::new(); + if let Some(d) = date.map(slugify).filter(|d| !d.is_empty()) { + parts.push(d); + } + if let Some(t) = title.map(slugify).filter(|t| !t.is_empty()) { + parts.push(truncate_slug(&t, Self::SLUG_MAX)); + } + parts.push(slugify(cache_id)); + ObjectName(parts.join("-")) + } + + /// The name for a document with no usable metadata — the cache id + /// alone, which is what the whole scheme degrades to. + #[cfg(test)] + pub(crate) fn bare(cache_id: &str) -> Self { + Self::new(cache_id, None, None) + } +} + +/// Lowercase, ASCII-alphanumeric, single dashes, no leading/trailing +/// dash. Deliberately lossy — this is a filename, not a title. +fn slugify(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut pending_dash = false; + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !out.is_empty() { + out.push('-'); + } + pending_dash = false; + out.push(ch.to_ascii_lowercase()); + } else { + pending_dash = true; + } + } + out +} + +/// Truncate on a dash boundary so a name never ends mid-word. +fn truncate_slug(slug: &str, max: usize) -> String { + if slug.len() <= max { + return slug.to_string(); + } + let cut = &slug[..max]; + match cut.rfind('-') { + Some(i) if i > 0 => cut[..i].to_string(), + _ => cut.to_string(), + } +} + +/// Name a document for a destination, reading the date and topic out of +/// the document itself so `share` and `p export object` agree without +/// either of them having to know where the document came from. +pub(crate) fn name_for(doc: &toolpath::v1::Graph, cache_id: &str) -> ObjectName { + let path = doc.paths.iter().find_map(|p| match p { + toolpath::v1::PathOrRef::Path(p) => Some(p.as_ref()), + toolpath::v1::PathOrRef::Ref(_) => None, + }); + let Some(path) = path else { + return ObjectName::new(cache_id, None, None); + }; + + // Earliest step wins: a session is dated when it started, so the + // name doesn't move as the conversation grows. + let date = path + .steps + .iter() + .map(|s| s.step.timestamp.as_str()) + .min() + .and_then(|ts| ts.split('T').next()) + .map(str::to_string); + + ObjectName::new(cache_id, date.as_deref(), topic_of(path).as_deref()) +} + +/// [`name_for`] against the serialized document — the exact bytes about +/// to be uploaded, so the name always describes what actually lands. +/// Degrades to the bare cache id if the body doesn't parse, because a +/// worse name is better than a failed share. +pub(crate) fn name_for_body(body: &str, cache_id: &str) -> ObjectName { + match toolpath::v1::Graph::from_json(body) { + Ok(doc) => name_for(&doc, cache_id), + Err(_) => ObjectName::new(cache_id, None, None), + } +} + +/// The first user prompt, which is what a session is *about*. +/// +/// Falls back to `meta.title`, but only when it looks like a real +/// title: `derive_path` synthesizes `" session: "` when +/// it has nothing better, and repeating the id in the slug would waste +/// the legible half of the name. +fn topic_of(path: &toolpath::v1::Path) -> Option { + for step in &path.steps { + for change in path_changes(step) { + let Some(structural) = &change.structural else { + continue; + }; + if structural.change_type != "conversation.append" { + continue; + } + let role = structural.extra.get("role").and_then(|v| v.as_str()); + if role != Some("user") { + continue; + } + if let Some(text) = structural + .extra + .get("text") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|t| !t.is_empty()) + { + return Some(text.to_string()); + } + } + } + path.meta + .as_ref() + .and_then(|m| m.title.as_deref()) + .filter(|t| !t.contains(" session: ")) + .map(str::to_string) +} + +/// Steps hold their changes in a map; iteration order is arbitrary, so +/// sort by artifact key to keep naming deterministic across runs. +fn path_changes(step: &toolpath::v1::Step) -> Vec<&toolpath::v1::ArtifactChange> { + let mut keys: Vec<&String> = step.change.keys().collect(); + keys.sort(); + keys.into_iter() + .filter_map(|k| step.change.get(k)) + .collect() +} + /// Parse a user-supplied location into a URL. /// /// A value carrying a scheme is taken at its word. A scheme-less value @@ -303,12 +709,27 @@ impl Destination { /// strings mean folders rather than buckets because that is what people /// type when designating a directory; an S3 bucket is named with an /// explicit `s3://`, which is unambiguous and self-documenting. +/// +/// A *bare relative* path (`my-bucket/traces`) is rejected. It is the +/// one shape that is genuinely ambiguous — overwhelmingly a bucket name +/// typed from memory — and silently resolving it against the current +/// directory would create `./my-bucket/traces` and report success. +/// `./my-bucket/traces` says "yes, relative, I meant it". fn parse_location(raw: &str) -> Result { let raw = raw.trim(); if raw.is_empty() { bail!("empty location"); } + if !raw.contains("://") && is_ambiguously_relative(raw) { + bail!( + "`{raw}` is ambiguous: a scheme-less location is a local path, but this \ + one is relative to the current directory.\n \ + s3://{raw} — if you meant an S3 bucket\n \ + ./{raw} — if you really meant a folder here" + ); + } + if raw.contains("://") { let url = Url::parse(raw).with_context(|| format!("`{raw}` is not a valid URL"))?; if !SCHEMES.contains(&url.scheme()) { @@ -339,6 +760,20 @@ fn parse_location(raw: &str) -> Result { }) } +/// True for a path that is relative *and* doesn't say so explicitly. +/// `./x` and `../x` are deliberate; `x` and `x/y` are the trap. +fn is_ambiguously_relative(raw: &str) -> bool { + !(raw.starts_with('/') + || raw.starts_with('~') + || raw.starts_with("./") + || raw.starts_with("../") + || raw == "." + || raw == ".." + // Windows: `C:\…` and `\\server\share`. + || raw.starts_with('\\') + || raw.as_bytes().get(1) == Some(&b':')) +} + /// Expand a leading `~/`. Shells normally do this, but a quoted or /// config-file value arrives literal, and `object_store` would happily /// create a directory actually named `~`. @@ -368,20 +803,30 @@ fn friendly(url: &Url) -> String { /// `NotFound` and `Unauthenticated` variants are the two that matter: /// the first usually means a typo'd key, the second an unconfigured or /// stale credential. -fn explain(err: object_store::Error, verb: &str, uri: &ObjectUri) -> anyhow::Error { +fn explain_location(err: object_store::Error, verb: &str, location: &str) -> anyhow::Error { match err { - object_store::Error::NotFound { .. } => anyhow!("{uri} not found"), + object_store::Error::NotFound { .. } => anyhow!("{location} not found"), object_store::Error::Unauthenticated { .. } | object_store::Error::PermissionDenied { .. } => { anyhow!( - "not authorized to {verb} {uri}. Run `path auth s3 login` to store \ + "not authorized to {verb} {location}. Run `path auth s3 login` to store \ credentials, or check the bucket policy for the ones you have." ) } - e => anyhow!(e).context(format!("failed to {verb} {uri}")), + e => anyhow!("failed to {verb} {location}: {}", terse(&e)), } } +/// `object_store` folds "no such bucket" into a generic transport +/// error, so the only handle on it is the message S3 returned. Worth +/// the string match: a typo'd bucket is the single most common way a +/// share target is wrong, and "NoSuchBucket" buried in an XML dump is +/// not an answer. +fn is_missing_container(err: &object_store::Error) -> bool { + let msg = err.to_string(); + msg.contains("NoSuchBucket") || msg.contains("NoSuchHost") || msg.contains("dns error") +} + /// `object_store` is async; the rest of path-cli is sync. Same tunnel /// the Pathbase client uses, so both share one runtime. fn block_on(f: F) -> F::Output { @@ -429,7 +874,7 @@ mod tests { let d = Destination::parse("s3://bkt/pre/fix").unwrap(); assert!(!d.is_local()); assert_eq!( - d.uri_for("claude-abc").to_string(), + d.uri_for(&ObjectName::bare("claude-abc")).to_string(), "s3://bkt/pre/fix/claude-abc.json" ); } @@ -438,7 +883,7 @@ mod tests { fn s3_destination_without_a_prefix_writes_at_the_bucket_root() { let d = Destination::parse("s3://bkt").unwrap(); assert_eq!( - d.uri_for("claude-abc").to_string(), + d.uri_for(&ObjectName::bare("claude-abc")).to_string(), "s3://bkt/claude-abc.json" ); } @@ -450,18 +895,44 @@ mod tests { assert_eq!(d.to_string(), "/srv/traces"); assert_eq!(d.as_url(), "file:///srv/traces/"); assert_eq!( - d.uri_for("claude-abc").to_string(), + d.uri_for(&ObjectName::bare("claude-abc")).to_string(), "/srv/traces/claude-abc.json" ); } #[test] - fn a_relative_path_is_made_absolute_so_a_stored_default_is_stable() { + fn an_explicitly_relative_path_is_made_absolute_so_a_stored_default_is_stable() { let d = Destination::parse("./out").unwrap(); let expected = std::path::absolute("./out").unwrap(); assert_eq!(d.to_string(), expected.to_string_lossy()); } + #[test] + fn a_bare_relative_path_is_rejected_as_ambiguous() { + // The trap this guards: someone types a bucket name from memory + // and gets ./my-bucket/traces created under their cwd, with the + // share reporting success. + let err = Destination::parse("my-bucket/traces") + .unwrap_err() + .to_string(); + assert!(err.contains("s3://my-bucket/traces"), "{err}"); + assert!(err.contains("./my-bucket/traces"), "{err}"); + + // A single bare word is the same mistake. + assert!(Destination::parse("my-bucket").is_err()); + // Saying "relative, I meant it" is accepted. + assert!(Destination::parse("./my-bucket/traces").is_ok()); + assert!(Destination::parse("../sibling").is_ok()); + } + + #[test] + fn memory_urls_are_rejected() { + // A fresh store per process: anything "shared" there is gone + // before the command exits. + let err = Destination::parse("memory:///x").unwrap_err().to_string(); + assert!(err.contains("unsupported location scheme"), "{err}"); + } + #[test] fn a_tilde_path_expands_against_home() { let home = std::env::var("HOME").unwrap(); @@ -474,8 +945,8 @@ mod tests { let from_path = Destination::parse("/srv/traces").unwrap(); let from_url = Destination::parse("file:///srv/traces").unwrap(); assert_eq!( - from_path.uri_for("x").to_string(), - from_url.uri_for("x").to_string() + from_path.uri_for(&ObjectName::bare("x")).to_string(), + from_url.uri_for(&ObjectName::bare("x")).to_string() ); } @@ -485,7 +956,7 @@ mod tests { // and `path resume /abs/path.json` already works. let uri = Destination::parse("/srv/traces") .unwrap() - .uri_for("claude-abc"); + .uri_for(&ObjectName::bare("claude-abc")); assert_eq!(uri.to_string(), "/srv/traces/claude-abc.json"); } @@ -590,7 +1061,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); let cfg = S3Settings::default(); - let uri = dest.uri_for("claude-abc"); + let uri = dest.uri_for(&ObjectName::bare("claude-abc")); let body = br#"{"graph":{"id":"g"},"paths":[]}"#; uri.put(&cfg, body).unwrap(); @@ -603,7 +1074,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); let cfg = S3Settings::default(); - let uri = dest.uri_for("claude-abc"); + let uri = dest.uri_for(&ObjectName::bare("claude-abc")); uri.put(&cfg, b"{\"v\":1}").unwrap(); uri.put(&cfg, b"{\"v\":2}").unwrap(); @@ -614,7 +1085,7 @@ mod tests { fn nested_prefixes_are_created_on_write() { let dir = tempfile::tempdir().unwrap(); let dest = Destination::parse(&format!("{}/a/b/c", dir.path().display())).unwrap(); - dest.uri_for("claude-abc") + dest.uri_for(&ObjectName::bare("claude-abc")) .put(&S3Settings::default(), b"{}") .unwrap(); assert!(dir.path().join("a/b/c/claude-abc.json").is_file()); @@ -625,9 +1096,181 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let uri = Destination::parse(&dir.path().to_string_lossy()) .unwrap() - .uri_for("nope"); + .uri_for(&ObjectName::bare("nope")); let err = uri.get(&S3Settings::default()).unwrap_err().to_string(); assert!(err.contains("not found"), "{err}"); assert!(err.contains("nope.json"), "{err}"); } + + // ── Naming ─────────────────────────────────────────────────────── + + /// A one-step agent document whose first user turn is `prompt`. + fn doc_with(prompt: &str, timestamp: &str) -> toolpath::v1::Graph { + let body = serde_json::json!({ + "graph": { "id": "g1" }, + "paths": [{ + "path": { "id": "p1", "head": "s1" }, + "steps": [{ + "step": { + "id": "s1", "parents": [], + "actor": "agent:claude-code", + "timestamp": timestamp + }, + "change": { "claude-code://s": { "structural": { + "type": "conversation.append", + "role": "user", + "text": prompt + }}} + }] + }] + }); + toolpath::v1::Graph::from_json(&body.to_string()).unwrap() + } + + #[test] + fn a_name_leads_with_the_date_and_topic() { + let doc = doc_with("Add S3 support to share", "2026-08-07T09:15:00Z"); + assert_eq!( + name_for(&doc, "claude-abc123").to_string(), + "2026-08-07-add-s3-support-to-share-claude-abc123" + ); + } + + #[test] + fn a_name_is_stable_as_the_session_grows() { + // The date comes from the *earliest* step, so appending turns + // can't move the object and leave a duplicate behind. + let short = doc_with("Fix the parser", "2026-08-07T09:15:00Z"); + let name = name_for(&short, "claude-abc"); + + let mut grown = short.clone(); + if let toolpath::v1::PathOrRef::Path(p) = &mut grown.paths[0] { + let mut later = p.steps[0].clone(); + later.step.id = "s2".to_string(); + later.step.timestamp = "2026-08-09T18:00:00Z".to_string(); + p.steps.push(later); + } + assert_eq!(name_for(&grown, "claude-abc"), name); + } + + #[test] + fn a_long_prompt_is_truncated_on_a_word_boundary() { + let doc = doc_with( + "Add support to share and resume to and from S3 and a way to configure credentials", + "2026-08-07T00:00:00Z", + ); + let name = name_for(&doc, "claude-abc").to_string(); + assert!( + name.starts_with("2026-08-07-add-support-to-share"), + "{name}" + ); + assert!(name.ends_with("-claude-abc"), "{name}"); + assert!(!name.contains("--"), "no empty slug segments: {name}"); + } + + #[test] + fn a_prompt_of_pure_punctuation_degrades_to_date_and_id() { + let doc = doc_with("!!! ???", "2026-08-07T00:00:00Z"); + assert_eq!( + name_for(&doc, "claude-abc").to_string(), + "2026-08-07-claude-abc" + ); + } + + #[test] + fn a_synthesized_title_is_not_worth_slugging() { + // `derive_path` writes "claude-code session: abc" when it has + // nothing better; repeating the id would waste the legible half + // of the name. + let body = serde_json::json!({ + "graph": { "id": "g1" }, + "paths": [{ + "path": { "id": "p1", "head": "s1" }, + "meta": { "title": "claude-code session: abc123" }, + "steps": [{ + "step": { "id": "s1", "parents": [], "actor": "agent:claude-code", + "timestamp": "2026-08-07T00:00:00Z" }, + "change": { "f": { "structural": { "type": "file.edit" } } } + }] + }] + }); + let doc = toolpath::v1::Graph::from_json(&body.to_string()).unwrap(); + assert_eq!( + name_for(&doc, "claude-abc").to_string(), + "2026-08-07-claude-abc" + ); + } + + #[test] + fn an_unparseable_body_still_gets_a_name() { + // A worse name beats a failed share. + assert_eq!( + name_for_body("not json", "claude-abc").to_string(), + "claude-abc" + ); + } + + // ── Listing ────────────────────────────────────────────────────── + + #[test] + fn listing_returns_shared_documents_newest_first() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + + for name in ["2026-08-01-older-claude-a", "2026-08-09-newer-claude-b"] { + dest.uri_for(&ObjectName::bare(name)) + .put(&cfg, b"{}") + .unwrap(); + // Distinct mtimes; the local backend stamps on write. + std::thread::sleep(std::time::Duration::from_millis(10)); + } + // Noise that isn't a shared document. + std::fs::write(dir.path().join("README.txt"), "hi").unwrap(); + + let entries = dest.list(&cfg).unwrap(); + assert_eq!(entries.len(), 2, "{entries:?}"); + assert_eq!(entries[0].stem, "2026-08-09-newer-claude-b"); + assert_eq!(entries[1].stem, "2026-08-01-older-claude-a"); + assert!(entries[0].uri.to_string().ends_with(".json")); + assert!(entries[0].size > 0); + } + + #[test] + fn listing_does_not_recurse_into_sub_prefixes() { + // A destination is a place you share *to*; its immediate + // contents are what a picker should offer. + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + dest.uri_for(&ObjectName::bare("here")) + .put(&cfg, b"{}") + .unwrap(); + + let nested = Destination::parse(&format!("{}/deeper", dir.path().display())).unwrap(); + nested + .uri_for(&ObjectName::bare("there")) + .put(&cfg, b"{}") + .unwrap(); + + let entries = dest.list(&cfg).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].stem, "here"); + } + + #[test] + fn listing_an_empty_destination_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + assert!(dest.list(&S3Settings::default()).unwrap().is_empty()); + } + + #[test] + fn probing_a_local_destination_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + // Not created yet: a local destination makes itself on write, + // so there's nothing to preflight. + let dest = Destination::parse(&format!("{}/not/yet", dir.path().display())).unwrap(); + dest.probe(&S3Settings::default()).unwrap(); + } } diff --git a/crates/path-cli/src/target.rs b/crates/path-cli/src/target.rs index 9e1bf323..9db021d4 100644 --- a/crates/path-cli/src/target.rs +++ b/crates/path-cli/src/target.rs @@ -15,7 +15,7 @@ //! //! 1. `--to ` on the command //! 2. `$TOOLPATH_SHARE_TARGET` -//! 3. `default_target` in `config.json` (`path auth default `) +//! 3. `default_target` in `config.json` (`path target `) //! 4. Pathbase //! //! Nothing is inferred from which credentials happen to exist. A share @@ -100,7 +100,7 @@ impl Target { } } -/// Where a resolved target came from, so `path auth default` and the +/// Where a resolved target came from, so `path target` and the /// share confirmation can say *why* this is the destination. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Origin { @@ -173,8 +173,8 @@ pub(crate) fn resolve(flag: Option<&str>) -> Result<(Target, Origin)> { default target and no Pathbase login, and defaulting to Pathbase here \ would publish anonymously.\n\ \n \ - path auth default s3://my-bucket/traces # or a folder: path auth default ~/traces\n \ - path share --to pathbase # to publish to Pathbase this once" + path target s3://my-bucket/traces # or a folder: path target ~/traces\n \ + path share --to pathbase # to publish to Pathbase this once" ); } Ok((Target::Pathbase, Origin::Builtin)) @@ -192,7 +192,7 @@ pub(crate) fn describe_effective() -> Result { if fallback_is_a_bad_guess()? { return Ok( "not configured — S3 credentials are stored but no default target is set \ - (run `path auth default`)" + (run `path target`)" .to_string(), ); } @@ -264,9 +264,33 @@ pub(crate) fn apply_pathbase_flags( } } -/// Guard against a destination that can't work: an `s3://` target with -/// no credentials anywhere. A local folder never needs any, which is -/// the whole appeal of `path auth default ~/traces`. +/// Prove a target works, at the moment someone chooses it. +/// +/// Configuration time is the right time to fail: a target is set once +/// and used many times, so a wrong one discovered at share time has +/// already cost a session pick and a derivation — and is discovered +/// when the user wanted a result, not a setup step. +/// +/// Deliberately a real write (see [`Destination::verify`]), including +/// when no credentials are stored: that is the case *most* likely to +/// break later, and `object_store`'s credential chain may still supply +/// one from an instance role, so guessing helps nobody. Local folders +/// go through the same path — it catches an unwritable parent or a +/// read-only mount, and creates the folder as a side effect. +pub(crate) fn verify(target: &Target, settings: &crate::store::S3Settings) -> Result<()> { + match target { + // Pathbase has its own auth story, reported by `path auth status`. + Target::Pathbase => Ok(()), + Target::Object(dest) => dest.verify(settings), + } +} + +/// Cheap reachability check for the moment before an upload. +/// +/// Weaker than [`verify`] on purpose: the upload is about to happen and +/// will report its own failure, so all this needs to buy is not wasting +/// a derivation on a typo'd bucket. Local folders create themselves on +/// write and need nothing. pub(crate) fn check_reachable(target: &Target, settings: &crate::store::S3Settings) -> Result<()> { let Target::Object(dest) = target else { return Ok(()); @@ -274,17 +298,13 @@ pub(crate) fn check_reachable(target: &Target, settings: &crate::store::S3Settin if dest.is_local() { return Ok(()); } - if settings.access_key_id.is_some() { - return Ok(()); + if settings.access_key_id.is_none() { + // object_store will still try the AWS credential chain + // (instance role, web identity), so this is a note, not a + // failure. + eprintln!("note: no S3 credentials configured; falling back to the AWS credential chain."); } - // No explicit credentials — object_store will still try the AWS - // credential chain (instance role, web identity), so this is a - // note, not a failure. - eprintln!( - "note: no S3 credentials configured; falling back to the AWS credential chain. \ - Run `path auth s3 login` if the upload fails." - ); - Ok(()) + dest.probe(settings) } #[cfg(test)] @@ -415,7 +435,7 @@ mod tests { Err(e) => e.to_string(), Ok(t) => panic!("expected a refusal, got {t:?}"), }; - assert!(err.contains("path auth default"), "{err}"); + assert!(err.contains("path target"), "{err}"); }); } diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 408e1f77..b160cefe 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -427,6 +427,100 @@ fn missing_object_reports_not_found() { assert!(err.to_string().contains("not found"), "actual: {err}"); } +// ── Resuming from a destination rather than a document ────────────── +// +// `path share` has a picker across every harness; resuming from where +// you shared to needs one too, or a destination is a write-only hole. + +#[test] +fn a_destination_holding_one_document_resumes_it_without_asking() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-only-one", + )); + seed_object(bucket.path(), "2026-08-07-only-one-claude-abc", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + // The destination, not a document inside it. + input: bucket.path().to_string_lossy().into_owned(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + assert_eq!(recorder.captured().binary, "claude"); +} + +#[test] +fn an_empty_destination_says_how_to_fill_it() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let err = run_with_strategy( + ResumeArgs { + input: bucket.path().to_string_lossy().into_owned(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &RecordingExec::default(), + ) + .unwrap_err(); + let s = err.to_string(); + assert!(s.contains("no shared documents"), "actual: {s}"); + assert!(s.contains("path share --to"), "actual: {s}"); +} + +#[test] +fn a_destination_url_without_a_json_suffix_is_browsed_not_fetched() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-url-browse", + )); + seed_object(bucket.path(), "2026-08-07-browse-claude-abc", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + // A `file://` URL naming the container, not an object. + input: format!("file://{}", bucket.path().display()), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + assert_eq!(recorder.captured().binary, "claude"); +} + // ── Rejection cases ───────────────────────────────────────────────── #[test] diff --git a/crates/path-cli/tests/share_targets.rs b/crates/path-cli/tests/share_targets.rs index ed73151b..6b071980 100644 --- a/crates/path-cli/tests/share_targets.rs +++ b/crates/path-cli/tests/share_targets.rs @@ -64,7 +64,7 @@ fn write_doc(dir: &Path) -> std::path::PathBuf { p } -// ── path auth default ─────────────────────────────────────────────── +// ── path target ─────────────────────────────────────────────── #[test] fn a_folder_can_be_designated_as_the_share_target() { @@ -73,11 +73,11 @@ fn a_folder_can_be_designated_as_the_share_target() { let folder_str = folder.path().to_string_lossy().into_owned(); cmd(config.path()) - .args(["auth", "default", &folder_str]) + .args(["target", &folder_str]) .assert() .success() .stdout(predicate::str::contains(format!( - "Default share target set to {folder_str}" + "Share target set to {folder_str}" ))) // A folder needs no credentials, so nothing should nag about them. .stdout(predicate::str::contains("credentials").not()); @@ -87,51 +87,126 @@ fn a_folder_can_be_designated_as_the_share_target() { assert!(raw.contains("file://"), "{raw}"); // …but reported back as the path the user typed. cmd(config.path()) - .args(["auth", "default"]) + .args(["target"]) .assert() .success() .stdout(predicate::str::contains(&folder_str)) .stdout(predicate::str::contains("configured default")); } +/// Point the CLI at an endpoint nothing is listening on, so +/// verification fails deterministically and offline. +fn unreachable_s3(config: &Path) { + cmd(config) + .args(["auth", "s3", "login"]) + .args(["--endpoint", "http://127.0.0.1:1"]) + .args(["--access-key-id", "AK", "--secret-access-key", "SK"]) + .assert() + .success(); +} + #[test] -fn a_bucket_url_can_be_designated_as_the_share_target() { +fn a_bucket_that_cant_be_written_to_is_refused_at_configuration_time() { + // The whole point of checking here: a target is set once and used + // many times, so a wrong one must not survive to cost a session + // pick and a derivation later. let config = tempfile::tempdir().unwrap(); + unreachable_s3(config.path()); + cmd(config.path()) - .args(["auth", "default", "s3://my-bucket/traces"]) + .args(["target", "s3://my-bucket/traces"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "can't write to s3://my-bucket/traces", + )) + .stderr(predicate::str::contains("--no-verify")) + // object_store's retry epilogue is not an answer to anything. + .stderr(predicate::str::contains("max_retries").not()); + + assert!( + !config.path().join("config.json").exists(), + "a target that failed verification must not be stored" + ); +} + +#[test] +fn no_verify_stores_an_unchecked_target() { + let config = tempfile::tempdir().unwrap(); + unreachable_s3(config.path()); + + cmd(config.path()) + .args(["target", "s3://my-bucket/traces", "--no-verify"]) .assert() .success() - .stdout(predicate::str::contains("s3://my-bucket/traces")) - // No credentials stored yet, and S3 needs them — say so. - .stdout(predicate::str::contains("path auth s3 login")); + .stdout(predicate::str::contains("(not verified)")); + + let raw = std::fs::read_to_string(config.path().join("config.json")).unwrap(); + assert!(raw.contains("s3://my-bucket/traces"), "{raw}"); +} + +#[test] +fn designating_a_folder_creates_it_and_leaves_no_probe_behind() { + // Verification is a real write, so it proves the folder is usable + // and makes it exist — and cleans up after itself. + let config = tempfile::tempdir().unwrap(); + let parent = tempfile::tempdir().unwrap(); + let folder = parent.path().join("brand/new/traces"); + + cmd(config.path()) + .args(["target", &folder.to_string_lossy()]) + .assert() + .success(); + + assert!(folder.is_dir(), "the folder should exist after designation"); + let leftovers: Vec = std::fs::read_dir(&folder) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!(leftovers.is_empty(), "probe not cleaned up: {leftovers:?}"); } #[test] -fn auth_default_with_no_argument_explains_the_options() { +fn an_unwritable_folder_is_refused_at_configuration_time() { let config = tempfile::tempdir().unwrap(); + // A file, not a directory: nothing can be written underneath it. + let blocker = tempfile::tempdir().unwrap(); + let occupied = blocker.path().join("a-file"); + std::fs::write(&occupied, "not a directory").unwrap(); + cmd(config.path()) - .args(["auth", "default"]) + .args(["target", &occupied.join("traces").to_string_lossy()]) + .assert() + .failure() + .stderr(predicate::str::contains("can't write to")); +} + +#[test] +fn target_with_no_argument_explains_the_options() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target"]) .assert() .success() - .stdout(predicate::str::contains("No default share target")) + .stdout(predicate::str::contains("No share target")) .stdout(predicate::str::contains("In effect now: pathbase")) - .stdout(predicate::str::contains("path auth default ~/")); + .stdout(predicate::str::contains("path target ~/")); } #[test] -fn auth_default_clear_falls_back_to_pathbase() { +fn target_clear_falls_back_to_pathbase() { let config = tempfile::tempdir().unwrap(); cmd(config.path()) - .args(["auth", "default", "s3://my-bucket/traces"]) + .args(["target", "s3://my-bucket/traces", "--no-verify"]) .assert() .success(); cmd(config.path()) - .args(["auth", "default", "--clear"]) + .args(["target", "--clear"]) .assert() .success() .stdout(predicate::str::contains("uploads to Pathbase")); cmd(config.path()) - .args(["auth", "default"]) + .args(["target"]) .assert() .success() .stdout(predicate::str::contains("In effect now: pathbase")); @@ -141,11 +216,11 @@ fn auth_default_clear_falls_back_to_pathbase() { fn the_env_var_overrides_the_stored_default() { let config = tempfile::tempdir().unwrap(); cmd(config.path()) - .args(["auth", "default", "s3://stored/x"]) + .args(["target", "s3://stored/x", "--no-verify"]) .assert() .success(); cmd(config.path()) - .args(["auth", "default"]) + .args(["target"]) .env("TOOLPATH_SHARE_TARGET", "s3://from-env/y") .assert() .success() @@ -162,7 +237,7 @@ fn a_scheme_less_target_is_a_folder_not_a_bucket() { let config = tempfile::tempdir().unwrap(); let folder = tempfile::tempdir().unwrap(); cmd(config.path()) - .args(["auth", "default", &folder.path().to_string_lossy()]) + .args(["target", &folder.path().to_string_lossy()]) .assert() .success(); let raw = std::fs::read_to_string(config.path().join("config.json")).unwrap(); @@ -177,7 +252,7 @@ fn a_scheme_less_target_is_a_folder_not_a_bucket() { fn an_unsupported_scheme_is_rejected() { let config = tempfile::tempdir().unwrap(); cmd(config.path()) - .args(["auth", "default", "gs://bucket/x"]) + .args(["target", "gs://bucket/x"]) .assert() .failure() .stderr(predicate::str::contains("s3://")); @@ -205,7 +280,7 @@ fn auth_s3_login_stores_status_shows_and_logout_clears() { .success() .stdout(predicate::str::contains("S3 settings saved")) // Credentials alone don't redirect `path share` — say so. - .stdout(predicate::str::contains("path auth default")); + .stdout(predicate::str::contains("path target")); let stored = config.path().join("s3.json"); assert!(stored.is_file(), "s3.json not written"); @@ -297,7 +372,7 @@ fn s3_credentials_without_a_default_refuse_to_publish_anonymously() { .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) .assert() .failure() - .stderr(predicate::str::contains("path auth default")); + .stderr(predicate::str::contains("path target")); } // ── p export object / p import object ─────────────────────────────── @@ -319,8 +394,13 @@ fn export_then_import_round_trips_through_a_folder() { .unwrap() .trim() .to_string(); - assert!(uri.ends_with("/doc.json"), "unexpected location: {uri}"); - assert!(folder.path().join("doc.json").is_file()); + // Legible name: date and topic lead, cache id trails. The fixture + // is a 2026-01-01 session whose first prompt is "hello". + assert!( + uri.ends_with("/2026-01-01-hello-doc.json"), + "unexpected location: {uri}" + ); + assert!(folder.path().join("2026-01-01-hello-doc.json").is_file()); cmd(config.path()) .args(["p", "import", "object", &format!("file://{uri}")]) @@ -349,7 +429,7 @@ fn the_s3_subcommand_alias_still_works() { .args(["--to", &folder.path().to_string_lossy()]) .assert() .success(); - assert!(folder.path().join("doc.json").is_file()); + assert!(folder.path().join("2026-01-01-hello-doc.json").is_file()); } #[test] @@ -360,11 +440,7 @@ fn export_object_uses_the_configured_default_target() { let doc = write_doc(work.path()); cmd(config.path()) - .args([ - "auth", - "default", - &format!("{}/traces", folder.path().display()), - ]) + .args(["target", &format!("{}/traces", folder.path().display())]) .assert() .success(); @@ -372,9 +448,16 @@ fn export_object_uses_the_configured_default_target() { .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) .assert() .success() - .stdout(predicate::str::contains("/traces/doc.json")); + .stdout(predicate::str::contains( + "/traces/2026-01-01-hello-doc.json", + )); - assert!(folder.path().join("traces/doc.json").is_file()); + assert!( + folder + .path() + .join("traces/2026-01-01-hello-doc.json") + .is_file() + ); } #[test] @@ -384,7 +467,7 @@ fn export_object_with_a_pathbase_default_says_so() { let doc = write_doc(work.path()); cmd(config.path()) - .args(["auth", "default", "pathbase"]) + .args(["target", "pathbase"]) .assert() .success(); @@ -432,6 +515,66 @@ fn import_object_rejects_a_non_toolpath_object() { // ── path share ────────────────────────────────────────────────────── +#[test] +fn a_shared_object_keeps_its_name_when_the_session_is_re_shared() { + // Re-sharing must overwrite its own object, not leave a trail of + // near-duplicates — the reason every part of the name is a pure + // function of the document. + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + for _ in 0..2 { + cmd(config.path()) + .args(["p", "export", "object"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", &folder.path().to_string_lossy()]) + .assert() + .success(); + } + + let objects: Vec = std::fs::read_dir(folder.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(objects, vec!["2026-01-01-hello-doc.json".to_string()]); +} + +#[test] +fn a_bare_relative_target_is_rejected_rather_than_creating_a_folder() { + // The trap: a bucket name typed from memory becomes ./my-bucket + // under the cwd, and the share reports success. + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .current_dir(work.path()) + .args(["p", "export", "object"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", "my-bucket/traces"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://my-bucket/traces")) + .stderr(predicate::str::contains("./my-bucket/traces")); + + assert!( + !work.path().join("my-bucket").exists(), + "a rejected target must not leave a directory behind" + ); +} + +#[test] +fn target_rejects_a_bare_relative_value_too() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target", "my-bucket/traces"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://my-bucket/traces")); +} + #[test] fn share_rejects_pathbase_flags_alongside_an_object_target() { let config = tempfile::tempdir().unwrap(); From 1f4c5da9052317d95d0c403c27e142d2dabbef68 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Tue, 25 Aug 2026 12:38:56 -0400 Subject: [PATCH 3/3] feat(cli): resolve S3 credentials from ~/.aws, SSO included MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object_store` resolves static keys, EKS/IRSA web identity, ECS task roles, and EC2 instance metadata — the server cases. It reads no `~/.aws/credentials`, no `~/.aws/config`, no `AWS_PROFILE`, and no SSO, because it deliberately avoids depending on the AWS SDK. That left out how nearly every developer actually has S3 access. Someone who had already run `aws configure` or `aws sso login` got nothing, and the path we offered them was to mint a long-lived IAM key and paste it into a plaintext file — which in most orgs is disabled by policy, and is exactly what AWS guidance moves people away from. The "accessible" option was a security downgrade wearing a convenience hat. `aws_creds` resolves properly: - Static-key profiles are parsed straight out of `~/.aws/credentials`. It's a trivial ini file and the keys are right there. - SSO, `role_arn` chains, and `credential_process` are delegated to `aws configure export-credentials --format process`, which runs the AWS CLI's own resolver. Anyone using SSO already has the CLI — it's how they log in — and delegating keeps refresh, cache layout, and every future profile type the CLI's problem rather than ours. Precedence follows AWS's own with our stored settings on top: `path auth s3 login` → `--profile`/`$AWS_PROFILE` → `AWS_ACCESS_KEY_ID` → the `[default]` profile → object_store's instance chain. Region falls back to the profile's, so `~/.aws/config` isn't retyped. `path auth s3 status` reports which source won, and surfaces the reason when resolution fails — "no such profile" is the whole answer, and the previous code swallowed it. Storing keys is now documented as the fallback for endpoints AWS tooling doesn't know about (MinIO, R2, Ceph), where a scoped long-lived token is the right answer; the interactive prompt says so before asking for anything. `--profile` can also be stored. A profile name is not a key: it's a pointer to credentials the AWS tooling already manages, so it can't go stale and costs nothing at rest. Not `aws-config`: 31 crates, and its whole family currently requires rustc 1.94.1 against a repo that pins 1.94.0 in rust-toolchain.toml — an MSRV treadmill on an exactly-pinned toolchain. Measured, not assumed. The integration tests now pin `AWS_SHARED_CREDENTIALS_FILE` and `AWS_CONFIG_FILE` at nonexistent paths. Without that, reading `~/.aws` means a developer's real default profile leaks into the suite and the "no credentials" cases start making real network calls. --- CHANGELOG.md | 27 +- CLAUDE.md | 36 +- README.md | 6 +- crates/path-cli/src/aws_creds.rs | 607 +++++++++++++++++++++++++ crates/path-cli/src/cmd_auth.rs | 43 +- crates/path-cli/src/lib.rs | 2 + crates/path-cli/src/store.rs | 88 +++- crates/path-cli/src/target.rs | 12 +- crates/path-cli/tests/share_targets.rs | 9 + 9 files changed, 796 insertions(+), 34 deletions(-) create mode 100644 crates/path-cli/src/aws_creds.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b871502..8a56926d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,9 +87,30 @@ downloads under an `s3--` id, so `--force` and `--no-cache` behave exactly as they do for Pathbase. A document shared to a folder is resumed with its plain path, which already worked. -**S3 credentials.** `path auth s3 login` stores region, endpoint, -addressing style, and credentials at `~/.toolpath/s3.json` (0600) — -connection only, deliberately not a destination, so one stored +**S3 credentials come from wherever you already keep them.** If you use +the AWS CLI, `path` needs no configuration at all: `~/.aws/credentials` +and `~/.aws/config` are read directly, `AWS_PROFILE` and `--profile` +select a profile, and the profile's region is used if you haven't set +one. SSO, `role_arn` chains, and `credential_process` profiles are +resolved by shelling out to `aws configure export-credentials` — the +AWS CLI's own resolver, so refresh and every future profile type stay +its problem rather than ours. + +`object_store` alone would have covered only the server cases (EC2, ECS, +EKS); it reads no `~/.aws` because it avoids the AWS SDK. Taking on +`aws-config` to fix that would have meant 31 crates and an MSRV +treadmill — its family requires rustc 1.94.1 against a repo pinned to +1.94.0. + +`path auth s3 status` now reports *which* credential source won, because +that's the first question when an upload fails. + +**Storing keys is now the fallback, not the happy path.** `path auth s3 +login` is for endpoints AWS tooling doesn't know about — MinIO, R2, Ceph +— where a scoped long-lived token is the right answer. It stores region, +endpoint, +addressing style, an optional `profile` name, and credentials at +`~/.toolpath/s3.json` (0600) — connection only, deliberately not a destination, so one stored credential serves any number of buckets. It merges rather than replaces, so `path auth s3 login --region eu-west-1` is a valid tweak; run it bare in a terminal and it prompts, without echoing the secret. diff --git a/CLAUDE.md b/CLAUDE.md index 6f1394f1..9c820d69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,12 +233,38 @@ no Pathbase session, and no default is set, share **refuses** rather than falling through to the anonymous public Pathbase endpoint. Status commands use `target::describe_effective`, which never inherits that refusal. +**S3 credentials** are resolved by `crate::aws_creds`, not by `object_store` +alone. `object_store` covers the *server* cases (EKS/IRSA web identity, ECS +task roles, EC2 instance metadata) and deliberately reads no `~/.aws` at all, +because it avoids depending on the AWS SDK — which leaves out how nearly every +developer actually has S3 access. `aws_creds` fills that in: static-key +profiles are parsed straight out of `~/.aws/credentials` (trivial ini, no +deps), and anything else — SSO, `role_arn` chains, `credential_process` — is +delegated to `aws configure export-credentials --format process`, which runs +the AWS CLI's own resolver. Anyone using SSO already has the CLI (`aws sso +login` is how they authenticate), and delegating keeps refresh, cache layout, +and future profile types the CLI's problem. Depending on `aws-config` instead +would be 31 crates *and* an MSRV treadmill: its whole family currently requires +rustc 1.94.1 while `rust-toolchain.toml` pins 1.94.0. + +Precedence (AWS's own, with our stored settings layered on top): `path auth s3 +login` → `--profile` / `$AWS_PROFILE` → `AWS_ACCESS_KEY_ID` → the `[default]` +profile → `object_store`'s instance chain. Region falls back to the profile's, +so `~/.aws/config` doesn't have to be retyped. `path auth s3 status` prints +*which* source won — the first question when an upload fails is always which +credential was tried, and a stored key, a profile, and nothing at all are three +different fixes. `AWS_SHARED_CREDENTIALS_FILE` / `AWS_CONFIG_FILE` are honored, +which is also how the integration tests stay off a developer's real profiles. + `path auth s3 login` stores S3 *connection* settings at `~/.toolpath/s3.json` (0600, parent dir 0700, same `$TOOLPATH_CONFIG_DIR` -root): region, endpoint, access key id, secret access key, session token, -and an optional virtual-hosted-style toggle. Deliberately **not** a -destination — that's the share target — so one stored credential serves any -number of buckets, and a folder target needs no `auth s3` at all. It +root): region, endpoint, access key id, secret access key, session token, an +optional AWS `profile` name, and an optional virtual-hosted-style toggle. +Storing a *profile name* is very different from storing a key — it's a pointer +to credentials the AWS tooling already manages, so it can't go stale and costs +nothing at rest. The file is deliberately **not** a destination — that's the +share target — so one stored credential serves any number of buckets, and a +folder target needs no `auth s3` at all. It **merges** into what's already stored — only the fields you pass change — so `path auth s3 login --region eu-west-1` is a valid tweak; run it bare in a terminal with nothing stored yet and it prompts, reading the secret @@ -282,7 +308,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 413 unit + 154 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, share targets over a local-folder backend — `path target` set/print/clear/env-precedence + the bare-relative rejection + write-verification at configuration time (unreachable bucket refused and not stored, `--no-verify` escape hatch, folder created with the probe cleaned up, unwritable folder refused), legible-name stability across re-shares, `auth s3 login/status/logout`, `p export object` → `p import object` round trip, Pathbase-flag interaction, resume-from-object-storage with cache/`--no-cache`/missing-object cases, and resume-from-a-destination (single-document short-circuit, empty-destination guidance, container-URL detection) — fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 429 unit + 154 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, share targets over a local-folder backend — `path target` set/print/clear/env-precedence + the bare-relative rejection + write-verification at configuration time (unreachable bucket refused and not stored, `--no-verify` escape hatch, folder created with the probe cleaned up, unwritable folder refused), legible-name stability across re-shares, `auth s3 login/status/logout`, AWS credential resolution (profile precedence, SSO delegated to a stubbed AWS CLI, static profiles never shelling out, unknown-profile errors, ini edge cases), `p export object` → `p import object` round trip, Pathbase-flag interaction, resume-from-object-storage with cache/`--no-cache`/missing-object cases, and resume-from-a-destination (single-document short-circuit, empty-destination guidance, container-URL detection) — fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` diff --git a/README.md b/README.md index dee5b2b4..69c463ad 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,9 @@ path p import pathbase https://pathbase.dev/alex/pathstash/path-pr-42 # Send shares somewhere else instead. Designate it once, and bare # `path share` goes there from then on. -path auth s3 login # credentials, if you're using a bucket +# Your ~/.aws profiles are picked up automatically, SSO included — +# `path auth s3 login` is only for endpoints AWS tooling doesn't know +# about (MinIO, R2, Ceph). path target ~/Dropbox/toolpath-traces # a folder — no credentials needed path target s3://my-bucket/traces # or a bucket; checked before it's stored path share # → wherever you pointed it @@ -189,7 +191,7 @@ path auth login | status | whoami | logout [--url URL] s3 login [--region R] [--endpoint URL] [--access-key-id ID] [--secret-access-key KEY] [--session-token TOK] - [--virtual-hosted-style] + [--profile NAME] [--virtual-hosted-style] s3 status | s3 logout target # where `path share` uploads; no argument prints it. # Setting one writes a probe object to prove it works. diff --git a/crates/path-cli/src/aws_creds.rs b/crates/path-cli/src/aws_creds.rs new file mode 100644 index 00000000..7badc6e0 --- /dev/null +++ b/crates/path-cli/src/aws_creds.rs @@ -0,0 +1,607 @@ +//! Where S3 credentials actually come from. +//! +//! `object_store` resolves static keys, EKS/IRSA web identity, ECS task +//! roles, and EC2 instance metadata — the *server* cases. It reads no +//! `~/.aws/credentials`, no `~/.aws/config`, no `AWS_PROFILE`, and no +//! SSO, because it deliberately avoids depending on the AWS SDK. That +//! leaves out how nearly every developer actually has S3 access on a +//! laptop. +//! +//! This module fills that in without taking on the SDK: +//! +//! - **Static-key profiles** are read directly. `~/.aws/credentials` is +//! a trivial ini file and the keys are right there. +//! - **Everything else** — SSO, `role_arn` chains, `credential_process` +//! — is delegated to `aws configure export-credentials`, which runs +//! the AWS CLI's own resolver and hands back concrete keys. Anyone +//! using SSO already has the CLI, since `aws sso login` is how they +//! authenticate; and delegating means refresh, cache layout, and +//! every future profile type stay the CLI's problem, not ours. +//! +//! Depending on `aws-config` instead would be 31 crates, and its whole +//! family currently requires rustc 1.94.1 while this repo pins 1.94.0 +//! — an MSRV treadmill on a toolchain we pin exactly. + +#![cfg(not(target_os = "emscripten"))] + +use anyhow::{Context, Result, bail}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Concrete keys, plus where they came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Credentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: Option, +} + +/// Which resolution step supplied the credentials. +/// +/// Carried all the way to `path auth s3 status`, because the first +/// question when a share fails is always *which* credential was used — +/// and "the one you configured" and "whatever the AWS CLI resolved" are +/// very different debugging stories. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Source { + /// Stored by `path auth s3 login`. + Stored, + /// `AWS_ACCESS_KEY_ID` and friends. + Environment, + /// Static keys in `~/.aws/credentials`. + Profile { name: String, file: PathBuf }, + /// Resolved by the AWS CLI (SSO, assume-role, credential_process). + AwsCli { name: String }, + /// Nothing local; `object_store` will try instance metadata, ECS, + /// and web identity on its own. + InstanceChain, +} + +impl std::fmt::Display for Source { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Source::Stored => f.write_str("stored by `path auth s3 login`"), + Source::Environment => f.write_str("AWS_ACCESS_KEY_ID (environment)"), + Source::Profile { name, file } => { + write!(f, "profile `{name}` in {}", file.display()) + } + Source::AwsCli { name } => { + write!(f, "profile `{name}` via the AWS CLI") + } + Source::InstanceChain => { + f.write_str("none found locally — will try the EC2/ECS/EKS credential chain") + } + } + } +} + +/// The outcome of resolution: keys when we found them, always a source. +#[derive(Debug, Clone)] +pub(crate) struct Resolved { + pub credentials: Option, + pub source: Source, + /// Region the profile declares, if any — `~/.aws/config` is the + /// natural place for it and re-typing it would be silly. + pub region: Option, +} + +/// Everything resolution reads, injected so tests need no `$HOME` +/// games and no real AWS CLI. +pub(crate) struct Env<'a> { + pub home: Option, + pub var: &'a dyn Fn(&str) -> Option, + /// Runs `aws configure export-credentials --profile `, + /// returning its stdout. + pub aws_cli: &'a dyn Fn(&str) -> Result, +} + +impl Env<'_> { + fn credentials_file(&self) -> Option { + let explicit = self + .var("AWS_SHARED_CREDENTIALS_FILE") + .filter(|v| !v.trim().is_empty()); + match explicit { + Some(p) => Some(PathBuf::from(p)), + None => self.home.as_ref().map(|h| h.join(".aws/credentials")), + } + } + + fn config_file(&self) -> Option { + let explicit = self.var("AWS_CONFIG_FILE").filter(|v| !v.trim().is_empty()); + match explicit { + Some(p) => Some(PathBuf::from(p)), + None => self.home.as_ref().map(|h| h.join(".aws/config")), + } + } + + fn var(&self, key: &str) -> Option { + (self.var)(key) + } +} + +/// Resolve S3 credentials, following AWS's own precedence with our +/// stored settings layered on top. +/// +/// 1. `path auth s3 login` — an explicit local choice beats ambient +/// state, and it's the only way to configure a non-AWS endpoint. +/// 2. `--profile` / `AWS_PROFILE` — naming a profile is also explicit. +/// 3. `AWS_ACCESS_KEY_ID` — what CI sets. +/// 4. The `[default]` profile. +/// 5. Nothing: leave it to `object_store`'s instance chain. +pub(crate) fn resolve( + stored: Option, + profile_flag: Option<&str>, + env: &Env<'_>, +) -> Result { + if let Some(credentials) = stored { + return Ok(Resolved { + credentials: Some(credentials), + source: Source::Stored, + region: None, + }); + } + + let named = profile_flag + .map(str::to_string) + .or_else(|| env.var("AWS_PROFILE")) + .filter(|p| !p.trim().is_empty()); + + if let Some(name) = &named { + return from_profile(name, env).with_context(|| format!("profile `{name}`")); + } + + if let (Some(key), Some(secret)) = ( + env.var("AWS_ACCESS_KEY_ID") + .filter(|v| !v.trim().is_empty()), + env.var("AWS_SECRET_ACCESS_KEY") + .filter(|v| !v.trim().is_empty()), + ) { + return Ok(Resolved { + credentials: Some(Credentials { + access_key_id: key, + secret_access_key: secret, + session_token: env.var("AWS_SESSION_TOKEN").filter(|v| !v.is_empty()), + }), + source: Source::Environment, + region: env + .var("AWS_REGION") + .or_else(|| env.var("AWS_DEFAULT_REGION")), + }); + } + + // A `[default]` profile is only used when it exists; its absence is + // not an error, it just means we fall through to the chain. + if profile_exists("default", env) { + return from_profile("default", env).context("profile `default`"); + } + + Ok(Resolved { + credentials: None, + source: Source::InstanceChain, + region: None, + }) +} + +fn profile_exists(name: &str, env: &Env<'_>) -> bool { + let in_credentials = env + .credentials_file() + .and_then(|p| read_ini(&p).ok()) + .is_some_and(|ini| ini.contains_key(name)); + let in_config = env + .config_file() + .and_then(|p| read_ini(&p).ok()) + .is_some_and(|ini| ini.contains_key(&config_section(name)) || ini.contains_key(name)); + in_credentials || in_config +} + +/// Read one profile: static keys if it has them, otherwise the AWS CLI. +fn from_profile(name: &str, env: &Env<'_>) -> Result { + let credentials_path = env.credentials_file(); + let region = profile_region(name, env); + + if let Some(path) = &credentials_path + && let Ok(ini) = read_ini(path) + && let Some(section) = ini.get(name) + && let (Some(key), Some(secret)) = ( + section.get("aws_access_key_id"), + section.get("aws_secret_access_key"), + ) + { + return Ok(Resolved { + credentials: Some(Credentials { + access_key_id: key.clone(), + secret_access_key: secret.clone(), + session_token: section.get("aws_session_token").cloned(), + }), + source: Source::Profile { + name: name.to_string(), + file: path.clone(), + }, + region, + }); + } + + if !profile_exists(name, env) { + bail!( + "no such profile. Check `aws configure list-profiles`, or run \ + `path auth s3 login` to store keys directly." + ); + } + + // The profile exists but carries no static keys: SSO, an assume-role + // chain, or credential_process. The AWS CLI already knows how to + // resolve all of those, including refresh. + let raw = (env.aws_cli)(name)?; + let creds = parse_export_credentials(&raw)?; + Ok(Resolved { + credentials: Some(creds), + source: Source::AwsCli { + name: name.to_string(), + }, + region, + }) +} + +fn profile_region(name: &str, env: &Env<'_>) -> Option { + let ini = read_ini(&env.config_file()?).ok()?; + // `~/.aws/config` spells non-default profiles `[profile name]`. + let section = ini.get(&config_section(name)).or_else(|| ini.get(name))?; + section.get("region").cloned() +} + +fn config_section(name: &str) -> String { + if name == "default" { + name.to_string() + } else { + format!("profile {name}") + } +} + +/// `aws configure export-credentials --format process` output. +fn parse_export_credentials(raw: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(raw.trim()).context("the AWS CLI returned output that isn't JSON")?; + let field = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string); + let (Some(access_key_id), Some(secret_access_key)) = + (field("AccessKeyId"), field("SecretAccessKey")) + else { + bail!("the AWS CLI returned no credentials"); + }; + Ok(Credentials { + access_key_id, + secret_access_key, + session_token: field("SessionToken"), + }) +} + +/// Run the real AWS CLI. Kept behind [`Env::aws_cli`] so tests don't. +pub(crate) fn run_aws_cli(profile: &str) -> Result { + let out = std::process::Command::new("aws") + .args(["configure", "export-credentials", "--profile", profile]) + .args(["--format", "process"]) + .output() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => anyhow::anyhow!( + "this profile needs the AWS CLI to resolve (SSO, assume-role, or \ + credential_process), but `aws` isn't on PATH. Install it, or run \ + `path auth s3 login` to store keys directly." + ), + _ => anyhow::anyhow!("running `aws configure export-credentials`: {e}"), + })?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let hint = if stderr.contains("sso") || stderr.contains("SSO") { + "\nIf this is an SSO profile, run `aws sso login` first." + } else { + "" + }; + bail!( + "the AWS CLI could not resolve this profile: {}{hint}", + stderr.trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +// ── Minimal ini ───────────────────────────────────────────────────────── + +/// Parse the subset of ini that AWS config files actually use: +/// `[section]` headers, `key = value` pairs, `#`/`;` comments. +/// +/// Nested sub-sections (the `[services]` style) are ignored rather than +/// mis-parsed — nothing we read lives in one. +fn read_ini(path: &Path) -> Result>> { + let text = std::fs::read_to_string(path)?; + Ok(parse_ini(&text)) +} + +fn parse_ini(text: &str) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + let mut current: Option = None; + for line in text.lines() { + // Indented lines continue a nested sub-section; skip them. + if line.starts_with(char::is_whitespace) { + continue; + } + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + let name = name.trim().to_string(); + out.entry(name.clone()).or_default(); + current = Some(name); + continue; + } + if let (Some(section), Some((key, value))) = (current.as_ref(), line.split_once('=')) { + out.entry(section.clone()) + .or_default() + .insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An `Env` over an in-memory fake `~/.aws` and a stub AWS CLI, so + /// nothing here touches the developer's real credentials. + struct Fake { + dir: tempfile::TempDir, + vars: HashMap, + cli_result: std::cell::RefCell>, + cli_calls: std::cell::RefCell>, + } + + impl Fake { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".aws")).unwrap(); + Fake { + dir, + vars: HashMap::new(), + cli_result: std::cell::RefCell::new(Err("aws CLI not stubbed".into())), + cli_calls: std::cell::RefCell::new(Vec::new()), + } + } + fn credentials(self, body: &str) -> Self { + std::fs::write(self.dir.path().join(".aws/credentials"), body).unwrap(); + self + } + fn config(self, body: &str) -> Self { + std::fs::write(self.dir.path().join(".aws/config"), body).unwrap(); + self + } + fn var(mut self, k: &str, v: &str) -> Self { + self.vars.insert(k.to_string(), v.to_string()); + self + } + fn cli(self, out: &str) -> Self { + *self.cli_result.borrow_mut() = Ok(out.to_string()); + self + } + fn resolve(&self, stored: Option, profile: Option<&str>) -> Result { + let var = |k: &str| self.vars.get(k).cloned(); + let cli = |name: &str| { + self.cli_calls.borrow_mut().push(name.to_string()); + self.cli_result + .borrow() + .clone() + .map_err(|e| anyhow::anyhow!(e)) + }; + resolve( + stored, + profile, + &Env { + home: Some(self.dir.path().to_path_buf()), + var: &var, + aws_cli: &cli, + }, + ) + } + } + + const STATIC_PROFILE: &str = "\ +[default] +aws_access_key_id = AKIADEFAULT +aws_secret_access_key = defaultsecret + +[work] +aws_access_key_id = AKIAWORK +aws_secret_access_key = worksecret +aws_session_token = worktoken +"; + + #[test] + fn a_default_profile_is_used_with_no_configuration_at_all() { + // The whole point: someone who has run `aws configure` gets S3 + // access without telling us anything. + let f = Fake::new().credentials(STATIC_PROFILE); + let r = f.resolve(None, None).unwrap(); + let c = r.credentials.unwrap(); + assert_eq!(c.access_key_id, "AKIADEFAULT"); + assert!(matches!(r.source, Source::Profile { .. })); + } + + #[test] + fn an_explicit_profile_beats_the_default() { + let f = Fake::new().credentials(STATIC_PROFILE); + let r = f.resolve(None, Some("work")).unwrap(); + let c = r.credentials.unwrap(); + assert_eq!(c.access_key_id, "AKIAWORK"); + assert_eq!(c.session_token.as_deref(), Some("worktoken")); + } + + #[test] + fn aws_profile_env_selects_the_profile() { + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_PROFILE", "work"); + let r = f.resolve(None, None).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIAWORK"); + } + + #[test] + fn the_profile_flag_beats_aws_profile() { + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_PROFILE", "work"); + let r = f.resolve(None, Some("default")).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIADEFAULT"); + } + + #[test] + fn stored_settings_beat_everything_ambient() { + // Running `path auth s3 login` is an explicit local choice, and + // it's the only way to reach a non-AWS endpoint. + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_ACCESS_KEY_ID", "AKIAENV") + .var("AWS_SECRET_ACCESS_KEY", "envsecret"); + let stored = Credentials { + access_key_id: "AKIASTORED".into(), + secret_access_key: "storedsecret".into(), + session_token: None, + }; + let r = f.resolve(Some(stored), None).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIASTORED"); + assert_eq!(r.source, Source::Stored); + } + + #[test] + fn env_keys_beat_the_default_profile() { + // AWS's own precedence, and what CI sets. + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_ACCESS_KEY_ID", "AKIAENV") + .var("AWS_SECRET_ACCESS_KEY", "envsecret"); + let r = f.resolve(None, None).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIAENV"); + assert_eq!(r.source, Source::Environment); + } + + #[test] + fn nothing_configured_defers_to_the_instance_chain() { + // On a server this is the correct answer, not a failure. + let f = Fake::new(); + let r = f.resolve(None, None).unwrap(); + assert!(r.credentials.is_none()); + assert_eq!(r.source, Source::InstanceChain); + } + + #[test] + fn an_sso_profile_is_resolved_through_the_aws_cli() { + // No static keys in the file — exactly what `aws sso login` + // leaves behind. The CLI knows how to turn it into keys. + let f = Fake::new() + .config("[profile sso-work]\nsso_session = corp\nsso_account_id = 1234\n") + .cli(r#"{"Version":1,"AccessKeyId":"ASIASSO","SecretAccessKey":"ssosecret","SessionToken":"ssotoken"}"#); + let r = f.resolve(None, Some("sso-work")).unwrap(); + let c = r.credentials.unwrap(); + assert_eq!(c.access_key_id, "ASIASSO"); + assert_eq!(c.session_token.as_deref(), Some("ssotoken")); + assert_eq!( + r.source, + Source::AwsCli { + name: "sso-work".into() + } + ); + assert_eq!(*f.cli_calls.borrow(), vec!["sso-work".to_string()]); + } + + #[test] + fn a_static_profile_never_shells_out() { + let f = Fake::new().credentials(STATIC_PROFILE); + f.resolve(None, Some("work")).unwrap(); + assert!( + f.cli_calls.borrow().is_empty(), + "static keys are right there; spawning the AWS CLI would be silly" + ); + } + + #[test] + fn an_unknown_profile_says_so_rather_than_shelling_out() { + let f = Fake::new().credentials(STATIC_PROFILE); + let err = f.resolve(None, Some("nope")).unwrap_err().to_string(); + assert!(err.contains("nope"), "{err}"); + assert!(f.cli_calls.borrow().is_empty()); + } + + #[test] + fn region_comes_from_the_profile_when_we_have_none() { + // `~/.aws/config` spells non-default profiles `[profile name]`. + let f = Fake::new() + .credentials(STATIC_PROFILE) + .config("[default]\nregion = us-east-2\n\n[profile work]\nregion = eu-west-1\n"); + assert_eq!( + f.resolve(None, Some("work")).unwrap().region.as_deref(), + Some("eu-west-1") + ); + assert_eq!( + f.resolve(None, None).unwrap().region.as_deref(), + Some("us-east-2") + ); + } + + #[test] + fn credentials_file_location_honors_the_aws_env_overrides() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("elsewhere"); + std::fs::write(&path, STATIC_PROFILE).unwrap(); + let f = Fake::new().var("AWS_SHARED_CREDENTIALS_FILE", path.to_str().unwrap()); + assert_eq!( + f.resolve(None, None) + .unwrap() + .credentials + .unwrap() + .access_key_id, + "AKIADEFAULT" + ); + } + + // ── ini parsing ────────────────────────────────────────────────── + + #[test] + fn ini_ignores_comments_and_nested_subsections() { + let ini = parse_ini( + "\ +# a comment +; another +[default] +aws_access_key_id = AKIA ; trailing content is part of the value +region=us-east-1 + +[services x] + s3 = + endpoint_url = http://nested +", + ); + assert_eq!(ini["default"]["region"], "us-east-1"); + // Indented sub-section bodies must not leak into the section. + assert!(!ini["services x"].contains_key("endpoint_url")); + } + + #[test] + fn ini_keys_are_case_insensitive() { + let ini = parse_ini("[default]\nAWS_ACCESS_KEY_ID = AKIA\n"); + assert_eq!(ini["default"]["aws_access_key_id"], "AKIA"); + } + + #[test] + fn export_credentials_output_without_keys_is_an_error() { + let err = parse_export_credentials(r#"{"Version":1}"#) + .unwrap_err() + .to_string(); + assert!(err.contains("no credentials"), "{err}"); + } + + #[test] + fn export_credentials_non_json_is_an_error() { + let err = parse_export_credentials("Unable to locate credentials") + .unwrap_err() + .to_string(); + assert!(err.contains("isn't JSON"), "{err}"); + } +} diff --git a/crates/path-cli/src/cmd_auth.rs b/crates/path-cli/src/cmd_auth.rs index 8cde387f..57a9c20f 100644 --- a/crates/path-cli/src/cmd_auth.rs +++ b/crates/path-cli/src/cmd_auth.rs @@ -84,6 +84,12 @@ pub struct S3LoginArgs { #[arg(long)] pub session_token: Option, + /// AWS profile to resolve credentials from, instead of storing keys. + /// Works with SSO and assume-role profiles — those are resolved + /// through the AWS CLI, so nothing expires in our config. + #[arg(long)] + pub profile: Option, + /// Address the bucket as `bucket.host/key` instead of `host/bucket/key` #[arg(long)] pub virtual_hosted_style: bool, @@ -215,6 +221,7 @@ fn s3_login(path: &Path, args: S3LoginArgs) -> Result<()> { set(&mut cfg.access_key_id, args.access_key_id); set(&mut cfg.secret_access_key, args.secret_access_key); set(&mut cfg.session_token, args.session_token); + set(&mut cfg.profile, args.profile); if args.virtual_hosted_style { cfg.virtual_hosted_style = Some(true); } @@ -243,8 +250,12 @@ fn s3_login(path: &Path, args: S3LoginArgs) -> Result<()> { /// so a targeted `--region` update doesn't re-interrogate the user /// about credentials they already stored. fn prompt_missing(cfg: &mut S3Settings) -> Result<()> { - println!("Store S3 credentials for `s3://` share and resume targets."); - println!("Leave a field blank to skip it (credentials can come from the AWS environment)."); + println!("Store S3 connection settings for `s3://` share and resume targets."); + println!(); + println!("If you already use the AWS CLI, you probably need none of this — your"); + println!("`~/.aws` profiles are picked up automatically, including SSO. This is"); + println!("for endpoints the AWS tooling doesn't know about (MinIO, R2, Ceph)."); + println!("Leave any field blank to skip it."); println!(); if cfg.region.is_none() { @@ -292,6 +303,7 @@ fn s3_status(path: &Path) -> Result<()> { } else { print_settings(&effective, &stored.unwrap_or_default()); } + print_credential_source(&effective); println!("Share target: {}", target::describe_effective()?); Ok(()) } @@ -345,8 +357,31 @@ fn print_settings(effective: &S3Settings, stored: &S3Settings) { effective.session_token.as_deref().map(redact).as_deref(), stored.session_token.is_some(), ); - if effective.access_key_id.is_none() { - println!(" credentials: none stored — falling back to the AWS credential chain"); + line( + "profile", + effective.profile.as_deref(), + stored.profile.is_some(), + ); +} + +/// Say which credentials a share would actually use. +/// +/// The first question when an upload fails is *which* credential was +/// tried — a stored key, an AWS profile, or nothing at all are three +/// completely different fixes, and only this line distinguishes them. +fn print_credential_source(effective: &S3Settings) { + match effective.resolve_real() { + Ok(r) => { + println!(" credentials: {}", r.source); + if let Some(region) = &r.region + && effective.region.is_none() + { + println!(" region: {region} (from the profile)"); + } + } + // The reason *is* the answer here — "no such profile" tells the + // user exactly what to fix. + Err(e) => println!(" credentials: unresolved — {e:#}"), } } diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 6e912bec..0fc1fb16 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -1,4 +1,6 @@ pub mod artifact; +#[cfg(not(target_os = "emscripten"))] +mod aws_creds; mod cache; #[cfg(not(target_os = "emscripten"))] mod cmd_auth; diff --git a/crates/path-cli/src/store.rs b/crates/path-cli/src/store.rs index 3ee11881..542a0c7c 100644 --- a/crates/path-cli/src/store.rs +++ b/crates/path-cli/src/store.rs @@ -80,6 +80,54 @@ pub(crate) struct S3Settings { /// path style, which every S3-compatible endpoint accepts. #[serde(default, skip_serializing_if = "Option::is_none")] pub virtual_hosted_style: Option, + /// AWS profile to resolve credentials from, when you don't want the + /// `AWS_PROFILE` / `[default]` answer. Storing a profile name is + /// very different from storing a key: it's a pointer to credentials + /// the AWS tooling already manages, so it can't go stale and it + /// costs nothing at rest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, +} + +/// The credential resolution this settings blob implies. +/// +/// `profile` is threaded through so `--profile` on a command reaches +/// the resolver; everything else comes from the ambient AWS setup. +impl S3Settings { + pub(crate) fn resolved_credentials(&self) -> Option { + self.resolve_real().ok() + } + + /// [`resolved_credentials`](Self::resolved_credentials), keeping the + /// error. Anything *reporting* on credentials wants the reason — + /// "no such profile" is the whole answer, and swallowing it leaves + /// the user with nothing to act on. + pub(crate) fn resolve_real(&self) -> Result { + self.resolve_with(&crate::aws_creds::Env { + home: std::env::var_os("HOME").map(PathBuf::from), + var: &|k| std::env::var(k).ok(), + aws_cli: &crate::aws_creds::run_aws_cli, + }) + } + + /// [`resolved_credentials`](Self::resolved_credentials) against an + /// injected environment, and propagating the error so callers that + /// want to *report* a failure (rather than fall through to the + /// instance chain) can. + pub(crate) fn resolve_with( + &self, + env: &crate::aws_creds::Env<'_>, + ) -> Result { + let stored = match (&self.access_key_id, &self.secret_access_key) { + (Some(k), Some(s)) => Some(crate::aws_creds::Credentials { + access_key_id: k.clone(), + secret_access_key: s.clone(), + session_token: self.session_token.clone(), + }), + _ => None, + }; + crate::aws_creds::resolve(stored, self.profile.as_deref(), env) + } } pub(crate) fn config_path() -> Result { @@ -143,24 +191,34 @@ pub(crate) fn merge_env Option>(mut cfg: S3Settings, env: /// ignored by `parse_url_opts`, so the same list is safe to pass for a /// `file://` URL as for `s3://`. fn store_options(cfg: &S3Settings) -> Vec<(&'static str, String)> { - let mut opts: Vec<(&'static str, String)> = Vec::new(); - let mut push = |k: &'static str, v: &Option| { + fn push(opts: &mut Vec<(&'static str, String)>, k: &'static str, v: &Option) { if let Some(v) = v.as_deref().map(str::trim).filter(|v| !v.is_empty()) { opts.push((k, v.to_string())); } - }; - push("aws_access_key_id", &cfg.access_key_id); - push("aws_secret_access_key", &cfg.secret_access_key); - push("aws_session_token", &cfg.session_token); - push("aws_endpoint", &cfg.endpoint); - push( - "aws_region", - &Some( - cfg.region - .clone() - .unwrap_or_else(|| DEFAULT_REGION.to_string()), - ), - ); + } + + let mut opts: Vec<(&'static str, String)> = Vec::new(); + + // Credentials come from `resolve_credentials`, not straight off + // `cfg` — a stored key is only one of the places they can live, and + // the common laptop case is an AWS profile we had to go find. + let resolved = cfg.resolved_credentials(); + if let Some(c) = resolved.as_ref().and_then(|r| r.credentials.as_ref()) { + opts.push(("aws_access_key_id", c.access_key_id.clone())); + opts.push(("aws_secret_access_key", c.secret_access_key.clone())); + if let Some(t) = &c.session_token { + opts.push(("aws_session_token", t.clone())); + } + } + + push(&mut opts, "aws_endpoint", &cfg.endpoint); + let region = cfg + .region + .clone() + .or_else(|| resolved.as_ref().and_then(|r| r.region.clone())) + .unwrap_or_else(|| DEFAULT_REGION.to_string()); + opts.push(("aws_region", region)); + if let Some(v) = cfg.virtual_hosted_style { opts.push(("aws_virtual_hosted_style_request", v.to_string())); } diff --git a/crates/path-cli/src/target.rs b/crates/path-cli/src/target.rs index 9db021d4..cbbd9ea7 100644 --- a/crates/path-cli/src/target.rs +++ b/crates/path-cli/src/target.rs @@ -298,11 +298,13 @@ pub(crate) fn check_reachable(target: &Target, settings: &crate::store::S3Settin if dest.is_local() { return Ok(()); } - if settings.access_key_id.is_none() { - // object_store will still try the AWS credential chain - // (instance role, web identity), so this is a note, not a - // failure. - eprintln!("note: no S3 credentials configured; falling back to the AWS credential chain."); + if let Some(r) = settings.resolved_credentials() + && r.credentials.is_none() + { + // Nothing local. object_store will still try instance metadata, + // ECS, and web identity, which is exactly right on a server — + // so this is a note, not a failure. + eprintln!("note: no local S3 credentials found ({}).", r.source); } dest.probe(settings) } diff --git a/crates/path-cli/tests/share_targets.rs b/crates/path-cli/tests/share_targets.rs index 6b071980..aba386c8 100644 --- a/crates/path-cli/tests/share_targets.rs +++ b/crates/path-cli/tests/share_targets.rs @@ -27,10 +27,19 @@ fn cmd(config_dir: &Path) -> Command { "AWS_DEFAULT_REGION", "AWS_ENDPOINT_URL", "AWS_ENDPOINT_URL_S3", + "AWS_PROFILE", "TOOLPATH_SHARE_TARGET", ] { c.env_remove(k); } + // Credential resolution reads `~/.aws` now, so point it at files + // that don't exist. Without this a developer's real default profile + // leaks in and the "no credentials" tests make real network calls. + c.env( + "AWS_SHARED_CREDENTIALS_FILE", + "/nonexistent/toolpath-test/credentials", + ); + c.env("AWS_CONFIG_FILE", "/nonexistent/toolpath-test/config"); c }