Skip to content
Open
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
182 changes: 182 additions & 0 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6905,4 +6905,186 @@ mod tests {
);
server.abort();
}

// ── Permission-mode bounds (#4098 / #3729 regression pins) ──────────────
//
// These pin the two halves of the permission-mode fix class:
// 1. `agent_supports_mode` — the advertisement gate behind the #3729
// (OpenCode hang) fix: never send a mode the agent did not list.
// 2. `apply_permission_mode` — the 5s PERMISSION_MODE_TIMEOUT bound behind
// the #4098 hypothesis: a silent agent yields a fast fatal Timeout,
// never the 60s REQUEST_TIMEOUT stall from the issue log.

/// A Hermes-shaped agent that answers `session/new` advertising the mode and
/// then goes silent on `session/set_config_option` must fail fast: the outer
/// PERMISSION_MODE_TIMEOUT (5s) fires and maps to a fatal AcpError::Timeout —
/// never the 60s REQUEST_TIMEOUT stall from the #4098 log.
#[cfg(unix)]
#[tokio::test]
async fn test_apply_permission_mode_silent_agent_fails_fast_with_timeout() {
// Reads stdin forever, never writes a response. The test watchdog keeps
// a broken timeout implementation from hanging the suite indefinitely.
let mut acp = AcpClient::spawn(
"bash",
&["-c".to_string(), "cat > /dev/null".to_string()],
&[],
false,
)
.await
.expect("failed to spawn silent test agent");

let result = tokio::time::timeout(
Duration::from_secs(15),
apply_permission_mode(&mut acp, "ses_silent", &PermissionMode::BypassPermissions),
)
.await;
// Do this before assertions so normal assertion failures do not leave a
// live subprocess behind; AcpClient::Drop remains only the panic-path
// fallback.
acp.shutdown().await;

let result = result.expect("permission-mode test exceeded its 15s watchdog");
let err = result.expect_err("a silent agent must fail the permission-mode set");
assert!(
matches!(err, AcpError::Timeout(d) if d == PERMISSION_MODE_TIMEOUT),
"expected AcpError::Timeout(PERMISSION_MODE_TIMEOUT), got {err:?}"
);
}

/// Positive end-to-end pin: an advertised non-default mode must emit the
/// expected `session/set_config_option` frame, not merely pass the helper
/// advertisement unit test.
#[cfg(unix)]
#[tokio::test]
async fn test_advertised_permission_mode_emits_set_config_option() {
let capture = std::env::temp_dir().join(format!(
"buzz-acp-positive-capture-{}.jsonl",
Uuid::new_v4()
));
let extra_env = vec![(
"CAPTURE_FILE".to_string(),
capture.to_string_lossy().into_owned(),
)];

// Record each inbound frame and answer the two requests used by this
// test. The path comes through the environment so arbitrary temp paths
// cannot change the Bash program's syntax.
let script = r#"while IFS= read -r -t 10 line; do
printf '%s\n' "$line" >> "$CAPTURE_FILE"
case "$line" in
*session/new*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"sessionId":"ses_advertised","modes":{"availableModes":[{"id":"bypassPermissions","name":"Bypass"}]}}}' ;;
*session/set_config_option*) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{}}' ;;
esac
done"#;

let acp = AcpClient::spawn(
"bash",
&["-c".to_string(), script.to_string()],
&extra_env,
false,
)
.await
.expect("failed to spawn advertised-mode test agent");
let mut agent = OwnedAgent {
index: 0,
acp,
state: SessionState::default(),
model_capabilities: None,
desired_model: None,
model_overridden: false,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
};
let mut ctx = make_prompt_context_no_owner();
ctx.permission_mode = PermissionMode::BypassPermissions;

let session_result =
create_session_and_apply_model(&mut agent, &ctx, None, None, None).await;
agent.acp.shutdown().await;
let session_id = session_result.expect("advertised mode session setup must succeed");
let sent = std::fs::read_to_string(&capture).expect("capture file must exist");

assert_eq!(session_id, "ses_advertised");
assert!(
sent.contains("session/set_config_option"),
"advertised mode must emit session/set_config_option: {sent}"
);
assert!(
sent.contains("\"configId\":\"mode\""),
"permission mode must use configId=mode: {sent}"
);
assert!(
sent.contains("\"value\":\"bypassPermissions\""),
"permission mode must emit the requested wire value: {sent}"
);

let _ = std::fs::remove_file(&capture);
}

/// Gate (#3729): when `session/new` advertises no modes, a non-default
/// permission mode must NOT emit `session/set_config_option`, while session
/// setup still succeeds. These Bash/`read -t` subprocess tests are Unix-only
/// because they intentionally exercise the repository's POSIX agent-spawn path.
#[cfg(unix)]
#[tokio::test]
async fn test_unadvertised_permission_mode_sends_no_set_config_option() {
let capture =
std::env::temp_dir().join(format!("buzz-acp-gate-capture-{}.jsonl", Uuid::new_v4()));
let extra_env = vec![(
"CAPTURE_FILE".to_string(),
capture.to_string_lossy().into_owned(),
)];

// Respond to session/new with a Hermes-shaped result (no `modes`
// member), recording every inbound frame first. Stay silent on anything
// else, so an (unwanted) set_config_option would be captured.
let script = r#"while IFS= read -r -t 10 line; do
printf '%s\n' "$line" >> "$CAPTURE_FILE"
case "$line" in
*session/new*) printf '%s\n' '{"jsonrpc":"2.0","id":0,"result":{"sessionId":"ses_gate"}}' ;;
esac
done"#;

let acp = AcpClient::spawn(
"bash",
&["-c".to_string(), script.to_string()],
&extra_env,
false,
)
.await
.expect("failed to spawn gate test agent");
let mut agent = OwnedAgent {
index: 0,
acp,
state: SessionState::default(),
model_capabilities: None,
desired_model: None,
model_overridden: false,
agent_name: "unknown".into(),
goose_system_prompt_supported: None,
protocol_version: 2,
};
let mut ctx = make_prompt_context_no_owner();
ctx.permission_mode = PermissionMode::BypassPermissions;

let session_result =
create_session_and_apply_model(&mut agent, &ctx, None, None, None).await;
agent.acp.shutdown().await;
let session_id =
session_result.expect("session setup must succeed when the mode is gated out");
let sent = std::fs::read_to_string(&capture).expect("capture file must exist");

assert_eq!(session_id, "ses_gate");
assert!(
sent.contains("session/new"),
"sanity: the capture must contain the session/new frame: {sent}"
);
assert!(
!sent.contains("session/set_config_option"),
"no set_config_option frame may be sent for an unadvertised mode: {sent}"
);

let _ = std::fs::remove_file(&capture);
}
}