Skip to content

Commit f353eb0

Browse files
author
unraid
committed
fix: bound agent communication memory growth
UDS messaging now uses private local capabilities instead of exposing auth tokens through SDK metadata, environment variables, session registry, peer listing, or tool output. The receive path bounds NDJSON frames, response buffers, active clients, and pending inbox bytes, and strips auth metadata before messages enter the prompt queue. Teammate mailboxes now validate file and message sizes, fail closed on corrupt mutation inputs, compact by count and retained bytes, and use stable message identity for in-process acknowledgements. Agent summaries now fork only a bounded recent context using lazy size estimation and content fingerprints instead of retaining or serializing unbounded histories. Constraint: PR #361 was already merged; this branch is based on upstream/main@c2ac9a74. Rejected: Default-disabling COORDINATOR_MODE/TEAMMEM only | explicit feature enablement still hit unbounded paths. Rejected: Persisting UDS auth in SDK/env/session registry | bridge/remote metadata can leak local capability secrets. Rejected: Inline uds #token addresses | observable/tool/classifier paths can reflect raw addresses outside the UDS request frame. Rejected: Positional mailbox marking after compaction | compaction can shift indices across the lock boundary. Confidence: high Scope-risk: moderate Directive: Do not expose UDS capability tokens through SDK messages, environment variables, session registry, peer-list output, or SendMessage result/classifier surfaces. Directive: Do not reintroduce positional mailbox acknowledgements unless compaction is removed or read+mark is atomic under one lock. Tested: bun test src/utils/__tests__/ndjsonFramer.test.ts src/utils/__tests__/udsMessaging.test.ts packages/builtin-tools/src/tools/SendMessageTool/__tests__/udsRecipientSanitization.test.ts Tested: bunx tsc --noEmit --pretty false Tested: bun run lint Tested: bunx biome lint modified src/package files Tested: bun run test:all (3704 pass, 0 fail, 6734 expects) Tested: bun audit (No vulnerabilities found) Tested: bun run build Tested: bun run build:vite Tested: git diff --check Not-tested: End-to-end external UDS client driving a full production headless model turn.
1 parent c2ac9a7 commit f353eb0

17 files changed

Lines changed: 2086 additions & 136 deletions

File tree

packages/builtin-tools/src/tools/ListPeersTool/ListPeersTool.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -85,21 +85,35 @@ Use this tool to discover messaging targets before sending cross-session message
8585
// and optionally includes Remote Control bridge peers.
8686
const peers: PeerInfo[] = []
8787

88-
// Discovery is handled by the UDS messaging subsystem initialized in setup.ts.
89-
// Return discovered peers from the app state.
90-
const appState = context.getAppState()
91-
const messagingSocketPath = (appState as Record<string, unknown>).messagingSocketPath as string | undefined
88+
/* eslint-disable @typescript-eslint/no-require-imports */
89+
const udsMessaging =
90+
require('src/utils/udsMessaging.js') as typeof import('src/utils/udsMessaging.js')
91+
const udsClient =
92+
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
93+
/* eslint-enable @typescript-eslint/no-require-imports */
94+
95+
const messagingSocketPath = udsMessaging.getUdsMessagingSocketPath()
9296
if (messagingSocketPath) {
9397
// Self entry for reference
9498
if (_input.include_self) {
9599
peers.push({
96-
address: `uds:${messagingSocketPath}`,
100+
address: udsMessaging.formatUdsAddress(messagingSocketPath),
97101
name: 'self',
98102
pid: process.pid,
99103
})
100104
}
101105
}
102106

107+
for (const peer of await udsClient.listPeers()) {
108+
if (!peer.messagingSocketPath) continue
109+
peers.push({
110+
address: udsMessaging.formatUdsAddress(peer.messagingSocketPath),
111+
name: peer.name ?? peer.kind,
112+
cwd: peer.cwd,
113+
pid: peer.pid,
114+
})
115+
}
116+
103117
return {
104118
data: { peers },
105119
}

packages/builtin-tools/src/tools/SendMessageTool/SendMessageTool.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,43 @@ export type SendMessageToolOutput =
130130
| RequestOutput
131131
| ResponseOutput
132132

133+
const UDS_INLINE_TOKEN_MARKER = '#token='
134+
const UDS_INLINE_TOKEN_REJECTED_KEY = '__udsInlineTokenRejected'
135+
136+
function stripInlineUdsToken(target: string): string {
137+
const markerIndex = target.lastIndexOf(UDS_INLINE_TOKEN_MARKER)
138+
return markerIndex === -1 ? target : target.slice(0, markerIndex)
139+
}
140+
141+
function hasInlineUdsToken(to: string): boolean {
142+
const addr = parseAddress(to)
143+
return (
144+
addr.scheme === 'uds' && addr.target.includes(UDS_INLINE_TOKEN_MARKER)
145+
)
146+
}
147+
148+
function recipientForDisplay(to: string): string {
149+
const addr = parseAddress(to)
150+
if (addr.scheme !== 'uds') return to
151+
return `uds:${stripInlineUdsToken(addr.target)}`
152+
}
153+
154+
function markAndRedactInlineUdsToken(
155+
input: { to: string } & Record<string, unknown>,
156+
): void {
157+
if (!hasInlineUdsToken(input.to)) return
158+
input.to = recipientForDisplay(input.to)
159+
input[UDS_INLINE_TOKEN_REJECTED_KEY] = true
160+
}
161+
162+
function wasInlineUdsTokenRejected(input: unknown): boolean {
163+
return (
164+
typeof input === 'object' &&
165+
input !== null &&
166+
(input as Record<string, unknown>)[UDS_INLINE_TOKEN_REJECTED_KEY] === true
167+
)
168+
}
169+
133170
function findTeammateColor(
134171
appState: {
135172
teamContext?: { teammates: { [id: string]: { color?: string } } }
@@ -541,15 +578,19 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
541578
},
542579

543580
backfillObservableInput(input) {
544-
if ('type' in input) return
545581
if (typeof input.to !== 'string') return
546582

583+
markAndRedactInlineUdsToken(
584+
input as { to: string } & Record<string, unknown>,
585+
)
586+
if ('type' in input) return
587+
547588
if (input.to === '*') {
548589
input.type = 'broadcast'
549590
if (typeof input.message === 'string') input.content = input.message
550591
} else if (typeof input.message === 'string') {
551592
input.type = 'message'
552-
input.recipient = input.to
593+
input.recipient = recipientForDisplay(input.to)
553594
input.content = input.message
554595
} else if (typeof input.message === 'object' && input.message !== null) {
555596
const msg = input.message as {
@@ -560,7 +601,7 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
560601
feedback?: string
561602
}
562603
input.type = msg.type
563-
input.recipient = input.to
604+
input.recipient = recipientForDisplay(input.to)
564605
if (msg.request_id !== undefined) input.request_id = msg.request_id
565606
if (msg.approve !== undefined) input.approve = msg.approve
566607
const content = msg.reason ?? msg.feedback
@@ -569,16 +610,17 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
569610
},
570611

571612
toAutoClassifierInput(input) {
613+
const recipient = recipientForDisplay(input.to)
572614
if (typeof input.message === 'string') {
573-
return `to ${input.to}: ${input.message}`
615+
return `to ${recipient}: ${input.message}`
574616
}
575617
switch (input.message.type) {
576618
case 'shutdown_request':
577-
return `shutdown_request to ${input.to}`
619+
return `shutdown_request to ${recipient}`
578620
case 'shutdown_response':
579621
return `shutdown_response ${input.message.approve ? 'approve' : 'reject'} ${input.message.request_id}`
580622
case 'plan_approval_response':
581-
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${input.to}`
623+
return `plan_approval ${input.message.approve ? 'approve' : 'reject'} to ${recipient}`
582624
}
583625
},
584626

@@ -630,6 +672,19 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
630672
errorCode: 9,
631673
}
632674
}
675+
if (feature('UDS_INBOX')) {
676+
if (
677+
addr.scheme === 'uds' &&
678+
(hasInlineUdsToken(input.to) || wasInlineUdsTokenRejected(input))
679+
) {
680+
return {
681+
result: false,
682+
message:
683+
'uds addresses must not include inline auth tokens; use the ListPeers address',
684+
errorCode: 9,
685+
}
686+
}
687+
}
633688
if (input.to.includes('@')) {
634689
return {
635690
result: false,
@@ -787,6 +842,16 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
787842
}
788843
}
789844
if (addr.scheme === 'uds') {
845+
const recipient = recipientForDisplay(input.to)
846+
if (hasInlineUdsToken(input.to) || wasInlineUdsTokenRejected(input)) {
847+
return {
848+
data: {
849+
success: false,
850+
message:
851+
'uds addresses must not include inline auth tokens; use the ListPeers address',
852+
},
853+
}
854+
}
790855
/* eslint-disable @typescript-eslint/no-require-imports */
791856
const { sendToUdsSocket } =
792857
require('src/utils/udsClient.js') as typeof import('src/utils/udsClient.js')
@@ -797,14 +862,14 @@ export const SendMessageTool: Tool<InputSchema, SendMessageToolOutput> =
797862
return {
798863
data: {
799864
success: true,
800-
message: `”${preview}” → ${input.to}`,
865+
message: `”${preview}” → ${recipient}`,
801866
},
802867
}
803868
} catch (e) {
804869
return {
805870
data: {
806871
success: false,
807-
message: `Failed to send to ${input.to}: ${errorMessage(e)}`,
872+
message: `Failed to send to ${recipient}: ${errorMessage(e)}`,
808873
},
809874
}
810875
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, mock, test } from 'bun:test'
2+
3+
mock.module('bun:bundle', () => ({
4+
feature: (name: string) => name === 'UDS_INBOX',
5+
}))
6+
7+
describe('SendMessageTool UDS recipient handling', () => {
8+
test('redacts inline UDS tokens before classifier and observable paths', async () => {
9+
const { SendMessageTool } = await import('../SendMessageTool.js')
10+
const tokenAddress = 'uds:/tmp/peer.sock#token=secret-token'
11+
12+
const observableInput = {
13+
to: tokenAddress,
14+
message: 'hello',
15+
} as Record<string, unknown>
16+
SendMessageTool.backfillObservableInput!(observableInput)
17+
18+
expect(observableInput.recipient).toBe('uds:/tmp/peer.sock')
19+
expect(JSON.stringify(observableInput)).not.toContain('secret-token')
20+
expect(
21+
SendMessageTool.toAutoClassifierInput({
22+
to: tokenAddress,
23+
message: 'hello',
24+
}),
25+
).toBe('to uds:/tmp/peer.sock: hello')
26+
})
27+
28+
test('rejects inline UDS tokens during validation', async () => {
29+
const { SendMessageTool } = await import('../SendMessageTool.js')
30+
const result = await SendMessageTool.validateInput!(
31+
{
32+
to: 'uds:/tmp/peer.sock#token=secret-token',
33+
message: 'hello',
34+
},
35+
{} as never,
36+
)
37+
38+
expect(result.result).toBe(false)
39+
expect(JSON.stringify(result)).not.toContain('secret-token')
40+
})
41+
})

src/cli/print.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2763,13 +2763,37 @@ function runHeadlessStreaming(
27632763
// when a message arrives via the UDS socket in headless mode.
27642764
if (feature('UDS_INBOX')) {
27652765
/* eslint-disable @typescript-eslint/no-require-imports */
2766-
const { setOnEnqueue } = require('../utils/udsMessaging.js')
2766+
const { drainInbox, setOnEnqueue } =
2767+
require('../utils/udsMessaging.js') as typeof import('../utils/udsMessaging.js')
27672768
/* eslint-enable @typescript-eslint/no-require-imports */
2769+
2770+
const enqueueUdsInboxMessages = (): boolean => {
2771+
const entries = drainInbox()
2772+
for (const entry of entries) {
2773+
const value =
2774+
typeof entry.message.data === 'string'
2775+
? entry.message.data
2776+
: jsonStringify(entry.message)
2777+
enqueue({
2778+
mode: 'prompt',
2779+
value,
2780+
uuid: randomUUID(),
2781+
})
2782+
}
2783+
return entries.length > 0
2784+
}
2785+
27682786
setOnEnqueue(() => {
27692787
if (!inputClosed) {
2770-
void run()
2788+
if (enqueueUdsInboxMessages()) {
2789+
void run()
2790+
}
27712791
}
27722792
})
2793+
2794+
if (enqueueUdsInboxMessages()) {
2795+
void run()
2796+
}
27732797
}
27742798

27752799
// Cron scheduler: runs scheduled_tasks.json tasks in SDK/-p mode.

src/commands/peers/peers.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { LocalCommandCall } from '../../types/command.js'
22
import { listPeers, isPeerAlive } from '../../utils/udsClient.js'
3-
import { getUdsMessagingSocketPath } from '../../utils/udsMessaging.js'
3+
import {
4+
formatUdsAddress,
5+
getUdsMessagingSocketPath,
6+
} from '../../utils/udsMessaging.js'
47

58
export const call: LocalCommandCall = async (_args, _context) => {
69
const mySocket = getUdsMessagingSocketPath()
@@ -29,11 +32,11 @@ export const call: LocalCommandCall = async (_args, _context) => {
2932
? ` started: ${formatAge(peer.startedAt)}`
3033
: ''
3134

32-
lines.push(
33-
` [${status}] PID ${peer.pid} (${label})${cwd}${age}`,
34-
)
35+
lines.push(` [${status}] PID ${peer.pid} (${label})${cwd}${age}`)
3536
if (peer.messagingSocketPath) {
36-
lines.push(` socket: ${peer.messagingSocketPath}`)
37+
lines.push(
38+
` socket: ${formatUdsAddress(peer.messagingSocketPath)}`,
39+
)
3740
}
3841
if (peer.sessionId) {
3942
lines.push(` session: ${peer.sessionId}`)
@@ -43,7 +46,7 @@ export const call: LocalCommandCall = async (_args, _context) => {
4346

4447
lines.push('')
4548
lines.push(
46-
'To message a peer: use SendMessage with to="uds:<socket-path>"',
49+
'To message a peer: use SendMessage with the shown uds:<socket-path> address',
4750
)
4851

4952
return { type: 'text', value: lines.join('\n') }

0 commit comments

Comments
 (0)