Skip to content

Commit f2b85a7

Browse files
committed
fix(cli): tighten terminal command broker protocol path handling
Return reapOwnProcessGroup() directly from the protocol write failure path so a failed write never outlives a successful one, and flush the stderr marker through a bounded wait before the group is killed. On POSIX process.stderr is an async pipe, so the previous fire-and-forget write could lose the failure reason to the immediate SIGKILL. Remove protocolWriteTargets(): the raw-environment fallback skipped temp-directory validation, and its rejection case could not be reproduced because the broker inherits the parent environment. Compare the protocol directory after resolving symlinks, fold case on Windows, and cover both spellings plus the reaping path with tests.
1 parent c1d7fc3 commit f2b85a7

2 files changed

Lines changed: 186 additions & 38 deletions

File tree

‎cli/src/utils/__tests__/terminal-command-broker.test.ts‎

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, expect, test } from 'bun:test'
22
import { spawn, spawnSync } from 'child_process'
3-
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'
3+
import {
4+
existsSync,
5+
mkdtempSync,
6+
readdirSync,
7+
readFileSync,
8+
rmSync,
9+
symlinkSync,
10+
writeFileSync,
11+
} from 'fs'
412
import { tmpdir } from 'os'
513
import path from 'path'
614

@@ -173,6 +181,54 @@ describe('terminal command broker', () => {
173181
).toThrow('terminal command broker protocol path was invalid')
174182
})
175183

184+
test('accepts a protocol path spelled through a symlinked temp directory', () => {
185+
const linkPath = path.join(
186+
tmpdir(),
187+
`freebuff-broker-tmp-link-${crypto.randomUUID()}`,
188+
)
189+
try {
190+
symlinkSync(tmpdir(), linkPath, 'dir')
191+
} catch {
192+
// Hosts without symlink permission cannot exercise this spelling.
193+
return
194+
}
195+
const validName = `freebuff-terminal-command-broker-${process.pid}-${crypto.randomUUID()}.json`
196+
try {
197+
expect(
198+
protocolPathFromEnv({
199+
CODEBUFF_TERMINAL_COMMAND_BROKER_PROTOCOL: path.join(
200+
linkPath,
201+
validName,
202+
),
203+
}),
204+
).toBe(path.join(linkPath, validName))
205+
expect(() =>
206+
protocolPathFromEnv({
207+
CODEBUFF_TERMINAL_COMMAND_BROKER_PROTOCOL: path.join(
208+
linkPath,
209+
'wrong-prefix.json',
210+
),
211+
}),
212+
).toThrow('terminal command broker protocol path was invalid')
213+
} finally {
214+
rmSync(linkPath, { force: true })
215+
}
216+
})
217+
218+
test('compares the protocol directory case-insensitively on Windows', () => {
219+
if (process.platform !== 'win32') return
220+
const validName = `freebuff-terminal-command-broker-${process.pid}-${crypto.randomUUID()}.json`
221+
const upperCasedDirectory = tmpdir().toUpperCase()
222+
expect(
223+
protocolPathFromEnv({
224+
CODEBUFF_TERMINAL_COMMAND_BROKER_PROTOCOL: path.join(
225+
upperCasedDirectory,
226+
validName,
227+
),
228+
}),
229+
).toBe(path.join(upperCasedDirectory, validName))
230+
})
231+
176232
test('relays stdout, stderr, and the command exit code', async () => {
177233
const [{ value }] = await runTerminalCommand({
178234
command: `printf 'OUT'; printf 'ERR' >&2; exit 7`,
@@ -419,6 +475,70 @@ describe('terminal command broker', () => {
419475
expect(result[0].value).toMatchObject({ exitCode: 0 })
420476
})
421477

478+
test('reaps the shell tree promptly when the protocol write fails', async () => {
479+
if (process.platform === 'win32') return
480+
const tempDir = mkdtempSync(path.join(tmpdir(), 'codebuff-broker-reap-'))
481+
const commandPidPath = path.join(tempDir, 'command.pid')
482+
const protocolPath = path.join(
483+
tmpdir(),
484+
`freebuff-terminal-command-broker-${process.pid}-${crypto.randomUUID()}.json`,
485+
)
486+
// `wx` refuses to replace an existing file, so the one-shot write fails
487+
// while the path is still the validated temp path.
488+
writeFileSync(protocolPath, 'already here')
489+
const broker = spawn(process.execPath, [brokerFixture], {
490+
env: {
491+
...process.env,
492+
CODEBUFF_TERMINAL_COMMAND_BROKER: '1',
493+
CODEBUFF_TERMINAL_COMMAND_BROKER_PROTOCOL: protocolPath,
494+
},
495+
stdio: ['pipe', 'pipe', 'pipe'],
496+
detached: true,
497+
})
498+
const brokerPid = broker.pid as number
499+
let stderr = ''
500+
broker.stderr?.on('data', (chunk: Buffer) => {
501+
stderr += chunk.toString('utf8')
502+
})
503+
const brokerClosed = new Promise<void>((resolve) => {
504+
broker.once('close', () => resolve())
505+
})
506+
broker.stdin?.end(
507+
JSON.stringify({
508+
executable: '/bin/sh',
509+
args: ['-c', `sleep 30 & echo $! > ${JSON.stringify(commandPidPath)}`],
510+
cwd: process.cwd(),
511+
env: { PATH: process.env.PATH ?? '' },
512+
}),
513+
)
514+
515+
const commandPid = (await waitFor(() => existsSync(commandPidPath), 3_000))
516+
? Number(readFileSync(commandPidPath, 'utf8'))
517+
: 0
518+
try {
519+
await Promise.race([brokerClosed, Bun.sleep(3_000)])
520+
expect(commandPid).toBeGreaterThan(0)
521+
expect(await waitFor(() => !isProcessRunning(commandPid), 3_000)).toBe(
522+
true,
523+
)
524+
expect(() => process.kill(-brokerPid, 0)).toThrow()
525+
expect(stderr).toContain('[freebuff-broker] protocol write failed:')
526+
} finally {
527+
try {
528+
process.kill(-brokerPid, 'SIGKILL')
529+
} catch {
530+
// The broker already reaped its whole process group.
531+
}
532+
try {
533+
process.kill(commandPid, 'SIGKILL')
534+
} catch {
535+
// The command descendant was reaped together with the broker group.
536+
}
537+
rmSync(protocolPath, { force: true })
538+
rmSync(tempDir, { recursive: true, force: true })
539+
}
540+
})
541+
422542
test('does not add broker recovery guidance to a command spawn failure', async () => {
423543
const missingCwd = path.join(
424544
tmpdir(),

‎cli/src/utils/terminal-command-broker.ts‎

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawn, spawnSync } from 'child_process'
2-
import { readFileSync, rmSync, writeFileSync } from 'fs'
2+
import { readFileSync, realpathSync, rmSync, writeFileSync } from 'fs'
33
import os from 'os'
44
import path from 'path'
55

@@ -29,6 +29,9 @@ const MAX_BROKER_STDERR_TAIL_BYTES = 4 * 1024
2929
// Cross-process contract: the detached broker emits this marker on stderr
3030
// before reaping itself so the parent can surface the write failure reason.
3131
const BROKER_STDERR_MARKER = '[freebuff-broker] protocol write failed:'
32+
// The marker must be flushed before the broker SIGKILLs its own process group,
33+
// but never at the cost of holding the shell tree open indefinitely.
34+
const BROKER_STDERR_FLUSH_TIMEOUT_MS = 200
3235

3336
export type TerminalBrokerFailureStage = 'spawn' | 'stdio' | 'completion'
3437
export type TerminalBrokerFailureCode =
@@ -128,6 +131,22 @@ function isSpawnRequest(value: unknown): value is TerminalCommandSpawnRequest {
128131
)
129132
}
130133

134+
/**
135+
* Compare the protocol directory after resolving symlinks and Windows casing:
136+
* a raw string compare rejects the parent's own path when the temp dir is
137+
* reached through a link (macOS `/var` -> `/private/var`) or a differently
138+
* cased spelling.
139+
*/
140+
function normalizeProtocolDirectory(directory: string): string {
141+
let resolved = path.resolve(directory)
142+
try {
143+
resolved = realpathSync.native(resolved)
144+
} catch {
145+
// The directory may not exist yet; the raw resolution stays comparable.
146+
}
147+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
148+
}
149+
131150
export function protocolPathFromEnv(
132151
env: NodeJS.ProcessEnv = getSystemProcessEnv(),
133152
): string {
@@ -138,7 +157,8 @@ export function protocolPathFromEnv(
138157

139158
const resolvedProtocolPath = path.resolve(protocolPath)
140159
if (
141-
path.dirname(resolvedProtocolPath) !== path.resolve(os.tmpdir()) ||
160+
normalizeProtocolDirectory(path.dirname(resolvedProtocolPath)) !==
161+
normalizeProtocolDirectory(os.tmpdir()) ||
142162
!path.basename(resolvedProtocolPath).startsWith(PROTOCOL_FILE_PREFIX)
143163
) {
144164
throw new Error('terminal command broker protocol path was invalid')
@@ -162,41 +182,21 @@ function removeProtocolFile(protocolPath: string): void {
162182
}
163183
}
164184

165-
function protocolWriteTargets(): string[] {
166-
try {
167-
return [protocolPathFromEnv()]
168-
} catch {
169-
// Path validation can only reject a broken environment; still attempt the
170-
// parent-provided path so a valid response is not lost to over-strictness.
171-
const raw = getSystemProcessEnv()[TERMINAL_COMMAND_BROKER_PROTOCOL_ENV]
172-
return raw ? [path.resolve(raw)] : []
173-
}
174-
}
175-
176185
function writeProtocol(message: BrokerProtocol): void {
177186
const payload = `${JSON.stringify(message)}\n`
178187
if (Buffer.byteLength(payload) > MAX_PROTOCOL_BYTES) {
179188
throw new Error('terminal command broker response was too large')
180189
}
181-
let lastError: unknown = new Error(
182-
'terminal command broker protocol path was invalid',
183-
)
184-
for (const target of protocolWriteTargets()) {
185-
try {
186-
// A constrained one-shot file avoids Bun's unreliable custom stdio pipes on
187-
// Windows. `wx` ensures even an accidentally reused path is never replaced.
188-
writeFileSync(target, payload, {
189-
encoding: 'utf8',
190-
flag: 'wx',
191-
mode: 0o600,
192-
})
193-
return
194-
} catch (error) {
195-
lastError = error
196-
}
197-
}
198-
if (lastError instanceof Error) throw lastError
199-
throw new Error(errorMessage(lastError))
190+
// A constrained one-shot file avoids Bun's unreliable custom stdio pipes on
191+
// Windows. `wx` ensures even an accidentally reused path is never replaced.
192+
// Only the validated path is written: an unvalidated fallback would let a
193+
// corrupted environment place the protocol file outside the temp directory,
194+
// and a rejected path now surfaces on stderr instead of losing the response.
195+
writeFileSync(protocolPathFromEnv(), payload, {
196+
encoding: 'utf8',
197+
flag: 'wx',
198+
mode: 0o600,
199+
})
200200
}
201201

202202
function waitForParentDisconnect(): Promise<void> {
@@ -265,6 +265,35 @@ async function readRequest(): Promise<TerminalCommandSpawnRequest> {
265265
return value
266266
}
267267

268+
/**
269+
* Deliver the write-failure marker before the detached broker kills its own
270+
* process group. `process.stderr` is an asynchronous pipe on POSIX, so without
271+
* waiting for the write callback the reason can be lost with the process; the
272+
* wait is bounded so shell-tree containment never stalls behind it.
273+
*/
274+
function flushBrokerStderrMarker(error: unknown): Promise<void> {
275+
return new Promise((resolve) => {
276+
let settled = false
277+
let timer: ReturnType<typeof setTimeout> | undefined
278+
const finish = () => {
279+
if (settled) return
280+
settled = true
281+
if (timer) clearTimeout(timer)
282+
resolve()
283+
}
284+
timer = setTimeout(finish, BROKER_STDERR_FLUSH_TIMEOUT_MS)
285+
try {
286+
process.stderr.write(
287+
`\n${BROKER_STDERR_MARKER} ${errorMessage(error)}\n`,
288+
finish,
289+
)
290+
} catch {
291+
// The pipe may already be gone; the parent still sees the missing file.
292+
finish()
293+
}
294+
})
295+
}
296+
268297
/** Run inside the detached helper process. It never initializes OpenTUI. */
269298
export async function serveTerminalCommandBroker(): Promise<void> {
270299
const parentDisconnected = waitForParentDisconnect()
@@ -301,11 +330,10 @@ export async function serveTerminalCommandBroker(): Promise<void> {
301330
} catch (error) {
302331
// Never vanish silently: relay the write failure on stderr so the parent
303332
// can explain the missing protocol instead of a generic ENOENT message.
304-
try {
305-
process.stderr.write(`\n${BROKER_STDERR_MARKER} ${errorMessage(error)}\n`)
306-
} catch {
307-
// The pipe may already be gone; the parent still sees the missing file.
308-
}
333+
await flushBrokerStderrMarker(error)
334+
// Reap here explicitly: a failed write must not leave the detached shell
335+
// tree alive any longer than a successful one.
336+
return reapOwnProcessGroup()
309337
}
310338

311339
// Normal cleanup belongs to this detached process. In particular, Windows

0 commit comments

Comments
 (0)