Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `HarnessDriverClient.spawn()` now polls the broker's startup handshake for the full `startupTimeoutMs` budget (default 45s) instead of a fixed ~10s, so a slow-but-healthy Relaycast handshake that keeps answering `503` while warming up is no longer misreported as a spawn failure.
- `agent-relay integration subscribe` now resolves provider-native `--resource` values through relayfile before binding, so Slack channel names, GitHub repos, Linear team keys, and Telegram chats bind to matching relayfile VFS globs while explicit `/`-prefixed globs still work.
- `agent-relay integration subscribe` is now idempotent and supports multiple resources/channels per provider. Each inbound webhook is scoped to its `(provider, resource)` binding (not one-per-provider), so subscribing a second Slack channel — or two sources into the same relay channel — no longer collides on the unique `(workspace, webhook name)` index or clobbers the other binding's webhook. Re-subscribing creates the replacement webhook/subscription before retiring the old one, so a transient failure can't leave you with no working binding; a failed cleanup now warns instead of being silently swallowed. The relay channel id is normalized (`#general` → `general`) consistently across the webhook, subscription filter, relayfile bind, and writeback-secret lookup, and `listBindings` now maps relayfile's `pathGlob` field so unsubscribe/replace match correctly.
- `agent-relay-broker` bootstrap `node.register` no longer advertises a generic `"spawn"` capability. Because the engine does not treat bare `"spawn"` as a placement capability (only `spawn:*`), it materialized a `spawn` action pinned to whichever node bootstrapped first, which then hijacked capability-based spawn placement for the whole workspace — every `spawn` invoke was dispatched to that node, ignoring `cli`/`target_node`/least-loaded routing. The pre-sidecar descriptor now carries no capabilities; real `spawn:*`/action capabilities arrive on the sidecar's `node.register`.
Expand Down
16 changes: 15 additions & 1 deletion crates/broker/src/relaycast/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,9 @@ impl RelaycastHttpClient {
/// via [`AgentClient::reply`] instead of a plain channel post is what
/// actually creates real thread/conversation grouping on the Relaycast
/// side, as opposed to passing an opaque value the server doesn't
/// interpret as a reply.
/// interpret as a reply. `reply` takes no injection mode, so a threaded
/// reply is always delivered with Wait semantics — a `Steer` request with
/// a `thread_id` is downgraded to a normal reply (logged, not dropped).
pub async fn send_with_mode(
&self,
to: &str,
Expand All @@ -523,6 +525,18 @@ impl RelaycastHttpClient {
MessageInjectionMode::Steer => relaycast::MessageInjectionMode::Steer,
};
if let Some(thread_id) = thread_id {
// `AgentClient::reply` has no injection-mode parameter, so a
// threaded reply is always delivered with Wait semantics.
// `Steer` can't be honored on a reply; downgrade rather than
// drop the message, but log it so the loss of steer is visible
// instead of silent.
if matches!(mode, MessageInjectionMode::Steer) {
tracing::warn!(
target = "relay_broker::relaycast",
thread_id = %thread_id,
"steer injection mode is not supported on threaded replies; delivering as a normal reply"
);
}
agent_client
.reply(thread_id, text, None, None)
Comment on lines +527 to +541

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate thread IDs before using Relaycast reply

For channel targets, any supplied thread_id is now treated as a Relaycast message id. Broker clients commonly pass the event_id returned by /api/send, which is still a synthetic http_* id, and /api/threads can also surface synthetic grouping keys such as #general/direct:*; Relaycast cannot reply to those ids, so these channel follow-ups now fail instead of posting with the provided thread context. Gate this path to real Relaycast message ids or return/use the Relaycast id from the initial publish.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bf68b92. Added is_relaycast_reply_target() (reuses the existing synthetic-event-id classifier and also rejects #channel / direct:* grouping keys); the send handler only forwards thread_id to reply() when it's a real message id and otherwise falls back to a plain post, so a synthetic http_*/grouping id no longer fails the send.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Thread replies now require a Relaycast message id, but the broker send API exposes broker event_ids to callers. Follow-up sends using the returned id will fail at reply instead of publishing the message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/broker/src/relaycast/ws.rs, line 527:

<comment>Thread replies now require a Relaycast message id, but the broker send API exposes broker `event_id`s to callers. Follow-up sends using the returned id will fail at `reply` instead of publishing the message.</comment>

<file context>
@@ -464,31 +486,57 @@ impl RelaycastHttpClient {
-                .map_err(|e| anyhow::anyhow!("relaycast send_to_channel failed: {e}"))?;
+            if let Some(thread_id) = thread_id {
+                agent_client
+                    .reply(thread_id, text, None, None)
+                    .await
+                    .map_err(|e| anyhow::anyhow!("relaycast thread reply failed: {e}"))?;
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bf68b92 — see reply above. Synthetic http_* event ids (and #channel/direct:* keys) are no longer passed to reply(); they fall back to a plain post so the follow-up send still publishes.

Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.await
Expand Down
37 changes: 30 additions & 7 deletions crates/broker/src/runtime/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,12 +709,17 @@ impl BrokerRuntime {
// worse, ROTATE (and thereby invalidate) the live token of
// an unrelated, already-registered agent that happens to
// share the name. Falling back to the broker's own identity
// is always safe; impersonation is not.
let publish_from = if workers.has_worker(&delivery_from) {
delivery_from.as_str()
} else {
workspace_self_name.as_str()
};
// is always safe; impersonation is not. The worker must also
// belong to the workspace we're publishing into — a worker
// attached to another attached workspace is not ours to
// impersonate here (it would register/rotate that name in the
// wrong Relaycast workspace).
let publish_from =
if workers.has_worker_in_workspace(&delivery_from, &selected_workspace_id) {
delivery_from.as_str()
} else {
workspace_self_name.as_str()
};

record_thread_history_event(
recent_thread_messages,
Expand Down Expand Up @@ -755,6 +760,24 @@ impl BrokerRuntime {
relaycast_timeout_ms = %relaycast_timeout.as_millis(),
"publishing to relaycast"
);
// Only forward `thread_id` to the Relaycast publish when it's a
// real message id we can reply to. Broker-minted synthetic ids
// (`http_*`) and channel/DM grouping keys (`#general`,
// `direct:*`) that a client may echo back from `/api/send` or
// `/api/threads` aren't reply targets — Relaycast would reject
// the reply and fail the whole send. Fall back to a plain post
// (unthreaded) for those, preserving delivery.
let reply_thread_id = thread_id
.as_deref()
.filter(|tid| is_relaycast_reply_target(tid));
Comment on lines +770 to +772

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pass the trimmed reply id to Relaycast, not the original untrimmed slice. Current validation trims for classification but forwards whitespace to AgentClient::reply, which can still fail the send this fallback is meant to protect.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/broker/src/runtime/api.rs, line 770:

<comment>Pass the trimmed reply id to Relaycast, not the original untrimmed slice. Current validation trims for classification but forwards whitespace to `AgentClient::reply`, which can still fail the send this fallback is meant to protect.</comment>

<file context>
@@ -755,6 +760,24 @@ impl BrokerRuntime {
+                // `/api/threads` aren't reply targets — Relaycast would reject
+                // the reply and fail the whole send. Fall back to a plain post
+                // (unthreaded) for those, preserving delivery.
+                let reply_thread_id = thread_id
+                    .as_deref()
+                    .filter(|tid| is_relaycast_reply_target(tid));
</file context>
Suggested change
let reply_thread_id = thread_id
.as_deref()
.filter(|tid| is_relaycast_reply_target(tid));
let reply_thread_id = thread_id
.as_deref()
.map(str::trim)
.filter(|tid| is_relaycast_reply_target(tid));

if thread_id.is_some() && reply_thread_id.is_none() {
tracing::debug!(
target = "relay_broker::http_api",
event_id = %event_id,
thread_id = ?thread_id,
"thread_id is not a Relaycast message id; publishing without a thread reply"
);
}
let relaycast_start = Instant::now();
match timeout(
relaycast_timeout,
Expand All @@ -763,7 +786,7 @@ impl BrokerRuntime {
&text,
mode.clone(),
publish_from,
thread_id.as_deref(),
reply_thread_id,
),
)
.await
Expand Down
44 changes: 44 additions & 0 deletions crates/broker/src/runtime/delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,20 @@ pub(crate) fn delivery_read_ack_is_relaycast_message(event_id: &EventId) -> bool
synthetic_delivery_read_ack_reason(event_id).is_none()
}

/// True when `thread_id` is a real Relaycast message id we can `reply()` to,
/// as opposed to a broker-minted synthetic event id (`http_`/`init_`/… — see
/// [`synthetic_delivery_read_ack_reason`]) or a channel/DM grouping key
/// (`#channel`, `direct:*`) that `/api/threads` can surface. Relaycast rejects
/// a reply to anything that isn't a real message id, so the publish path must
/// fall back to a plain post for these rather than fail the whole send.
pub(crate) fn is_relaycast_reply_target(thread_id: &str) -> bool {
let id = thread_id.trim();
if id.is_empty() || id.starts_with('#') || id.starts_with("direct:") {
return false;
}
synthetic_delivery_read_ack_reason(&EventId::new(id)).is_none()
}

pub(crate) fn seed_supplied_agent_token(
relaycast_http: &RelaycastHttpClient,
agent_name: &str,
Expand Down Expand Up @@ -903,3 +917,33 @@ pub(crate) fn clear_pending_delivery_if_event_matches(
}
None
}

#[cfg(test)]
mod reply_target_tests {
use super::is_relaycast_reply_target;

#[test]
fn real_message_ids_are_reply_targets() {
assert!(is_relaycast_reply_target("msg_abc123"));
assert!(is_relaycast_reply_target("evt_01hxyz"));
}

#[test]
fn synthetic_and_grouping_ids_are_not_reply_targets() {
for id in [
"",
" ",
"#general",
"direct:alice",
"http_deadbeef",
"init_task",
"cont_load_1",
"flush_1",
] {
assert!(
!is_relaycast_reply_target(id),
"expected non-target: {id:?}"
);
}
}
}
19 changes: 19 additions & 0 deletions crates/broker/src/runtime/fleet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,25 @@ impl BrokerRuntime {
msg_id = %deliver.msg_id,
"queued node delivery (manual_flush inbound delivery mode)"
);
// Surface the hold as a `delivery_queued` event, as the
// now-removed local send path did. `attach --drive`
// counts these to show pending messages; node delivery
// is the only delivery path now, so this is the only
// place the event can originate. The `name` field is
// what scopes it to the worker on the consumer side.
let _ = send_event(
&self.sdk_out_tx,
json!({
"kind": "delivery_queued",
"name": deliver.agent.as_str(),
"event_id": deliver.msg_id.as_str(),
"delivery_id": deliver.delivery_id.as_str(),
"from": fields.from.as_str(),
"target": fields.target.as_str(),
"reason": "inbound_delivery_manual_flush",
}),
)
.await;
Ok(())
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
InboundQueueOutcome::DrainNow(to_drain) => {
Expand Down
26 changes: 26 additions & 0 deletions crates/broker/src/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,25 @@ impl WorkerRegistry {
self.workers.contains_key(name)
}

/// True when a worker named `name` exists and either has no recorded
/// workspace or belongs to `workspace_id`. Gates sender impersonation on
/// Relaycast publish: a worker attached to workspace A must not be
/// impersonated when publishing into workspace B, which would register or
/// rotate that name's token in the wrong workspace.
pub(crate) fn has_worker_in_workspace(
&self,
name: &str,
workspace_id: &crate::ids::WorkspaceId,
) -> bool {
match self.workers.get(name) {
Some(handle) => match &handle.workspace_id {
Some(worker_ws) => worker_ws == workspace_id,
None => true,
},
None => false,
}
}

Comment on lines +212 to +230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: check where WorkerHandle.workspace_id is set to None vs Some,
# to confirm None only occurs in single-workspace configurations.
rg -n -B3 -A3 'workspace_id\s*:\s*None' crates/broker/src/worker.rs crates/broker/src/runtime/*.rs
rg -n -B3 -A10 'struct WorkerHandle' crates/broker/src/worker.rs

Repository: AgentWorkforce/relay

Length of output: 1240


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the worker and API call sites first.
ast-grep outline crates/broker/src/worker.rs --view expanded
ast-grep outline crates/broker/src/runtime/api.rs --view expanded
ast-grep outline crates/broker/src/runtime/app_server.rs --view expanded

# Read the relevant sections around workspace_id handling and the impersonation gate.
sed -n '1,140p' crates/broker/src/worker.rs
sed -n '1,240p' crates/broker/src/runtime/api.rs
sed -n '340,430p' crates/broker/src/runtime/app_server.rs

# Find all assignments/usages of WorkerHandle.workspace_id and related workspace scoping.
rg -n -B3 -A3 'workspace_id\s*:' crates/broker/src
rg -n -B3 -A6 'has_worker_in_workspace|from:|workspace_id' crates/broker/src/runtime/api.rs crates/broker/src/worker.rs

# Check tests around the new helper and any multi-workspace behavior.
rg -n -B2 -A8 'has_worker_in_workspace|workspace_id: None|workspace_id: Some|RelayDelivery' crates/broker/src

Repository: AgentWorkforce/relay

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the impersonation gate and worker creation paths with tight ranges.
sed -n '210,260p' crates/broker/src/worker.rs
sed -n '760,900p' crates/broker/src/worker.rs
sed -n '1,220p' crates/broker/src/runtime/api.rs

# Find all places a WorkerHandle is constructed and how workspace_id is populated.
rg -n -B4 -A12 'WorkerHandle \{' crates/broker/src
rg -n -B4 -A8 'workspace_id:\s*(Some\(|None|workspace_id)' crates/broker/src/worker.rs crates/broker/src/runtime crates/broker/src

# Read the routing rule that already treats None as match-all.
sed -n '1,80p' crates/broker/src/routing.rs

# Narrow tests around the new helper and any workspace-related behavior.
sed -n '1570,1610p' crates/broker/src/worker.rs

Repository: AgentWorkforce/relay

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the exact impersonation gate in runtime/api.rs.
rg -n -B8 -A18 'has_worker_in_workspace|impersonat|sender|from' crates/broker/src/runtime/api.rs

# Inspect the worker workspace propagation path from spawn call sites.
rg -n -B6 -A10 'spawn\(' crates/broker/src/runtime/api.rs crates/broker/src/runtime/tests.rs crates/broker/src/listen_api.rs

# Check the helper tests and add any nearby assertions around None/workspace match behavior.
sed -n '1588,1608p' crates/broker/src/worker.rs
sed -n '1,80p' crates/broker/src/routing.rs

Repository: AgentWorkforce/relay

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find every production construction of WorkerHandle and inspect its workspace_id value.
rg -n -B6 -A14 'WorkerHandle \{' crates/broker/src --glob '!**/*test*' --glob '!**/tests.rs'

# Find every direct use of `workspace_id: None` in worker creation contexts.
rg -n -B4 -A6 'workspace_id:\s*None' crates/broker/src --glob '!**/*test*' --glob '!**/tests.rs'

# Show the full spawn signature and the workspace_id value path again, plus any other spawn call sites.
rg -n -B4 -A12 'pub\(crate\) async fn spawn\(' crates/broker/src/worker.rs
rg -n -n 'spawn\(' crates/broker/src/runtime/api.rs crates/broker/src/listen_api.rs crates/broker/src/pty_worker.rs crates/broker/src/node_control.rs crates/broker/src/fleet.rs

Repository: AgentWorkforce/relay

Length of output: 14237


Keep the workspace check, but add coverage for all branches. The None fallback matches the existing legacy/SDK worker rule in crates/broker/src/routing.rs, so it looks intentional; add tests for the same-workspace, mismatched-workspace, and unscoped cases so this impersonation gate doesn’t regress.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/broker/src/worker.rs` around lines 212 - 230, Keep the workspace check
in has_worker_in_workspace, but add tests covering every branch of the worker
lookup logic. Add cases for a worker with the same workspace_id, a worker with a
different workspace_id, and a worker whose workspace_id is None to verify the
legacy/SDK fallback behavior matches the routing rule. Use the
has_worker_in_workspace method and the workers map/handle.workspace_id paths as
the targets for the new coverage.

pub(crate) fn worker_pid(&self, name: &str) -> Option<u32> {
self.workers.get(name).and_then(|h| h.child.id())
}
Expand Down Expand Up @@ -1568,6 +1587,13 @@ mod tests {
assert!(!reg.has_worker("nonexistent"));
}

#[test]
fn has_worker_in_workspace_returns_false_for_unknown() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The test for has_worker_in_workspace only covers the "unknown worker" branch. Since this function gates sender impersonation (security-sensitive), add tests for the same-workspace (returns true), mismatched-workspace (returns false), and unscoped/None workspace (returns true) branches to prevent regressions on the impersonation gate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/broker/src/worker.rs, line 1591:

<comment>The test for `has_worker_in_workspace` only covers the "unknown worker" branch. Since this function gates sender impersonation (security-sensitive), add tests for the same-workspace (returns true), mismatched-workspace (returns false), and unscoped/None workspace (returns true) branches to prevent regressions on the impersonation gate.</comment>

<file context>
@@ -1568,6 +1587,13 @@ mod tests {
     }
 
+    #[test]
+    fn has_worker_in_workspace_returns_false_for_unknown() {
+        let reg = make_registry(vec![]);
+        let workspace = crate::ids::WorkspaceId::new("ws_1".to_string());
</file context>

let reg = make_registry(vec![]);
let workspace = crate::ids::WorkspaceId::new("ws_1".to_string());
assert!(!reg.has_worker_in_workspace("nonexistent", &workspace));
}

#[test]
fn worker_log_path_rejects_path_traversal() {
let reg = make_registry(vec![]);
Expand Down
22 changes: 17 additions & 5 deletions packages/harness-driver/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,15 +374,27 @@ export class HarnessDriverClient {

onStep?.('Waiting for broker session handshake...');
let session: SessionInfo | undefined;
for (let attempt = 0; attempt < 10; attempt++) {
// The Relaycast handshake can take many seconds on a cold or slow network,
// during which the startup-only API answers 503. Poll for the full startup
// budget (`timeoutMs`) rather than a fixed attempt count so a slow-but-
// healthy handshake isn't misreported as a spawn failure. The `brokerExited`
// race still surfaces a dead broker immediately, so this only extends how
// long we wait on a broker that is alive and warming up.
const handshakeDeadline = Date.now() + timeoutMs;
for (let attempt = 0; ; attempt++) {
try {
session = await Promise.race([client.getSession(), brokerExited]);
break;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const is503 = message.includes('503') || message.includes('Service Unavailable');
if (!is503 || attempt >= 9) throw err;
onStep?.(`Broker still starting (handshake attempt ${attempt + 1}/10), retrying in 1s...`);
// The broker's startup-only API returns a structured 503
// (`http_503`) while it warms up. Prefer the typed fields over the
// formatted message, which the broker is free to customize.
const is503 =
err instanceof HarnessDriverProtocolError
? err.status === 503 || err.code === 'http_503'
: /503|Service Unavailable/.test(err instanceof Error ? err.message : String(err));
if (!is503 || Date.now() >= handshakeDeadline) throw err;
onStep?.(`Broker still starting (handshake attempt ${attempt + 1}), retrying in 1s...`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
Loading