Skip to content

Commit 6aab0b1

Browse files
committed
fix(ai-sandbox): address Blaxel review feedback
1 parent e58d7bf commit 6aab0b1

5 files changed

Lines changed: 133 additions & 46 deletions

File tree

packages/ai-sandbox-blaxel/src/handle.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,16 @@ export const BLAXEL_CAPS: SandboxCapabilities = {
5353
/** Default workspace root created inside the sandbox. */
5454
export const BLAXEL_DEFAULT_WORKDIR = '/workspace'
5555

56-
/** How long a minted preview token stays valid. */
56+
/** Fallback when a preview TTL uses an unknown server-side duration format. */
5757
const PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1000
58+
const PREVIEW_TTL_UNIT_MS: Record<string, number> = {
59+
ms: 1,
60+
s: 1000,
61+
m: 60 * 1000,
62+
h: 60 * 60 * 1000,
63+
d: 24 * 60 * 60 * 1000,
64+
w: 7 * 24 * 60 * 60 * 1000,
65+
}
5866

5967
/** Maximum unread stdout or stderr retained per spawned process. */
6068
const STREAM_BUFFER_LIMIT_BYTES = 8 * 1024 * 1024
@@ -139,7 +147,7 @@ export interface BlaxelProcessLike {
139147
}
140148

141149
export interface BlaxelPreviewLike {
142-
spec?: { url?: string; public?: boolean; port?: number }
150+
spec?: { url?: string; public?: boolean; port?: number; expires?: string }
143151
tokens: { create: (expiresAt: Date) => Promise<{ value: string }> }
144152
}
145153

@@ -161,6 +169,37 @@ export interface BlaxelHandleDeps {
161169
previewTtl: string
162170
}
163171

172+
function previewTtlMilliseconds(ttl: string): number {
173+
let milliseconds = 0
174+
let offset = 0
175+
for (const match of ttl.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/g)) {
176+
if (match.index !== offset) return PREVIEW_TOKEN_TTL_MS
177+
const amount = match[1]
178+
const unitMilliseconds = PREVIEW_TTL_UNIT_MS[match[2] ?? '']
179+
if (amount === undefined || unitMilliseconds === undefined) {
180+
return PREVIEW_TOKEN_TTL_MS
181+
}
182+
milliseconds += Number(amount) * unitMilliseconds
183+
offset += match[0].length
184+
}
185+
return offset === ttl.length &&
186+
milliseconds > 0 &&
187+
Number.isFinite(milliseconds)
188+
? milliseconds
189+
: PREVIEW_TOKEN_TTL_MS
190+
}
191+
192+
function previewTokenExpiresAt(
193+
preview: BlaxelPreviewLike,
194+
previewTtl: string,
195+
): Date {
196+
if (preview.spec?.expires) {
197+
const serverExpiry = new Date(preview.spec.expires)
198+
if (Number.isFinite(serverExpiry.getTime())) return serverExpiry
199+
}
200+
return new Date(Date.now() + previewTtlMilliseconds(previewTtl))
201+
}
202+
164203
export class BlaxelHandle implements SandboxHandle {
165204
readonly id: string
166205
readonly provider = 'blaxel'
@@ -382,7 +421,7 @@ export class BlaxelHandle implements SandboxHandle {
382421
` cat ${chunkFile} >> ${file} || { __tanstack_capture_status=1; break; }`,
383422
` printf '%s' ${q(recordPrefix)}`,
384423
` base64 < ${chunkFile} | tr -d '\r\n'`,
385-
` printf '\n'`,
424+
` printf '\\n'`,
386425
' __tanstack_remaining=$((__tanstack_remaining - __tanstack_chunk_size))',
387426
'done',
388427
'if [ "$__tanstack_capture_status" -eq 0 ] && [ "$__tanstack_remaining" -eq 0 ]; then',
@@ -395,8 +434,8 @@ export class BlaxelHandle implements SandboxHandle {
395434
'fi',
396435
`rm -f -- ${chunkFile}`,
397436
'if [ "$__tanstack_capture_status" -ne 0 ]; then',
398-
` printf '%s\n' ${q(label)} >> ${limitsFile}`,
399-
` printf '%s\n' ${q(overflowMarker)}`,
437+
` printf '%s\\n' ${q(label)} >> ${limitsFile}`,
438+
` printf '%s\\n' ${q(overflowMarker)}`,
400439
'fi',
401440
`) < ${pipe}${redirect} &`,
402441
].join('\n')
@@ -954,7 +993,7 @@ export class BlaxelHandle implements SandboxHandle {
954993
// Keep the credential out of the URL and report Blaxel's explicit preview
955994
// header. The separate token field remains available to channel consumers.
956995
const token = await preview.tokens.create(
957-
new Date(Date.now() + PREVIEW_TOKEN_TTL_MS),
996+
previewTokenExpiresAt(preview, this.previewTtl),
958997
)
959998
if (!token.value) {
960999
throw new Error(

packages/ai-sandbox-blaxel/tests/blaxel.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe.skipIf(gated)(
153153
// survives in a terminal state — so resume() has to read the status.
154154
// Without that check this returns a handle to a dead sandbox.
155155
const provider = blaxelSandbox({ apiKey, workspace })
156-
const sbx = await provider.create({})
156+
const sbx = track(await provider.create({}))
157157
await sbx.destroy()
158158
expect(await provider.resume({ id: sbx.id })).toBeNull()
159159
}, 180_000)

packages/ai-sandbox-blaxel/tests/handle.test.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable @typescript-eslint/require-await -- trivial fixed-value fakes */
22
import { SandboxInstance } from '@blaxel/core'
33
import { toLines } from '@tanstack/ai-sandbox'
4-
import { spawn as spawnChild } from 'node:child_process'
4+
import { spawn as spawnChild, spawnSync } from 'node:child_process'
55
import { existsSync, readFileSync, rmSync } from 'node:fs'
66
import { describe, expect, it, vi } from 'vitest'
77
import { BLAXEL_CAPS, BlaxelHandle, previewName } from '../src/handle'
@@ -23,6 +23,7 @@ interface FakeOptions {
2323
previewUrl?: string | undefined
2424
previewPublic?: boolean
2525
previewPort?: number
26+
previewExpires?: string
2627
watchBase?: string
2728
waitResult?: BlaxelProcessLike
2829
waitGate?: Promise<BlaxelProcessLike>
@@ -178,6 +179,9 @@ function fakeSandbox(options: FakeOptions = {}): {
178179
: { url: 'https://abc.preview.bl.run' }),
179180
public: options.previewPublic ?? preview.spec.public,
180181
port: options.previewPort ?? preview.spec.port,
182+
...(options.previewExpires
183+
? { expires: options.previewExpires }
184+
: {}),
181185
},
182186
tokens: { create: tokenCreate },
183187
}
@@ -204,6 +208,7 @@ function makeHandle(
204208
overrides: {
205209
workdir?: string
206210
publicPreviews?: boolean
211+
previewTtl?: string
207212
} = {},
208213
): {
209214
handle: BlaxelHandle
@@ -215,7 +220,7 @@ function makeHandle(
215220
name: 'sb',
216221
workdir: overrides.workdir ?? '/workspace',
217222
publicPreviews: overrides.publicPreviews ?? false,
218-
previewTtl: '1h',
223+
previewTtl: overrides.previewTtl ?? '1h',
219224
})
220225
return { handle, fake }
221226
}
@@ -683,7 +688,10 @@ describe('BlaxelHandle process', () => {
683688
expect(await collect(spawned.stderr)).toBe('warned')
684689
})
685690

686-
it.runIf(process.platform !== 'win32')(
691+
it.runIf(
692+
process.platform !== 'win32' &&
693+
spawnSync('bash', ['-c', 'exit 0'], { stdio: 'ignore' }).status === 0,
694+
)(
687695
'supervisor reaps the command and capture process groups on termination',
688696
async () => {
689697
let resolveWait!: (value: BlaxelProcessLike) => void
@@ -703,25 +711,35 @@ describe('BlaxelHandle process', () => {
703711
const child = spawnChild('/bin/sh', ['-c', script], {
704712
stdio: 'ignore',
705713
})
706-
const pidsPath = `${outputDir!}/pids`
707-
await vi.waitFor(() => expect(existsSync(pidsPath)).toBe(true))
708-
const pids = readFileSync(pidsPath, 'utf8')
709-
.trim()
710-
.split(/\s+/)
711-
.map(Number)
712714
const exited = new Promise<void>((resolve, reject) => {
713715
child.once('error', reject)
714716
child.once('exit', () => resolve())
715717
})
716-
child.kill('SIGTERM')
717-
await exited
718-
await vi.waitFor(() => {
718+
let pids: Array<number> = []
719+
try {
720+
const pidsPath = `${outputDir!}/pids`
721+
await vi.waitFor(() => expect(existsSync(pidsPath)).toBe(true))
722+
pids = readFileSync(pidsPath, 'utf8').trim().split(/\s+/).map(Number)
723+
child.kill('SIGTERM')
724+
await exited
725+
await vi.waitFor(() => {
726+
for (const pid of pids) {
727+
expect(() => process.kill(-pid, 0)).toThrow()
728+
}
729+
})
730+
} finally {
731+
child.kill('SIGKILL')
719732
for (const pid of pids) {
720-
expect(() => process.kill(-pid, 0)).toThrow()
733+
try {
734+
process.kill(-pid, 'SIGKILL')
735+
} catch {
736+
// The process group already exited.
737+
}
721738
}
722-
})
723-
rmSync(outputDir!, { recursive: true, force: true })
724-
resolveWait({ exitCode: 0, stdout: '', stderr: '' })
739+
await exited.catch(() => undefined)
740+
rmSync(outputDir!, { recursive: true, force: true })
741+
resolveWait({ exitCode: 0, stdout: '', stderr: '' })
742+
}
725743
await spawned.wait()
726744
},
727745
)
@@ -945,6 +963,25 @@ describe('BlaxelHandle ports', () => {
945963
})
946964
})
947965

966+
it('keeps a private preview token valid for a custom preview TTL', async () => {
967+
const before = Date.now()
968+
const { handle, fake } = makeHandle({}, { previewTtl: '4h' })
969+
await handle.ports.connect(3000)
970+
const after = Date.now()
971+
const expiresAt = fake.tokenCreate.mock.calls[0]?.[0] as Date
972+
expect(expiresAt.getTime()).toBeGreaterThanOrEqual(
973+
before + 4 * 60 * 60 * 1000,
974+
)
975+
expect(expiresAt.getTime()).toBeLessThanOrEqual(after + 4 * 60 * 60 * 1000)
976+
})
977+
978+
it('uses the preview expiration reported by Blaxel for its token', async () => {
979+
const previewExpires = new Date(Date.now() + 90 * 60 * 1000).toISOString()
980+
const { handle, fake } = makeHandle({ previewExpires })
981+
await handle.ports.connect(3000)
982+
expect(fake.tokenCreate).toHaveBeenCalledWith(new Date(previewExpires))
983+
})
984+
948985
it('returns a bare URL and mints no token for a public preview', async () => {
949986
const { handle, fake } = makeHandle({}, { publicPreviews: true })
950987
expect(await handle.ports.connect(8080)).toEqual({

packages/ai-sandbox-blaxel/tests/provider.test.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ const calls: {
1010

1111
let getError: unknown
1212
let getFactory: ((name: string) => Record<string, unknown>) | undefined
13-
let createGate: Promise<Record<string, unknown>> | undefined
13+
let createGate: (() => Promise<Record<string, unknown>>) | undefined
1414
let mkdirError: unknown
15-
let deleteGate: Promise<Record<string, never>> | undefined
15+
let deleteGate: (() => Promise<Record<string, never>>) | undefined
1616

1717
let getStatus: string | undefined = 'DEPLOYED'
1818

@@ -73,13 +73,12 @@ vi.mock('@blaxel/core', () => ({
7373
SandboxInstance: {
7474
createIfNotExists: async (config: Record<string, unknown>) => {
7575
calls.created.push(config)
76-
return (
77-
createGate ??
78-
fakeInstance(
79-
String(config.name),
80-
config.labels as Record<string, string> | undefined,
81-
)
82-
)
76+
return createGate
77+
? await createGate()
78+
: fakeInstance(
79+
String(config.name),
80+
config.labels as Record<string, string> | undefined,
81+
)
8382
},
8483
get: async (name: string) => {
8584
calls.got.push(name)
@@ -88,7 +87,7 @@ vi.mock('@blaxel/core', () => ({
8887
},
8988
delete: async (name: string) => {
9089
calls.deleted.push(name)
91-
return deleteGate ?? {}
90+
return deleteGate ? await deleteGate() : {}
9291
},
9392
},
9493
}))
@@ -244,9 +243,10 @@ describe('blaxelSandbox create', () => {
244243

245244
it('aborts an in-flight create and deletes the late billed sandbox', async () => {
246245
let resolveCreate!: (sandbox: Record<string, unknown>) => void
247-
createGate = new Promise((resolve) => {
248-
resolveCreate = resolve
249-
})
246+
createGate = () =>
247+
new Promise((resolve) => {
248+
resolveCreate = resolve
249+
})
250250
const controller = new AbortController()
251251
const provider = blaxelSandbox()
252252
const creating = provider.create({
@@ -266,7 +266,7 @@ describe('blaxelSandbox create', () => {
266266
})
267267

268268
it('deletes an aborted create whose SDK promise never settles', async () => {
269-
createGate = new Promise(() => undefined)
269+
createGate = () => new Promise(() => undefined)
270270
let visible = false
271271
let visibilityScheduled = false
272272
getFactory = (name) => {
@@ -312,9 +312,12 @@ describe('blaxelSandbox create', () => {
312312

313313
it('never deletes a same-name sandbox reused by concurrent callers', async () => {
314314
mkdirError = new Error('mkdir failed')
315-
createGate = Promise.resolve(
316-
fakeInstance('shared', { 'tanstack-ai-create-attempt': 'other-attempt' }),
317-
)
315+
createGate = () =>
316+
Promise.resolve(
317+
fakeInstance('shared', {
318+
'tanstack-ai-create-attempt': 'other-attempt',
319+
}),
320+
)
318321
const provider = blaxelSandbox()
319322
const results = await Promise.allSettled([
320323
provider.create({ id: 'shared' }),
@@ -328,7 +331,8 @@ describe('blaxelSandbox create', () => {
328331
})
329332

330333
it('reconciles and deletes an owned create accepted before a late 504', async () => {
331-
createGate = Promise.reject({ status: 504, message: 'gateway timeout' })
334+
createGate = () =>
335+
Promise.reject({ status: 504, message: 'gateway timeout' })
332336
getFactory = (name) =>
333337
fakeInstance(
334338
name,
@@ -345,13 +349,14 @@ describe('blaxelSandbox create', () => {
345349
})
346350

347351
it('reports cleanup failure after reconciling an accepted create error', async () => {
348-
createGate = Promise.reject({ status: 504, message: 'gateway timeout' })
352+
createGate = () =>
353+
Promise.reject({ status: 504, message: 'gateway timeout' })
349354
getFactory = (name) =>
350355
fakeInstance(
351356
name,
352357
calls.created[0]?.labels as Record<string, string> | undefined,
353358
)
354-
deleteGate = Promise.reject({ status: 500, message: 'delete failed' })
359+
deleteGate = () => Promise.reject({ status: 500, message: 'delete failed' })
355360
const provider = blaxelSandbox()
356361
await expect(
357362
provider.create({ id: 'accepted-cleanup-failed' }),
@@ -361,7 +366,7 @@ describe('blaxelSandbox create', () => {
361366

362367
it('reports both preparation and owned-sandbox cleanup failures', async () => {
363368
mkdirError = new Error('mkdir failed')
364-
deleteGate = Promise.reject({ status: 500, message: 'delete failed' })
369+
deleteGate = () => Promise.reject({ status: 500, message: 'delete failed' })
365370
const provider = blaxelSandbox()
366371
await expect(provider.create({ id: 'cleanup-failed' })).rejects.toThrow(
367372
/cleanup also failed|could not be cleaned up/,
@@ -443,13 +448,13 @@ describe('blaxelSandbox destroy', () => {
443448
})
444449

445450
it('treats an already deleted sandbox as a successful destroy', async () => {
446-
deleteGate = Promise.reject({ code: 404 })
451+
deleteGate = () => Promise.reject({ code: 404 })
447452
const provider = blaxelSandbox()
448453
await expect(provider.destroy({ id: 'sb-gone' })).resolves.toBeUndefined()
449454
})
450455

451456
it('does not hang past the destroy signal when the SDK call stalls', async () => {
452-
deleteGate = new Promise(() => {})
457+
deleteGate = () => new Promise(() => {})
453458
const controller = new AbortController()
454459
const provider = blaxelSandbox()
455460
const destroying = provider.destroy({

packages/ai-sandbox/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ Pick a **provider** package for where the sandbox runs:
5555
| `@tanstack/ai-sandbox-sprites` | Sprites stateful sandboxes |
5656
| `@tanstack/ai-sandbox-blaxel` | Blaxel cloud sandboxes and previews |
5757

58+
Install the provider you select separately. For Blaxel:
59+
60+
```bash
61+
npm install @tanstack/ai-sandbox-blaxel
62+
```
63+
5864
**Harness adapters** are separate packages. The default path is **Grok Build** (`@tanstack/ai-grok-build`); others include `@tanstack/ai-claude-code`, `@tanstack/ai-codex`, and `@tanstack/ai-opencode`. All require `withSandbox(...)` middleware — `chat()` fails fast without it.
5965

6066
## Three moving parts

0 commit comments

Comments
 (0)