Skip to content

Commit 4a6ac48

Browse files
fix(cli): review round 1 — flag lookup, terminal controls, download safety
## CLI flags silently dropped (Cursor, High) Commander camelCases every multi-word flag, so `--min-duration-ms` is stored as `minDurationMs`. `buildRequest` looked flags up by their own kebab name, found nothing, and dropped the field — no error, it just never reached the API. That was every multi-word flag on every generated command. The unit tests passed because they fed flag values already keyed by flag name, which is not what commander produces — they validated a fiction. Added `build.test.ts`, which parses real argv through the built commands; three of its assertions fail against the previous code. The old tests now use camelCase keys with a comment saying why. ## Terminal control sequences (Greptile, P1 security) `stripAnsi` matched only SGR (`ESC [ … m`), so a knowledge document, table cell, or workflow name could carry OSC, non-SGR CSI, or `ESC c` through to an interactive terminal — setting the window title, moving the cursor to overwrite what was already printed, or resetting the terminal. Replaced with a `sanitize` covering OSC (BEL- and ST-terminated), CSI, any ESC + printable, and the bare C0/C1 range, keeping tab and newline. Applied where API values become display text, so the colour the CLI adds afterwards still works. ## Downloads (Greptile, P1 ×2) `createWriteStream` truncated silently, and the destination name usually comes from the server's content-disposition rather than anything the caller typed — so a download could irreversibly replace an unrelated local file. Now opens `wx` and fails with a message naming `--force`, which was added for the deliberate overwrite. The stream's error listener was attached after the read loop finished, so an EEXIST/EACCES/ENOSPC during writing was an unhandled 'error' event that took down the process. It is now registered before the first write and raced against the pump. ## Personal-key caption (Cursor, Low) With "No workspace (personal key)" picked, the caption still promised a default workspace the approval does not send. It now distinguishes no-pick from picked-but-not-admin. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsThUPqZXjwuuyRjBbmVkj
1 parent 3e423ba commit 4a6ac48

10 files changed

Lines changed: 324 additions & 58 deletions

File tree

apps/sim/app/cli/auth/cli-auth-view.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,12 @@ export function CliAuthView() {
121121
? 'Could not load your workspaces. Connecting still works and issues a personal key; reload to pick a default workspace.'
122122
: bindsToWorkspace
123123
? `Issues a key that can only reach ${chosen.name}.`
124-
: 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'}
124+
: chosen
125+
? 'Issues a personal key tied to your account, defaulting to this workspace. Workspace-scoped keys need admin.'
126+
: // No workspace picked, so none is sent and none becomes the
127+
// profile default — promising one here would describe a
128+
// grant that Connect is not about to make.
129+
'Issues a personal key tied to your account, with no default workspace.'}
125130
</p>
126131
</div>
127132
)}

packages/sim-cli/src/commands/auth.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,22 @@ import { printRecord } from '../output/render.js'
2525
* falls through to the user pasting it somewhere.
2626
*/
2727
function openBrowser(url: string): void {
28-
const command =
29-
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
28+
/**
29+
* Windows needs `cmd /c start "" <url>`.
30+
*
31+
* `start` is a cmd builtin, so it needs a shell — but its first quoted
32+
* argument is the *window title*, and node quotes the URL because of the `?`
33+
* and `&` in the query. Passing the URL alone therefore opens a console
34+
* titled with the handoff link and no browser at all. The empty `""` takes
35+
* the title slot so the URL lands where it belongs.
36+
*/
37+
const [command, args] =
38+
process.platform === 'win32'
39+
? ['cmd', ['/c', 'start', '', url]]
40+
: [process.platform === 'darwin' ? 'open' : 'xdg-open', [url]]
41+
3042
try {
31-
const child = spawn(command, [url], {
32-
stdio: 'ignore',
33-
detached: true,
34-
shell: process.platform === 'win32',
35-
})
43+
const child = spawn(command, args, { stdio: 'ignore', detached: true })
3644
child.on('error', () => {})
3745
child.unref()
3846
} catch {}

packages/sim-cli/src/commands/hand-written.ts

Lines changed: 79 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { Command } from 'commander'
66
import { clientFrom } from '../context.js'
77
import type { QueryRowsResponse } from '../generated/v2-api.js'
88
import { SimApiError } from '../http/client.js'
9-
import { type Column, printList, text } from '../output/render.js'
9+
import { type Column, printList, sanitize, text } from '../output/render.js'
1010

1111
/**
1212
* Commands the generated runtime cannot produce.
@@ -28,23 +28,44 @@ type Row = QueryRowsResponse['data'][number]
2828
* cast that would erase exactly the typing this keeps honest.
2929
*/
3030
async function streamToFile(body: ReadableStream<Uint8Array>, file: WriteStream): Promise<void> {
31-
const reader = body.getReader()
31+
// Registered before the first write, not after the loop. `createWriteStream`
32+
// opens lazily, so an EEXIST/EACCES/ENOSPC can surface at any point — with no
33+
// listener attached it is an unhandled 'error' event that takes down the
34+
// process instead of failing the download.
35+
const failed = new Promise<never>((_resolve, reject) => {
36+
file.once('error', reject)
37+
})
38+
39+
const pump = (async () => {
40+
const reader = body.getReader()
41+
try {
42+
while (true) {
43+
const { done, value } = await reader.read()
44+
if (done) break
45+
// `write` returning false means the buffer is full; waiting for `drain`
46+
// is what stops a large file being buffered entirely in memory.
47+
if (!file.write(value)) await once(file, 'drain')
48+
}
49+
} finally {
50+
reader.releaseLock()
51+
}
52+
53+
await new Promise<void>((resolve) => file.end(resolve))
54+
})()
55+
3256
try {
33-
while (true) {
34-
const { done, value } = await reader.read()
35-
if (done) break
36-
// `write` returning false means the buffer is full; waiting for `drain` is
37-
// what stops a large file being buffered entirely in memory.
38-
if (!file.write(value)) await once(file, 'drain')
57+
await Promise.race([pump, failed])
58+
} catch (error) {
59+
file.destroy()
60+
const code = (error as NodeJS.ErrnoException).code
61+
if (code === 'EEXIST') {
62+
throw new SimApiError(
63+
`${file.path} already exists. Pass --force to overwrite, or -o to write elsewhere.`,
64+
0
65+
)
3966
}
40-
} finally {
41-
reader.releaseLock()
67+
throw new SimApiError(`Could not write ${file.path}: ${(error as Error).message}`, 0)
4268
}
43-
44-
await new Promise<void>((resolve, reject) => {
45-
file.once('error', reject)
46-
file.end(resolve)
47-
})
4869
}
4970

5071
/**
@@ -70,7 +91,8 @@ function rowColumns(rows: Row[]): Column<Row>[] {
7091
value: (row: Row) => {
7192
const value = row.data[key]
7293
if (value === null || value === undefined) return text(null)
73-
return typeof value === 'object' ? JSON.stringify(value) : String(value)
94+
// User-defined cell data is remote content; strip terminal controls.
95+
return sanitize(typeof value === 'object' ? JSON.stringify(value) : String(value))
7496
},
7597
})),
7698
]
@@ -89,36 +111,49 @@ export function attachHandWritten(program: Command): void {
89111
.command('download <fileId>')
90112
.description('Download a file')
91113
.option('-o, --output-file <path>', 'Where to write it (defaults to the file name)')
92-
.action(async (fileId: string, options: { outputFile?: string }, command: Command) => {
93-
const { client, profile } = clientFrom(command)
94-
const workspaceId = client.requireWorkspace()
95-
96-
if (!profile.apiKey) {
97-
throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0)
98-
}
99-
100-
const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`)
101-
url.searchParams.set('workspaceId', workspaceId)
102-
103-
const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } })
104-
if (!response.ok || !response.body) {
105-
const raw = await response.text().catch(() => '')
106-
throw new SimApiError(
107-
raw || `Download failed with status ${response.status}`,
108-
response.status
114+
.option('--force', 'Overwrite the destination if it already exists')
115+
.action(
116+
async (
117+
fileId: string,
118+
options: { outputFile?: string; force?: boolean },
119+
command: Command
120+
) => {
121+
const { client, profile } = clientFrom(command)
122+
const workspaceId = client.requireWorkspace()
123+
124+
if (!profile.apiKey) {
125+
throw new SimApiError(`Not logged in on profile "${profile.name}". Run: sim login`, 0)
126+
}
127+
128+
const url = new URL(`${profile.endpoint}/api/v2/files/${encodeURIComponent(fileId)}`)
129+
url.searchParams.set('workspaceId', workspaceId)
130+
131+
const response = await fetch(url, { headers: { 'x-api-key': profile.apiKey } })
132+
if (!response.ok || !response.body) {
133+
const raw = await response.text().catch(() => '')
134+
throw new SimApiError(
135+
raw || `Download failed with status ${response.status}`,
136+
response.status
137+
)
138+
}
139+
140+
const target =
141+
options.outputFile ??
142+
basename(
143+
/filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ??
144+
fileId
145+
)
146+
147+
// `wx` fails rather than truncating: a download that silently replaces an
148+
// existing file is unrecoverable, and the name often comes from the
149+
// server's content-disposition rather than anything the caller typed.
150+
await streamToFile(
151+
response.body,
152+
createWriteStream(target, { flags: options.force ? 'w' : 'wx' })
109153
)
154+
console.log(chalk.green(`✓ Saved ${target}`))
110155
}
111-
112-
const target =
113-
options.outputFile ??
114-
basename(
115-
/filename="?([^";]+)"?/.exec(response.headers.get('content-disposition') ?? '')?.[1] ??
116-
fileId
117-
)
118-
119-
await streamToFile(response.body, createWriteStream(target))
120-
console.log(chalk.green(`✓ Saved ${target}`))
121-
})
156+
)
122157

123158
// ── tables rows list ── columns come from user-defined row data ───────────
124159
const tables = group(program, 'tables')

packages/sim-cli/src/output/render.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@ import {
77
duration,
88
printList,
99
printRecord,
10+
sanitize,
1011
text,
1112
visibleWidth,
1213
} from './render.js'
1314

15+
const ESC = String.fromCharCode(27)
16+
1417
/** Colour is stripped when not writing to a TTY, so force it on for these assertions. */
1518
const coloured = new Chalk({ level: 1 })
1619

@@ -189,3 +192,49 @@ describe('formatters', () => {
189192
expect(duration(90_000)).toBe('1m30s')
190193
})
191194
})
195+
196+
describe('sanitize', () => {
197+
// Remote content — knowledge document text, table cell values, workflow names —
198+
// reaches an interactive terminal through the human-readable renderers.
199+
it('removes an OSC window-title sequence', () => {
200+
expect(sanitize(`${ESC}]0;pwned\u0007hello`)).toBe('hello')
201+
})
202+
203+
it('removes OSC terminated by ST rather than BEL', () => {
204+
expect(sanitize(`${ESC}]0;pwned${ESC}\\hello`)).toBe('hello')
205+
})
206+
207+
it('removes cursor movement that would overwrite what was already printed', () => {
208+
expect(sanitize(`before${ESC}[2A${ESC}[2Kafter`)).toBe('beforeafter')
209+
})
210+
211+
it('removes a full terminal reset', () => {
212+
expect(sanitize(`${ESC}creset`)).toBe('reset')
213+
})
214+
215+
it('removes non-SGR CSI, which the old SGR-only pattern left executable', () => {
216+
// The reported hole: stripping only `ESC [ … m` passed everything else through.
217+
expect(sanitize(`${ESC}[6n`)).toBe('')
218+
expect(sanitize(`${ESC}[?1049h`)).toBe('')
219+
})
220+
221+
it('removes bare C0 and C1 control characters', () => {
222+
expect(sanitize('a\u0000b\u0008c\u009bd')).toBe('abcd')
223+
})
224+
225+
it('takes the following byte with a bare ESC, since ESC + printable is a sequence', () => {
226+
expect(sanitize('a\u001bdb')).toBe('ab')
227+
})
228+
229+
it('keeps tabs and newlines, which are legitimate content', () => {
230+
expect(sanitize('a\tb\nc')).toBe('a\tb\nc')
231+
})
232+
233+
it('leaves ordinary text untouched', () => {
234+
expect(sanitize('refund policy — 30 days')).toBe('refund policy — 30 days')
235+
})
236+
237+
it('is applied to values passing through text()', () => {
238+
expect(text(`${ESC}]0;x\u0007safe`)).toBe('safe')
239+
})
240+
})

packages/sim-cli/src/output/render.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,50 @@ const EMPTY_GLYPH = '—'
1313
/** Cell text for values that have no useful rendering, kept visually quiet. */
1414
const EMPTY = chalk.dim(EMPTY_GLYPH)
1515

16+
/**
17+
* Escape sequences and control characters that must never reach a terminal
18+
* from server-supplied data.
19+
*
20+
* Covers CSI (`ESC [ … final`), OSC (`ESC ] … BEL|ST`), single-character escapes
21+
* such as `ESC c` (full reset), and the bare C0/C1 control range. Anything a
22+
* knowledge document, table cell, or workflow name contains is remote content —
23+
* a document could set the window title, move the cursor to overwrite what was
24+
* already printed, reset the terminal, or on some emulators drive clipboard and
25+
* paste controls.
26+
*
27+
* Matching only SGR (`… m`) was the hole: it stripped colour and left every
28+
* other sequence executable.
29+
*/
30+
const ESC = String.fromCharCode(27)
31+
const CONTROL_PATTERN = new RegExp(
32+
[
33+
`${ESC}\\][^\\u0007${ESC}]*(?:\\u0007|${ESC}\\\\)?`, // OSC … BEL or ST
34+
`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, // CSI … final byte
35+
// Any other ESC + printable: `ESC c` (full reset), `ESC 7`/`ESC 8` (cursor
36+
// save/restore), `ESC (0` (line-drawing charset), and the rest. ESC is never
37+
// legitimate content, so the whole two-byte form goes. OSC and CSI are
38+
// matched above, so they win at the same position.
39+
`${ESC}[ -~]`,
40+
`${ESC}`, // a lone ESC with nothing valid after it
41+
'[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f-\\u009f]', // C0/C1, keeping \t and \n
42+
].join('|'),
43+
'g'
44+
)
45+
46+
/**
47+
* Removes terminal control sequences from a server-supplied string.
48+
*
49+
* Applied where API values become display text, so the colour the CLI adds
50+
* afterwards still works — sanitizing the finished cell would strip our own
51+
* formatting too.
52+
*/
53+
export function sanitize(value: string): string {
54+
return value.replace(CONTROL_PATTERN, '')
55+
}
56+
1657
export function text(value: unknown): string {
1758
if (value === null || value === undefined || value === '') return EMPTY
18-
return String(value)
59+
return sanitize(String(value))
1960
}
2061

2162
/** ISO timestamps are the wire format everywhere; show them without the milliseconds. */

0 commit comments

Comments
 (0)