Conversation
The daemon previously only fetched messages from cloud during its 60-second heartbeat cycle, causing unacceptable delays for Slack-initiated messages. This adds a separate 5-second message poll timer so cloud messages (like Slack mentions routed to agents) are delivered within seconds instead of up to a minute. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the 5-second setInterval message polling with a long-poll loop that holds the HTTP connection open until messages arrive or a 25s timeout expires. This reduces latency from up to 5s to near-instant delivery. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Daemon now injects RELAY_CLOUD_URL, WORKSPACE_TOKEN, and WORKSPACE_ID into spawned agents' environments (same pattern as GH_TOKEN for gh CLI). For messages routed to existing agents, the daemon enriches the message with the correct inline slack CLI command using its known cloud URL. Also adds __cloud__ relay target support so agents can forward messages to the cloud server via the daemon's connection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| */ | ||
| async spawn(request: SpawnRequest): Promise<SpawnResult> { | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive, model: modelOverride } = request; | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive, model: modelOverride, extraEnv } = request; |
Check failure
Code scanning / CodeQL
Insecure randomness High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general: replace uses of Math.random() for generating these agent names with a cryptographically secure source of randomness (crypto.randomInt or crypto.getRandomValues) so that the selection of adjectives/nouns and the numeric suffix are not predictable, while keeping the resulting format (AdjectiveNoun or AdjectiveNounNNN) and all call sites unchanged.
Concretely for this project, the only insecure randomness is in packages/utils/src/name-generator.ts. We can fix it by:
- Importing Node’s
cryptomodule in that file. - Implementing a small helper function
secureRandomInt(max: number)usingcrypto.randomInt(which is available in Node and avoids modulo bias). - Using
secureRandomIntinstead ofMath.random()when indexing intoADJECTIVESandNOUNS, and when generating the fallback numeric suffix.
This change is local to name-generator.ts; no callers need to change, and it preserves the types and return values. The spawner and CLI code will continue to work as before, but the generated names will now be derived from a CSPRNG.
| @@ -3,6 +3,8 @@ | ||
| * Inspired by mcp_agent_mail's approach. | ||
| */ | ||
|
|
||
| import crypto from 'node:crypto'; | ||
|
|
||
| const ADJECTIVES = [ | ||
| 'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber', | ||
| 'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper', | ||
| @@ -25,12 +27,17 @@ | ||
| 'Star', 'Moon', 'Sun', 'Comet', 'Cloud', 'Storm', 'Thunder', 'Lightning', | ||
| ]; | ||
|
|
||
| function secureRandomInt(max: number): number { | ||
| // crypto.randomInt generates a cryptographically secure integer in [0, max) | ||
| return crypto.randomInt(0, max); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a random agent name (AdjectiveNoun format). | ||
| */ | ||
| export function generateAgentName(): string { | ||
| const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; | ||
| const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; | ||
| const adjective = ADJECTIVES[secureRandomInt(ADJECTIVES.length)]; | ||
| const noun = NOUNS[secureRandomInt(NOUNS.length)]; | ||
| return `${adjective}${noun}`; | ||
| } | ||
|
|
||
| @@ -45,7 +48,7 @@ | ||
| } | ||
| } | ||
| // Fallback: append random suffix | ||
| return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`; | ||
| return `${generateAgentName()}${secureRandomInt(1000)}`; | ||
| } | ||
|
|
||
| /** |
| */ | ||
| async spawn(request: SpawnRequest): Promise<SpawnResult> { | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive, model: modelOverride } = request; | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive, model: modelOverride, extraEnv } = request; |
Check failure
Code scanning / CodeQL
Insecure randomness High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 9 days ago
General fix: replace all uses of Math.random() in this agent name generation utility with a cryptographically secure pseudorandom number generator (CSPRNG). In Node.js, this is best done via crypto.randomInt (for uniform integer selection in a range) or crypto.randomBytes, which are both cryptographically secure. We should avoid introducing bias when mapping bytes/numbers to indices.
Best concrete fix: in packages/utils/src/name-generator.ts, import Node’s crypto module and implement a small helper that uses crypto.randomInt(max) to select a random index for arrays and suffixes. Then:
- Change
generateAgentName()to usesecureRandomInt(ADJECTIVES.length)andsecureRandomInt(NOUNS.length)instead ofMath.random(). - Change the fallback in
generateUniqueAgentName()to usesecureRandomInt(1000)instead ofMath.random().
This confines the change to the randomness implementation while leaving public function signatures and overall behavior intact (still adjective+noun optionally with numeric suffix). No other files (src/cli/index.ts, packages/config/src/shadow-config.ts, packages/bridge/src/spawner.ts) need code changes; they will automatically benefit from the improved randomness.
Concretely:
- Edit
packages/utils/src/name-generator.tsto:- Add
import crypto from 'node:crypto';at the top (without altering existing exports). - Add a small helper
function secureRandomInt(max: number): numberthat callscrypto.randomInt(max). - Replace each
Math.floor(Math.random() * ADJECTIVES.length)/NOUNS.length/1000withsecureRandomInt(...).
- Add
No changes are needed to imports or code in the other snippets, since they do not call Math.random() directly.
| @@ -3,6 +3,8 @@ | ||
| * Inspired by mcp_agent_mail's approach. | ||
| */ | ||
|
|
||
| import crypto from 'node:crypto'; | ||
|
|
||
| const ADJECTIVES = [ | ||
| 'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber', | ||
| 'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper', | ||
| @@ -25,12 +27,17 @@ | ||
| 'Star', 'Moon', 'Sun', 'Comet', 'Cloud', 'Storm', 'Thunder', 'Lightning', | ||
| ]; | ||
|
|
||
| function secureRandomInt(max: number): number { | ||
| // crypto.randomInt generates a uniform integer in [0, max) | ||
| return crypto.randomInt(max); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a random agent name (AdjectiveNoun format). | ||
| */ | ||
| export function generateAgentName(): string { | ||
| const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; | ||
| const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; | ||
| const adjective = ADJECTIVES[secureRandomInt(ADJECTIVES.length)]; | ||
| const noun = NOUNS[secureRandomInt(NOUNS.length)]; | ||
| return `${adjective}${noun}`; | ||
| } | ||
|
|
||
| @@ -45,7 +48,7 @@ | ||
| } | ||
| } | ||
| // Fallback: append random suffix | ||
| return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`; | ||
| return `${generateAgentName()}${secureRandomInt(1000)}`; | ||
| } | ||
|
|
||
| /** |
| */ | ||
| async spawn(request: SpawnRequest): Promise<SpawnResult> { | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive, model: modelOverride } = request; | ||
| const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive, model: modelOverride, extraEnv } = request; |
Check failure
Code scanning / CodeQL
Insecure randomness High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
To fix the problem, we need to replace the use of Math.random() in the name generation utilities with a cryptographically secure source of randomness. On Node.js, the recommended way is to use crypto.randomInt (or crypto.randomBytes), which is backed by a CSPRNG. We should keep the external behavior the same (still choosing a random adjective and noun and, in the fallback, a numeric suffix), but base all randomness on crypto instead of Math.random.
The best targeted fix is in packages/utils/src/name-generator.ts:
- Import Node’s
cryptomodule (e.g.,import crypto from 'node:crypto';). - Add a small helper
secureRandomIndex(max: number): numberthat returns an integer in[0, max)usingcrypto.randomInt. - Change
generateAgentName()to select indices usingsecureRandomIndex(ADJECTIVES.length)andsecureRandomIndex(NOUNS.length)instead ofMath.floor(Math.random() * ...). - Change the fallback in
generateUniqueAgentNameto usecrypto.randomInt(1000)instead ofMath.floor(Math.random() * 1000).
No changes are needed in src/cli/index.ts or packages/bridge/src/spawner.ts, because once generateAgentName() and generateUniqueAgentName() are secure, all downstream uses (including the task sent into spawn) will be derived from secure randomness.
Concretely:
- In
packages/utils/src/name-generator.ts, add thecryptoimport near the top with the other declarations. - Insert the helper function
secureRandomIndex. - Update the two lines using
Math.random()(line 32 and line 48) to usecrypto.randomIntvia the helper.
| @@ -3,6 +3,8 @@ | ||
| * Inspired by mcp_agent_mail's approach. | ||
| */ | ||
|
|
||
| import crypto from 'node:crypto'; | ||
|
|
||
| const ADJECTIVES = [ | ||
| 'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber', | ||
| 'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper', | ||
| @@ -26,11 +28,21 @@ | ||
| ]; | ||
|
|
||
| /** | ||
| * Get a cryptographically secure random integer in the range [0, max). | ||
| */ | ||
| function secureRandomIndex(max: number): number { | ||
| if (max <= 0) { | ||
| throw new Error('secureRandomIndex: max must be positive'); | ||
| } | ||
| return crypto.randomInt(0, max); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a random agent name (AdjectiveNoun format). | ||
| */ | ||
| export function generateAgentName(): string { | ||
| const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; | ||
| const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; | ||
| const adjective = ADJECTIVES[secureRandomIndex(ADJECTIVES.length)]; | ||
| const noun = NOUNS[secureRandomIndex(NOUNS.length)]; | ||
| return `${adjective}${noun}`; | ||
| } | ||
|
|
||
| @@ -45,7 +54,7 @@ | ||
| } | ||
| } | ||
| // Fallback: append random suffix | ||
| return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`; | ||
| return `${generateAgentName()}${crypto.randomInt(0, 1000)}`; | ||
| } | ||
|
|
||
| /** |
| const text = typeof body === 'string' ? body : JSON.stringify(body); | ||
|
|
||
| try { | ||
| const response = await fetch(`${this.cloudUrl}/api/daemons/slack-reply`, { |
Check warning
Code scanning / CodeQL
File data in outbound network request Medium
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${this.cloudApiKey}`, | ||
| }, |
Check warning
Code scanning / CodeQL
File data in outbound network request Medium
Comprehensive investigation and fixes for: 1. Thread context dropout - agents spawn without thread_ts 2. Missing message acknowledgment - spawn commands can be silently lost Proposes 4-phase solution: - Phase 1: Thread context propagation (P0) - Phase 2: Message ACK protocol with retry logic (P0) - Phase 3: Request correlation and tracing (P1) - Phase 4: Thread timestamp validation (P2) Includes: - Root cause analysis with affected file locations - Detailed implementation specs with code examples - Database schema changes for message tracking - ACK protocol implementation - Success metrics and monitoring strategy - Testing and rollout plans Repository branches: - relay: feature/fast-message-polling (PR #420) - relay-dashboard: feat/slack-integration-v2 (PR #47) - relay-cloud: feature/slack-cli (PR #84) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
| // Store Slack context for reply routing | ||
| if (msg.metadata?.slackReply && msg.metadata?.channelId && msg.metadata?.threadTs && msg.metadata?.workspaceId) { | ||
| this.slackContexts.set(msg.to, { | ||
| channelId: msg.metadata.channelId as string, | ||
| threadTs: msg.metadata.threadTs as string, | ||
| workspaceId: msg.metadata.workspaceId as string, | ||
| }); | ||
| } |
There was a problem hiding this comment.
🔴 Slack context stored under original target name, but fallback agent replies under its own name
When a cross-machine Slack message arrives, the Slack context (channelId, threadTs, workspaceId) is stored using msg.to as the key. However, the router's fallback logic may deliver the message to a different agent (e.g., "Lead"). When that agent replies to __cloud__, handleCloudMessage looks up the Slack context using the agent's own name, which won't match.
Root Cause and Impact
At packages/daemon/src/server.ts:811, the Slack context is stored as this.slackContexts.set(msg.to, ...) where msg.to is the original target agent name (e.g., "SomeAgent").
However, the router's cross-machine fallback at packages/daemon/src/router.ts:730-748 may reroute the message to "Lead" or the first available agent if the original target doesn't exist locally.
When the fallback agent ("Lead") later sends a reply to __cloud__, handleCloudMessage at packages/daemon/src/server.ts:945 calls this.slackContexts.get(from) where from is "Lead". Since the context was stored under the original target name (not "Lead"), the lookup returns undefined, and the reply is silently dropped with a warning log.
Impact: Slack replies from agents that received messages via the fallback routing will never be delivered back to Slack.
Prompt for agents
The Slack context is stored using msg.to as the key (the original intended target), but the router may deliver the message to a different agent via fallback (e.g., Lead). The fix should ensure the Slack context is stored under the name of the agent that actually receives the message, not the original target. One approach: after router.route() delivers the message, store the Slack context under the actual recipient's name. Alternatively, store the context under both the original target AND the fallback target. Another approach is to include the Slack context in the message data/metadata so the receiving agent can reference it when replying, rather than relying on a daemon-side lookup by agent name.
Was this helpful? React with 👍 or 👎 to provide feedback.
- router.ts: Update `to` parameter to `actualTo` after fallback for correct downstream tracking (recordReceive, setProcessing) - router.ts: Add .catch() handler for cloud message storage - server.ts: Fix metadata spread order to prevent _crossMachine overwrite - server.ts: Add Slack context fallback extraction from message data - server.ts: Add slackContexts.delete() cleanup in removeStaleAgent, onClose, onError, and stop() to prevent memory leaks Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Accept both 'channelId' and 'channel' field names in handleCloudSpawnCommand for compatibility with different cloud server metadata formats. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Instead of dropping messages when no connections exist, persist them to storage for later delivery when an agent connects. This prevents message loss during brief periods when no agents are connected. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| // Fallback 2: Try first available agent (excluding human users) | ||
| for (const [name, agent] of this.agents) { | ||
| agentTarget = agent; | ||
| actualTo = name; | ||
| routerLog.info(`Lead not found, falling back to first available agent "${name}"`, { from, requestedTarget: to }); | ||
| break; | ||
| } |
There was a problem hiding this comment.
🟡 Cross-machine fallback routing can redirect messages to unrelated agents
The cross-machine message fallback chain in sendDirect falls back to the first available agent in the this.agents Map when neither the target nor "Lead" is found. This can route messages to completely unrelated agents that have no context about the incoming task.
Detailed Explanation
At packages/daemon/src/router.ts:738-743, when a cross-machine message's target is not found and no "Lead" agent exists:
for (const [name, agent] of this.agents) {
agentTarget = agent;
actualTo = name;
break;
}This picks the first agent from the Map iteration order, which is insertion order. This could be any agent - a Dashboard connection, a worker doing something completely different, etc. The message gets silently redirected without the sender knowing.
While the code excludes human users (only iterates this.agents, not this.users), it doesn't filter out internal agents (like those starting with __). An internal agent like __cli_sender__ could receive the message.
Impact: Cross-machine messages (e.g., from Slack) could be delivered to the wrong agent, causing confusion and potentially triggering unintended actions. The original sender has no indication that their message was rerouted.
Was this helpful? React with 👍 or 👎 to provide feedback.
Add fallback values for from/to fields since SendEnvelope types have these as optional, but saveMessage requires strings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.