Compare commits

...
Author SHA1 Message Date
CodeWhale Bot e9c9de775f fix(installer): stop the release refusing and looping around non-release builds
Two defects made a local `0.10.2 (dev)` build permanent for the person who
installed it: the website installer refused to replace it, and `codewhale
update` reported "Already up to date" forever because it compared only
CARGO_PKG_VERSION.

install.sh (website and release archive):
- Resolve and validate the install directory before downloading, so a managed
  or symlinked directory fails before ~100 MB of release assets.
- CODEWHALE_FORCE_REINSTALL=1 replaces a differing codewhale/codew in the
  install directory and keeps each previous file beside it as
  <name>.bak-<utc-timestamp>. Symlinks and non-regular files are still never
  replaced, and every other guard is unchanged.
- A forced run that fails after moving a file aside puts it back. The
  publication state and exit trap now install before the preflight checks, so
  a refusal or a failed publish cannot leave a command missing from the
  directory while its bytes sit in a .bak file.
- Dropped the codewhale-tui compatibility-path guard. It was not a published
  destination: the installer never writes that name, so any stale legacy file
  or symlink beside the real commands aborted a correct install.

crates/cli/src/update.rs:
- `(dev)` in CODEWHALE_BUILD_VERSION is the build script's marker for an
  unstamped checkout; a release artifact carries its short SHA and a Cargo
  source package carries the plain version. When the installed build carries
  that marker and its version equals the latest tag, `update` and
  `update --check` install the release artifact instead of reporting up to
  date. Packaged-source and release builds are untouched.
- `Current version:` prints the build version, so the output names the actual
  provenance (`0.10.2 (dev)` vs `0.10.2 (ff466b2ed0)`).

docs/INSTALL.md and docs/zh_hans/INSTALL.md describe the force flag, the
same-version non-release replacement, and the download-before-refusal wording.

Verified on macOS arm64, source 4d2b3fdb1b plus this patch:
- cargo build -p codewhale-cli --locked: exit 0 (16m55s cold, 6s incremental;
  one pre-existing __eh_frame linker warning).
- scripts/dev-test.sh cli: 501 passed, 0 failed.
- scripts/dev-test.sh release: 57 passed, 0 failed.
- web node scripts/derive-install.mjs && node scripts/check-docs.mjs: PASS.
- Website installer matrix against a local file:// fixture: fresh install,
  identical re-run, differing-without-force (original preserved), force
  (backup holds the original bytes), stale codewhale-tui present (untouched),
  symlink destination with force (still a symlink), managed directory
  (refused with zero downloads). All seven behave as described.
- Interrupted forced run (second destination present as a directory): exits 1
  and restores the first command instead of leaving it in .bak. Same case for
  the archive installer.
- Real release: installed into a scratch directory from GitHub, reports
  0.10.2 (ff466b2ed0), sha256 da022961...590d matches the manifest, for both
  codewhale and codew.
- Real self-update: a fresh `0.10.2 (dev)` build ran `update --check` then
  `update`, downloaded and verified the release, and ended at
  0.10.2 (ff466b2ed0); a second `--check` reports Already up to date.

Not verified: clippy, Windows/NSIS installer, Linux/binary install paths,
concurrent forced runs in the same directory, and the deployed codewhale.net
copy of install.sh (needs a deploy, which stays a separate authorized action).
The packaged-source (`cargo install`) branch of the new predicate is reasoned
from format_build_version, not executed.
2026-10-24 22:58:47 -07:00
CodeWhale BotandClaude Opus 5.5 4d2b3fdb1b feat(cli): whales in account agents and cloud dispatch (CW-7182)
`codewhale account agents create NAME --provider P --model M` sends the
exact route as the Agent's `model`; the service pins it in the whale's
account Fleet revision and the CLI refuses an answer whose saved route
differs from the request. `account agents list` prints each whale's saved
provider/model.

`codewhale dispatch --whale AGENT_ID "task"` creates the cloud conversation
with that whale's agentId and no client route, so the service takes the
route from the whale's pinned Fleet member. The memory-only key handoff
resolves the local configuration for the whale's own provider and model
(CliRuntimeOverrides) and applies the existing portability check; later
`--thread` follow-ups on a whale conversation re-read the whale's current
saved route for the key and turn, so a changed whale applies to the next
turn. `--whale` conflicts with --thread/--cloud-provider/--cloud-model.
The non-whale portability check now runs after the conversation is read,
so an existing whale thread is not refused by an unrelated local default.
Desktop drives the same path (`dispatch --prepare-model`).

docs/CLOUD_DISPATCH.md and the cloud-computer-dispatch registry row
describe whale dispatch.

Verification: dev-cargo test -p codewhale-cli --locked --lib cloud:
123 passed, 0 failed (378 filtered). The existing create_agent test call was
updated for the new signature. Compiled together with an unrelated peer's
uncommitted crates/cli/src/update.rs edit, which is not part of this
commit. No live account, provider or Boat call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:52:33 -07:00
CodeWhale BotandClaude Opus 5.5 135319aa1a feat(acp): --full-access flag for serve --acp, accepted after the subcommand
Probing the installed 0.10.2 binary over ACP showed three things:
- `codewhale serve --acp` runs under Ask;
- `codewhale --yolo serve --acp` runs under Full Access;
- `codewhale serve --acp --yolo` is rejected ("unexpected argument", exit 2).
That last form is the one editors write in their agent config. Founder
decision: Full Access is yolo (ApprovalMode::Bypass). A configured
`sandbox_mode = "danger-full-access"` must not widen Ask or Auto-Review, and
no state may have the sandbox off while the agent still asks.

Changes:
- `--full-access` is the visible flag; `--yolo` stays an alias
  (runtime_options.rs).
- The dispatcher lifts `--full-access` and `--yolo` out of the `serve`
  passthrough arguments (`capture_serve_full_access`), stopping at a `--`
  separator. It sets the same runtime option as the global flag, so the
  value still flows through the CODEWHALE_YOLO env layer, and managed
  config above that layer still wins. Setting `config.yolo` after load
  would have bypassed a managed `yolo = false`.
- `serve --acp` writes one stderr line naming the ACP permission and
  sandbox it will run with, computed with `sandbox_policy_for_turn`. When
  Full Access is off, the line says how to turn it on.
- The ACP hint and the approval_policy "never" error name `--full-access`.
- `SandboxPolicy::config_value()` replaces a duplicated match in
  `apply_requirements`.
- docs/MODES.md, docs/RUNTIME_API.md and CHANGELOG are updated.

Verified:
- `scripts/dev-cargo.sh build -p codewhale-cli --locked`: built, 0
  warnings.
- The built binary was probed over ACP. For each launch, a fresh
  CODEWHALE_HOME and runtime dir; then `initialize`, `session/new`, and a
  read of the `permission` configOption:
  - `serve --acp`: ask. stderr: "ACP permission: ask; sandbox:
    workspace-write. For Full Access start with `codewhale serve --acp
    --full-access` ...".
  - `--full-access serve --acp`: full-access, with sandbox
    danger-full-access.
  - `serve --acp --full-access`: full-access.
  - `serve --acp --yolo`: full-access.
- nextest codewhale-cli, filtered: 36 passed, including
  `clap_command_definition_is_consistent` and
  `yolo_flag_writes_only_the_codewhale_env_var`.
- nextest codewhale-tui, filtered to acp_server, requirements,
  sandbox::policy and runtime_options: 61 passed (38 acp_server, 6
  requirements).
- Not run: clippy, Windows, hosted CI.

The Seatbelt half of #4955 is not done.

Refs #6992 #4955

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 22:27:30 -07:00
18 changed files with 404 additions and 106 deletions

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `--full-access` is now the documented flag for Full Access (`--yolo` stays an
alias), and `serve` accepts it after the subcommand: `codewhale serve --acp
--yolo` used to fail with "unexpected argument". `serve --acp` also writes one
stderr line naming the permission and sandbox it runs with, and the ACP
permission option's Full Access hint names the working command. A configured
`sandbox_mode = "danger-full-access"` still never widens Ask or Auto-Review
(#6992).
- Preview: `dispatch --computer` starts an account cloud conversation that can
also be opened in desktop. `--thread` continues or inspects it; `--stop`,
`--approve` and `--deny` control its current task. Explicit `--key-stdin`
+67 -2
View File
@@ -196,6 +196,12 @@ enum CloudAgentsCommand {
/// Assign an existing account Project to this Agent.
#[arg(long)]
project_id: Option<String>,
/// Exact configured provider id saved in the whale's Fleet member.
#[arg(long, requires = "model")]
provider: Option<String>,
/// Exact model id on that provider.
#[arg(long, requires = "provider")]
model: Option<String>,
#[arg(long)]
operation_key: String,
},
@@ -632,6 +638,23 @@ struct AccountAgent {
#[serde(default)]
project_id: String,
revision: u64,
#[serde(default)]
configuration: Option<AgentConfiguration>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfiguration {
#[serde(default)]
revision: u64,
#[serde(default)]
model: Option<AgentModelRoute>,
}
#[derive(Clone, Deserialize)]
struct AgentModelRoute {
provider: String,
model: String,
}
#[derive(Deserialize)]
@@ -1176,6 +1199,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
&self,
name: &str,
project_id: Option<&str>,
route: Option<(&str, &str)>,
operation_key: &str,
) -> Result<AccountAgent> {
let name = validate_named_text(name, "Agent name", 80)?;
@@ -1184,6 +1208,12 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
"name": name,
"operationKey": operation_key,
});
if let Some((provider, model)) = route {
body["model"] = serde_json::json!({
"provider": validate_named_text(provider, "Provider", 256)?,
"model": validate_named_text(model, "Model", 256)?,
});
}
if let Some(project_id) = project_id {
body["projectId"] = serde_json::json!(validate_resource_id(project_id, "Project")?);
}
@@ -2313,16 +2343,33 @@ fn run_agents<T: CloudTransport, W: Write>(
writeln!(out, "Codewhale Agents ({})", listing.agents.len())?;
for agent in listing.agents {
validate_resource_id(&agent.id, "Agent")?;
writeln!(out, "{} — {}", printable(&agent.name), agent.id)?;
match agent
.configuration
.as_ref()
.and_then(|configuration| configuration.model.as_ref())
{
Some(route) => writeln!(
out,
"{} — {} ({}/{})",
printable(&agent.name),
agent.id,
printable(&route.provider),
printable(&route.model)
)?,
None => writeln!(out, "{} — {}", printable(&agent.name), agent.id)?,
}
}
Ok(())
}
CloudAgentsCommand::Create {
name,
project_id,
provider,
model,
operation_key,
} => {
let agent = client.create_agent(&name, project_id.as_deref(), &operation_key)?;
let route = provider.as_deref().zip(model.as_deref());
let agent = client.create_agent(&name, project_id.as_deref(), route, &operation_key)?;
validate_resource_id(&agent.id, "Agent")?;
if let Some(project_id) = project_id.as_deref()
&& agent.project_id != validate_resource_id(project_id, "Project")?
@@ -2331,6 +2378,24 @@ fn run_agents<T: CloudTransport, W: Write>(
}
writeln!(out, "Agent: {}", printable(&agent.name))?;
writeln!(out, "ID: {}", agent.id)?;
if let Some(configuration) = agent.configuration.as_ref()
&& let Some(saved) = configuration.model.as_ref()
{
if route.is_some_and(|(provider, model)| {
provider != saved.provider || model != saved.model
}) {
bail!("The Codewhale service saved a different model than requested");
}
writeln!(
out,
"Model: {}/{} (Fleet revision {})",
printable(&saved.provider),
printable(&saved.model),
configuration.revision
)?;
} else if route.is_some() {
bail!("The Codewhale service did not save the requested model");
}
if !agent.project_id.is_empty() {
writeln!(
out,
+60 -7
View File
@@ -58,6 +58,13 @@ pub(crate) struct ComputerOptions {
pub prepare_model: bool,
#[arg(long, hide = true)]
pub expected_account: Option<String>,
#[arg(
long,
value_name = "AGENT_ID",
conflicts_with_all = ["thread", "cloud_provider", "cloud_model"],
help = "Start a cloud conversation with this whale, using its saved model"
)]
pub whale: Option<String>,
}
impl ComputerOptions {
@@ -73,6 +80,7 @@ impl ComputerOptions {
|| self.json
|| self.configured_model
|| self.prepare_model
|| self.whale.is_some()
}
}
@@ -169,7 +177,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
config: &ConfigStore,
secrets: &Secrets,
) -> Result<()> {
let local = config.config.resolve_runtime_options_with_secrets(
let mut local = config.config.resolve_runtime_options_with_secrets(
&codewhale_config::CliRuntimeOverrides::default(),
secrets,
);
@@ -242,13 +250,13 @@ pub(super) fn run<T: CloudTransport, W: Write>(
if options.thread.is_none() && prompt.is_empty() {
bail!("Give a task to dispatch, or continue with --thread <id>.");
}
if let Some(value) = &options.whale {
validate_resource_id(value, "Whale")?;
}
let use_local_model = options.cloud_provider.is_none()
&& !options.key_stdin
&& (!prompt.is_empty() || options.prepare_model);
if use_local_model {
portable_model(&local, config)?;
}
if options.thread.is_none() && options.cloud_provider.is_none() {
if options.thread.is_none() && options.cloud_provider.is_none() && options.whale.is_none() {
options.cloud_provider = Some(local.provider.as_str().to_string());
options.cloud_model = Some(local.model.clone());
}
@@ -316,6 +324,49 @@ pub(super) fn run<T: CloudTransport, W: Write>(
bail!("This account conversation is unavailable.");
}
}
let whale_id = options.whale.clone().or_else(|| {
thread["agentId"]
.as_str()
.filter(|id| !id.is_empty() && thread["agentFleetRevision"].as_u64().is_some())
.map(str::to_string)
});
let whale_route = match &whale_id {
Some(id) => {
validate_resource_id(id, "Whale")?;
let agent = account.request(HttpMethod::Get, &format!("/api/agents/{id}"), None)?;
let route = &agent["agent"]["configuration"]["model"];
match (route["provider"].as_str(), route["model"].as_str()) {
(Some(provider), Some(model)) if agent["agent"]["id"].as_str() == Some(id) => {
validate_model_route(provider, model)?;
Some((provider.to_string(), model.to_string()))
}
_ => bail!(
"This whale has no saved model. Give it a configured model in Codewhale before dispatching."
),
}
}
None => None,
};
if let Some((provider, model)) = &whale_route
&& (use_local_model || options.prepare_model)
{
let kind = codewhale_config::ProviderKind::parse(provider).with_context(|| {
format!(
"Configure the {provider} provider in Codewhale before dispatching to this whale."
)
})?;
local = config.config.resolve_runtime_options_with_secrets(
&codewhale_config::CliRuntimeOverrides {
provider: Some(kind),
model: Some(model.clone()),
..Default::default()
},
secrets,
);
portable_model(&local, config)?;
} else if use_local_model {
portable_model(&local, config)?;
}
let selected = if options.computer.is_none() && options.thread.is_none() {
let list = account.request(HttpMethod::Get, "/api/computers", None)?;
let machines = list["computers"]
@@ -369,7 +420,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
}
if options.thread.is_none() {
let value = account.request(HttpMethod::Post, "/v1/threads", Some(json!({
"title": prompt.chars().take(120).collect::<String>(), "agentId": "", "computerId": computer,
"title": prompt.chars().take(120).collect::<String>(), "agentId": whale_id.as_deref().unwrap_or(""), "computerId": computer,
"productMode": "chat", "mode": "chat", "billingMode": "byok_external", "requiresByok": true,
"modelProvider": options.cloud_provider, "model": options.cloud_model, "operationKey": operation_key,
})))?;
@@ -427,6 +478,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
let provider = options
.cloud_provider
.as_deref()
.or(whale_route.as_ref().map(|(provider, _)| provider.as_str()))
.or_else(|| latest.and_then(|turn| turn["effective_provider"].as_str()))
.or_else(|| {
latest_id
@@ -438,6 +490,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
let model = options
.cloud_model
.as_deref()
.or(whale_route.as_ref().map(|(_, model)| model.as_str()))
.or_else(|| latest.and_then(|turn| turn["effective_model"].as_str()))
.or_else(|| {
latest_id
@@ -446,7 +499,7 @@ pub(super) fn run<T: CloudTransport, W: Write>(
.flatten()
})
.unwrap_or("");
let provider_id = if options.cloud_provider.is_some() {
let provider_id = if options.cloud_provider.is_some() || whale_route.is_some() {
None
} else {
latest
+5 -1
View File
@@ -3163,7 +3163,11 @@ fn account_agents_refuse_foreign_threads_ambiguous_main_and_machine_keys() {
.contains("interactive Codewhale account login")
);
assert!(transport.requests().is_empty());
assert!(client.create_agent("Whale", None, "invalid/key").is_err());
assert!(
client
.create_agent("Whale", None, None, "invalid/key")
.is_err()
);
assert!(transport.requests().is_empty());
client
+18
View File
@@ -364,6 +364,7 @@ Forwarded serve options:
--cors-origin <URL> Additional CORS origin to allow (repeatable)
--auth-token <TOKEN> Require this bearer token for /v1/* runtime API routes
--insecure Disable runtime API auth when no token is configured
--full-access Run with Full Access: no approval prompts, no Codewhale sandbox (alias: --yolo)
`codewhale serve --http` and `codewhale serve --mobile` remain compatibility
aliases for `codewhale app-server --http` and `codewhale app-server --mobile`.
@@ -2283,6 +2284,7 @@ fn run() -> Result<()> {
);
preserve_exec_separator(&mut cli, &argv);
capture_exec_startup_options(&mut cli)?;
capture_serve_full_access(&mut cli);
// The detached log proxy must not depend on user config parsing: its job
// is to frame child output and publish a terminal receipt even when the
@@ -2986,6 +2988,22 @@ fn preserve_exec_separator(cli: &mut Cli, argv: &[impl AsRef<std::ffi::OsStr>])
}
}
fn capture_serve_full_access(cli: &mut Cli) {
let Some(Commands::Serve(args)) = cli.command.as_mut() else {
return;
};
let before = args.args.len();
let separator = args.args.iter().position(|arg| arg == "--").unwrap_or(before);
let mut index = 0;
args.args.retain(|arg| {
index += 1;
index > separator || !matches!(arg.as_str(), "--full-access" | "--yolo")
});
if args.args.len() != before {
cli.runtime_options.yolo = true;
}
}
/// Admit exec's recognized startup options through the same Clap definitions
/// before the one runtime override capture. Unknown forwarded options and all
/// tokens after `--` retain their order and their existing exec meaning.
+23 -3
View File
@@ -61,6 +61,12 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
let plan = update_plan_for_exe(&current_exe);
let channel = ReleaseChannel::from_beta_flag(beta);
let current_version = env!("CARGO_PKG_VERSION");
let build_version = env!("CODEWHALE_BUILD_VERSION");
// The build script marks an unstamped checkout with `(dev)` and nothing
// else: a release artifact carries its short SHA and a Cargo source
// package carries the plain version. Only the checkout marker means the
// installed file has no published artifact behind it.
let unreleased_checkout_build = build_version.ends_with(" (dev)");
let proxy = proxy_arg
.as_deref()
.map(validate_and_build_proxy)
@@ -68,7 +74,7 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
println!("Checking for {} updates...", channel.label());
println!("Current binary: {}", current_exe.display());
println!("Current version: v{current_version}");
println!("Current version: v{build_version}");
if legacy_binary {
println!();
println!("{}", legacy_binary_message(&current_exe));
@@ -94,7 +100,14 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
.with_context(update_network_fallback_hint)?;
let latest_tag = &fetched.release.tag_name;
println!("Latest {} release: {latest_tag}", channel.label());
if update_is_needed(channel, current_version, latest_tag)? {
let replace_same_version = unreleased_checkout_build
&& compare_release_versions(current_version, latest_tag)? == Ordering::Equal;
if update_is_needed(channel, current_version, latest_tag)? || replace_same_version {
if replace_same_version {
println!(
"This build is not a published release ({build_version}); the {latest_tag} release artifact replaces it."
);
}
if install_method.supports_self_update() && !protected_location {
println!(
"Update available. Run `{} update` to install {latest_tag}.",
@@ -138,7 +151,9 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
fetched.source.describe()
);
}
if !update_is_needed(channel, current_version, latest_tag)? {
let replace_same_version = unreleased_checkout_build
&& compare_release_versions(current_version, latest_tag)? == Ordering::Equal;
if !update_is_needed(channel, current_version, latest_tag)? && !replace_same_version {
if compare_release_versions(current_version, latest_tag)? == Ordering::Greater {
println!(
"Current build is newer than the latest published release; keeping v{current_version}. No downgrade or download performed."
@@ -148,6 +163,11 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
}
return Ok(());
}
if replace_same_version {
println!(
"This build is not a published release ({build_version}); installing the {latest_tag} release artifact over it."
);
}
// Reject unrelated command paths before downloads or any sibling changes.
for target in &plan.target_paths {
+37 -9
View File
@@ -3,6 +3,7 @@
//! This module keeps only connection configuration, canonical bindings and
//! JSON-RPC correlation. Secure cross-process attachment is not yet qualified.
use crate::config::{Config, ProviderKind};
use crate::core::authority::{SandboxNetworkAccess, sandbox_policy_for_turn};
#[cfg(test)]
use crate::runtime_threads::RuntimeThreadManagerConfig;
use crate::runtime_threads::{CreateThreadRequest, RuntimeThreadManager, UpdateThreadRequest};
@@ -11,7 +12,7 @@ use codewhale_config::AppMode;
use codewhale_execpolicy::ApprovalMode;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
#[cfg(test)]
@@ -32,6 +33,7 @@ pub async fn run_acp_server(
config_profile: Option<String>,
) -> Result<()> {
ensure_acp_approval_posture(&config)?;
eprintln!("{}", acp_startup_posture(&config, &default_cwd));
crate::runtime_api::run_http_server(
config,
default_cwd,
@@ -628,12 +630,7 @@ impl AcpServer {
Ok(())
}
fn permission_value(&self) -> &'static str {
match acp_approval_mode(&self.config) {
ApprovalMode::Bypass => "full-access",
ApprovalMode::Auto => "auto-review",
ApprovalMode::Never => "never",
ApprovalMode::Suggest => "ask",
}
acp_permission_value(&self.config)
}
async fn session_configuration(
&self,
@@ -839,7 +836,38 @@ impl AcpServer {
Ok(self.current_model())
}
}
const ACP_FULL_ACCESS_HINT: &str = "Start the server with `codewhale --yolo serve --acp`, or set `yolo = true` in config.toml. Full Access also turns off Codewhale's own sandbox unless sandbox_mode tightens it; Plan stays read-only.";
const ACP_FULL_ACCESS_HINT: &str = "Start the server with `codewhale serve --acp --full-access` (alias `--yolo`), or set `yolo = true` in config.toml. Full Access also turns off Codewhale's own sandbox unless sandbox_mode tightens it; Plan stays read-only.";
fn acp_permission_value(config: &Config) -> &'static str {
match acp_approval_mode(config) {
ApprovalMode::Bypass => "full-access",
ApprovalMode::Auto => "auto-review",
ApprovalMode::Never => "never",
ApprovalMode::Suggest => "ask",
}
}
fn acp_startup_posture(config: &Config, workspace: &Path) -> String {
let approval = acp_approval_mode(config);
let sandbox = sandbox_policy_for_turn(
acp_mode(config),
approval,
config.sandbox_mode.as_deref(),
workspace,
SandboxNetworkAccess::from_config(config.sandbox_network_access),
);
let mut line = format!(
"ACP permission: {}; sandbox: {}.",
acp_permission_value(config),
sandbox.config_value()
);
if approval != ApprovalMode::Bypass {
line.push_str(
" For Full Access start with `codewhale serve --acp --full-access` or set `yolo = true`; sandbox_mode alone never turns it on.",
);
}
line
}
fn acp_mode(config: &Config) -> AppMode {
if config.sandbox_mode.as_deref() == Some("read-only") {
@@ -869,7 +897,7 @@ fn acp_approval_mode(config: &Config) -> ApprovalMode {
fn ensure_acp_approval_posture(config: &Config) -> Result<()> {
anyhow::ensure!(
acp_approval_mode(config) != ApprovalMode::Never,
"approval_policy \"never\" is not supported by `codewhale serve --acp`: ACP sessions run under Ask, Auto-Review or Full Access. Set approval_policy = \"on-request\" for this server, or start it with --yolo for Full Access."
"approval_policy \"never\" is not supported by `codewhale serve --acp`: ACP sessions run under Ask, Auto-Review or Full Access. Set approval_policy = \"on-request\" for this server, or start it with --full-access for Full Access."
);
Ok(())
}
+3 -8
View File
@@ -11114,7 +11114,6 @@ fn apply_requirements(config: &mut Config) -> Result<()> {
.map(str::to_ascii_lowercase)
.unwrap_or_else(|| {
use crate::core::authority::{SandboxNetworkAccess, sandbox_policy_for_turn};
use crate::sandbox::SandboxPolicy;
use codewhale_execpolicy::ApprovalMode;
// Sandbox requirements lock approval posture at startup,
@@ -11124,18 +11123,14 @@ fn apply_requirements(config: &mut Config) -> Result<()> {
.as_deref()
.and_then(ApprovalMode::from_config_value)
.unwrap_or_default();
match sandbox_policy_for_turn(
sandbox_policy_for_turn(
codewhale_config::AppMode::Agent,
approval,
None,
Path::new("."),
SandboxNetworkAccess::from_config(config.sandbox_network_access),
) {
SandboxPolicy::ReadOnly { .. } => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ExternalSandbox { .. } => "external-sandbox",
}
)
.config_value()
.to_string()
});
if !requirements
+4 -3
View File
@@ -45,8 +45,9 @@ pub struct RuntimeOptions {
/// Disable a feature for this run (repeatable).
#[arg(long = "disable", value_name = "FEATURE", action = clap::ArgAction::Append, global = true)]
pub disable: Vec<String>,
/// Legacy compatibility alias for Act + Full Access.
#[arg(long, hide = true)]
/// Run with Full Access: no approval prompts and no Codewhale sandbox
/// (the same as `yolo = true`; `--yolo` is an alias).
#[arg(long = "full-access", alias = "yolo")]
pub yolo: bool,
/// Maximum number of concurrent sub-agents (1-128; default 64).
#[arg(long)]
@@ -97,7 +98,7 @@ impl RuntimeOptions {
flag!(skip_onboarding, "--skip-onboarding");
flag!(fresh, "--fresh");
flag!(no_project_config, "--no-project-config");
flag!(yolo, "--yolo");
flag!(yolo, "--full-access");
flag!(verbose, "--verbose");
flag!(remote_control, "--remote-control");
if self.mouse_capture && self.no_mouse_capture {
+9
View File
@@ -105,6 +105,15 @@ impl Default for SandboxPolicy {
}
impl SandboxPolicy {
pub fn config_value(&self) -> &'static str {
match self {
SandboxPolicy::ReadOnly { .. } => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ExternalSandbox { .. } => "external-sandbox",
}
}
pub fn read_only() -> Self {
SandboxPolicy::ReadOnly {
network_access: false,
+10
View File
@@ -54,6 +54,16 @@ configured model still matches the conversation before transferring a key.
without returning a credential. `--prepare-model --thread ID` reconnects that
model without starting a turn.
A whale is a named account collaborator with a saved provider and model.
`codewhale account agents create NAME --provider PROVIDER --model MODEL
--operation-key KEY` saves one without starting work or a computer, and
`codewhale account agents list` shows each whale's saved model.
`codewhale dispatch --whale AGENT_ID "your task"` starts a cloud conversation
with that whale: the service takes the route from the whale's saved
configuration, and your local configuration for that provider supplies the
memory-only key. Later `--thread` follow-ups keep using the whale's current
saved model; a changed whale applies to the next turn, not to a running one.
Every send prints a replay key before submission. After an uncertain response,
repeat the original command and prompt with `--operation-key KEY`. Keep the
original `--computer` target when retrying creation, or the original `--thread`
+17 -2
View File
@@ -170,7 +170,9 @@ What it does (verified):
* It **never uses sudo and never edits your shell profile.** It refuses to
install into system or package-manager directories (`/usr/bin`,
`~/.cargo/bin`, Homebrew, `node_modules`, `/nix/store`…) and refuses to
overwrite a *different* existing `codewhale`.
overwrite a *different* existing `codewhale` unless you ask for it with
`CODEWHALE_FORCE_REINSTALL=1` (see [re-running the
installer](#re-running-the-installer)).
Expected output:
@@ -258,8 +260,15 @@ codewhale doctor # diagnostics; see the note in §8 about what it does NO
`Already installed: …` and exits 0.
* Different version already installed: it **refuses**
(`codewhale install: refusing to replace existing …/codewhale`), exits 1 and
changes nothing. It downloads ~160 MB before refusing. Use
changes nothing. It downloads the release before refusing. Use
[`codewhale update`](#7-updating-and-rolling-back) instead.
* Not a release build, at the same version (a local `cargo build` reports
`0.10.2 (dev)`): `codewhale update` still reinstalls the release artifact
over it, because that build has no release provenance to preserve.
* Deliberate replacement: `CODEWHALE_FORCE_REINSTALL=1` replaces a differing
`codewhale`/`codew` in the install directory and keeps each previous file
beside it as `<name>.bak-<utc-timestamp>`. It never replaces a symlink or a
non-regular file, and never replaces a file in a managed directory.
### Upgrade / uninstall
@@ -587,6 +596,12 @@ It updates `codewhale` **and** `codew` together. It took 10 s here. Run it
again and you get `Already up to date; no download needed.` Other options:
`--beta` and `--proxy <URL>`.
A build that is not a published release reports its provenance in
`codewhale --version` (`0.10.2 (dev)` for a local build, a commit hash for a
release). `codewhale update` compares the release, not just the version, so a
same-version `(dev)` build is replaced by the release artifact instead of
reporting "Already up to date" forever.
<a id="roll-back-to-a-previous-release"></a>
### Rolling back (e.g. to v0.9.13)
+1
View File
@@ -354,6 +354,7 @@ Run `codewhale --help` for the canonical list. Common flags:
- `-r, --resume <ID|PREFIX|latest>`: resume a saved session
- `-c, --continue`: resume the most recent session in this workspace
- `--max-subagents <N>`: clamp to `1..=128`
- `--full-access` (alias `--yolo`): start with Full Access, no approval prompts and no Codewhale sandbox; the same as `yolo = true`. `serve --acp --full-access` and `--full-access serve --acp` are equivalent.
- `--mouse-capture` / `--no-mouse-capture`: opt in or out of internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. Mouse capture is enabled by default on non-Windows terminals and on Windows Terminal/ConEmu/Cmder so drag selection copies only transcript text, removes visual wrap-column line breaks from paragraphs, and stays scoped to the transcript pane; hold Shift while dragging or use `--no-mouse-capture` for raw terminal selection. It defaults off on legacy Windows console (CMD without `WT_SESSION` / `ConEmuPID`) and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where the terminal advertises mouse support but forwards SGR mouse events as raw text (#878, #898). Use `--mouse-capture` to opt in anywhere it's defaulted off. Raw terminal selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.
- `--profile <NAME>`: select config profile
- `--config <PATH>`: config file path
+9
View File
@@ -785,6 +785,15 @@ posture are server-owned read-only options; the editor cannot widen them.
`approval_policy = "never"` is not an ACP posture, so the server refuses to
start with it instead of serving it as Ask.
Full Access is `yolo`. Start it with `codewhale serve --acp --full-access`
(the flag may also come before `serve`; `--yolo` is an alias) or set
`yolo = true` in config. A configured `sandbox_mode = "danger-full-access"` does
not turn it on: that value can only narrow the sandbox the permission picks, so
under Ask or Auto-Review it changes nothing, and `ask` is not a config key. At
startup the server writes one line to stderr naming the permission and sandbox it
will run with, so an editor's agent log shows which posture a launch command
produced.
Tool updates begin `pending`. `in_progress` means Core reached final dispatch;
`completed`/`failed` and typed image blocks come from the actual Core result.
`session/request_permission` correlates a private JSON-RPC ID to the same
+1 -1
View File
@@ -39,7 +39,7 @@
[[feature]]
id = "cloud-computer-dispatch"
name = "Cloud computer conversations"
summary = "Dispatch, inspect, continue and stop the same account cloud conversation from terminal and desktop."
summary = "Dispatch, inspect, continue and stop the same account cloud conversation from terminal and desktop, including conversations with a saved whale."
status = "experimental"
surfaces = { tui = "preview" }
since = "unreleased"
+5 -1
View File
@@ -198,7 +198,9 @@ codewhale doctor # diagnostics; see the note in §8 about what it does NO
### 重新运行安装器
* 已安装的就是同一版本:无害。它会打印 `Already installed: …` 并以 0 退出。
* 已安装的是不同版本:它会**拒绝**(`codewhale install: refusing to replace existing …/codewhale`),以 1 退出,并且不改动任何东西。拒绝之前它已经下载了约 160 MB。请改用 [`codewhale update`](#7-更新与回滚)。
* 已安装的是不同版本:它会**拒绝**(`codewhale install: refusing to replace existing …/codewhale`),以 1 退出,并且不改动任何东西。拒绝之前它已经下载了发布包。请改用 [`codewhale update`](#7-更新与回滚)。
* 是同一版本但不是发布构建(本地 `cargo build` 会报告 `0.10.2 (dev)`):`codewhale update` 仍会用发布产物覆盖它,因为这种构建没有需要保留的发布溯源信息。
* 主动替换:`CODEWHALE_FORCE_REINSTALL=1` 会替换安装目录中不同的 `codewhale`/`codew`,并把每个旧文件保留在同目录的 `<名称>.bak-<UTC 时间戳>`。它绝不替换符号链接或非普通文件,也绝不替换受管目录中的文件。
### 升级 / 卸载
@@ -447,6 +449,8 @@ codewhale update
它会把 `codewhale` **和** `codew` 一起更新,在测试机上用了 10 秒。再运行一次会得到 `Already up to date; no download needed.`。其他选项:`--beta` 和 `--proxy <URL>`。
不是发布构建的构建会在 `codewhale --version` 中显示自己的来源(本地构建是 `0.10.2 (dev)`,发布构建是提交哈希)。`codewhale update` 比较的是发布产物而不只是版本号,因此同版本的 `(dev)` 构建会被发布产物替换,而不是永远报告 "Already up to date"。
<a id="roll-back-to-a-previous-release"></a>
### 回滚(例如回到 v0.9.13)
+45 -22
View File
@@ -95,7 +95,8 @@ preflight_glibc() {
}
# This script installs an already checksum-verified archive. Existing different
# binaries go through `codewhale update`, the sole version-aware updater.
# binaries go through `codewhale update`, or through CODEWHALE_FORCE_REINSTALL=1
# here when the installed file is not a published release artifact.
case "$BIN_DIR" in
/*) ;;
*) echo "ERROR: PREFIX must be an absolute user path" >&2; exit 1 ;;
@@ -119,8 +120,21 @@ check_destination() {
destination_exists=1
return 0
fi
if [[ "${CODEWHALE_FORCE_REINSTALL:-}" == 1 && ! -L "$dst" && -f "$dst" ]]; then
local backup="$dst.bak-$(date -u +%Y%m%dT%H%M%SZ)" suffix=1
while [[ -e "$backup" || -L "$backup" ]]; do
backup="$dst.bak-$(date -u +%Y%m%dT%H%M%SZ)-$suffix"
suffix=$((suffix + 1))
done
mv "$dst" "$backup"
forced_backups+=("$backup")
echo "Replacing $dst (previous file kept at $backup)"
return 0
fi
echo "ERROR: refusing to replace existing $dst; no existing file was changed." >&2
echo "For an existing direct Codewhale install, run its full path with 'update'." >&2
echo "To replace it with this archive and keep the previous file beside it:" >&2
echo ' CODEWHALE_FORCE_REINSTALL=1 ./install.sh' >&2
echo "To migrate, install this verified archive into a fresh user prefix:" >&2
echo ' mkdir -p "$HOME/.local"' >&2
echo ' codewhale_prefix="$(mktemp -d "$HOME/.local/codewhale-release.XXXXXX")"' >&2
@@ -133,26 +147,10 @@ check_destination() {
# The image is an explicit installer choice; presence never changes Core's
# Node default. Archives carry notices/source beside any qualified image.
payloads=(codewhale codew)
if [[ "${CODEWHALE_INSTALL_COMPILED_HOST:-}" == 1 ]]; then
for file in codewhale-extension-host codewhale-extension-host.LICENSES.txt codewhale-extension-host.relink-source.tar.gz codewhale-extension-host.release.json; do
[[ -f "$SCRIPT_DIR/$file" && ! -L "$SCRIPT_DIR/$file" ]] || { echo "ERROR: compiled host requested but archive has no complete qualified payload ($file); use Node" >&2; exit 1; }
payloads+=("$file")
done
fi
# Validate every source and destination before the first write.
for bin in "${payloads[@]}"; do
src="$SCRIPT_DIR/$bin"
[[ -f "$src" ]] || { echo "ERROR: $src not found in archive" >&2; exit 1; }
mode=0644
case "$bin" in codewhale|codew|codewhale-extension-host) mode=0755; preflight_glibc "$src" "${bin/codewhale-extension-host/compiled host}" ;; esac
check_destination "$src" "$BIN_DIR/$bin" "$mode"
done
legacy_tui="$BIN_DIR/codewhale-tui"
if [[ -e "$legacy_tui" || -L "$legacy_tui" ]]; then
check_destination "$SCRIPT_DIR/codewhale" "$legacy_tui"
fi
# Backups this run made under CODEWHALE_FORCE_REINSTALL. A run that fails after
# moving a command aside puts it back, so a forced install that does not
# complete never leaves a command missing from the prefix.
forced_backups=()
stage=""
stage_dir=""
# Commands this run published, as "source<TAB>destination" lines. If a later
@@ -171,12 +169,37 @@ rollback_published() {
done <<< "$published"
}
on_exit() {
local status=$?
local status=$? dst backup
if [[ -n "$stage" ]]; then rm -f "$stage"; fi
if [[ -n "$stage_dir" ]]; then rmdir "$stage_dir"; fi
if [[ "$status" -ne 0 && -n "$published" ]]; then rollback_published; fi
if [[ "$status" -ne 0 ]]; then
for backup in "${forced_backups[@]}"; do
[[ -e "$backup" ]] || continue
dst="$(printf '%s' "$backup" | sed 's/\.bak-[0-9TZ]*\(-[0-9][0-9]*\)\{0,1\}$//')"
if [[ ! -e "$dst" && ! -L "$dst" ]]; then
mv "$backup" "$dst" && echo "Restored $dst: this install did not complete." >&2
fi
done
fi
}
trap on_exit EXIT
payloads=(codewhale codew)
if [[ "${CODEWHALE_INSTALL_COMPILED_HOST:-}" == 1 ]]; then
for file in codewhale-extension-host codewhale-extension-host.LICENSES.txt codewhale-extension-host.relink-source.tar.gz codewhale-extension-host.release.json; do
[[ -f "$SCRIPT_DIR/$file" && ! -L "$SCRIPT_DIR/$file" ]] || { echo "ERROR: compiled host requested but archive has no complete qualified payload ($file); use Node" >&2; exit 1; }
payloads+=("$file")
done
fi
# Validate every source and destination before the first write.
for bin in "${payloads[@]}"; do
src="$SCRIPT_DIR/$bin"
[[ -f "$src" ]] || { echo "ERROR: $src not found in archive" >&2; exit 1; }
mode=0644
case "$bin" in codewhale|codew|codewhale-extension-host) mode=0755; preflight_glibc "$src" "${bin/codewhale-extension-host/compiled host}" ;; esac
check_destination "$src" "$BIN_DIR/$bin" "$mode"
done
install_binary() {
local src="$1" dst="$2" mode="${3:-0755}"
check_destination "$src" "$dst" "$mode"
+83 -47
View File
@@ -16,6 +16,9 @@ Usage:
Environment:
CODEWHALE_INSTALL_DIR Install directory. Default: $HOME/.local/bin
CODEWHALE_VERSION Release tag for a fresh directory. Default: latest
CODEWHALE_FORCE_REINSTALL=1
Replace differing codewhale/codew already in the install
directory, keeping each old file beside it as .bak-<time>
CODEWHALE_RELEASE_BASE_URL
Custom release asset base URL ending in /download
CODEWHALE_INSTALL_COMPILED_HOST=1
@@ -223,9 +226,75 @@ cli_asset="codewhale-$target"
shim_asset="codew-$target"
manifest_asset="codewhale-artifacts-sha256.txt"
# Resolve the real directory before applying managed-prefix checks. Never use
# sudo or allow an install directory symlink to obscure which files will change.
case "$install_dir" in
/*) ;;
*) fail "CODEWHALE_INSTALL_DIR must be an absolute path" ;;
esac
[ ! -L "$install_dir" ] || fail "install directory is a symlink: $install_dir; choose a fresh user directory"
mkdir -p "$install_dir" || fail "cannot create $install_dir; choose a writable user directory (no sudo is used)"
install_dir="$(cd -P "$install_dir" && pwd)"
case "$install_dir/" in
/bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/nix/store/*|/gnu/store/*|*/node_modules/*|*/Cellar/*|*/.linuxbrew/*|*/linuxbrew/*|*/.cargo/bin/*)
fail "refusing managed/system directory $install_dir; use a fresh user directory"
;;
esac
[ -w "$install_dir" ] || fail "$install_dir is not writable; choose a user directory (no sudo is used)"
tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t codewhale-install)"
trap 'rm -rf "$tmpdir"' EXIT INT TERM
# Backups this run made under CODEWHALE_FORCE_REINSTALL, newest last. A run that
# fails after moving a command aside puts it back, so a forced install that does
# not complete never leaves the command missing from the install directory.
forced_backups="$tmpdir/.forced-backups"
: > "$forced_backups"
stage=""
stage_dir=""
# Commands this run published. If a later publication fails they are removed
# again, but only while each is still the exact file this run wrote, so a
# failed install leaves no half-installed pair and never touches a file that
# was already installed.
published="$tmpdir/.published"
: > "$published"
rollback_published() {
while IFS= read -r name; do
destination="$install_dir/$name"
if [ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$tmpdir/$name" "$destination"; then
rm -f "$destination"
say "Removed $destination: this install did not complete." >&2
fi
done < "$published"
}
restore_forced_backups() {
[ -f "$forced_backups" ] || return 0
while IFS= read -r backup; do
[ -n "$backup" ] || continue
[ -e "$backup" ] || continue
# The name this run generated ends in .bak-<utc-stamp>[-<n>]; the
# destination is everything before it.
destination="$(printf '%s' "$backup" | sed 's/\.bak-[0-9TZ]*\(-[0-9][0-9]*\)\{0,1\}$//')"
if [ ! -e "$destination" ] && [ ! -L "$destination" ]; then
mv "$backup" "$destination" && say "Restored $destination: this install did not complete." >&2
fi
done < "$forced_backups"
}
on_exit() {
status=$?
if [ -n "$stage" ]; then rm -f "$stage"; fi
if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi
if [ "$status" -ne 0 ]; then
rollback_published
restore_forced_backups
fi
rm -rf "$tmpdir"
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
say "Installing Codewhale for $target"
say "Release assets: $release_base"
say "Install dir: $install_dir"
@@ -243,22 +312,6 @@ if command -v xattr >/dev/null 2>&1; then
xattr -d com.apple.quarantine "$tmpdir/codewhale" "$tmpdir/codew" 2>/dev/null || true
fi
# Resolve the real directory before applying managed-prefix checks. Never use
# sudo or allow an install directory symlink to obscure which files will change.
case "$install_dir" in
/*) ;;
*) fail "CODEWHALE_INSTALL_DIR must be an absolute path" ;;
esac
[ ! -L "$install_dir" ] || fail "install directory is a symlink: $install_dir; choose a fresh user directory"
mkdir -p "$install_dir" || fail "cannot create $install_dir; choose a writable user directory (no sudo is used)"
install_dir="$(cd -P "$install_dir" && pwd)"
case "$install_dir/" in
/bin/*|/sbin/*|/usr/bin/*|/usr/sbin/*|/nix/store/*|/gnu/store/*|*/node_modules/*|*/Cellar/*|*/.linuxbrew/*|*/linuxbrew/*|*/.cargo/bin/*)
fail "refusing managed/system directory $install_dir; use a fresh user directory"
;;
esac
[ -w "$install_dir" ] || fail "$install_dir is not writable; choose a user directory (no sudo is used)"
check_destination() {
destination="$1"
source="$2"
@@ -269,10 +322,24 @@ check_destination() {
destination_exists=1
return
fi
if [ "${CODEWHALE_FORCE_REINSTALL:-}" = 1 ] && [ ! -L "$destination" ] && [ -f "$destination" ]; then
backup="$destination.bak-$(date -u +%Y%m%dT%H%M%SZ)"
suffix=1
while [ -e "$backup" ] || [ -L "$backup" ]; do
backup="$destination.bak-$(date -u +%Y%m%dT%H%M%SZ)-$suffix"
suffix=$((suffix + 1))
done
mv "$destination" "$backup" || fail "cannot move $destination aside; it was not replaced"
printf '%s\n' "$backup" >> "$forced_backups"
say "Replacing $destination (previous file kept at $backup)"
return
fi
cat >&2 <<EOF
codewhale install: refusing to replace existing $destination.
It may be a newer build, another installation, or a symlink. No existing file was changed.
For an existing direct Codewhale install, run its full path with 'update'.
To replace it with this release and keep the previous file beside it:
curl -fsSL https://codewhale.net/install.sh | CODEWHALE_FORCE_REINSTALL=1 sh
To migrate safely from a package manager or mixed installation, create a fresh directory:
mkdir -p "\$HOME/.local"
codewhale_install_dir="\$(mktemp -d "\$HOME/.local/codewhale-release.XXXXXX")"
@@ -316,38 +383,7 @@ fi
check_destination "$install_dir/codewhale" "$tmpdir/codewhale"
check_destination "$install_dir/codew" "$tmpdir/codew"
legacy_tui="$install_dir/codewhale-tui"
if [ -e "$legacy_tui" ] || [ -L "$legacy_tui" ]; then
check_destination "$legacy_tui" "$tmpdir/codewhale"
fi
stage=""
stage_dir=""
# Commands this run published. If a later publication fails they are removed
# again, but only while each is still the exact file this run wrote, so a
# failed install leaves no half-installed pair and never touches a file that
# was already installed.
published="$tmpdir/.published"
: > "$published"
rollback_published() {
while IFS= read -r name; do
destination="$install_dir/$name"
if [ ! -L "$destination" ] && [ -f "$destination" ] && cmp -s "$tmpdir/$name" "$destination"; then
rm -f "$destination"
say "Removed $destination: this install did not complete." >&2
fi
done < "$published"
}
on_exit() {
status=$?
if [ -n "$stage" ]; then rm -f "$stage"; fi
if [ -n "$stage_dir" ]; then rmdir "$stage_dir"; fi
if [ "$status" -ne 0 ]; then rollback_published; fi
rm -rf "$tmpdir"
}
trap on_exit EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
install_binary() {
source="$1"
destination="$2"