From 0d11a14788aa7880b4253881c8c1d82e8a7a1a16 Mon Sep 17 00:00:00 2001 From: Adan Lopez Date: Mon, 31 Aug 2026 18:58:36 -0700 Subject: [PATCH 1/2] fork: present --api-key instead of silently ignoring it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --api-key is documented as valid for fork, but fork() called ctx.biscuit() directly instead of ctx.authority(), so a passed key was never presented and the command fell through to the cached workspace token. fork now holds either carrier the way archive does, and the client method takes Auth and posts the bearer with an empty body biscuit. Only an ACCOUNT-WIDE key forks (ADR-0069 §3 amendment, 2026-08-31): the fleet refuses a workspace-scoped key, whose children would be born outside its scope list. The new api_key_scoped_cannot_fork refusal gets a sentence row pointing at an unscoped `keys mint`, and the --api-key help says which kind of key fork takes. Pairs with the controld change that gives /fork the same rpak1-to-Biscuit exchange every other keyed route already had. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EQwbWnXNHsyFfmj9LXwMqo --- reachpad/src/api.rs | 8 +++++--- reachpad/src/cli.rs | 4 +++- reachpad/src/commands.rs | 11 +++++------ reachpad/src/errors.rs | 9 +++++++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/reachpad/src/api.rs b/reachpad/src/api.rs index 6f70502..e6973c4 100644 --- a/reachpad/src/api.rs +++ b/reachpad/src/api.rs @@ -1200,11 +1200,12 @@ impl Client { pub async fn fork( &self, workspace: &str, - biscuit_b64: &str, + auth: Auth<'_>, snapshot_id: Option<&str>, name: Option<&str>, ) -> Result { - let mut req = json!({ "biscuit": biscuit_b64 }); + let (bearer, biscuit) = auth.split(); + let mut req = json!({ "biscuit": biscuit.unwrap_or_default() }); if let Some(id) = snapshot_id { req["snapshot_id"] = json!(id); } @@ -1212,9 +1213,10 @@ impl Client { req["name"] = json!(n); } let body = self - .post( + .post_auth( &format!("/v1/workspaces/{}/fork", encode_segment(workspace)), req, + bearer, ) .await?; Ok(Forked { diff --git a/reachpad/src/cli.rs b/reachpad/src/cli.rs index 8672304..ae8b112 100644 --- a/reachpad/src/cli.rs +++ b/reachpad/src/cli.rs @@ -41,7 +41,9 @@ pub struct Cli { /// An API key (`rpak1.…`) instead of your saved credential, for the verbs /// that act on ONE workspace: `status`, `run`, `pause`, `fork`, `archive`, - /// `events`, `ports`. + /// `events`, `ports`. `fork` takes an ACCOUNT-WIDE key only (one minted + /// with no `--workspace` scope): a scoped key's children would be born + /// outside its scope, so the fleet refuses them. /// /// Account-wide verbs — `list`, `budget`, `keys`, `auth` — need your own /// credential. They REFUSE a key rather than quietly falling back to diff --git a/reachpad/src/commands.rs b/reachpad/src/commands.rs index d92c1e4..e9471ac 100644 --- a/reachpad/src/commands.rs +++ b/reachpad/src/commands.rs @@ -1186,8 +1186,10 @@ async fn fork( "--name names one workspace; with --count the server names them.", )); } - let biscuit = ctx.biscuit(&workspace).await?; - let held = Held::Biscuit(biscuit); + // Either carrier: `--api-key` when one was passed (documented for fork + // since v1, silently ignored until 2026-08-31), the workspace's own + // token otherwise. + let held = ctx.authority(&workspace).await?; let client = ctx.client(); // ONE snapshot for all N children: resolved here, so a fan-out cannot @@ -1204,11 +1206,8 @@ async fn fork( let mut children = Vec::new(); let mut rows = Vec::new(); for _ in 0..count { - let Held::Biscuit(biscuit) = &held else { - unreachable!("fork presents the workspace's own token"); - }; let forked = match client - .fork(&workspace, biscuit, snapshot.as_deref(), name.as_deref()) + .fork(&workspace, held.auth(), snapshot.as_deref(), name.as_deref()) .await { Ok(forked) => forked, diff --git a/reachpad/src/errors.rs b/reachpad/src/errors.rs index 3c77a4e..5432281 100644 --- a/reachpad/src/errors.rs +++ b/reachpad/src/errors.rs @@ -348,6 +348,15 @@ pub const TABLE: &[Row] = &[ exit_code: EXIT_CREDENTIAL, retriable: Retriable::No, }, + Row { + code: "api_key_scoped_cannot_fork", + selector: None, + sentence: "A workspace-scoped key cannot fork: the child would be outside its scope. Mint an account-wide key (`reachpad keys mint` with no `--workspace`) or use your signed-in credential.", + numbers: None, + next_command: Some("reachpad keys mint"), + exit_code: EXIT_CREDENTIAL, + retriable: Retriable::No, + }, Row { code: "api_key_lookup_failed", selector: None, From 2cc494204a1cddb565674853d37c1e5de11cec3b Mon Sep 17 00:00:00 2001 From: LopezAdan <43300482+LopezAdan@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:35:50 +0000 Subject: [PATCH 2/2] Format the multi-arg fork call the way rustfmt wants it Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VLbzGZPZLCHZSng8deSdeb --- reachpad/src/commands.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/reachpad/src/commands.rs b/reachpad/src/commands.rs index e9471ac..085de38 100644 --- a/reachpad/src/commands.rs +++ b/reachpad/src/commands.rs @@ -1207,7 +1207,12 @@ async fn fork( let mut rows = Vec::new(); for _ in 0..count { let forked = match client - .fork(&workspace, held.auth(), snapshot.as_deref(), name.as_deref()) + .fork( + &workspace, + held.auth(), + snapshot.as_deref(), + name.as_deref(), + ) .await { Ok(forked) => forked,