Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
38 changes: 32 additions & 6 deletions packages/builtin-tools/src/tools/ListPeersTool/ListPeersTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,22 +84,48 @@ Use this tool to discover messaging targets before sending cross-session message
// UDS socket directory. The implementation scans for live sockets
// and optionally includes Remote Control bridge peers.
const peers: PeerInfo[] = []
const seen = new Set<string>()
const addPeer = (peer: PeerInfo): void => {
if (seen.has(peer.address)) return
seen.add(peer.address)
peers.push(peer)
}

/* eslint-disable @typescript-eslint/no-require-imports */
const udsMessaging =
require('src/utils/udsMessaging.js') as typeof import('src/utils/udsMessaging.js')
const udsClient =
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
const bridgePeers =
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
/* eslint-enable @typescript-eslint/no-require-imports */

// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
// Return discovered peers from the app state.
const appState = context.getAppState()
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
const messagingSocketPath = udsMessaging.getUdsMessagingSocketPath()
if (messagingSocketPath) {
// Self entry for reference
if (_input.include_self) {
peers.push({
address: `uds:${messagingSocketPath}`,
addPeer({
address: udsMessaging.formatUdsAddress(messagingSocketPath),
name: 'self',
pid: process.pid,
})
}
}

for (const peer of await udsClient.listPeers()) {
if (!peer.messagingSocketPath) continue
addPeer({
address: udsMessaging.formatUdsAddress(peer.messagingSocketPath),
name: peer.name ?? peer.kind,
cwd: peer.cwd,
pid: peer.pid,
})
}

for (const peer of await bridgePeers.listBridgePeers()) {
addPeer(peer)
}

return {
data: { peers },
}
Expand Down
99 changes: 89 additions & 10 deletions packages/builtin-tools/src/tools/SendMessageTool/SendMessageTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,43 @@ export type SendMessageToolOutput =
| RequestOutput
| ResponseOutput

const UDS_INLINE_TOKEN_MARKER = '#token='
const UDS_INLINE_TOKEN_REJECTED_KEY = '__udsInlineTokenRejected'

function stripInlineUdsToken(target: string): string {
const markerIndex = target.lastIndexOf(UDS_INLINE_TOKEN_MARKER)
return markerIndex === -1 ? target : target.slice(0, markerIndex)
}

function hasInlineUdsToken(to: string): boolean {
const addr = parseAddress(to)
return (
addr.scheme === 'uds' && addr.target.includes(UDS_INLINE_TOKEN_MARKER)
)
}

function recipientForDisplay(to: string): string {
const addr = parseAddress(to)
if (addr.scheme !== 'uds') return to
return `uds:${stripInlineUdsToken(addr.target)}`
}

function markAndRedactInlineUdsToken(
input: { to: string } & Record<string, unknown>,
): void {
if (!hasInlineUdsToken(input.to)) return
input.to = recipientForDisplay(input.to)
input[UDS_INLINE_TOKEN_REJECTED_KEY] = true
}

function wasInlineUdsTokenRejected(input: unknown): boolean {
return (
typeof input === 'object' &&
input !== null &&
(input as Record<string, unknown>)[UDS_INLINE_TOKEN_REJECTED_KEY] === true
)
}

function findTeammateColor(
appState: {
teamContext?: { teammates: { [id: string]: { color?: string } } }
Expand Down Expand Up @@ -541,15 +578,19 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},

backfillObservableInput(input) {
if ('type' in input) return
if (typeof input.to !== 'string') return

markAndRedactInlineUdsToken(
input as { to: string } & Record<string, unknown>,
)
if ('type' in input) return

if (input.to === '*') {
input.type = 'broadcast'
if (typeof input.message === 'string') input.content = input.message
} else if (typeof input.message === 'string') {
input.type = 'message'
input.recipient = input.to
input.recipient = recipientForDisplay(input.to)
input.content = input.message
} else if (typeof input.message === 'object' && input.message !== null) {
const msg = input.message as {
Expand All @@ -560,7 +601,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
feedback?: string
}
input.type = msg.type
input.recipient = input.to
input.recipient = recipientForDisplay(input.to)
if (msg.request_id !== undefined) input.request_id = msg.request_id
if (msg.approve !== undefined) input.approve = msg.approve
const content = msg.reason ?? msg.feedback
Expand All @@ -569,16 +610,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},

toAutoClassifierInput(input) {
const recipient = recipientForDisplay(input.to)
if (typeof input.message === 'string') {
return `to ${input.to}: ${input.message}`
return `to ${recipient}: ${input.message}`
}
switch (input.message.type) {
case 'shutdown_request':
return `shutdown_request to ${input.to}`
return `shutdown_request to ${recipient}`
case 'shutdown_response':
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
case 'plan_approval_response':
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${recipient}`
}
},

Expand Down Expand Up @@ -630,6 +672,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
errorCode: 9,
}
}
if (
addr.scheme === 'uds' &&
(hasInlineUdsToken(input.to) || wasInlineUdsTokenRejected(input))
) {
return {
result: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
errorCode: 9,
}
}
if (input.to.includes('@')) {
return {
result: false,
Expand Down Expand Up @@ -753,6 +806,22 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
},

async call(input, context, canUseTool, assistantMessage) {
if (typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (
addr.scheme === 'uds' &&
(hasInlineUdsToken(input.to) || wasInlineUdsTokenRejected(input))
) {
return {
data: {
success: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
},
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (feature('UDS_INBOX') && typeof input.message === 'string') {
const addr = parseAddress(input.to)
if (addr.scheme === 'bridge') {
Expand All @@ -772,10 +841,10 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
const { postInterClaudeMessage } =
require('src/bridge/peerSessions.js') as typeof import('src/bridge/peerSessions.js')
/* eslint-enable @typescript-eslint/no-require-imports */
const result = await postInterClaudeMessage(
const result = (await postInterClaudeMessage(
addr.target,
input.message,
) as { ok: boolean; error?: string }
)) as { ok: boolean; error?: string }
const preview = input.summary || truncate(input.message, 50)
return {
data: {
Expand All @@ -787,6 +856,16 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
}
}
if (addr.scheme === 'uds') {
const recipient = recipientForDisplay(input.to)
if (hasInlineUdsToken(input.to) || wasInlineUdsTokenRejected(input)) {
return {
data: {
success: false,
message:
'uds addresses must not include inline auth tokens; use the ListPeers address',
},
}
}
/* eslint-disable @typescript-eslint/no-require-imports */
const { sendToUdsSocket } =
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
Expand All @@ -797,14 +876,14 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
return {
data: {
success: true,
message: `”${preview}” → ${input.to}`,
message: `”${preview}” → ${recipient}`,
},
}
} catch (e) {
return {
data: {
success: false,
message: `Failed to send to ${input.to}: ${errorMessage(e)}`,
message: `Failed to send to ${recipient}: ${errorMessage(e)}`,
},
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, test } from 'bun:test'

describe('SendMessageTool UDS recipient handling', () => {
test('redacts inline UDS tokens before classifier and observable paths', async () => {
const { SendMessageTool } = await import('../SendMessageTool.js')
const tokenAddress = 'uds:/tmp/peer.sock#token=secret-token'

const observableInput = {
to: tokenAddress,
message: 'hello',
} as Record<string, unknown>
SendMessageTool.backfillObservableInput!(observableInput)

expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
expect(
SendMessageTool.toAutoClassifierInput({
to: tokenAddress,
message: 'hello',
}),
).toBe('to uds:/tmp/peer.sock: hello')
})

test('keeps redacted UDS token rejection through observable backfill', async () => {
const { SendMessageTool } = await import('../SendMessageTool.js')
const observableInput = {
to: 'uds:/tmp/peer.sock#token=secret-token',
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: false,
reason: 'needs tests',
},
} as Record<string, unknown>

SendMessageTool.backfillObservableInput!(observableInput)

expect(observableInput.to).toBe('uds:/tmp/peer.sock')
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
expect(observableInput.type).toBe('plan_approval_response')
expect(observableInput.request_id).toBe('req-1')
expect(observableInput.approve).toBe(false)
expect(observableInput.content).toBe('needs tests')
expect(JSON.stringify(observableInput)).not.toContain('secret-token')

const result = await SendMessageTool.validateInput!(
observableInput as never,
{} as never,
)

expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject redacted inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
})

test('redacts UDS tokens in structured classifier text', async () => {
const { SendMessageTool } = await import('../SendMessageTool.js')
const to = 'uds:/tmp/peer.sock#token=secret-token'

expect(
SendMessageTool.toAutoClassifierInput({
to,
message: { type: 'shutdown_request' },
}),
).toBe('shutdown_request to uds:/tmp/peer.sock')
expect(
SendMessageTool.toAutoClassifierInput({
to,
message: {
type: 'plan_approval_response',
request_id: 'req-1',
approve: true,
},
}),
).toBe('plan_approval approve to uds:/tmp/peer.sock')
})

test('rejects inline UDS tokens during validation', async () => {
const { SendMessageTool } = await import('../SendMessageTool.js')
const result = await SendMessageTool.validateInput!(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
)

expect(result.result).toBe(false)
if (result.result !== false) {
throw new Error('expected validation to reject inline UDS token')
}
expect(result.message).toContain('inline auth tokens')
expect(JSON.stringify(result)).not.toContain('secret-token')
})

test('rejects inline UDS tokens during execution without leaking them', async () => {
const { SendMessageTool } = await import('../SendMessageTool.js')
const result = await SendMessageTool.call(
{
to: 'uds:/tmp/peer.sock#token=secret-token',
message: 'hello',
},
{} as never,
undefined as never,
undefined as never,
)

expect(result.data.success).toBe(false)
expect(JSON.stringify(result)).not.toContain('secret-token')
})
})
32 changes: 32 additions & 0 deletions src/bridge/peerSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ import { getBridgeAccessToken } from './bridgeConfig.js'
import { getReplBridgeHandle } from './replBridgeHandle.js'
import { toCompatSessionId } from './sessionIdCompat.js'

export type BridgePeerSession = {
address: string
name?: string
cwd?: string
pid?: number
}

/**
* List locally registered sessions that have published a Remote Control
* session ID. The PID registry is the local source of truth for bridge peers
* already known to this machine; SendMessage can use these bridge:<id>
* addresses when the current process has an active bridge handle.
*/
export async function listBridgePeers(): Promise<BridgePeerSession[]> {
const { listAllLiveSessions } = await import('../utils/udsClient.js')
const sessions = await listAllLiveSessions()
const peers: BridgePeerSession[] = []

for (const session of sessions) {
if (session.pid === process.pid || !session.bridgeSessionId) continue
const compatId = toCompatSessionId(session.bridgeSessionId)
peers.push({
address: `bridge:${compatId}`,
name: session.name ?? session.kind,
cwd: session.cwd,
pid: session.pid,
})
}

return peers
}

/**
* Send a plain-text message to another Claude session via the bridge API.
*
Expand Down
Loading
Loading