Skip to content

Commit c5a6607

Browse files
fix(setup): harden mint cleanup, Windows browser, container detection, helm cwd
Review findings from #5911: - a post-mint completeApproval failure no longer routes into releaseMint — the mint lock now outlives the approval (shared TTL), so a cleanup blip can't leave a re-mintable window and orphan a key; cleanup is best-effort after the key ships - compose doctor --fix writes the feature-flag twin to the layout's primary env (root .env on a compose install), not always apps/sim/.env - Windows opens the browser via `cmd /c start "" <url>` — `start` is a shell builtin, so spawning it directly ENOENT'd and the handoff never opened - managed-container detection filters loosely and pins the exact name in code; Docker's `name=^x$` anchor matches the internal `/x` form and often missed, skipping the reuse branch - the shared helm/kind run helper pins cwd to the repo root, matching helm test, so `helm upgrade --install ./helm/sim` works from any working directory
1 parent 8e8c5ca commit c5a6607

8 files changed

Lines changed: 59 additions & 14 deletions

File tree

apps/sim/app/api/cli/auth/poll/route.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,19 @@ describe('POST /api/cli/auth/poll', () => {
8181
expect(mockCompleteApproval).not.toHaveBeenCalled()
8282
})
8383

84+
it('still returns the key when post-mint cleanup fails — never releases the lock', async () => {
85+
mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' })
86+
mockCompleteApproval.mockRejectedValue(new Error('redis blip'))
87+
const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER }))
88+
expect(response.status).toBe(200)
89+
await expect(response.json()).resolves.toEqual({
90+
status: 'complete',
91+
key: { id: 'key-1', apiKey: 'sk-test' },
92+
})
93+
// A cleanup failure must not release the mint lock — that would allow a re-mint.
94+
expect(mockReleaseMint).not.toHaveBeenCalled()
95+
})
96+
8497
it('rejects a malformed verifier before touching the store', async () => {
8598
const response = await POST(pollRequest({ request: REQUEST, verifier: 'too-short' }))
8699
expect(response.status).toBe(400)

apps/sim/app/api/cli/auth/poll/route.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
3535
return NextResponse.json({ status: 'pending' })
3636
}
3737

38+
let key: Awaited<ReturnType<typeof generateCopilotApiKey>>
3839
try {
39-
const key = await generateCopilotApiKey(result.userId, cliKeyName())
40-
await completeApproval(requestId)
41-
logger.info('Minted CLI key on approved poll', { userId: result.userId })
42-
return NextResponse.json({ status: 'complete', key })
40+
key = await generateCopilotApiKey(result.userId, cliKeyName())
4341
} catch (error) {
42+
// Mint failed — release the reservation so a later poll can retry.
4443
await releaseMint(requestId)
4544
const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined
4645
return NextResponse.json(
4746
{ error: 'Failed to generate copilot API key' },
4847
{ status: status ?? 500 }
4948
)
5049
}
50+
51+
// Mint succeeded — the key exists. Consuming the approval is best-effort: a
52+
// cleanup failure must NOT release the lock (that would let a later poll mint
53+
// a second, orphaned key). The record and lock share a TTL and expire together.
54+
await completeApproval(requestId).catch((error) => {
55+
logger.error('Failed to consume CLI approval after minting', {
56+
error,
57+
userId: result.userId,
58+
})
59+
})
60+
logger.info('Minted CLI key on approved poll', { userId: result.userId })
61+
return NextResponse.json({ status: 'complete', key })
5162
})

apps/sim/lib/cli-auth/approval-store.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,11 @@ describe('cli-auth approval store', () => {
6969
status: 'approved',
7070
userId: 'user-1',
7171
})
72-
// NX lock on the mint key with a TTL; the approval itself is NOT deleted here.
72+
// NX lock on the mint key; TTL matches the approval so they expire together
73+
// and a failed cleanup can't leave a re-mintable window. Record not deleted here.
7374
const [key, val, px, ttl, nx] = mockSet.mock.calls[0]
7475
expect(key).toContain('cli:auth:mint:')
75-
expect([val, px, ttl, nx]).toEqual(['1', 'PX', 30_000, 'NX'])
76+
expect([val, px, ttl, nx]).toEqual(['1', 'PX', 120_000, 'NX'])
7677
expect(mockDel).not.toHaveBeenCalled()
7778
})
7879

apps/sim/lib/cli-auth/approval-store.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import { getRedisClient } from '@/lib/core/config/redis'
1717
*/
1818

1919
const APPROVAL_TTL_MS = 120_000
20-
const MINT_LOCK_TTL_MS = 30_000
20+
// The mint lock must outlive the approval it guards: if a mint succeeds but the
21+
// cleanup delete fails, the still-held lock is what stops a later poll from
22+
// re-minting the now-orphaned key. Matching the approval TTL means the record
23+
// and the lock expire together, so there is never a window where the approval
24+
// is redeemable but the lock is gone.
25+
const MINT_LOCK_TTL_MS = APPROVAL_TTL_MS
2126

2227
interface ApprovalRecord {
2328
/** BASE64URL(SHA256(pollSecret)) — the CLI proves possession of the secret at poll time. */

scripts/setup/checks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,8 @@ function checkCoherence(ctx: CheckContext): Finding[] {
375375
status: 'fail',
376376
message: `${setSide} is set but its twin ${missingSide} disagrees — server and browser will render different features`,
377377
fix: `doctor --fix sets ${missingSide}=${value}`,
378-
autofix: () => writeEnvValues('sim', { [missingSide]: value }),
378+
// Write to the layout's primary env (root on a compose install), not always sim.
379+
autofix: () => writeEnvValues(sim.target, { [missingSide]: value }),
379380
})
380381
}
381382

scripts/setup/cli-auth.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ const POLL_INTERVAL_MS = 2000
1111

1212
function openBrowser(url: string): void {
1313
if (process.env.SIM_SETUP_NO_BROWSER) return
14-
const command =
15-
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
14+
if (process.platform === 'win32') {
15+
// `start` is a cmd builtin, not an executable — spawning it directly ENOENTs.
16+
// The empty '' is start's window-title argument, which it needs before the URL.
17+
spawnSync('cmd', ['/c', 'start', '', url], { stdio: 'ignore' })
18+
return
19+
}
20+
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
1621
spawnSync(command, [url], { stdio: 'ignore' })
1722
}
1823

scripts/setup/detect.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,23 @@ function commandOutput(command: string, args: string[]): string | null {
7171

7272
function detectContainer(dockerRunning: boolean, name: string): Detection['dbContainer'] {
7373
if (!dockerRunning) return null
74+
// Docker's `name=^x$` anchor matches against the internal `/x` form and often
75+
// misses, so filter loosely (substring) and pin the exact name in code.
7476
const out = commandOutput('docker', [
7577
'ps',
7678
'-a',
7779
'--filter',
78-
`name=^${name}$`,
80+
`name=${name}`,
7981
'--format',
80-
'{{.State}}\t{{.Labels}}',
82+
'{{.Names}}\t{{.State}}\t{{.Labels}}',
8183
])
8284
if (!out) return null
83-
const [state, labels = ''] = out.split('\t')
85+
const row = out
86+
.split('\n')
87+
.map((line) => line.split('\t'))
88+
.find(([containerName]) => containerName === name)
89+
if (!row) return null
90+
const [, state, labels = ''] = row
8491
return {
8592
state: state === 'running' ? 'running' : 'stopped',
8693
managed: labels.includes(MANAGED_LABEL),

scripts/setup/modes/k8s.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ const LOCAL_CONTEXT_PREFIXES = ['kind-', 'docker-desktop', 'minikube', 'orbstack
1616
* by any process on the machine, so secrets must never travel that way.
1717
*/
1818
function run(command: string, args: string[], failMessage: string, input?: string): string {
19-
const result = spawnSync(command, args, { encoding: 'utf8', input })
19+
// `helm upgrade --install ./helm/sim` uses chart paths relative to the repo
20+
// root, so pin cwd regardless of where the wizard was invoked from.
21+
const result = spawnSync(command, args, { encoding: 'utf8', input, cwd: ROOT })
2022
if (result.status !== 0) {
2123
throw new Error(`${failMessage}: ${result.stderr.trim() || result.stdout.trim()}`)
2224
}

0 commit comments

Comments
 (0)