11import { spawn , spawnSync } from 'child_process'
2- import { readFileSync , rmSync , writeFileSync } from 'fs'
2+ import { readFileSync , realpathSync , rmSync , writeFileSync } from 'fs'
33import os from 'os'
44import 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.
3131const 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
3336export type TerminalBrokerFailureStage = 'spawn' | 'stdio' | 'completion'
3437export 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+
131150export 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-
176185function 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
202202function 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. */
269298export 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