Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
eff5f79
fix(fleet): preserve Cloud sandbox identity across cutover
Sep 8, 2026
4256581
fix(cloud): reject untrusted sandbox cleanup identity
Sep 8, 2026
0763beb
fix(fleet): preserve legacy sandbox naming compatibility
Sep 8, 2026
0eb71ff
fix(cloud): verify stable sandbox node names
Sep 8, 2026
b09797c
fix(pr-proof): retain Cloud failure diagnostics
Sep 8, 2026
c8afa97
chore: restore unreleased patch metadata
Sep 8, 2026
cddb9d9
chore: mark sandbox replay as minor
Sep 8, 2026
155af79
fix(pr-proof): bound multibyte diagnostics
Sep 8, 2026
1b160f3
fix(cloud): retain replay identity after failed provisioning
Sep 8, 2026
814579a
fix(pr-proof): sanitize Cloud status diagnostics
Sep 8, 2026
1925c1a
fix(pr-proof): redact all Cloud diagnostics
Sep 8, 2026
e22d2b3
fix(pr-proof): hide malformed status payloads
Sep 8, 2026
02155c4
fix(proof): retry malformed cloud status polls
Sep 8, 2026
a787b1f
fix(cloud): preserve legacy sandbox responses
Sep 8, 2026
17b075f
fix(pr-proof): redact credential prefixes safely
Sep 8, 2026
2dcc6cb
test: align Agent37 base proof with legacy response
Sep 8, 2026
02c907b
test: harden Agent37 proof diagnostics
Sep 8, 2026
ee27443
fix: harden sandbox failure and proof redaction
Sep 8, 2026
462907f
fix: keep proof redaction linear
Sep 8, 2026
56c5da5
fix: contain proof output transform failures
Sep 8, 2026
9942464
fix: preserve transform failure rejection
Sep 8, 2026
a5dca89
fix: kill descendants on final transform failure
Sep 8, 2026
7703e7f
test: generate long proof credential in child
Sep 8, 2026
0bfc175
fix: preserve unknown sandbox replay identity
Sep 8, 2026
0a41237
test: avoid dynamic code in redaction proof
Sep 8, 2026
515d543
fix: redact cross-stream credential fragments
Sep 8, 2026
afadcf6
style: auto-format with Prettier
github-actions[bot] Sep 8, 2026
cf64da4
fix: mask cross-stream credential fragments atomically
Sep 8, 2026
f7163e7
feat: route Agent37 sandboxes through isolated Relaycast
Sep 9, 2026
cbe0ff6
style: auto-format with Prettier
github-actions[bot] Sep 9, 2026
86f7466
ci: verify Agent37 routing after formatting
Sep 9, 2026
97263a8
fix(ci): use resolvable Relaycast smoke origin
Sep 9, 2026
f93f050
fix(security): enforce trusted fleet Relaycast routes
Sep 9, 2026
4563d75
style: auto-format with Prettier
github-actions[bot] Sep 9, 2026
e80584e
test(relayflow): model isolated Relaycast target
Sep 9, 2026
794753d
fix(fleet): preserve replay and workspace identity
Sep 9, 2026
9fdc4c6
test(smoke): bound startup within workspace lease
Sep 9, 2026
2635252
fix: bind cloud sandbox routing safely
Sep 9, 2026
1e1a649
fix: serialize project target persistence
Sep 9, 2026
bfda6e6
fix(relaycast): retry only precommit failures
Sep 9, 2026
7bc5edf
fix: close relayflow transport and proof review gaps
Sep 10, 2026
5026e73
fix: address final cutover review gaps
Sep 10, 2026
f419ec6
fix(cli): close Agent37 routing review gaps
Sep 10, 2026
f7067f3
fix(cli): preserve isolated Agent37 routing
Sep 10, 2026
18efa8d
fix(cli): pair persisted relaycast route credentials
Sep 10, 2026
dce672c
fix(ci): prove workspace deletion after ambiguous response
Sep 10, 2026
3a3b7c4
fix(ci): trust committed workspace deletion
Sep 10, 2026
9c8d1f5
fix(ci): retry transient cleanup verification
Sep 10, 2026
2f2701c
fix(broker): honor registration cooldown
Sep 10, 2026
17875d5
fix(ci): parse retry-after portably
Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/package-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -354,5 +354,4 @@ jobs:
- name: Smoke standalone lifecycle
env:
AGENT_RELAY_STARTUP_DEBUG: 1
RELAY_WORKSPACE_KEY: ${{ secrets.RELAY_CI_WORKSPACE_KEY }}
run: bash scripts/ci-standalone-smoke.sh "$STANDALONE_CLI" "$STANDALONE_BROKER"
2 changes: 0 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1521,8 +1521,6 @@ jobs:
echo "✓ Uncompressed binary verified"

- name: Smoke standalone lifecycle
env:
RELAY_WORKSPACE_KEY: ${{ secrets.RELAY_CI_WORKSPACE_KEY }}
run: |
if [ "$(uname -m)" != "${{ matrix.expected_arch }}" ]; then
echo "Skipping lifecycle smoke for ${{ matrix.expected_arch }} on $(uname -m) host"
Expand Down
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,21 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Minor]

### Changed

- Operators can keep using custom `--sandbox-name` values with `agent-relay fleet spawn --sandbox`; launches without a custom name generate a stable `sbx_<UUID>` identity automatically, while only `--sandbox-id` replay requires the matching deterministic lowercase `fleet-sandbox-<UUID>` name.

### Fixed

- Ambiguous Cloud sandbox responses retain their stable `--sandbox-id` for replay instead of automatically deleting an allocation whose outcome is unknown.
- Persisting an Agent37 Relaycast target keeps the canonical Cloud workspace key as the durable selector and stores the route-scoped transport credential separately, so later commands can reuse the original explicit key.
- Relaycast credentials and origins now resolve as one transport pair for attach, observer, Fleet, and Relayfile provisioning commands; stale sandbox responses cannot overwrite a project workspace that was rebound while provisioning was in flight.
- Legacy non-Agent37 Cloud sandbox responses remain usable when they omit the newer Relaycast target, while any target Cloud does return is verified and persisted for both newly provisioned and reused providers.
- Standalone package smoke workspaces remain valid for five minutes, and startup overrides are capped at four minutes so shutdown and cleanup verification always retain a full-minute lease margin.
- Startup retries now require Relaycast's typed pre-commit storage-admission codes instead of replaying every 5xx response from an unkeyed workspace or agent-registration request.
- Agent registration retries now honor Relaycast's typed cooldown (capped at one minute) instead of immediately retrying through the same write-capacity window, while a three-minute aggregate deadline prevents hung requests from stranding callers.

## [11.10.4] - 2026-09-08

Expand Down
156 changes: 147 additions & 9 deletions crates/broker/src/relaycast/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -933,17 +933,26 @@ const RELAYCAST_HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_se
/// 34099838274 lost three jobs to exactly that.
const TRANSIENT_STARTUP_RETRY_BACKOFFS_MS: [u64; 2] = [200, 400];

/// The server-side statuses worth replaying: 500, 502, 503, 504. A 501 is a
/// contract mismatch rather than a transient and is deliberately excluded, as
/// are transport errors — a timed-out `POST /v1/agents` may already have
/// created the agent, and re-sending it is the AR-448 duplicate shape.
/// Replay only server failures whose typed error code establishes that the
/// request failed at the storage-admission boundary. Retrying every 5xx by
/// status is unsafe for these unkeyed POSTs: an application-level 503 or a 500
/// returned after commit could create the AR-448 duplicate shape when replayed.
///
/// `database_overloaded` is Relaycast's D1 admission failure and
/// `workspace_storage_unavailable` is emitted when workspace persistence never
/// starts. Both are explicitly pre-commit contracts. Transport errors remain
/// terminal because a timed-out request may already have committed.
fn is_transient_server_error(error: &RelayError) -> bool {
matches!(
error,
RelayError::Api {
code,
status: 500 | 502 | 503 | 504,
..
}
} if matches!(
code.trim(),
"database_overloaded" | "workspace_storage_unavailable"
)
)
}

Expand Down Expand Up @@ -1535,10 +1544,10 @@ mod tests {

use super::{
hash_identity_key, is_agent_token_invalid, is_agent_token_invalid_anyhow,
is_agent_token_invalid_code, reclaim_legacy_identity, relay_error_to_anyhow,
relay_request_with_timeout, resolve_relaycast_base_url, retry_transient_relay_error,
stable_node_identity_key, AuthClient, AuthHttpError, CredentialCache,
AGENT_TOKEN_INVALID_CODE, DEFAULT_RELAYCAST_BASE_URL,
is_agent_token_invalid_code, is_transient_server_error, reclaim_legacy_identity,
relay_error_to_anyhow, relay_request_with_timeout, resolve_relaycast_base_url,
retry_transient_relay_error, stable_node_identity_key, AuthClient, AuthHttpError,
CredentialCache, AGENT_TOKEN_INVALID_CODE, DEFAULT_RELAYCAST_BASE_URL,
};
use relaycast::RelayError;

Expand Down Expand Up @@ -2076,6 +2085,135 @@ mod tests {
}
}

#[tokio::test]
async fn unknown_application_503_is_not_retried() {
use std::sync::atomic::{AtomicUsize, Ordering};

let calls = AtomicUsize::new(0);
let error = retry_transient_relay_error("probing an application failure", || {
calls.fetch_add(1, Ordering::SeqCst);
async {
Err::<(), _>(RelayError::api(
"application_temporarily_unavailable",
"the application rejected the request",
503,
))
}
})
.await
.expect_err("an unclassified 503 must not replay an unkeyed POST");

assert_eq!(calls.load(Ordering::SeqCst), 1);
match error {
RelayError::Api {
code,
status,
attempts,
..
} => {
assert_eq!(code, "application_temporarily_unavailable");
assert_eq!(status, 503);
assert_eq!(attempts, 1);
}
other => panic!("expected the original terminal API error, got {other}"),
}
}

#[test]
fn transient_retry_requires_both_a_safe_code_and_server_status() {
assert!(is_transient_server_error(&RelayError::api(
"database_overloaded",
"overloaded",
503,
)));
assert!(is_transient_server_error(&RelayError::api(
"workspace_storage_unavailable",
"storage unavailable",
502,
)));
assert!(!is_transient_server_error(&RelayError::api(
"database_overloaded",
"conflict",
409,
)));
assert!(!is_transient_server_error(&RelayError::api(
"unknown_error",
"server failure",
500,
)));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unknown_application_503_exits_registration_without_a_replay() {
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};

use axum::{
extract::State, http::StatusCode as AxumStatusCode, routing::post, Json, Router,
};

async fn reject_registration(
State(attempts): State<Arc<AtomicUsize>>,
) -> (AxumStatusCode, Json<Value>) {
attempts.fetch_add(1, Ordering::SeqCst);
(
AxumStatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"ok": false,
"error": {
"code": "application_temporarily_unavailable",
"message": "The request could not be completed."
}
})),
)
}

let _env_guard = clear_relay_env();
// SAFETY: test-only, serialized by RELAY_ENV_MUTEX via clear_relay_env.
unsafe {
std::env::set_var("AGENT_RELAY_WORKSPACE_KEY", "rk_live_env");
}

let attempts = Arc::new(AtomicUsize::new(0));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server_state = attempts.clone();
let server = tokio::spawn(async move {
axum::serve(
listener,
Router::new()
.route("/v1/agents", post(reject_registration))
.with_state(server_state),
)
.await
});

let error = AuthClient::new(Some(format!("http://{address}")))
.startup_session(Some("lead"))
.await
.expect_err("an unclassified application 503 must be terminal");

let message = format!("{error:#}");
assert!(
message.contains("application_temporarily_unavailable"),
"{message}"
);
assert!(message.contains("attempts: 1"), "{message}");
assert_eq!(
attempts.load(Ordering::SeqCst),
1,
"an unkeyed registration must not be replayed on an unknown 503"
);

server.abort();
// SAFETY: test-only, serialized by RELAY_ENV_MUTEX via clear_relay_env.
unsafe {
std::env::remove_var("AGENT_RELAY_WORKSPACE_KEY");
}
}

/// The retry budget is bounded, and the terminal error still carries the
/// registration diagnostics PR #1673 added. An operator chasing a sandbox
/// worker that spawns but never registers (AgentWorkforce/cloud#3401)
Expand Down
Loading
Loading