mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
🤖 ## Summary Scripts, scheduled jobs, and other automation need to give an agent one prepared task and wait for a result without starting a long-running chat listener. This adds `buzz-acp run`: it reads a task from a local file or stdin, runs one fresh agent session with the configured tools and instructions, cleans up, and exits with a machine-readable status. ```sh buzz-acp run --task ./task.json cat task.json | buzz-acp run --task - ``` - The versioned JSON task specifies a task ID, intended agent public key, prepared prompt, and execution time limit. Invalid input or an identity mismatch is rejected before agent launch. Credentials, executable, tools, and permissions remain controlled by trusted launch settings, not the task document. - Each invocation uses the normal agent equipment: base/persona/team instructions, enabled memory, tools, model and effort settings, permissions, and Git identity, credentials, and signing. Both task and conversational modes obtain these capabilities from one shared runtime owner; new shared setup belongs there rather than in separate entry points. - Launch configuration reads have a separate ten-second bound and can be cancelled even when a prompt file blocks. A deadline covers adapter startup through the task turn. Interrupts and termination signals trigger bounded cleanup. Stdout contains one terminal JSON record; diagnostics stay on stderr. A normal turn ending is not proof that the task achieved its goal. - Normal invocation remains the conversational service. Task mode does not start chat subscriptions, presence, heartbeat turns, or a persistent agent pool. Tasks are trusted executable instructions, not sandboxed input. Reusing a task ID does not prevent duplicate execution, and uncertain outcomes must not be blindly retried. See [the task contract](crates/buzz-acp/TASKS.md) for the document format, exit codes, limits, and examples. URL retrieval, a task server, and a persistent execution ledger are outside this change. ### Related issue None found in the existing issue and PR search. ### Testing - At `4e86bbc97`: 964 unit tests and 22 integration tests passed, with three existing ignored tests. The package suite ran serially with inherited harness/Git options cleared. A final test-only shutdown-result assertion was then checked with the focused memory test and all-target Clippy. - All eight real-binary task tests independently passed on the exact candidate, including blocked configuration reads, file/stdin validation, session equipment, deadlines, cancellation, and cleanup. The memory test passed 20 repeated runs. Removing the oversized-input guard fails the new error assertion; restoring it passes. - Package all-target Clippy, formatting, file-size checks, and pre-push Rust/Tauri checks passed. All executed hosted checks passed on the updated head (54 successful, 28 skipped). The separate Codex review comment still requests authorization for this exact range. - Earlier revisions passed the release build and a real Goose 1.51.0 tool-use smoke test. No new release artifact or installed Janet runtime is claimed for this feedback revision. - Repository-wide `just ci` was previously attempted; workspace Clippy passed, then the desktop dependency check stopped on a local pnpm fetch failure. Hosted checks cover the applicable PR lanes. --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
224 lines
8.1 KiB
Rust
224 lines
8.1 KiB
Rust
//! Run the installed harness/helper personalities across a real process boundary.
|
|
#![cfg(unix)]
|
|
|
|
use base64::Engine;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
#[test]
|
|
fn harness_native_git_and_startup_shutdown_cleanup() {
|
|
check_native_git_and_startup_shutdown_cleanup(false);
|
|
}
|
|
|
|
#[test]
|
|
fn task_native_git_and_startup_shutdown_cleanup() {
|
|
check_native_git_and_startup_shutdown_cleanup(true);
|
|
}
|
|
|
|
fn check_native_git_and_startup_shutdown_cleanup(task: bool) {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let workspace = temp.path();
|
|
let adapter = workspace.join("adapter");
|
|
// Nothing in this script configures Git: all settings/helpers must come
|
|
// from the production harness spawn, including a CLI-only identity.
|
|
std::fs::write(&adapter, r#"#!/bin/sh
|
|
set -eu
|
|
cd "$PROBE_DIR"
|
|
test -z "${NOSTR_PRIVATE_KEY:-}"
|
|
test "$(git config test.inherited)" = preserved
|
|
test "$(git config user.name)" = 'Git Probe'
|
|
test "$(git config --get-urlmatch credential.helper https://relay.invalid/git/test/repo)" = nostr
|
|
! git config --get-urlmatch credential.helper https://unrelated.invalid/git/test/repo
|
|
! git config --get-urlmatch credential.helper https://relay.invalid/other/repo
|
|
! git config --get-urlmatch credential.helper https://relay.invalid/git-other/repo
|
|
git config nostr.keyfile > keyfile
|
|
command -v git-sign-nostr > signer
|
|
command -v git-credential-nostr > credential
|
|
git init -q repo
|
|
cd repo
|
|
git -c core.hooksPath=/dev/null commit -s --allow-empty -qm 'bootstrap probe'
|
|
git tag -m 'signed tag probe' probe
|
|
git verify-commit HEAD
|
|
git verify-tag probe
|
|
git show -s --format='%an%n%ae%n%cn%n%ce' HEAD > ../identity
|
|
printf 'capability[]=authtype\nprotocol=https\nhost=relay.invalid\npath=git/test/repo\nwwwauth[]=Nostr method="GET"\n\n' | git credential fill > ../credential-result
|
|
printf 'done' > ../done
|
|
# Stay alive until the harness is terminated during ACP initialization.
|
|
exec sleep 60
|
|
"#).unwrap();
|
|
std::fs::set_permissions(&adapter, std::fs::Permissions::from_mode(0o700)).unwrap();
|
|
let keys = nostr::Keys::generate();
|
|
let log = std::fs::File::create(workspace.join("harness.log")).unwrap();
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-acp"));
|
|
if task {
|
|
let path = workspace.join("task.json");
|
|
std::fs::write(
|
|
&path,
|
|
serde_json::json!({
|
|
"version": 1, "taskId": "git-probe", "agentPubkey": keys.public_key().to_hex(),
|
|
"prompt": "probe", "maxDurationMs": 30000
|
|
})
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
command
|
|
.arg("run")
|
|
.arg("--task")
|
|
.arg(path)
|
|
.arg("--no-memory");
|
|
}
|
|
let mut child = command
|
|
.env_clear()
|
|
.env("PATH", std::env::var_os("PATH").unwrap_or_default())
|
|
.args([
|
|
"--private-key",
|
|
&keys.secret_key().to_secret_hex(),
|
|
"--relay-url",
|
|
"wss://relay.invalid",
|
|
"--agent-command",
|
|
adapter.to_str().unwrap(),
|
|
"--agent-args",
|
|
"",
|
|
])
|
|
.env("PROBE_DIR", workspace)
|
|
.env("BUZZ_ACP_DISPLAY_NAME", "Git Probe")
|
|
.env("GIT_AUTHOR_NAME", "Inherited Human")
|
|
.env("GIT_AUTHOR_EMAIL", "human@example.invalid")
|
|
.env("GIT_COMMITTER_NAME", "Inherited Human")
|
|
.env("GIT_COMMITTER_EMAIL", "human@example.invalid")
|
|
.env("NOSTR_PRIVATE_KEY", "must-not-reach-adapter")
|
|
.env("GIT_CONFIG_COUNT", "3")
|
|
.env("GIT_CONFIG_KEY_0", "test.inherited")
|
|
.env("GIT_CONFIG_VALUE_0", "preserved")
|
|
// Emulate an old Desktop launcher's scoped entries. These must be
|
|
// carried through and overridden without duplicating config counts.
|
|
.env(
|
|
"GIT_CONFIG_KEY_1",
|
|
"credential.https://relay.invalid/git.helper",
|
|
)
|
|
.env("GIT_CONFIG_VALUE_1", "/nonexistent/old-helper")
|
|
.env(
|
|
"GIT_CONFIG_KEY_2",
|
|
"credential.https://relay.invalid/git.useHttpPath",
|
|
)
|
|
.env("GIT_CONFIG_VALUE_2", "true")
|
|
.env("GIT_CONFIG_NOSYSTEM", "1")
|
|
.env("GIT_CONFIG_GLOBAL", "/dev/null")
|
|
.env("GIT_CONFIG_PARAMETERS", "'user.name=Inherited Human'")
|
|
.env_remove("BUZZ_ACP_SETUP_PAYLOAD")
|
|
.env_remove("BUZZ_AUTH_TAG")
|
|
.stdin(Stdio::null())
|
|
.stdout(log.try_clone().unwrap())
|
|
.stderr(log)
|
|
.spawn()
|
|
.unwrap();
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
while !workspace.join("done").exists() && Instant::now() < deadline {
|
|
if child.try_wait().unwrap().is_some() {
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
// Always terminate, even on assertion failure, so regressions don't leave
|
|
// the subprocess or its ephemeral signing key behind.
|
|
nix::sys::signal::kill(
|
|
nix::unistd::Pid::from_raw(child.id() as i32),
|
|
nix::sys::signal::Signal::SIGTERM,
|
|
)
|
|
.ok();
|
|
let exit_deadline = Instant::now() + Duration::from_secs(5);
|
|
while child.try_wait().unwrap().is_none() && Instant::now() < exit_deadline {
|
|
std::thread::sleep(Duration::from_millis(20));
|
|
}
|
|
if child.try_wait().unwrap().is_none() {
|
|
child.kill().unwrap();
|
|
}
|
|
let status = child.wait().unwrap();
|
|
let logs = std::fs::read_to_string(workspace.join("harness.log")).unwrap();
|
|
assert!(workspace.join("done").exists(), "probe failed: {logs}");
|
|
assert_eq!(
|
|
status.code(),
|
|
Some(if task { 143 } else { 0 }),
|
|
"startup shutdown failed: {logs}"
|
|
);
|
|
let identity = std::fs::read_to_string(workspace.join("identity")).unwrap();
|
|
assert_eq!(
|
|
identity,
|
|
format!(
|
|
"Git Probe\n{0}@relay.invalid\nGit Probe\n{0}@relay.invalid\n",
|
|
keys.public_key().to_hex()
|
|
)
|
|
);
|
|
let credential = std::fs::read_to_string(workspace.join("credential-result")).unwrap();
|
|
assert!(credential.contains("authtype=Nostr"));
|
|
let encoded = credential
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("credential="))
|
|
.unwrap();
|
|
let event: nostr::Event = serde_json::from_slice(
|
|
&base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
event.verify().unwrap();
|
|
assert_eq!(event.pubkey, keys.public_key());
|
|
assert!(event
|
|
.tags
|
|
.iter()
|
|
.any(|tag| tag.as_slice() == ["u", "https://relay.invalid/git/test/repo"]));
|
|
for file in ["keyfile", "signer", "credential"] {
|
|
let path = std::fs::read_to_string(workspace.join(file)).unwrap();
|
|
assert!(
|
|
!std::path::Path::new(path.trim()).exists(),
|
|
"{file} survived harness shutdown"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keyfile_removed_after_adapter_failure() {
|
|
check_keyfile_removed_after_adapter_failure(false);
|
|
}
|
|
|
|
#[test]
|
|
fn task_keyfile_removed_after_adapter_failure() {
|
|
check_keyfile_removed_after_adapter_failure(true);
|
|
}
|
|
|
|
fn check_keyfile_removed_after_adapter_failure(task: bool) {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let keys = nostr::Keys::generate();
|
|
let input = tempfile::NamedTempFile::new().unwrap();
|
|
let mut command = Command::new(env!("CARGO_BIN_EXE_buzz-acp"));
|
|
if task {
|
|
std::fs::write(
|
|
input.path(),
|
|
serde_json::json!({
|
|
"version": 1, "taskId": "failure", "agentPubkey": keys.public_key().to_hex(),
|
|
"prompt": "probe", "maxDurationMs": 10000
|
|
})
|
|
.to_string(),
|
|
)
|
|
.unwrap();
|
|
command.arg("run").arg("--task").arg(input.path());
|
|
}
|
|
let output = command
|
|
.args([
|
|
"--private-key",
|
|
&keys.secret_key().to_secret_hex(),
|
|
"--relay-url",
|
|
"ws://localhost:1",
|
|
"--agent-command",
|
|
"/missing-buzz-adapter",
|
|
])
|
|
.env("TMPDIR", temp.path())
|
|
.env_remove("BUZZ_ACP_SETUP_PAYLOAD")
|
|
.env_remove("BUZZ_AUTH_TAG")
|
|
.output()
|
|
.unwrap();
|
|
assert!(!output.status.success());
|
|
assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0);
|
|
}
|