Skip to content
Open
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
17 changes: 14 additions & 3 deletions .github/workflows/auto-tag-on-release-pr-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ jobs:
echo "enabled=true"
echo "tag=${TAG_PREFIX}${VERSION}"
if [[ "$TAG_PREFIX" == desktop-v ]]; then
echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}"
echo "target_sha=${{ github.event.pull_request.head.sha }}"
echo "desktop=true"
else
echo "target_sha=$GITHUB_SHA"
Expand All @@ -112,6 +112,7 @@ jobs:
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
MERGED_AT: ${{ github.event.pull_request.merged_at }}
run: |
VERSION="${VERSION#desktop-v}"
export VERSION
Expand Down Expand Up @@ -146,7 +147,17 @@ jobs:
exit 1
fi
fi
gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \
-f ref="refs/tags/$TAG" \
-f sha="$TARGET_SHA" \
--silent
--silent; then
# Ref creation is atomic. A concurrent retry may have won the race;
# accept that only when it created the exact immutable ref.
EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)"
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
echo "Tag $TAG was concurrently created at $TARGET_SHA"
exit 0
fi
echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)"
exit 1
fi
2 changes: 2 additions & 0 deletions .github/workflows/desktop-release-candidate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:

permissions:
contents: read
pull-requests: read

jobs:
validate:
Expand All @@ -20,6 +21,7 @@ jobs:
- name: Validate immutable desktop candidate
if: startsWith(github.event.pull_request.head.ref, 'version-bump/')
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ github.event.pull_request.head.ref }}
run: |
VERSION="${VERSION#version-bump/}"
Expand Down
46 changes: 24 additions & 22 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,30 @@ or mobile GitHub Release.
### Desktop

1. Run `just release-desktop <version>` from a clean, up-to-date `main` checkout.
The script fetches the current `origin/main`, regenerates
`version-bump/<version>` as one
deterministic candidate commit, records the frozen base and proposed
`desktop-v<version>` tag in `.release/desktop-candidate.json`, updates every
desktop manifest and lockfile, writes a full-SHA changelog, and opens or
updates the PR.
2. Review the recorded base and candidate SHA, the complete changelog, and CI.
The required **Desktop Release Candidate** check validates the exact head.
A trusted repository member, owner, or collaborator must approve that exact
candidate head. Any regeneration or push changes the head, invalidates the
prior approval, and requires both the checks and approval to run again.
3. **Squash merge** the PR. The protected branch must still be exactly the
recorded base; otherwise regenerate the candidate from current `main`.
4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity,
required checks, and trusted approval on the exact candidate head, then tags
the squash commit as `desktop-v<version>`. An admin or ruleset bypass does not
authorize desktop tagging.
5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel
macOS, Windows, and Linux artifacts; publishes the versioned release only
after the complete set succeeds; then updates the rolling updater manifest
last for stable versions. A failed platform leaves no partially published
versioned release.
The script creates one deterministic candidate commit and records both its
frozen base and the verified prior release ledger in candidate metadata.
2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or
pushing the branch creates a new candidate and requires checks to run again.
3. **Squash merge** the PR after all protected-branch checks pass. The merge is
the human authorization event; an authorized owner/admin bypass is treated
the same way. Unrelated changes reaching `main` do not invalidate the
reviewed candidate.
4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR
identity, validates candidate content, and proves every required check came
from its trusted producer and was successful when the PR merged. It creates
`desktop-v<version>` at the exact reviewed PR head—not the squash commit.
Retries accept that tag only at the same SHA and never move it. GitHub does
not expose when an individual check rerun was created, so an ordinary rerun
after merge deliberately makes tag verification fail closed; inspect that
run and create a new candidate version rather than retrying the blocked tag.
5. The tag triggers `release.yml`. It builds and stages all platform artifacts,
publishes the versioned release only after the complete set succeeds, then
updates the rolling updater manifest last for stable versions.

Because squash merging leaves immutable candidate tags on side history, the next
release uses validated prior candidate metadata as its ledger boundary. It
includes unrelated commits after the prior frozen base and excludes exactly the
prior release's recorded squash commit; tag ancestry is deliberately irrelevant.

### Relay

Expand Down
2 changes: 1 addition & 1 deletion VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate.
| Type | Visibility | Join | Create |
|------|-----------|------|--------|
| **Open channels** | Searchable by all members | Self-join | Any member |
| **Private channels** | Hidden, invite-only | Invited by member | Any member |
| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member |
| **DMs** | Participants only | N/A (up to 9) | Any member |
| **Guests** | Scoped to specific channels | Invited | N/A |

Expand Down
193 changes: 115 additions & 78 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ pub struct AcpClient {
/// a `cancelled` outcome before the agent returns from `session/prompt`.
pending_permission_id: Option<serde_json::Value>,
/// Whether we have already sent a response to the pending permission request.
/// Guards against double-response if a timeout fires after the allow_once
/// Guards against double-response if a timeout fires after the rejection
/// response was written but before `pending_permission_id` was cleared.
permission_responded: bool,
/// The JSON-RPC id of the most recently sent `session/prompt` request.
Expand Down Expand Up @@ -1162,7 +1162,8 @@ impl AcpClient {
///
/// While waiting, handles:
/// - `session/update` notifications → logged via tracing
/// - `session/request_permission` requests → auto-approved with `allow_once`
/// - `session/request_permission` requests → rejected unless an owner has
/// already selected a non-interactive permission mode at session setup
/// - Any other messages → debug-logged and ignored; if they carry an `id`
/// (i.e. they are requests, not notifications), a JSON-RPC -32601 error is sent.
///
Expand Down Expand Up @@ -1870,12 +1871,12 @@ impl AcpClient {
}
}

/// Auto-approve a `session/request_permission` request from the agent.
/// Reject a `session/request_permission` request from the agent.
///
/// Finds the option with `kind == "allow_once"` and responds with its `optionId`.
/// If no `allow_once` option exists, falls back to `reject_once`.
///
/// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`.
/// Buzz has no human permission prompt in this harness, so selecting
/// `allow_once` would turn any admitted prompt into an implicit approval.
/// Find `reject_once` by kind when the adapter offers it; otherwise use the
/// protocol's cancelled outcome, which is also fail-closed.
///
/// The request `id` is stored as `serde_json::Value` to support both numeric
/// and string IDs per JSON-RPC 2.0.
Expand All @@ -1901,40 +1902,7 @@ impl AcpClient {
options.len()
);

// Find allow_once by kind — NEVER hardcode optionId.
let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));

let response = if let Some(opt) = allow_once {
let option_id = opt["optionId"]
.as_str()
.ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?;
tracing::info!(
target: "acp::permission",
"auto-approving permission id={id} with allow_once optionId={option_id:?}"
);
permission_response_selected(&id, option_id)
} else {
// No allow_once — fall back to reject_once.
tracing::warn!(
target: "acp::permission",
"no allow_once option found in permission request id={id}, falling back to reject_once"
);
let reject = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));

if let Some(opt) = reject {
let option_id = opt["optionId"].as_str().unwrap_or("reject");
permission_response_selected(&id, option_id)
} else {
return Err(AcpError::Protocol(
"no suitable permission option found (neither allow_once nor reject_once)"
.into(),
));
}
};
let response = permission_denial_response(&id, options)?;

// Write the response first, then mark as responded.
//
Expand Down Expand Up @@ -2046,6 +2014,42 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value {
})
}

/// Choose the fail-closed response to a `session/request_permission` request.
///
/// Buzz has no human permission prompt in this harness, so selecting
/// `allow_once` would turn any admitted prompt into an implicit approval.
/// Prefer the adapter's `reject_once` option — matched by `kind`, never by a
/// hardcoded `optionId` — and fall back to the protocol's cancelled outcome for
/// adapters that do not offer one. Both answers deny.
///
/// Kept free of the client so the decision is testable without an agent
/// subprocess: `AcpClient` owns a real `Child` and its stdio pipes.
fn permission_denial_response(
id: &serde_json::Value,
options: &[serde_json::Value],
) -> Result<serde_json::Value, AcpError> {
let reject_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));

let Some(opt) = reject_once else {
tracing::warn!(
target: "acp::permission",
"no reject_once option found in permission request id={id}, cancelling"
);
return Ok(permission_response_cancelled(id));
};

let option_id = opt["optionId"]
.as_str()
.ok_or_else(|| AcpError::Protocol("reject_once option missing optionId".into()))?;
tracing::info!(
target: "acp::permission",
"rejecting permission id={id} with reject_once optionId={option_id:?}"
);
Ok(permission_response_selected(id, option_id))
}

/// Full `session/new` response — session ID plus the raw JSON result.
///
/// Callers use the extractor helpers to pull model info from `raw`.
Expand Down Expand Up @@ -2300,63 +2304,96 @@ mod tests {
assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal));
}

fn options(json: &str) -> Vec<serde_json::Value> {
serde_json::from_str(json).expect("option list")
}

fn outcome(response: &serde_json::Value) -> Option<&str> {
response["result"]["outcome"]["outcome"].as_str()
}

/// The offered `allow_once` and `allow_always` options must be ignored:
/// there is no human to click them, so choosing either would make every
/// admitted prompt an implicit approval. `optionId`s are deliberately
/// non-obvious to prove they are matched by `kind`, never hardcoded.
#[test]
fn find_allow_once_by_kind_not_by_option_id() {
// optionId values are intentionally non-obvious to prove we don't hardcode them.
let options: Vec<serde_json::Value> = serde_json::from_str(
fn permission_requests_select_reject_once_not_allow_once() {
let options = options(
r#"[
{"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"},
{"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"},
{"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"}
]"#,
)
.unwrap();
);

let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
let response =
permission_denial_response(&serde_json::json!(7), &options).expect("denial response");

assert!(allow_once.is_some(), "should find allow_once option");
let opt = allow_once.unwrap();
// Found by kind, not by hardcoded optionId
assert_eq!(opt["kind"].as_str(), Some("allow_once"));
assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99"));
assert_eq!(outcome(&response), Some("selected"));
assert_eq!(
response["result"]["outcome"]["optionId"].as_str(),
Some("opt-reject-42"),
"must select reject_once even when allow options are offered"
);
}

/// Fail-closed backstop: an adapter that offers no `reject_once` must still
/// be denied, via the protocol's cancelled outcome rather than an error or
/// an approval.
#[test]
fn find_allow_once_returns_none_when_absent() {
let options: Vec<serde_json::Value> = serde_json::from_str(
fn permission_request_without_reject_once_is_cancelled() {
let options = options(
r#"[
{"optionId": "reject-1", "name": "Reject", "kind": "reject_once"},
{"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"}
{"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"},
{"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"}
]"#,
)
.unwrap();
);

let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
let response = permission_denial_response(&serde_json::json!("req-1"), &options)
.expect("cancelled response");

assert!(allow_once.is_none());
assert_eq!(outcome(&response), Some("cancelled"));
assert_eq!(
response["id"].as_str(),
Some("req-1"),
"string ids must round-trip per JSON-RPC 2.0"
);
}

/// An empty option list is the degenerate form of the same backstop.
#[test]
fn find_reject_once_fallback_when_no_allow_once() {
let options: Vec<serde_json::Value> = serde_json::from_str(
r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#,
)
.unwrap();
fn permission_request_with_no_options_is_cancelled() {
let response =
permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response");

let allow_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once"));
assert!(allow_once.is_none());
assert_eq!(outcome(&response), Some("cancelled"));
}

let reject_once = options
.iter()
.find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once"));
assert!(reject_once.is_some());
assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x"));
/// A `reject_once` option missing its `optionId` is a protocol violation.
/// Erroring propagates to the caller, which tears the turn down — still no
/// approval is ever sent.
#[test]
fn reject_once_without_option_id_is_a_protocol_error() {
let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#);

let err = permission_denial_response(&serde_json::json!(1), &options)
.expect_err("missing optionId must error");

assert!(matches!(err, AcpError::Protocol(_)), "got {err:?}");
}

#[test]
fn find_reject_once_by_kind() {
let options =
options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#);

let response =
permission_denial_response(&serde_json::json!(1), &options).expect("denial response");

assert_eq!(
response["result"]["outcome"]["optionId"].as_str(),
Some("rej-x")
);
}

#[test]
Expand Down
Loading
Loading