-
Notifications
You must be signed in to change notification settings - Fork 69
fix(broker): bump relaycast to 7.0.0 and reclaim agent names by audited takeover #1596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
221f9c4
c8b383c
df086d3
723e5f8
7146558
1ba8c47
5c87446
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,9 @@ | ||
| use anyhow::{Context, Result}; | ||
| use chrono::{DateTime, Utc}; | ||
| use relaycast::{CreateAgentRequest, RelayCast, RelayCastOptions, RelayError}; | ||
| use relaycast::{ | ||
| CreateAgentRequest, RecoverAgentRequest, RelayCast, RelayCastOptions, RelayError, | ||
| WorkspaceProvenance, | ||
| }; | ||
| use reqwest::StatusCode; | ||
| use serde::{Deserialize, Serialize}; | ||
| use serde_json::Value; | ||
|
|
@@ -458,9 +461,16 @@ impl AuthClient { | |
| let api_key = normalize_workspace_key(&cached.api_key) | ||
| .context("cached api_key is not a valid workspace key")?; | ||
|
|
||
| // Rotation is authenticated as the agent itself (relaycast 7.0.0): it | ||
| // needs this agent's current token, not the workspace key. Without a | ||
| // cached token there is nothing to roll over, and silently falling back | ||
| // to the workspace key is what used to fail as an opaque 401. | ||
| let agent_token = cached.agent_token.as_deref().context( | ||
| "cannot rotate token without the agent's current token; re-register or recover the identity", | ||
| )?; | ||
| let relay = build_relay_client(&api_key, self.base_url.as_deref())?; | ||
| let result = relay | ||
| .rotate_agent_token(agent_name) | ||
| .rotate_agent_token(agent_name, agent_token) | ||
| .await | ||
| .map_err(relay_error_to_anyhow)?; | ||
| let token = result.token; | ||
|
|
@@ -696,7 +706,13 @@ impl AuthClient { | |
| } | ||
|
|
||
| async fn create_workspace(&self, name: &str) -> Result<(String, String)> { | ||
| match RelayCast::create_workspace(name, self.base_url.as_deref()).await { | ||
| match RelayCast::create_workspace( | ||
| name, | ||
| self.base_url.as_deref(), | ||
| WorkspaceProvenance::sdk(), | ||
| ) | ||
| .await | ||
| { | ||
| Ok(result) => Ok((result.workspace_id, result.api_key)), | ||
| Err(error) if is_workspace_name_conflict(&error) => { | ||
| let suffix = Uuid::new_v4().simple().to_string(); | ||
|
|
@@ -706,9 +722,13 @@ impl AuthClient { | |
| fallback_name = %fallback_name, | ||
| "workspace already exists; retrying with a fresh fallback name" | ||
| ); | ||
| let result = RelayCast::create_workspace(&fallback_name, self.base_url.as_deref()) | ||
| .await | ||
| .map_err(relay_error_to_anyhow)?; | ||
| let result = RelayCast::create_workspace( | ||
| &fallback_name, | ||
| self.base_url.as_deref(), | ||
| WorkspaceProvenance::sdk(), | ||
| ) | ||
| .await | ||
| .map_err(relay_error_to_anyhow)?; | ||
| Ok((result.workspace_id, result.api_key)) | ||
| } | ||
| Err(error) => Err(relay_error_to_anyhow(error)), | ||
|
|
@@ -735,7 +755,7 @@ impl AuthClient { | |
| .map(ToOwned::to_owned) | ||
| .unwrap_or_else(|| format!("agent-{}", Uuid::new_v4().simple())); | ||
|
|
||
| admit_agent_registration(&relay, &name, agent_type, identity_key).await | ||
| admit_agent_registration(&relay, workspace_key, &name, agent_type, identity_key).await | ||
| } | ||
|
|
||
| pub async fn workspace_key_is_live(&self, workspace_key: &str) -> Result<bool> { | ||
|
|
@@ -984,6 +1004,9 @@ pub(crate) fn identity_key_fingerprint(raw: &str) -> String { | |
| /// collision is rejected. | ||
| async fn admit_agent_registration( | ||
| relay: &RelayCast, | ||
| // Needed only for the pre-8.2.0 rotate fallback below, where the workspace | ||
| // key is still an accepted credential for reclaiming an agent's token. | ||
| workspace_key: &str, | ||
| name: &str, | ||
| agent_type: Option<&str>, | ||
| identity_key: Option<&str>, | ||
|
|
@@ -1031,14 +1054,58 @@ async fn admit_agent_registration( | |
| })); | ||
| } | ||
|
|
||
| // Reclaiming a crashed work unit's identity. We have just proven | ||
| // ownership via the work-unit identity key but do not hold the | ||
| // agent's token — which is precisely the case `recover` exists for | ||
| // (relaycast 7.0.0). Rotation is self-rollover only and cannot serve | ||
| // this path: attempting it with the workspace key is what produced | ||
| // an opaque `401 Agent token required` here. | ||
| let token_response = relay | ||
| .rotate_agent_token(&existing.name) | ||
| .await | ||
| .map_err(relay_error_to_anyhow)?; | ||
| .recover_agent( | ||
| &existing.name, | ||
| RecoverAgentRequest { | ||
| expected_agent_id: existing.id.clone(), | ||
| recovery_proof: None, | ||
| reason: Some( | ||
| "work-unit identity key proved ownership after a crash".to_string(), | ||
| ), | ||
| // Hashed, never raw: the identity key is a replayable | ||
| // credential and the surrounding code hashes it before | ||
| // anything workspace-readable. An audit record is | ||
| // workspace-readable, so it gets the same treatment — | ||
| // still correlatable, not replayable. | ||
| session_ref: identity_key.map(hash_identity_key), | ||
| node_id: None, | ||
|
Comment on lines
+1066
to
+1078
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🌐 Web query:
💡 Result: In the Relaycast Rust crate version 7.0.0, the Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target function ---'
sed -n '1000,1105p' crates/broker/src/relaycast/auth.rs
printf '%s\n' '--- relevant RelayCast symbols and dependency declarations ---'
rg -n --glob '*.rs' --glob 'Cargo.toml' --glob 'Cargo.lock' \
'RecoverAgentRequest|recover_agent|recovery_proof|session_ref|admit_agent_registration|relaycast' .
printf '%s\n' '--- repository files near RelayCast integration ---'
git ls-files | rg '(^|/)(Cargo.toml|Cargo.lock|relaycast|auth\.rs)$' | head -200Repository: AgentWorkforce/relay Length of output: 50376 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- relaycast dependency ---'
rg -n -A12 -B4 'name = "relaycast"' Cargo.lock
rg -n -A8 -B4 'relaycast' --glob 'Cargo.toml' .
printf '%s\n' '--- focused auth tests ---'
sed -n '1580,1775p' crates/broker/src/relaycast/auth.rs
sed -n '1800,2005p' crates/broker/src/relaycast/auth.rs
printf '%s\n' '--- recovery and request-body references ---'
rg -n -C3 'RecoverAgentRequest|recover_agent|recovery_proof|expected_agent_id|session_ref' \
crates/broker/src/relaycast crates/broker/tests tests 2>/dev/null | head -300Repository: AgentWorkforce/relay Length of output: 44046 🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl --fail --silent --show-error \
https://crates.io/api/v1/crates/relaycast/7.0.0/download \
-o "$tmpdir/relaycast.tgz"
tar -xzf "$tmpdir/relaycast.tgz" -C "$tmpdir"
src="$(find "$tmpdir" -maxdepth 2 -type d -name 'relaycast-7.0.0' -print -quit)"
printf '%s\n' '--- request and recovery definitions ---'
rg -n -C8 'RecoverAgentRequest|recover_agent|recovery_proof|session_ref|audit' "$src/src"
printf '%s\n' '--- source outline ---'
find "$src/src" -maxdepth 2 -type f -print | sortRepository: AgentWorkforce/relay Length of output: 209 🌐 Web query:
💡 Result: There is no standard API request named "RecoverAgentRequest" or a specific parameter named "recovery_proof" documented in the Relaycast platform's public repositories [1][2][3]. The Relaycast ecosystem (AgentWorkforce/relaycast) uses agent tokens for authentication and provides specific guidance for recovering from expired or invalid tokens [2][3]. According to official documentation, when an agent token is invalid or expired (HTTP 401), clients should recover by re-registering or rotating the agent identity and then retrying the failed operation [2][3]. Regarding "proofs," the platform uses the Ratify protocol for federated identity and task handoff [4]. Ratify-related "proof bundles" are carried in A2A message metadata under the Citations:
🌐 Web query:
💡 Result:
pub struct RecoverAgentRequest {
pub expected_agent_id: String,
pub recovery_proof: Option<String>,
pub reason: Option<String>,
pub session_ref: Option<String>,
pub node_id: Option<String>,
}Optional fields are omitted during serialization when 🌐 Web query:
💡 Result: In the Relaycast architecture, session_ref, audit_id, and agent-related recovery mechanisms are integral to managing agent state, identity, and delivery reliability across network reconnects and node failures [1][2][3]. Session and Identity Context - session_ref is a string identifier used to track and bind agent sessions, often appearing alongside audit_id or invocation_id in registry and message schemas [1][2][4]. - Agent registration via agent.register involves exchanging identity data. Historically, the engine emitted detailed objects containing session_ref and invocation_id, but these fields are often excluded from strict wire contracts to satisfy broker validation and schema constraints [1][2][5]. Recovery and Delivery Mechanisms - Agent Recovery: When an agent token is invalid or expired (HTTP 401), clients should recover by re-registering or rotating their agent identity and then retrying the failed operation [3]. - Delivery Cursor Negotiation: To ensure reliable message delivery across reconnects, brokers can negotiate "relay:delivery-cursor-v1" capability [3][6]. Upon successful negotiation, the system includes an authoritative delivery_ack_seq in the agent.register reply [3][6]. - Readiness Gates: A critical recovery challenge involves "delivery readiness." When a node re-registers (e.g., after a connection blip or restart), the engine resets the provider connection's readiness state [7]. Until the node follows up with inventory.sync or agent.register for hosted agents, the system may silently drop delivery frames [6][7]. Recovering from this state requires re-establishing readiness through these synchronization calls [7]. Operational Note - If a spawn or action invocation remains stuck in a pending or dispatched state, it is often due to an incomplete sync or a mismatch in the registry state, which inventory.sync is intended to resolve by re-binding agents and reconciling open invocations [1]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
for path in src/types.rs src/client.rs src/lib.rs openapi.yaml; do
url="https://raw.githubusercontent.com/AgentWorkforce/relaycast/main/$path"
printf '\n--- %s ---\n' "$path"
curl --fail --silent --show-error -L "$url" \
| rg -n -C10 'RecoverAgentRequest|recover_agent|recovery_proof|session_ref|audit_id|audit' \
|| true
done
printf '\n--- repository tags ---\n'
curl --fail --silent --show-error \
'https://api.github.com/repos/AgentWorkforce/relaycast/tags?per_page=100' \
| rg -o '"name": "[^"]+"' | head -100Repository: AgentWorkforce/relay Length of output: 15604 🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl --fail --silent --show-error -L \
https://github.com/AgentWorkforce/relaycast/archive/refs/tags/v7.0.0.tar.gz \
-o "$tmpdir/relaycast.tgz"
tar -xzf "$tmpdir/relaycast.tgz" -C "$tmpdir"
root="$(find "$tmpdir" -maxdepth 1 -type d -name 'relaycast-7.0.0' -print -quit)"
printf '%s\n' '--- v7.0.0 files containing recovery symbols ---'
rg -l 'RecoverAgentRequest|recover_agent|recovery_proof|session_ref' "$root" | sort
printf '%s\n' '--- v7.0.0 recovery implementation and audit references ---'
rg -n -C12 'RecoverAgentRequest|recover_agent|recovery_proof|session_ref|audit_id|audit record' "$root" \
| head -500Repository: AgentWorkforce/relay Length of output: 50376 🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl --fail --silent --show-error -L \
https://github.com/AgentWorkforce/relaycast/archive/refs/tags/sdk-rust-v7.0.0.tar.gz \
-o "$tmpdir/sdk.tgz"
tar -xzf "$tmpdir/sdk.tgz" -C "$tmpdir"
root="$(find "$tmpdir" -maxdepth 1 -type d -name 'relaycast-sdk-rust-v7.0.0' -o -name '*sdk-rust-v7.0.0' | head -1)"
printf '%s\n' '--- SDK recovery definitions ---'
rg -n -C15 'RecoverAgentRequest|recover_agent|recovery_proof|session_ref' "$root"
printf '%s\n' '--- v7 endpoint declaration ---'
curl --fail --silent --show-error -L \
https://raw.githubusercontent.com/AgentWorkforce/relaycast/v7.0.0/openapi.yaml \
| sed -n '2240,2330p'
printf '%s\n' '--- v7 implementation references ---'
curl --fail --silent --show-error -L \
https://api.github.com/repos/AgentWorkforce/relaycast/git/trees/v7.0.0?recursive=1 \
| rg -o '"path": "[^"]+"' \
| rg -i 'agent|audit|recovery|route' \
| head -200Repository: AgentWorkforce/relay Length of output: 50377 🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl --fail --silent --show-error -L \
https://github.com/AgentWorkforce/relaycast/archive/refs/tags/sdk-rust-v7.0.0.tar.gz \
-o "$tmpdir/sdk.tgz"
tar -xzf "$tmpdir/sdk.tgz" -C "$tmpdir"
root="$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d -print -quit)"
printf '%s\n' '--- SDK recovery symbols ---'
rg -n -C12 'RecoverAgentRequest|recover_agent|recovery_proof' \
"$root/packages/sdk-rust/src" "$root/packages/sdk-rust/tests" || true
printf '%s\n' '--- v7 recovery schema ---'
sed -n '2255,2335p' "$root/openapi.yaml"
printf '%s\n' '--- v7 recovery implementation symbols ---'
rg -n -i -C8 'recover|recovery|audit' \
"$root/packages/engine/src" \
--glob '*.ts' \
| rg -i -C3 'agent|identity|audit|proof' \
| head -250Repository: AgentWorkforce/relay Length of output: 50376 🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl --fail --silent --show-error -L \
https://github.com/AgentWorkforce/relaycast/archive/refs/tags/sdk-rust-v7.0.0.tar.gz \
-o "$tmpdir/sdk.tgz"
tar -xzf "$tmpdir/sdk.tgz" -C "$tmpdir"
root="$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d -print -quit)"
printf '%s\n' '--- files implementing HTTP recovery ---'
rg -l -i 'recoverAgent|recover-agent|/recover|agentIdentityAudit|rotateAgentIdentity' \
"$root/packages/engine/src" | sort
printf '%s\n' '--- recovery route and authorization ---'
rg -n -i -C18 'recoverAgent|recover-agent|/recover|recovery_proof|recoveryProof|take.?over' \
"$root/packages/engine/src/routes" "$root/packages/engine/src/engine" \
| head -500
printf '%s\n' '--- audit schema and writer ---'
rg -n -C12 'agentIdentityAudit|agent_identity_audit|identityAudit|sessionRef|session_ref' \
"$root/packages/engine/src/db" "$root/packages/engine/src/engine" \
| head -400Repository: AgentWorkforce/relay Length of output: 50377 🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl --fail --silent --show-error -L \
https://github.com/AgentWorkforce/relaycast/archive/refs/tags/sdk-rust-v7.0.0.tar.gz \
-o "$tmpdir/sdk.tgz"
tar -xzf "$tmpdir/sdk.tgz" -C "$tmpdir"
root="$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d -print -quit)"
printf '%s\n' '--- complete HTTP recovery authorization path ---'
sed -n '345,430p' "$root/packages/engine/src/routes/agent.ts"
printf '%s\n' '--- identity rotation and audit insert ---'
rg -n -C10 'rotateAgentIdentity|agentIdentityAudit|sessionRef|session_ref|originActor' \
"$root/packages/engine/src/engine/agentIdentity.ts" \
"$root/packages/engine/src/db/schema.ts"
printf '%s\n' '--- recovery conformance assertions ---'
rg -n -C12 'recover|recovery_proof|session_ref|audit' \
"$root/packages/engine/src/__tests__/conformance/agentIdentityRecovery.test.ts" \
| head -350Repository: AgentWorkforce/relay Length of output: 50376 🏁 Script executed: #!/bin/bash
set -eu
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
curl --fail --silent --show-error -L \
https://github.com/AgentWorkforce/relaycast/archive/refs/tags/sdk-rust-v7.0.0.tar.gz \
-o "$tmpdir/sdk.tgz"
tar -xzf "$tmpdir/sdk.tgz" -C "$tmpdir"
root="$(find "$tmpdir" -mindepth 1 -maxdepth 1 -type d -print -quit)"
python3 - "$root" <<'PY'
import pathlib
import re
import sys
root = pathlib.Path(sys.argv[1])
local = pathlib.Path("crates/broker/src/relaycast/auth.rs").read_text()
types = (root / "packages/sdk-rust/src/types.rs").read_text()
route = (root / "packages/engine/src/routes/agent.ts").read_text()
identity = (root / "packages/engine/src/engine/agentIdentity.ts").read_text()
request = re.search(
r"pub struct RecoverAgentRequest\s*\{(.*?)\n\}",
types,
re.S,
)
assert request, "RecoverAgentRequest is missing"
body = request.group(1)
assert re.search(
r"#\[serde\(skip_serializing_if = \"Option::is_none\"\)\]\s*"
r"pub recovery_proof: Option<String>",
body,
), "recovery_proof is not omitted when None"
assert re.search(
r"#\[serde\(skip_serializing_if = \"Option::is_none\"\)\]\s*"
r"pub session_ref: Option<String>",
body,
), "session_ref serialization contract is missing"
assert "if (parsed.data.recovery_proof)" in route
assert "getRecoveryCredentialByProof" in route
assert "authenticate({ token, require: 'sender', db })" in route
assert "authority = 'current_agent_token'" in route
assert "authority = 'origin_node'" in route
assert "authority = 'work_unit_proof'" in route
assert "sessionRef: parsed.data.session_ref ?? target.sessionRef" in route
assert "sessionRef: audit.sessionRef ?? null" in identity
assert "as('session_ref')" in identity
call = re.search(
r"recover_agent\(\s*&existing\.name,\s*RecoverAgentRequest\s*\{(.*?)\n\s*\}",
local,
re.S,
)
assert call, "local recovery request construction is missing"
assert re.search(r"recovery_proof:\s*None", call.group(1))
assert re.search(r"session_ref:\s*identity_key\.map\(str::to_string\)", call.group(1))
print("PASS: None omits recovery_proof; workspace-key recovery has no accepted authority branch; session_ref reaches the audit session_ref column; local code sends the raw identity key as session_ref.")
PYRepository: AgentWorkforce/relay Length of output: 352 Use an authorized recovery proof and keep the identity key out of
🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| }, | ||
| ) | ||
| .await; | ||
| // Engines before 8.2.0 have no `/recover` route — the identity | ||
| // recovery surface arrived with it — and answer "Route not found". | ||
| // Those engines still let the workspace key rotate an agent's | ||
| // token, which is what this path did before, so fall back rather | ||
| // than failing every node restart against an older engine. The | ||
| // agent-specific 404 (`agent_not_found`) is a real failure and is | ||
| // deliberately excluded. | ||
| let token_response = match token_response { | ||
| Ok(response) => response.token, | ||
| Err(RelayError::Api { | ||
| status: 404, | ||
| ref code, | ||
| .. | ||
| }) if code != "agent_not_found" => relay | ||
| .rotate_agent_token(&existing.name, workspace_key) | ||
| .await | ||
| .map_err(relay_error_to_anyhow) | ||
| .context( | ||
| "recover unavailable on this engine and the legacy rotate fallback failed", | ||
| )? | ||
| .token, | ||
| Err(error) => return Err(relay_error_to_anyhow(error)), | ||
| }; | ||
| Ok(( | ||
| existing.id, | ||
| existing.name, | ||
| token_response.token, | ||
| token_response, | ||
| existing.workspace_id, | ||
| )) | ||
| } | ||
|
|
@@ -1620,13 +1687,16 @@ mod tests { | |
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":true,"data":{"id":"a_existing","name":"lead","type":"agent","status":"offline","persona":null,"metadata":{},"last_seen":"2025-01-01T00:00:00Z","channels":[]}}"#); | ||
| }); | ||
| // Identity reclaim uses `recover`, not rotation (relaycast 7.0.0). | ||
| let rotate = server.mock(|when, then| { | ||
| when.method(POST) | ||
| .path("/v1/agents/lead/rotate-token") | ||
| .path("/v1/agents/lead/recover") | ||
| .header("authorization", "Bearer rk_live_shared"); | ||
| then.status(200) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":true,"data":{"name":"lead","token":"at_live_rotated"}}"#); | ||
| .body( | ||
| r#"{"ok":true,"data":{"agent_id":"a_existing","name":"lead","token":"at_live_rotated","audit_id":"aud_1"}}"#, | ||
| ); | ||
| }); | ||
|
|
||
| let client = AuthClient::new(Some(server.base_url())); | ||
|
|
@@ -1652,6 +1722,64 @@ mod tests { | |
| } | ||
| } | ||
|
|
||
| /// Engines before 8.2.0 have no `/recover`. A node restarting against one | ||
| /// must still reclaim its identity via the legacy workspace-key rotate — | ||
| /// this is the exact path the two-node fleet e2e drives, against a pinned | ||
| /// v7.0.0 engine. | ||
| #[tokio::test] | ||
| async fn identity_reclaim_falls_back_to_legacy_rotate_on_older_engines() { | ||
| let _env_guard = clear_relay_env(); | ||
| let identity = "work-unit-42"; | ||
| let identity_hash = hash_identity_key(identity); | ||
| let server = MockServer::start(); | ||
| unsafe { | ||
| std::env::set_var("RELAY_API_KEY", "rk_live_shared"); | ||
| std::env::set_var("RELAY_AGENT_IDENTITY_KEY", identity); | ||
| } | ||
| server.mock(|when, then| { | ||
| when.method(POST).path("/v1/agents"); | ||
| then.status(409) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":false,"error":{"code":"agent_already_exists","message":"name_taken"}}"#); | ||
| }); | ||
| server.mock(|when, then| { | ||
| when.method(GET).path("/v1/agents/lead"); | ||
| then.status(200) | ||
| .header("content-type", "application/json") | ||
| .body(format!( | ||
| r#"{{"ok":true,"data":{{"id":"a_existing","name":"lead","type":"agent","status":"offline","persona":null,"metadata":{{"identity_key":"{identity_hash}"}},"last_seen":"2025-01-01T00:00:00Z","channels":[]}}}}"# | ||
| )); | ||
| }); | ||
| // Older engine: the recovery surface simply is not mounted. | ||
| let recover = server.mock(|when, then| { | ||
| when.method(POST).path("/v1/agents/lead/recover"); | ||
| then.status(404) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":false,"error":{"code":"not_found","message":"Route not found"}}"#); | ||
| }); | ||
| let legacy_rotate = server.mock(|when, then| { | ||
| when.method(POST) | ||
| .path("/v1/agents/lead/rotate-token") | ||
| .header("authorization", "Bearer rk_live_shared"); | ||
| then.status(200) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":true,"data":{"name":"lead","token":"at_live_legacy_reclaim"}}"#); | ||
| }); | ||
|
|
||
| let client = AuthClient::new(Some(server.base_url())); | ||
| let session = client | ||
| .startup_session_set_with_identity(Some("lead"), true, None, Some(identity)) | ||
| .await | ||
| .expect("an older engine must still let a restarting node reclaim its name") | ||
| .default_session() | ||
| .cloned() | ||
| .expect("a session was registered"); | ||
|
|
||
| assert_eq!(session.token, "at_live_legacy_reclaim"); | ||
| recover.assert_hits(1); | ||
| legacy_rotate.assert_hits(1); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn strict_name_conflict_with_matching_identity_reclaims_existing_agent() { | ||
| // Crash-recovery resume: the SAME work unit re-registers under the | ||
|
|
@@ -1691,13 +1819,16 @@ mod tests { | |
| r#"{{"ok":true,"data":{{"id":"a_existing","name":"lead","type":"agent","status":"offline","persona":null,"metadata":{{"identity_key":"{identity_hash}"}},"last_seen":"2025-01-01T00:00:00Z","channels":[]}}}}"# | ||
| )); | ||
| }); | ||
| // Identity reclaim uses `recover`, not rotation (relaycast 7.0.0). | ||
| let rotate = server.mock(|when, then| { | ||
| when.method(POST) | ||
| .path("/v1/agents/lead/rotate-token") | ||
| .path("/v1/agents/lead/recover") | ||
| .header("authorization", "Bearer rk_live_shared"); | ||
| then.status(200) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":true,"data":{"name":"lead","token":"at_live_rotated"}}"#); | ||
| .body( | ||
| r#"{"ok":true,"data":{"agent_id":"a_existing","name":"lead","token":"at_live_rotated","audit_id":"aud_1"}}"#, | ||
| ); | ||
| }); | ||
|
|
||
| let client = AuthClient::new(Some(server.base_url())); | ||
|
|
@@ -1780,13 +1911,18 @@ mod tests { | |
| r#"{{"ok":true,"data":{{"id":"a_existing","name":"node-a","type":"agent","status":"offline","persona":null,"metadata":{{"identity_key":"{identity_hash}"}},"last_seen":"2025-01-01T00:00:00Z","channels":[]}}}}"# | ||
| )); | ||
| }); | ||
| // Reclaiming a crashed identity goes through `recover`, not rotation: | ||
| // rotation is self-rollover only and this path holds the work-unit | ||
| // identity proof rather than the agent's token (relaycast 7.0.0). | ||
| let rotate = server.mock(|when, then| { | ||
| when.method(POST) | ||
| .path("/v1/agents/node-a/rotate-token") | ||
| .path("/v1/agents/node-a/recover") | ||
| .header("authorization", "Bearer rk_live_shared"); | ||
| then.status(200) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":true,"data":{"name":"node-a","token":"at_live_rotated"}}"#); | ||
| .body( | ||
| r#"{"ok":true,"data":{"agent_id":"a_existing","name":"node-a","token":"at_live_rotated","audit_id":"aud_1"}}"#, | ||
| ); | ||
| }); | ||
|
|
||
| let client = AuthClient::new(Some(server.base_url())); | ||
|
|
@@ -2415,7 +2551,11 @@ mod tests { | |
| let first_conflict = server.mock(|when, then| { | ||
| when.method(POST) | ||
| .path("/v1/workspaces") | ||
| .json_body(json!({ "name": workspace_name })); | ||
| // `body_contains` rather than an exact `json_body`: the request | ||
| // now also carries workspace provenance (relaycast 7.0.0), and | ||
| // this mock only cares that the first attempt uses the | ||
| // deterministic name. | ||
| .body_contains(format!("\"name\":\"{workspace_name}\"")); | ||
| then.status(409) | ||
| .header("content-type", "application/json") | ||
| .body( | ||
|
|
@@ -2457,7 +2597,8 @@ mod tests { | |
| let rotate = server.mock(|when, then| { | ||
| when.method(POST) | ||
| .path("/v1/agents/lead/rotate-token") | ||
| .header("authorization", "Bearer rk_live_cached"); | ||
| // Self-rollover authenticates as the agent, not the workspace. | ||
| .header("authorization", "Bearer at_live_current"); | ||
| then.status(200) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":true,"data":{"token":"at_live_rotated","name":"lead"}}"#); | ||
|
|
@@ -2471,7 +2612,7 @@ mod tests { | |
| agent_id: "a_old".into(), | ||
| api_key: "rk_live_cached".into(), | ||
| agent_name: Some("lead".into()), | ||
| agent_token: None, | ||
| agent_token: Some("at_live_current".into()), | ||
| updated_at: chrono::Utc::now(), | ||
| }; | ||
|
|
||
|
|
@@ -2490,7 +2631,8 @@ mod tests { | |
| let rotate_404 = server.mock(|when, then| { | ||
| when.method(POST) | ||
| .path("/v1/agents/lead/rotate-token") | ||
| .header("authorization", "Bearer rk_live_cached"); | ||
| // Self-rollover authenticates as the agent, not the workspace. | ||
| .header("authorization", "Bearer at_live_current"); | ||
| then.status(404) | ||
| .header("content-type", "application/json") | ||
| .body(r#"{"ok":false,"error":{"code":"not_found","message":"not found"}}"#); | ||
|
|
@@ -2512,7 +2654,7 @@ mod tests { | |
| agent_id: "a_old".into(), | ||
| api_key: "rk_live_cached".into(), | ||
| agent_name: Some("lead".into()), | ||
| agent_token: None, | ||
| agent_token: Some("at_live_current".into()), | ||
| updated_at: chrono::Utc::now(), | ||
| }; | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This dependency bump accompanies a user-visible fix to agent-name recovery and token rotation, but the commit leaves
CHANGELOG.mdunchanged even though[Unreleased - Minor]already exists. Add a conciseFixedentry describing that previously used agent names can now be reclaimed through audited takeover.AGENTS.md reference: AGENTS.md:L29-L34
Useful? React with 👍 / 👎.