Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions reachpad/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1200,21 +1200,23 @@ impl Client {
pub async fn fork(
&self,
workspace: &str,
biscuit_b64: &str,
auth: Auth<'_>,
snapshot_id: Option<&str>,
name: Option<&str>,
) -> Result<Forked, ApiError> {
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);
}
if let Some(n) = name {
req["name"] = json!(n);
}
let body = self
.post(
.post_auth(
&format!("/v1/workspaces/{}/fork", encode_segment(workspace)),
req,
bearer,
)
.await?;
Ok(Forked {
Expand Down
4 changes: 3 additions & 1 deletion reachpad/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 10 additions & 6 deletions reachpad/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1204,11 +1206,13 @@ 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,
Expand Down
9 changes: 9 additions & 0 deletions reachpad/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading