feat(cli): cloud Work speaks the neutral platform dialect; launch defaults to the quoted seconds

Lands the previously uncommitted crates/cli/src/cloud.rs, cloud/work.rs and
cloud/tests.rs edits (repo-free taskContext via --context-file/--file,
served-offer variable seconds, --recovery-from, account file custody) and
moves the cloud Work flow off vendor names:

- reads GET /api/sandbox/work-offer (not the vendor-named alias), expects sku
  task-small and funding trial_credit/compute_cu, and sends sandboxSku
  task-small; the quote no longer asserts a provider adapter;
- hint codes are the ones the server emits (work_vm_trial_unavailable,
  work_vm_provider_unavailable), not the dead boat_work_* ones;
- customer-visible text drops the vendor ("small cloud computer in the EU",
  "Computer usage:", "Computer: <status>", consent text) and the computer
  trial flag is --computer-trial with --boat-trial kept as a hidden alias;
- work-launch without --seconds now loads the offer and uses the same default
  as work-quote instead of a fixed 300 seconds, so a balance-funded launch can
  no longer disagree with its quote;
- run usage is read from computeUsage first, then the current boatUsage field.

Wire-level names the server still emits stay: error codes boat_task_*, the
boatUsage field, and the computers API provider/boatEuComputeOptIn fields.
Tests migrated to the neutral fixtures; the quote-adapter tamper case was
removed with the check; LAUNCH_ARGV now passes --seconds 300.

Evidence (L1 local only): cargo test -p codewhale-cli cloud: 123 passed, 0
failed. Built on a tree that also holds another agent's uncommitted crate-split
and grants.rs edits (not staged here); tui needed a temporary
#[allow(dead_code)] on GrantStore::degraded_reason, left uncommitted in
grants.rs, to compile under -D warnings. No clippy run, no live call.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-09 21:55:41 -07:00
1 parent c1b4cd5846
commit d4a61574bd
12 files changed
+513 -129

No files matched your search

+135 -32
View File
@@ -239,7 +239,7 @@ enum CloudAgentsCommand {
#[arg(long, default_value_t = 0)]
since_seq: u64,
},
/// Give a named Agent durable repository Work. This records a request; it does not start compute.
/// Give a named Agent durable Work. This records a request; it does not start compute.
///
/// The Agent reads the message first: a plain instruction becomes Work, a
/// command such as "stop" is applied to its active Work, a correction
@@ -254,6 +254,13 @@ enum CloudAgentsCommand {
/// Stable message ID for safe retry after an uncertain response.
#[arg(long)]
message_id: String,
#[arg(long, help = "UTF-8 source context file, at most 64000 UTF-16 units")]
context_file: Option<PathBuf>,
#[arg(
long = "file",
help = "Saved account file reference FILE_ID@VERSION; repeat for up to ten files"
)]
file_refs: Vec<String>,
},
/// Read the account-owned status of a Work request.
WorkStatus { id: String },
@@ -280,7 +287,7 @@ enum CloudAgentsCommand {
#[arg(long)]
json: bool,
},
/// Quote bounded Boat trial computer time for queued Work. Nothing starts.
/// Quote offered computer time for Work. Nothing starts.
///
/// Prints the funding, EU placement and time disclosure plus a short-lived
/// confirmation for `work-launch`. Reuse one --operation-key for the quote
@@ -290,12 +297,22 @@ enum CloudAgentsCommand {
/// Stable launch ID, reused by `work-launch` so a retry cannot start two computers.
#[arg(long)]
operation_key: String,
#[arg(
long,
help = "Whole seconds within the served offer; defaults to its maximum"
)]
seconds: Option<u64>,
#[arg(
long,
help = "Failed workspace ID to recover on a fresh computer with the same Work ID"
)]
recovery_from: Option<String>,
},
/// Start quoted Boat trial Work on EU compute (five minutes at most, $0 Codewhale charge).
/// Start quoted Work on EU compute using the reviewed computer balance or trial.
///
/// Requires the confirmation printed by `work-quote` and --confirm-eu-compute,
/// which agrees that repository code and Work files run on Boat's EU
/// compute. Re-running with the same --operation-key and --confirmation is
/// which agrees that repository code and Work files run on a cloud
/// computer in the EU. Re-running with the same --operation-key and --confirmation is
/// safe: it replays the launch instead of starting another computer.
WorkLaunch {
id: String,
@@ -307,6 +324,13 @@ enum CloudAgentsCommand {
/// Agree that repository code and Work files run on EU compute.
#[arg(long)]
confirm_eu_compute: bool,
#[arg(
long,
help = "Exact whole seconds printed by work-quote; legacy trial default is 300"
)]
seconds: Option<u64>,
#[arg(long, help = "Exact failed workspace ID used for the recovery quote")]
recovery_from: Option<String>,
},
}
@@ -327,11 +351,11 @@ enum CloudComputersCommand {
/// Save a Computer identity; compute is allocated only when it starts.
Create {
name: String,
/// Try Boat compute in the EU for this Computer.
#[arg(long, requires = "eu_compute_opt_in")]
boat_trial: bool,
/// Confirm that Boat trial code and files run on EU compute.
#[arg(long, requires = "boat_trial")]
/// Try cloud compute in the EU for this Computer.
#[arg(long, alias = "boat-trial", requires = "eu_compute_opt_in")]
computer_trial: bool,
/// Confirm that trial code and files run on EU compute.
#[arg(long, requires = "computer_trial")]
eu_compute_opt_in: bool,
},
/// Show one Computer; --json includes allowance and meter data.
@@ -340,7 +364,7 @@ enum CloudComputersCommand {
#[arg(long)]
json: bool,
},
/// Read Boat trial usage receipts for one Computer.
/// Read trial usage receipts for one Computer.
Usage {
id: String,
/// Print the full metering response.
@@ -628,6 +652,22 @@ struct AccountAgentWork {
status: String,
#[serde(default)]
objective: String,
#[serde(default)]
task_context_digest: String,
}
#[derive(Serialize)]
struct WorkTaskContext {
text: String,
#[serde(rename = "fileRefs")]
file_refs: Vec<WorkFileRef>,
}
#[derive(Serialize)]
struct WorkFileRef {
#[serde(rename = "fileId")]
file_id: String,
version: u64,
}
#[derive(Deserialize)]
@@ -1248,20 +1288,22 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
agent_id: &str,
objective: &str,
message_id: &str,
task_context: Option<&WorkTaskContext>,
) -> Result<AgentWorkMessageResponse> {
let agent_id = validate_resource_id(agent_id, "Agent")?;
let objective = objective.trim();
if objective.is_empty() || objective.chars().count() > 32_000 {
if objective.is_empty() || objective.encode_utf16().count() > 32_000 {
bail!("Work objective must contain 1-32000 characters");
}
let message_id = validate_operation_key(message_id)?;
let mut body = serde_json::json!({ "messageId": message_id, "text": objective });
if let Some(context) = task_context {
body["taskContext"] = serde_json::to_value(context)?;
}
let response = self.execute_authenticated(
HttpMethod::Post,
&format!("/api/agents/{agent_id}/messages"),
Some(json_body(&serde_json::json!({
"messageId": message_id,
"text": objective,
}))?),
Some(json_body(&body)?),
)?;
if ![200, 201, 202].contains(&response.status) {
return Err(response_error(&response));
@@ -1304,11 +1346,11 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
fn create_computer(
&self,
name: &str,
boat_trial: bool,
computer_trial: bool,
eu_compute_opt_in: bool,
) -> Result<AccountComputer> {
if boat_trial != eu_compute_opt_in {
bail!("Boat trial requires both --boat-trial and --eu-compute-opt-in");
if computer_trial != eu_compute_opt_in {
bail!("A computer trial requires both --computer-trial and --eu-compute-opt-in");
}
let name = validate_computer_name(name)?;
let response = self.execute_authenticated(
@@ -1316,8 +1358,8 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
"/api/computers",
Some(json_body(&ComputerCreateRequest {
name: &name,
provider: boat_trial.then_some("boat"),
boat_eu_compute_opt_in: boat_trial.then_some(true),
provider: computer_trial.then_some("boat"),
boat_eu_compute_opt_in: computer_trial.then_some(true),
})?),
)?;
let result: ComputerResponse = expect_json(response, &[200, 201])?;
@@ -2331,17 +2373,57 @@ fn run_agents<T: CloudTransport, W: Write>(
agent,
objective,
message_id,
context_file,
file_refs,
} => {
let objective = objective.trim();
if objective.is_empty() || objective.encode_utf16().count() > 32_000 {
bail!("Work objective must contain 1-32000 UTF-16 units");
}
validate_operation_key(&message_id)?;
let mut context = work::task_context(context_file.as_deref(), &file_refs)?;
if objective.encode_utf16().count() > 12_000 {
let context = context.get_or_insert_with(|| WorkTaskContext {
text: String::new(),
file_refs: Vec::new(),
});
context.text = if context.text.is_empty() {
objective.to_string()
} else {
format!("{objective}\n\n{}", context.text)
};
if context.text.encode_utf16().count() > 64_000 {
bail!("Task source context exceeds 64000 UTF-16 units");
}
}
let listing: AgentListResponse = serde_json::from_value(client.agents()?)
.context("The Codewhale service returned an invalid Agent list")?;
let selected = resolve_account_agent(&listing.agents, &agent)?;
let mut selected = resolve_account_agent(&listing.agents, &agent)?.clone();
if selected.project_id.is_empty() {
bail!(
"Agent {} needs a repository Project before it can do Work",
printable(&selected.name)
);
let response: AgentResponse = expect_json(
client.execute_authenticated(
HttpMethod::Post,
"/api/work-box",
Some(json_body(&serde_json::json!({}))?),
)?,
&[200],
)?;
if response.agent.id != selected.id || response.agent.project_id.is_empty() {
bail!(
"This Agent cannot use the account's repository-free task context. Use Agent {}",
printable(&response.agent.name)
);
}
selected = response.agent;
}
work::assign(client, out, &selected, &objective, &message_id)
work::assign(
client,
out,
&selected,
objective,
&message_id,
context.as_ref(),
)
}
CloudAgentsCommand::WorkStatus { id } => {
let result = client.agent_work_status(&id)?;
@@ -2367,20 +2449,39 @@ fn run_agents<T: CloudTransport, W: Write>(
if let Some(title) = run.get("title").and_then(|value| value.as_str()) {
writeln!(out, "Objective: {}", printable(title))?;
}
if let Some(workspace) = run
.get("workspaceId")
.and_then(|value| value.as_str())
.filter(|id| !id.is_empty())
{
writeln!(out, "Workspace ID: {}", printable(workspace))?;
}
Ok(())
}
CloudAgentsCommand::WorkCancel { id, queue, reason } => {
work::cancel(client, out, &id, queue, reason.as_deref())
}
CloudAgentsCommand::WorkResult { id, json } => work::result(client, out, &id, json),
CloudAgentsCommand::WorkQuote { id, operation_key } => {
work::quote(client, out, &id, &operation_key)
}
CloudAgentsCommand::WorkQuote {
id,
operation_key,
seconds,
recovery_from,
} => work::quote(
client,
out,
&id,
&operation_key,
seconds,
recovery_from.as_deref(),
),
CloudAgentsCommand::WorkLaunch {
id,
operation_key,
confirmation,
confirm_eu_compute,
seconds,
recovery_from,
} => {
let confirmation = if confirmation == "-" && confirm_eu_compute {
if io::stdin().is_terminal() {
@@ -2399,6 +2500,8 @@ fn run_agents<T: CloudTransport, W: Write>(
&operation_key,
&confirmation,
confirm_eu_compute,
seconds,
recovery_from.as_deref(),
)
}
}
@@ -2454,10 +2557,10 @@ fn run_computers<T: CloudTransport, W: Write>(
}
CloudComputersCommand::Create {
name,
boat_trial,
computer_trial,
eu_compute_opt_in,
} => {
let computer = client.create_computer(&name, boat_trial, eu_compute_opt_in)?;
let computer = client.create_computer(&name, computer_trial, eu_compute_opt_in)?;
write_computer(out, &computer)?;
writeln!(
out,
+58 -32
View File
@@ -241,18 +241,18 @@ fn parses_cloud_command_matrix_and_rejects_inline_keys() {
"computers",
"create",
"Trial",
"--boat-trial",
"--computer-trial",
"--eu-compute-opt-in"
]),
CloudCommand::Computers(CloudComputersArgs {
command: CloudComputersCommand::Create {
boat_trial: true,
computer_trial: true,
eu_compute_opt_in: true,
..
}
})
));
for lone_flag in ["--boat-trial", "--eu-compute-opt-in"] {
for lone_flag in ["--computer-trial", "--eu-compute-opt-in"] {
assert!(
Cli::try_parse_from([
"codewhale",
@@ -2311,7 +2311,7 @@ fn account_computers_boat_trial_and_usage_send_explicit_consent_and_read_receipt
"computers",
"create",
"Trial",
"--boat-trial",
"--computer-trial",
"--eu-compute-opt-in",
],
vec!["codewhale", "account", "computers", "usage", ID, "--json"],
@@ -2908,6 +2908,8 @@ fn account_agent_work_records_one_idempotent_request_without_allocating_compute(
agent: "Whale".into(),
objective: "Fix the build".into(),
message_id: "work-request-1".into(),
context_file: None,
file_refs: Vec::new(),
},
&client,
&machine::MachineKeyEnv::default(),
@@ -3409,12 +3411,20 @@ fn projects_step() -> Scripted {
)
}
fn boat_quote() -> serde_json::Value {
fn work_offer() -> serde_json::Value {
json!({
"mode": "trial", "sku": "task-small", "region": "eu", "minSeconds": 60,
"maxSeconds": 300, "model": { "provider": "deepseek", "model": "deepseek-flash" },
"funding": "trial_credit", "computeRemainingSeconds": null, "emptyWorkspace": false
})
}
fn work_quote() -> serde_json::Value {
json!({
"runner": {},
"quote": { "sku": "boat-small", "adapter": "boat", "target": "eu", "pricingStatus": "provider_trial" },
"quote": { "sku": "task-small", "target": "eu", "pricingStatus": "provider_trial" },
"disclosure": {
"funding": "provider_trial", "customerCreditsChargedUsd": 0,
"funding": "trial_credit", "customerCreditsChargedUsd": 0,
"providerCostEstimateUsd": 0.0207, "sandboxTargetRegion": "eu",
"euPlacementConsentRequired": true,
"modelInference": { "billing": "byok_external" },
@@ -3424,8 +3434,8 @@ fn boat_quote() -> serde_json::Value {
"token": "tok.abc-123_DEF", "expiresAt": "2026-09-30T12:00:00.000Z", "workRunId": WORK_ID
},
"confirmCopy": {
"title": "Run this five-minute Boat trial Work?",
"body": "Boat trial credit covers its computer time.\u{1b}[31m",
"title": "Run this five-minute trial Work?",
"body": "Trial credit covers its computer time.\u{1b}[31m",
"confirmLabel": "Start trial Work"
}
})
@@ -3437,7 +3447,7 @@ fn cloud_session() -> serde_json::Value {
"id": format!("session_{WORK_ID}"),
"run": { "id": WORK_ID, "state": "planning" },
"attempt": { "status": "accepted" },
"sandbox": { "provider": "boat", "providerId": "bx_abcd1234", "status": "running" },
"sandbox": { "status": "running" },
"sandboxTargetRegion": "eu",
"quote": { "customerCreditsChargedUsd": 0 },
"initialTurn": { "status": "queued" }
@@ -3453,7 +3463,7 @@ const QUOTE_ARGV: [&str; 7] = [
"--operation-key",
"launch-1",
];
const LAUNCH_ARGV: [&str; 10] = [
const LAUNCH_ARGV: [&str; 12] = [
"codewhale",
"account",
"agents",
@@ -3463,6 +3473,8 @@ const LAUNCH_ARGV: [&str; 10] = [
"launch-1",
"--confirmation",
"tok.abc-123_DEF",
"--seconds",
"300",
"--confirm-eu-compute",
];
@@ -3984,7 +3996,7 @@ fn work_result_summarises_evidence_pr_route_and_boat_usage() {
"Draft PR: https://github.com/octo-org/app/pull/42",
"Blockers: checks_failed",
"Model route: deepseek/deepseek-flash",
"Boat usage:",
"Computer usage:",
"Provider seconds: 212",
"Provider list price: $0.0011",
"Codewhale credits charged: $0",
@@ -4057,7 +4069,7 @@ fn work_result_never_shows_a_link_that_is_not_a_github_pull_request_and_survives
assert!(output.contains("Artifacts: none"));
assert!(output.contains("Draft PR: none"));
assert!(output.contains("Model route: not reported"));
assert!(!output.contains("Boat usage:"));
assert!(!output.contains("Computer usage:"));
// Another run's record is refused.
let transport = FakeTransport::new(vec![response(
200,
@@ -4110,7 +4122,8 @@ fn work_quote_builds_the_c5_request_from_served_records_and_prints_the_disclosur
"defaultRepoProvider": "github", "defaultRepo": "octo-org/app"
}] }),
),
response(200, boat_quote()),
response(200, work_offer()),
response(200, work_quote()),
]);
let (result, output) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
result.unwrap();
@@ -4124,16 +4137,17 @@ fn work_quote_builds_the_c5_request_from_served_records_and_prints_the_disclosur
format!("/api/runs/{WORK_ID}").as_str(),
"/api/agents",
"/api/projects",
"/api/sandbox/work-offer",
"/api/sandbox/launch-quote"
]
);
assert_eq!(requests[3].method, HttpMethod::Post);
assert_eq!(requests[4].method, HttpMethod::Post);
assert_eq!(
body_of(&requests[3]),
body_of(&requests[4]),
json!({
"workRunId": WORK_ID, "agentId": "agent-1", "projectId": "project-codewhale",
"repo": "octo-org/app", "provider": "github", "prompt": "Fix the build",
"runnerKind": "hosted", "sandboxSku": "boat-small", "estimatedSeconds": 300,
"runnerKind": "hosted", "sandboxSku": "task-small", "estimatedSeconds": 300,
"modelProvider": "deepseek", "model": "deepseek-flash",
"billingMode": "byok_external", "computeRegion": "eu",
"sandboxTargetRegion": "eu", "crossRegionSandboxOptIn": true,
@@ -4144,12 +4158,12 @@ fn work_quote_builds_the_c5_request_from_served_records_and_prints_the_disclosur
"Nothing has started and nothing is charged.",
"Repository: octo-org/app",
"deepseek/deepseek-flash",
"boat-small on Boat in the EU, up to 300 seconds",
"Funding: provider trial; Codewhale credits charged: $0",
"small cloud computer in the EU, up to 300 seconds",
"Funding: trial credit; Codewhale credits charged: $0",
"Provider cost estimate: $0.0207",
"EU placement",
"Run this five-minute Boat trial Work?",
"Boat trial credit covers its computer time.[31m",
"Run this five-minute trial Work?",
"Trial credit covers its computer time.[31m",
"Confirmation: tok.abc-123_DEF",
"Operation key: launch-1",
&format!(
@@ -4184,7 +4198,7 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
let (result, _) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
assert!(chain(&result.unwrap_err()).contains("no longer bound to this Work's Project"));
// A non-GitHub Work cannot use the Boat trial.
// A non-GitHub Work cannot use cloud Work.
let mut cnb = work_run("queued");
cnb["run"]["repoProvider"] = json!("cnb");
let transport = FakeTransport::new(vec![response(200, cnb)]);
@@ -4198,11 +4212,10 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
quote["disclosure"]["customerCreditsChargedUsd"] = json!(0.4)
},
|quote: &mut serde_json::Value| quote["disclosure"]["funding"] = json!("membership"),
|quote: &mut serde_json::Value| quote["quote"]["sku"] = json!("boat-large"),
|quote: &mut serde_json::Value| quote["quote"]["sku"] = json!("task-large"),
|quote: &mut serde_json::Value| {
quote["disclosure"]["sandboxTargetRegion"] = json!("us-west")
},
|quote: &mut serde_json::Value| quote["quote"]["adapter"] = json!("other"),
|quote: &mut serde_json::Value| {
quote["disclosure"]["computerTime"]["estimatedSeconds"] = json!(301)
},
@@ -4211,7 +4224,7 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
quote["disclosure"]["modelInference"]["billing"] = json!("managed_wallet")
},
] {
let mut quote = boat_quote();
let mut quote = work_quote();
tamper(&mut quote);
let transport = FakeTransport::new(vec![
response(200, work_run("queued")),
@@ -4223,6 +4236,7 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
"defaultRepoProvider": "github", "defaultRepo": "octo-org/app"
}] }),
),
response(200, work_offer()),
response(200, quote),
]);
let (result, output) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
@@ -4235,21 +4249,23 @@ fn work_quote_refuses_before_quoting_anything_it_cannot_launch_or_would_charge_f
reply(200, work_run("queued")),
agents_step(),
projects_step(),
reply(404, json!({ "code": "boat_work_trial_unavailable" })),
reply(200, work_offer()),
reply(404, json!({ "code": "work_vm_trial_unavailable" })),
]);
let (result, _) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
let message = chain(&result.unwrap_err());
assert!(message.contains("boat_work_trial_unavailable"));
assert!(message.contains("work_vm_trial_unavailable"));
assert!(message.contains("not available for this account"));
// The printed retry command must not carry remote shell metacharacters.
for token in ["tok.$(command)", "tok.`command`", "tok.abc;command"] {
let mut quote = boat_quote();
let mut quote = work_quote();
quote["confirmation"]["token"] = json!(token);
let transport = ScriptedTransport::new(vec![
reply(200, work_run("queued")),
agents_step(),
projects_step(),
reply(200, work_offer()),
reply(200, quote),
]);
let (result, output) = run_agent_command(&transport, &secrets, &QUOTE_ARGV);
@@ -4341,7 +4357,17 @@ fn work_confirmation_stdin_proof_reaches_only_the_confirmed_request() {
let client = CloudClient::new(&transport, &secrets, "default", DEFAULT_API_BASE);
let proof = work::read_confirmation(&b"consent.private-proof\n"[..]).unwrap();
let mut output = Vec::new();
work::launch(&client, &mut output, WORK_ID, "launch-stdin", &proof, true).unwrap();
work::launch(
&client,
&mut output,
WORK_ID,
"launch-stdin",
&proof,
true,
Some(300),
None,
)
.unwrap();
assert_eq!(
body_of(&transport.requests()[3])["launchQuoteConfirmation"],
proof
@@ -4395,7 +4421,7 @@ fn work_launch_requires_eu_consent_and_sends_the_confirmed_c5_request() {
json!({
"workRunId": WORK_ID, "agentId": "agent-1", "projectId": "project-codewhale",
"repo": "octo-org/app", "provider": "github", "prompt": "Fix the build",
"runnerKind": "hosted", "sandboxSku": "boat-small", "estimatedSeconds": 300,
"runnerKind": "hosted", "sandboxSku": "task-small", "estimatedSeconds": 300,
"modelProvider": "deepseek", "model": "deepseek-flash",
"billingMode": "byok_external", "computeRegion": "eu",
"sandboxTargetRegion": "eu", "crossRegionSandboxOptIn": true,
@@ -4405,10 +4431,10 @@ fn work_launch_requires_eu_consent_and_sends_the_confirmed_c5_request() {
})
);
for expected in [
"Work launched on bounded Boat trial compute.",
"Work launched on a cloud computer.",
&format!("Work ID: {WORK_ID}"),
"Status: planning",
"Computer: boat (running)",
"Computer: running",
"Compute region: eu",
"Codewhale credits charged: $0",
"Attempt: accepted",
+320 -65
View File
@@ -1,5 +1,5 @@
//! The account Work journey: assign Work, cancel it, read its outcome, quote
//! and launch bounded Boat trial compute, and connect a GitHub repository.
//! and launch offered cloud compute, and connect a GitHub repository.
//!
//! Everything here is a thin, honest client of the account control plane.
//! Remote text is untrusted: it only reaches the terminal through `printable`,
@@ -11,16 +11,62 @@ use serde_json::{Value, json};
use super::*;
/// The bounded Boat trial this CLI can start. These are the contract values
/// The cloud Work profile this CLI can start. These are the contract values
/// the control plane admits; anything else is refused by the server, so the
/// CLI states them instead of exposing knobs that could only fail.
const BOAT_TRIAL_SKU: &str = "boat-small";
const BOAT_TRIAL_SECONDS: u64 = 300;
const BOAT_TRIAL_MODEL_PROVIDER: &str = "deepseek";
const BOAT_TRIAL_MODEL: &str = "deepseek-flash";
const WORK_SKU: &str = "task-small";
const WORK_MODEL_PROVIDER: &str = "deepseek";
const WORK_MODEL: &str = "deepseek-flash";
const MAX_CONFIRMATION_BYTES: usize = 4096;
const MAX_CANCEL_REASON_CHARS: usize = 500;
pub(super) fn task_context(
path: Option<&Path>,
refs: &[String],
) -> Result<Option<WorkTaskContext>> {
if refs.len() > 10 {
bail!("Reference at most ten saved files per task");
}
let text = match path {
Some(path) => {
let mut bytes = Vec::new();
std::fs::File::open(path)
.context("Could not open the task source context file")?
.take(256_001)
.read_to_end(&mut bytes)?;
if bytes.len() > 256_000 {
bail!("Task source context exceeds 64000 UTF-16 units");
}
let text = String::from_utf8(bytes).context("Task source context must be UTF-8")?;
if text.encode_utf16().count() > 64_000 {
bail!("Task source context exceeds 64000 UTF-16 units");
}
text
}
None => String::new(),
};
let mut file_refs: Vec<WorkFileRef> = Vec::new();
for value in refs {
let (id, version) = value
.rsplit_once('@')
.ok_or_else(|| anyhow!("Saved file references must be FILE_ID@VERSION"))?;
let file_id = validate_account_file_id(id)?.to_string();
let version: u64 = version
.parse()
.context("Saved file version must be a positive integer")?;
if version == 0
|| version > 9_007_199_254_740_991
|| file_refs.iter().any(|ref_| ref_.file_id == file_id)
{
bail!(
"Saved file references need distinct file IDs and positive safe-integer versions"
);
}
file_refs.push(WorkFileRef { file_id, version });
}
Ok((!text.is_empty() || !file_refs.is_empty()).then_some(WorkTaskContext { text, file_refs }))
}
fn valid_confirmation(token: &str) -> bool {
!token.is_empty()
&& token.len() <= MAX_CONFIRMATION_BYTES
@@ -166,10 +212,11 @@ pub(super) fn assign<T: CloudTransport, W: Write>(
agent: &AccountAgent,
objective: &str,
message_id: &str,
task_context: Option<&WorkTaskContext>,
) -> Result<()> {
let message_id = validate_operation_key(message_id)?;
let receipt = client
.assign_agent_work(&agent.id, objective, message_id)
.assign_agent_work(&agent.id, objective, message_id, task_context)
.map_err(|err| {
if outcome_unknown(&err) {
err.context(format!(
@@ -185,6 +232,12 @@ pub(super) fn assign<T: CloudTransport, W: Write>(
if item.agent_id != agent.id {
bail!("The Codewhale service returned Work for a different Agent");
}
if let Some(context) = task_context {
let digest = crate::update::sha256_hex(&serde_json::to_vec(context)?);
if item.task_context_digest != digest {
return Err(CloudTransportError::new("The Work reply does not confirm the requested source context; replay the same message and context", std::io::Error::other("task context mismatch")).into());
}
}
items.push(item);
}
let intent = receipt.intent.as_str();
@@ -251,7 +304,7 @@ pub(super) fn assign<T: CloudTransport, W: Write>(
("actionable" | "queued", Some(first)) => {
writeln!(
out,
"Work is recorded, not started. To run it on bounded Boat trial compute, review a quote first:"
"Work is recorded, not started. To run it on a cloud computer, review a quote first:"
)?;
writeln!(
out,
@@ -417,11 +470,13 @@ fn usd(value: f64) -> String {
}
}
fn write_boat_usage<W: Write>(out: &mut W, envelope: &Value, result: &Value) -> Result<()> {
fn write_compute_usage<W: Write>(out: &mut W, envelope: &Value, result: &Value) -> Result<()> {
let block = [
at(result, &["computeUsage"]),
at(result, &["boatUsage"]),
at(result, &["providerUsage"]),
at(result, &["usage"]),
at(envelope, &["computeUsage"]),
at(envelope, &["boatUsage"]),
at(envelope, &["providerUsage"]),
at(envelope, &["usage"]),
@@ -432,7 +487,7 @@ fn write_boat_usage<W: Write>(out: &mut W, envelope: &Value, result: &Value) ->
let Some(block) = block else {
return Ok(());
};
writeln!(out, "Boat usage:")?;
writeln!(out, "Computer usage:")?;
let number = |keys: &[&str]| {
keys.iter()
.find_map(|key| block.get(*key).and_then(Value::as_f64))
@@ -620,7 +675,7 @@ fn write_work_result<W: Write>(
Some((provider, model)) => writeln!(out, "Model route: {provider}/{model}")?,
None => writeln!(out, "Model route: not reported by the account API")?,
}
write_boat_usage(out, envelope, result)?;
write_compute_usage(out, envelope, result)?;
match attempts {
Some(attempts) => {
let rows = at(attempts, &["attempts"])
@@ -687,6 +742,65 @@ struct LaunchTarget {
repo: String,
prompt: String,
state: String,
workspace_id: String,
}
struct WorkOffer {
funding: String,
min_seconds: u64,
max_seconds: u64,
remaining_seconds: Option<u64>,
empty_workspace: bool,
}
fn default_seconds(offer: &WorkOffer) -> u64 {
offer
.remaining_seconds
.map_or(offer.max_seconds, |remaining| {
remaining.min(offer.max_seconds)
})
}
fn load_work_offer<T: CloudTransport>(client: &CloudClient<'_, T>) -> Result<WorkOffer> {
let value: Value = expect_json(
client.execute_authenticated(HttpMethod::Get, "/api/sandbox/work-offer", None)?,
&[200],
)?;
let offer = value.get("offer").unwrap_or(&value);
let min_seconds = count_at(offer, &["minSeconds"]).unwrap_or(0);
let max_seconds = count_at(offer, &["maxSeconds"]).unwrap_or(0);
let funding = str_at(offer, &["funding"]).unwrap_or_default();
let mode_matches = matches!(
(str_at(offer, &["mode"]), funding),
(Some("trial"), "trial_credit") | (Some("open"), "compute_cu")
);
if !mode_matches
|| min_seconds == 0
|| max_seconds < min_seconds
|| max_seconds > 9_007_199_254_740_991
|| str_at(offer, &["sku"]) != Some(WORK_SKU)
|| str_at(offer, &["region"]) != Some("eu")
|| str_at(offer, &["model", "provider"]) != Some(WORK_MODEL_PROVIDER)
|| str_at(offer, &["model", "model"]) != Some(WORK_MODEL)
{
bail!("The account did not serve a supported cloud Work offer; nothing started");
}
let remaining_seconds = match offer.get("computeRemainingSeconds") {
None | Some(Value::Null) => None,
Some(value) => Some(
value
.as_u64()
.filter(|seconds| *seconds <= 9_007_199_254_740_991)
.ok_or_else(|| anyhow!("The computer balance in the Work offer is invalid"))?,
),
};
Ok(WorkOffer {
funding: funding.to_string(),
min_seconds,
max_seconds,
remaining_seconds,
empty_workspace: offer.get("emptyWorkspace").and_then(Value::as_bool) == Some(true),
})
}
/// The Work as the account serves it: the launch acts on this record, not on
@@ -710,7 +824,7 @@ fn load_launch_target<T: CloudTransport>(
let provider = field("repoProvider").to_ascii_lowercase();
if !provider.is_empty() && provider != "github" {
bail!(
"Boat trial Work supports GitHub repositories only; this Work uses {}",
"Cloud Work supports GitHub repositories only; this Work uses {}",
printable(&provider)
);
}
@@ -734,6 +848,7 @@ fn load_launch_target<T: CloudTransport>(
repo: field("repo"),
prompt,
state: field("state"),
workspace_id: field("workspaceId"),
})
}
@@ -755,14 +870,32 @@ fn verify_launch_authority<T: CloudTransport>(
.context("The Codewhale service returned an invalid Project list")?;
let project = projects
.projects
.iter()
.find(|project| project.id == target.project_id)
.ok_or_else(|| {
anyhow!(
"Project {} is not available on this account. Run `codewhale account projects list`",
target.project_id
)
})?;
.into_iter()
.find(|project| project.id == target.project_id);
let project = match project {
Some(project) => project,
None => {
let id = validate_resource_id(&target.project_id, "Project")?;
let reply: ProjectResponse = expect_json(
client.execute_authenticated(
HttpMethod::Get,
&format!("/api/projects/{id}"),
None,
)?,
&[200],
)?;
if reply.project.id != target.project_id {
bail!("The Codewhale service returned a different Work Project");
}
reply.project
}
};
if target.repo.is_empty()
&& project.default_repo.is_empty()
&& project.default_repo_provider.is_empty()
{
return Ok(());
}
if project.default_repo_provider != "github" || project.default_repo.trim().is_empty() {
bail!(
"Project {} has no GitHub repository, so its Work cannot be launched",
@@ -781,34 +914,53 @@ fn verify_launch_authority<T: CloudTransport>(
}
/// Contract C5: the launch-quote and cloud-sessions request for one bounded
/// Boat trial. The quote and the launch send the same fields so the signed
/// cloud task. The quote and the launch send the same fields so the signed
/// confirmation binds to exactly what starts.
fn launch_body(target: &LaunchTarget, operation_key: &str) -> Result<Value> {
Ok(json!({
fn launch_body(
target: &LaunchTarget,
operation_key: &str,
seconds: u64,
recovery_from: Option<&str>,
) -> Result<Value> {
if seconds == 0 || seconds > 9_007_199_254_740_991 {
bail!("Task time must be a positive safe-integer number of seconds");
}
let mut body = json!({
"workRunId": target.run_id,
"agentId": target.agent_id,
"projectId": target.project_id,
"repo": validate_github_repo(&target.repo)?,
"provider": "github",
"prompt": target.prompt,
"runnerKind": "hosted",
"sandboxSku": BOAT_TRIAL_SKU,
"estimatedSeconds": BOAT_TRIAL_SECONDS,
"modelProvider": BOAT_TRIAL_MODEL_PROVIDER,
"model": BOAT_TRIAL_MODEL,
"sandboxSku": WORK_SKU,
"estimatedSeconds": seconds,
"modelProvider": WORK_MODEL_PROVIDER,
"model": WORK_MODEL,
"billingMode": "byok_external",
"computeRegion": "eu",
"sandboxTargetRegion": "eu",
"crossRegionSandboxOptIn": true,
"operationKey": operation_key,
}))
});
if target.repo.is_empty() {
body["workspaceSource"] = json!("empty");
} else {
body["repo"] = json!(validate_github_repo(&target.repo)?);
body["provider"] = json!("github");
}
if let Some(previous) = recovery_from {
body["recovery"] = json!(true);
body["expectedPreviousWorkspaceId"] = json!(validate_resource_id(previous, "Workspace")?);
}
Ok(body)
}
/// What to do next for a control-plane code the launch flow knows about.
fn launch_hint(code: &str) -> Option<&'static str> {
Some(match code {
"boat_work_trial_unavailable" => "Boat trial Work is not available for this account",
"boat_work_provider_unavailable" => "Boat compute is not configured on this Codewhale API",
"work_vm_trial_unavailable" => "Cloud Work is not available for this account",
"work_vm_provider_unavailable" => {
"Cloud computers are not configured on this Codewhale API"
}
"hosted_launch_quote_required"
| "hosted_launch_quote_invalid"
| "hosted_launch_quote_expired"
@@ -837,18 +989,50 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
out: &mut W,
id: &str,
operation_key: &str,
seconds: Option<u64>,
recovery_from: Option<&str>,
) -> Result<()> {
let id = validate_work_uuid(id)?;
let operation_key = validate_operation_key(operation_key)?;
let mut target = load_launch_target(client, id)?;
if target.state != "queued" {
let recovery_from = recovery_from
.map(|id| validate_resource_id(id, "Workspace"))
.transpose()?;
if recovery_from.is_some() {
if target.state != "failed" || recovery_from != Some(target.workspace_id.as_str()) {
bail!(
"Recovery needs the exact failed Work workspace; refresh work-status before reviewing a replacement"
);
}
} else if target.state != "queued" {
bail!(
"Work is {}, and only queued Work can be quoted for launch. Run `codewhale account agents work-status {id}`",
printable(&target.state)
);
}
verify_launch_authority(client, &mut target)?;
let body = launch_body(&target, operation_key)?;
if recovery_from.is_none() {
verify_launch_authority(client, &mut target)?;
}
let offer = load_work_offer(client)?;
let seconds = seconds.unwrap_or_else(|| default_seconds(&offer));
if seconds < offer.min_seconds || seconds > offer.max_seconds {
bail!(
"Task time must be within the served offer: {}-{} seconds",
offer.min_seconds,
offer.max_seconds
);
}
if target.repo.is_empty() && !offer.empty_workspace {
bail!("The account's Work offer does not support an empty workspace");
}
if offer.funding == "compute_cu"
&& offer
.remaining_seconds
.is_some_and(|remaining| remaining < seconds)
{
bail!("The computer balance does not cover the reviewed task time");
}
let body = launch_body(&target, operation_key, seconds, recovery_from)?;
let response = client.execute_authenticated(
HttpMethod::Post,
"/api/sandbox/launch-quote",
@@ -863,48 +1047,87 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
}
let quote: Value = parse_json_body(&response.body)?;
// This command starts only the $0 EU Boat trial. If the service quotes
// anything else, refuse to hand out a confirmation for it.
let seconds = count_at(&quote, &["disclosure", "computerTime", "estimatedSeconds"]);
let quoted_seconds = count_at(&quote, &["disclosure", "computerTime", "estimatedSeconds"]);
let expected = at(&quote, &["disclosure", "funding"]).and_then(Value::as_str)
== Some("provider_trial")
== Some(offer.funding.as_str())
&& at(&quote, &["disclosure", "customerCreditsChargedUsd"]).and_then(Value::as_f64)
== Some(0.0)
&& str_at(&quote, &["quote", "sku"]) == Some(BOAT_TRIAL_SKU)
&& str_at(&quote, &["quote", "adapter"]) == Some("boat")
&& str_at(&quote, &["quote", "sku"]) == Some(WORK_SKU)
&& str_at(&quote, &["disclosure", "sandboxTargetRegion"]) == Some("eu")
&& str_at(&quote, &["disclosure", "modelInference", "billing"]) == Some("byok_external")
&& seconds == Some(BOAT_TRIAL_SECONDS);
&& quoted_seconds == Some(seconds)
&& (offer.funding != "compute_cu"
|| (at(
&quote,
&["disclosure", "computerTime", "coveredByComputeBalance"],
)
.and_then(Value::as_bool)
== Some(true)
&& count_at(
&quote,
&["disclosure", "computerTime", "remainingAfterSeconds"],
)
.is_some()))
&& (if target.repo.is_empty() {
str_at(&quote, &["workspace", "source"]) == Some("empty")
&& at(&quote, &["workspace", "clone"]).and_then(Value::as_bool) == Some(false)
&& at(&quote, &["workspace", "repository"]).is_none_or(Value::is_null)
} else {
str_at(&quote, &["workspace", "source"]) != Some("empty")
});
if !expected {
bail!(
"The Codewhale service quoted something other than the $0 five-minute EU Boat trial. Refusing to print a confirmation for it; nothing started"
"The Codewhale service quoted different terms from the reviewed Work offer. Refusing to print a confirmation for it; nothing started"
);
}
let token = str_at(&quote, &["confirmation", "token"]).unwrap_or_default();
if !valid_confirmation(token)
|| str_at(&quote, &["confirmation", "workRunId"]).is_some_and(|quoted| quoted != id)
|| recovery_from.is_some_and(|previous| {
str_at(&quote, &["confirmation", "workRunId"]) != Some(id)
|| at(&quote, &["confirmation", "recovery"]).and_then(Value::as_bool) != Some(true)
|| str_at(&quote, &["confirmation", "expectedPreviousWorkspaceId"])
!= Some(previous)
})
{
bail!("The Codewhale service returned an unusable launch confirmation");
}
let seconds = BOAT_TRIAL_SECONDS;
writeln!(
out,
"Boat trial Work quote. Nothing has started and nothing is charged."
"Cloud Work quote. Nothing has started and nothing is charged."
)?;
writeln!(out, "Work ID: {id}")?;
writeln!(out, "Repository: {}", printable(&target.repo))?;
if target.repo.is_empty() {
writeln!(out, "Workspace: empty; no repository will be cloned")?;
} else {
writeln!(out, "Repository: {}", printable(&target.repo))?;
}
if let Some(previous) = recovery_from {
writeln!(
out,
"Recovery: fresh computer; Work ID retained; previous workspace {}",
printable(previous)
)?;
}
writeln!(
out,
"Model: {BOAT_TRIAL_MODEL_PROVIDER}/{BOAT_TRIAL_MODEL} with your own DeepSeek key; DeepSeek bills your BYOK usage directly"
"Model: {WORK_MODEL_PROVIDER}/{WORK_MODEL} with your own DeepSeek key; DeepSeek bills your BYOK usage directly"
)?;
writeln!(
out,
"Computer: {BOAT_TRIAL_SKU} on Boat in the EU, up to {seconds} seconds"
)?;
writeln!(
out,
"Funding: provider trial; Codewhale credits charged: $0"
"Computer: small cloud computer in the EU, up to {seconds} seconds"
)?;
if offer.funding == "compute_cu" {
writeln!(
out,
"Funding: account computer balance; up to {seconds} seconds; Codewhale credits charged: $0"
)?;
if let Some(remaining) = offer.remaining_seconds {
writeln!(out, "Computer balance at review: {remaining} seconds")?;
}
} else {
writeln!(out, "Funding: trial credit; Codewhale credits charged: $0")?;
}
if let Some(estimate) =
at(&quote, &["disclosure", "providerCostEstimateUsd"]).and_then(Value::as_f64)
{
@@ -912,7 +1135,7 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
}
writeln!(
out,
"EU placement: Codewhale admission attestation; Boat reports no region field. Repository code and Work files are admitted to EU compute."
"EU placement: Codewhale admission attestation. Repository code and Work files are admitted to EU compute."
)?;
if let Some(title) = text_at(&quote, &["confirmCopy", "title"]) {
writeln!(out, "{title}")?;
@@ -928,9 +1151,12 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
}
writeln!(out, "Confirmation: {token}")?;
writeln!(out, "Operation key: {operation_key}")?;
let recovery_args = recovery_from
.map(|previous| format!(" --recovery-from {previous}"))
.unwrap_or_default();
writeln!(
out,
"To start: codewhale account agents work-launch {id} --operation-key {operation_key} --confirmation {token} --confirm-eu-compute"
"To start: codewhale account agents work-launch {id} --operation-key {operation_key} --confirmation {token} --confirm-eu-compute --seconds {seconds}{recovery_args}"
)?;
Ok(())
}
@@ -943,10 +1169,12 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
operation_key: &str,
confirmation: &str,
confirm_eu_compute: bool,
seconds: Option<u64>,
recovery_from: Option<&str>,
) -> Result<()> {
if !confirm_eu_compute {
bail!(
"Boat trial Work runs your repository code and Work files on Boat's EU compute. Nothing was sent; re-run with --confirm-eu-compute to agree"
"Cloud Work runs your source context and Work files on a cloud computer in the EU. Nothing was sent; re-run with --confirm-eu-compute to agree"
);
}
let id = validate_work_uuid(id)?;
@@ -960,10 +1188,14 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
// Any other state is a replay of a launch that already ran (or a Work that
// cannot launch); the control plane's operation ledger decides which, so
// the same command stays safe to repeat after a lost reply.
if target.state == "queued" {
if target.state == "queued" && recovery_from.is_none() {
verify_launch_authority(client, &mut target)?;
}
let mut body = launch_body(&target, operation_key)?;
let seconds = match seconds {
Some(seconds) => seconds,
None => default_seconds(&load_work_offer(client)?),
};
let mut body = launch_body(&target, operation_key, seconds, recovery_from)?;
body["launchQuoteConfirmation"] = json!(confirmation);
body["customerEuPlacementConsent"] = json!(true);
@@ -1029,7 +1261,35 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
"The Codewhale service launched a different Work than requested. Run `codewhale account agents work-status {id}` before doing anything else"
);
}
writeln!(out, "Work launched on bounded Boat trial compute.")?;
if let Some(previous) = recovery_from {
let workspace = str_at(session, &["workspace", "id"]);
let matches = workspace.is_some_and(|workspace| {
workspace != previous
&& validate_resource_id(workspace, "Workspace").is_ok()
&& str_at(session, &["attempt", "kind"]) == Some("recovery")
&& str_at(session, &["attempt", "workRunId"]) == Some(id)
&& str_at(session, &["attempt", "workspaceId"]) == Some(workspace)
&& str_at(session, &["attempt", "previousWorkspaceId"]) == Some(previous)
&& str_at(session, &["recovery", "workId"]) == Some(id)
&& str_at(session, &["recovery", "workspaceId"]) == Some(workspace)
&& str_at(session, &["recovery", "previousWorkspaceId"]) == Some(previous)
});
if !matches {
return Err(refused(
CloudTransportError::new(
"The reply does not confirm the reviewed replacement attempt",
std::io::Error::other("recovery identity mismatch"),
)
.into(),
));
}
writeln!(
out,
"Work recovered on a fresh cloud computer; the original Work ID is retained."
)?;
} else {
writeln!(out, "Work launched on a cloud computer.")?;
}
writeln!(out, "Work ID: {id}")?;
writeln!(
out,
@@ -1039,13 +1299,8 @@ pub(super) fn launch<T: CloudTransport, W: Write>(
if let Some(session_id) = text_at(session, &["id"]) {
writeln!(out, "Session: {session_id}")?;
}
if let Some(provider) = text_at(session, &["sandbox", "provider"]) {
writeln!(
out,
"Computer: {provider} ({})",
text_at(session, &["sandbox", "status"])
.unwrap_or_else(|| "status unknown".to_string())
)?;
if let Some(status) = text_at(session, &["sandbox", "status"]) {
writeln!(out, "Computer: {status}")?;
}
if let Some(region) = text_at(session, &["sandboxTargetRegion"]) {
writeln!(out, "Compute region: {region}")?;
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.