Skip to content

Commit dbc5372

Browse files
committed
harden shell detection and OSC guard
1 parent 5c5f59e commit dbc5372

File tree

5 files changed

+354
-144
lines changed

5 files changed

+354
-144
lines changed

cli/src/index.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,16 @@ async function runOscDetectionSubprocess(): Promise<void> {
5050
process.env.CODEBUFF_GITHUB_ACTIONS = 'true'
5151

5252
// Avoid importing logger or other modules that produce output
53-
const { detectTerminalTheme } = await import('./utils/terminal-color-detection')
53+
const { detectTerminalTheme, terminalSupportsOSC } = await import(
54+
'./utils/terminal-color-detection'
55+
)
56+
57+
if (!terminalSupportsOSC()) {
58+
console.log(JSON.stringify({ theme: null }))
59+
await new Promise((resolve) => setImmediate(resolve))
60+
process.exit(0)
61+
}
62+
5463
try {
5564
const theme = await detectTerminalTheme()
5665
console.log(JSON.stringify({ theme }))

cli/src/utils/detect-shell.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { execSync } from 'child_process'
2+
3+
type KnownShell =
4+
| 'bash'
5+
| 'zsh'
6+
| 'fish'
7+
| 'cmd.exe'
8+
| 'powershell'
9+
| 'unknown'
10+
11+
type ShellName = KnownShell | string
12+
13+
let cachedShell: ShellName | null = null
14+
15+
const SHELL_ALIASES: Record<string, KnownShell> = {
16+
bash: 'bash',
17+
zsh: 'zsh',
18+
fish: 'fish',
19+
cmd: 'cmd.exe',
20+
'cmd.exe': 'cmd.exe',
21+
pwsh: 'powershell',
22+
powershell: 'powershell',
23+
'powershell.exe': 'powershell',
24+
}
25+
26+
export function detectShell(): ShellName {
27+
if (cachedShell) {
28+
return cachedShell
29+
}
30+
31+
const detected =
32+
detectFromEnvironment() ?? detectViaParentProcessInspection() ?? 'unknown'
33+
cachedShell = detected
34+
return detected
35+
}
36+
37+
function detectFromEnvironment(): ShellName | null {
38+
const candidates: Array<string | undefined> = []
39+
40+
if (process.platform === 'win32') {
41+
candidates.push(process.env.COMSPEC, process.env.SHELL)
42+
} else {
43+
candidates.push(process.env.SHELL)
44+
}
45+
46+
for (const candidate of candidates) {
47+
const normalized = normalizeCandidate(candidate)
48+
if (normalized) {
49+
return normalized
50+
}
51+
}
52+
53+
return null
54+
}
55+
56+
function detectViaParentProcessInspection(): ShellName | null {
57+
try {
58+
if (process.platform === 'win32') {
59+
const parentProcess = execSync(
60+
'wmic process get ParentProcessId,CommandLine',
61+
{ stdio: 'pipe' },
62+
)
63+
.toString()
64+
.toLowerCase()
65+
66+
if (parentProcess.includes('powershell')) return 'powershell'
67+
if (parentProcess.includes('cmd.exe')) return 'cmd.exe'
68+
} else {
69+
const parentProcess = execSync(`ps -p ${process.ppid} -o comm=`, {
70+
stdio: 'pipe',
71+
})
72+
.toString()
73+
.trim()
74+
const normalized = normalizeCandidate(parentProcess)
75+
if (normalized) return normalized
76+
}
77+
} catch {
78+
// Ignore inspection errors
79+
}
80+
81+
return null
82+
}
83+
84+
function normalizeCandidate(value?: string | null): ShellName | null {
85+
if (!value) {
86+
return null
87+
}
88+
89+
const trimmed = value.trim()
90+
if (!trimmed) {
91+
return null
92+
}
93+
94+
const lower = trimmed.toLowerCase()
95+
const parts = lower.split(/[/\\]/)
96+
const last = parts.pop() ?? lower
97+
const base = last.endsWith('.exe') ? last.slice(0, -4) : last
98+
99+
if (SHELL_ALIASES[base]) {
100+
return SHELL_ALIASES[base]
101+
}
102+
103+
if (SHELL_ALIASES[last]) {
104+
return SHELL_ALIASES[last]
105+
}
106+
107+
if (base.endsWith('sh')) {
108+
return base
109+
}
110+
111+
return null
112+
}

cli/src/utils/terminal-color-detection.ts

Lines changed: 121 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -78,101 +78,99 @@ function buildOscQuery(oscCode: number): string {
7878
export async function queryTerminalOSC(
7979
oscCode: number,
8080
): Promise<string | null> {
81-
// OSC 10/11 logic commented out
82-
return null
83-
// return new Promise((resolve) => {
84-
// const ttyPath = process.platform === 'win32' ? 'CON' : '/dev/tty'
85-
86-
// let ttyReadFd: number | null = null
87-
// let ttyWriteFd: number | null = null
88-
// let timeout: NodeJS.Timeout | null = null
89-
// let readStream: Readable | null = null
90-
91-
// const cleanup = () => {
92-
// if (timeout) {
93-
// clearTimeout(timeout)
94-
// timeout = null
95-
// }
96-
// if (readStream) {
97-
// readStream.removeAllListeners()
98-
// readStream.destroy()
99-
// readStream = null
100-
// }
101-
// if (ttyWriteFd !== null) {
102-
// try {
103-
// closeSync(ttyWriteFd)
104-
// } catch {
105-
// // Ignore close errors
106-
// }
107-
// ttyWriteFd = null
108-
// }
109-
// // ttyReadFd is managed by the stream, so we don't close it separately
110-
// }
111-
112-
// try {
113-
// // Open TTY for reading and writing
114-
// try {
115-
// ttyReadFd = openSync(ttyPath, 'r')
116-
// ttyWriteFd = openSync(ttyPath, 'w')
117-
// } catch {
118-
// // Not in a TTY environment
119-
// resolve(null)
120-
// return
121-
// }
122-
123-
// // Set timeout for terminal response
124-
// timeout = setTimeout(() => {
125-
// cleanup()
126-
// resolve(null)
127-
// }, 1000) // 1 second timeout
128-
129-
// // Create read stream to capture response
130-
// readStream = createReadStream(ttyPath, {
131-
// fd: ttyReadFd,
132-
// encoding: 'utf8',
133-
// autoClose: true,
134-
// })
135-
136-
// let response = ''
137-
138-
// readStream.on('data', (chunk: Buffer | string) => {
139-
// response += chunk.toString()
140-
141-
// // Check for complete response
142-
// const hasBEL = response.includes('\x07')
143-
// const hasST = response.includes('\x1b\\')
144-
// const hasRGB =
145-
// /rgb:[0-9a-fA-F]{2,4}\/[0-9a-fA-F]{2,4}\/[0-9a-fA-F]{2,4}/.test(
146-
// response,
147-
// )
148-
149-
// if (hasBEL || hasST || hasRGB) {
150-
// cleanup()
151-
// resolve(response)
152-
// }
153-
// })
154-
155-
// readStream.on('error', () => {
156-
// cleanup()
157-
// resolve(null)
158-
// })
159-
160-
// readStream.on('close', () => {
161-
// // If stream closes before we get a complete response
162-
// if (timeout) {
163-
// cleanup()
164-
// resolve(null)
165-
// }
166-
// })
167-
168-
// // Send OSC query
169-
// const query = buildOscQuery(oscCode)
170-
// writeSync(ttyWriteFd, query)
171-
// } catch {
172-
// cleanup()
173-
// resolve(null)
174-
// }
175-
// })
81+
return new Promise((resolve) => {
82+
const ttyPath = process.platform === 'win32' ? 'CON' : '/dev/tty'
83+
84+
let ttyReadFd: number | null = null
85+
let ttyWriteFd: number | null = null
86+
let timeout: NodeJS.Timeout | null = null
87+
let readStream: Readable | null = null
88+
89+
const cleanup = () => {
90+
if (timeout) {
91+
clearTimeout(timeout)
92+
timeout = null
93+
}
94+
if (readStream) {
95+
readStream.removeAllListeners()
96+
readStream.destroy()
97+
readStream = null
98+
}
99+
if (ttyWriteFd !== null) {
100+
try {
101+
closeSync(ttyWriteFd)
102+
} catch {
103+
// Ignore close errors
104+
}
105+
ttyWriteFd = null
106+
}
107+
// ttyReadFd is managed by the stream, so we don't close it separately
108+
}
109+
110+
try {
111+
// Open TTY for reading and writing
112+
try {
113+
ttyReadFd = openSync(ttyPath, 'r')
114+
ttyWriteFd = openSync(ttyPath, 'w')
115+
} catch {
116+
// Not in a TTY environment
117+
resolve(null)
118+
return
119+
}
120+
121+
// Set timeout for terminal response
122+
timeout = setTimeout(() => {
123+
cleanup()
124+
resolve(null)
125+
}, 1000) // 1 second timeout
126+
127+
// Create read stream to capture response
128+
readStream = createReadStream(ttyPath, {
129+
fd: ttyReadFd,
130+
encoding: 'utf8',
131+
autoClose: true,
132+
})
133+
134+
let response = ''
135+
136+
readStream.on('data', (chunk: Buffer | string) => {
137+
response += chunk.toString()
138+
139+
// Check for complete response
140+
const hasBEL = response.includes('\x07')
141+
const hasST = response.includes('\x1b\\')
142+
const hasRGB =
143+
/rgb:[0-9a-fA-F]{2,4}\/[0-9a-fA-F]{2,4}\/[0-9a-fA-F]{2,4}/.test(
144+
response,
145+
)
146+
147+
if (hasBEL || hasST || hasRGB) {
148+
cleanup()
149+
resolve(response)
150+
}
151+
})
152+
153+
readStream.on('error', () => {
154+
cleanup()
155+
resolve(null)
156+
})
157+
158+
readStream.on('close', () => {
159+
// If stream closes before we get a complete response
160+
if (timeout) {
161+
cleanup()
162+
resolve(null)
163+
}
164+
})
165+
166+
// Send OSC query
167+
const query = buildOscQuery(oscCode)
168+
writeSync(ttyWriteFd, query)
169+
} catch {
170+
cleanup()
171+
resolve(null)
172+
}
173+
})
176174
}
177175

178176
/**
@@ -254,35 +252,32 @@ export function themeFromFgColor(rgb: [number, number, number]): 'dark' | 'light
254252
* @returns 'dark', 'light', or null if detection failed
255253
*/
256254
export async function detectTerminalTheme(): Promise<'dark' | 'light' | null> {
257-
// OSC 10/11 logic commented out
258-
return null
259-
// // Check if terminal supports OSC
260-
// if (!terminalSupportsOSC()) {
261-
// return null
262-
// }
263-
264-
// try {
265-
// // Try background color first (OSC 11) - more reliable
266-
// const bgResponse = await queryTerminalOSC(11)
267-
// if (bgResponse) {
268-
// const bgRgb = parseOSCResponse(bgResponse)
269-
// if (bgRgb) {
270-
// return themeFromBgColor(bgRgb)
271-
// }
272-
// }
273-
274-
// // Fallback to foreground color (OSC 10)
275-
// const fgResponse = await queryTerminalOSC(10)
276-
// if (fgResponse) {
277-
// const fgRgb = parseOSCResponse(fgResponse)
278-
// if (fgRgb) {
279-
// return themeFromFgColor(fgRgb)
280-
// }
281-
// }
282-
283-
// return null // Detection failed
284-
// } catch {
285-
// return null
286-
// }
287-
}
255+
// Check if terminal supports OSC
256+
if (!terminalSupportsOSC()) {
257+
return null
258+
}
288259

260+
try {
261+
// Try background color first (OSC 11) - more reliable
262+
const bgResponse = await queryTerminalOSC(11)
263+
if (bgResponse) {
264+
const bgRgb = parseOSCResponse(bgResponse)
265+
if (bgRgb) {
266+
return themeFromBgColor(bgRgb)
267+
}
268+
}
269+
270+
// Fallback to foreground color (OSC 10)
271+
const fgResponse = await queryTerminalOSC(10)
272+
if (fgResponse) {
273+
const fgRgb = parseOSCResponse(fgResponse)
274+
if (fgRgb) {
275+
return themeFromFgColor(fgRgb)
276+
}
277+
}
278+
279+
return null // Detection failed
280+
} catch {
281+
return null
282+
}
283+
}

0 commit comments

Comments
 (0)