Skip to content

Commit d817e09

Browse files
committed
fix(daytona): stop the lifecycle tools crashing on a non-string sandboxId
start/stop/delete echo sandboxId back as the output id when the API returns no body, via params.sandboxId.trim() inside transformResponse - after the request has already gone out. sandboxId is declared type: 'string' but arrives unvalidated, and now that safeUrlPathSegment accepts a numeric id a number builds a URL, sends the DELETE/START/STOP, and only then throws an unnamed TypeError. Both the old and new behaviour fail, so this is not a regression of a working workflow, but for delete_sandbox the side effect is irreversible and the caller cannot tell what happened. Fixed with a shared resolveSandboxId in utils.ts rather than a coercion at each of the three sites: utils.ts already owns every sandbox-id helper, the three tools already import from it, and the reasoning belongs in one place. The encoded value cannot be reused - it is percent-encoded and would be wrong as an output id. Behaviour for a string is unchanged.
1 parent 102dbd6 commit d817e09

5 files changed

Lines changed: 67 additions & 3 deletions

File tree

apps/sim/tools/daytona/delete_sandbox.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
extractDaytonaError,
77
mapDaytonaSandbox,
88
parseDaytonaJson,
9+
resolveSandboxId,
910
} from '@/tools/daytona/utils'
1011
import type { ToolConfig } from '@/tools/types'
1112

@@ -48,7 +49,7 @@ export const daytonaDeleteSandboxTool: ToolConfig<
4849
const data = await parseDaytonaJson(response)
4950
const sandbox = mapDaytonaSandbox(data)
5051
if (!sandbox.id && params) {
51-
sandbox.id = params.sandboxId.trim()
52+
sandbox.id = resolveSandboxId(params.sandboxId)
5253
}
5354
return {
5455
success: true,

apps/sim/tools/daytona/sandbox_path_safety.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,45 @@ describe('encodeSandboxId', () => {
180180
expect(encodeSandboxId(sandboxId)).toBe(sandboxId)
181181
})
182182
})
183+
184+
/**
185+
* The lifecycle tools echo `sandboxId` back as the output id when the API
186+
* returns no body. That runs in `transformResponse`, so the sandbox has already
187+
* been started, stopped, or irreversibly deleted by the time it executes — a
188+
* `.trim()` on a non-string id would surface the successful call as an unnamed
189+
* `TypeError`. `sandboxId` is declared `type: 'string'` but arrives unvalidated,
190+
* and `safeUrlPathSegment` now accepts a numeric id, so a number reaches here.
191+
*/
192+
describe('the sandbox id echoed back by the lifecycle tools', () => {
193+
const lifecycleTools = [
194+
daytonaTools.daytonaStartSandboxTool,
195+
daytonaTools.daytonaStopSandboxTool,
196+
daytonaTools.daytonaDeleteSandboxTool,
197+
] as AnyTool[]
198+
199+
const emptyBody = () => new Response('', { status: 200 })
200+
201+
it.each(lifecycleTools.map((tool) => [tool.id, tool] as const))(
202+
'%s reports a numeric sandboxId instead of throwing after the request went out',
203+
async (_id, tool) => {
204+
const result = await tool.transformResponse!(emptyBody(), {
205+
apiKey: 'k',
206+
sandboxId: 12345 as unknown as string,
207+
})
208+
209+
expect(result.output.sandbox.id).toBe('12345')
210+
}
211+
)
212+
213+
it.each(lifecycleTools.map((tool) => [tool.id, tool] as const))(
214+
'%s still trims a string sandboxId',
215+
async (_id, tool) => {
216+
const result = await tool.transformResponse!(emptyBody(), {
217+
apiKey: 'k',
218+
sandboxId: ' sbx_abc123 ',
219+
})
220+
221+
expect(result.output.sandbox.id).toBe('sbx_abc123')
222+
}
223+
)
224+
})

apps/sim/tools/daytona/start_sandbox.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
extractDaytonaError,
77
mapDaytonaSandbox,
88
parseDaytonaJson,
9+
resolveSandboxId,
910
} from '@/tools/daytona/utils'
1011
import type { ToolConfig } from '@/tools/types'
1112

@@ -48,7 +49,7 @@ export const daytonaStartSandboxTool: ToolConfig<
4849
const data = await parseDaytonaJson(response)
4950
const sandbox = mapDaytonaSandbox(data)
5051
if (!sandbox.id && params) {
51-
sandbox.id = params.sandboxId.trim()
52+
sandbox.id = resolveSandboxId(params.sandboxId)
5253
}
5354
return {
5455
success: true,

apps/sim/tools/daytona/stop_sandbox.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
extractDaytonaError,
77
mapDaytonaSandbox,
88
parseDaytonaJson,
9+
resolveSandboxId,
910
} from '@/tools/daytona/utils'
1011
import type { ToolConfig } from '@/tools/types'
1112

@@ -46,7 +47,7 @@ export const daytonaStopSandboxTool: ToolConfig<DaytonaStopSandboxParams, Dayton
4647
const data = await parseDaytonaJson(response)
4748
const sandbox = mapDaytonaSandbox(data)
4849
if (!sandbox.id && params) {
49-
sandbox.id = params.sandboxId.trim()
50+
sandbox.id = resolveSandboxId(params.sandboxId)
5051
}
5152
return {
5253
success: true,

apps/sim/tools/daytona/utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,25 @@ export function encodeSandboxId(sandboxId: string): string {
1818
return safeUrlPathSegment(sandboxId, 'Sandbox ID')
1919
}
2020

21+
/**
22+
* The sandbox identifier as it should read back in a tool's output, for the
23+
* lifecycle responses that return no body of their own.
24+
*
25+
* `sandboxId` is declared `type: 'string'`, but the value arrives unvalidated
26+
* from an LLM tool call or stored workflow state and a numeric-looking id can
27+
* land as a JSON number — the same widening {@link safeUrlPathSegment} accepts.
28+
* This runs inside `transformResponse`, so the request has *already* been sent:
29+
* a bare `.trim()` here would throw an unnamed `TypeError` after the sandbox was
30+
* started, stopped, or irreversibly deleted. Stringifying keeps the call's
31+
* result reportable.
32+
*
33+
* Only reached for a value {@link encodeSandboxId} already accepted, so the
34+
* rejection of unusable shapes stays where it belongs — before the request.
35+
*/
36+
export function resolveSandboxId(sandboxId: string | number | bigint): string {
37+
return String(sandboxId).trim()
38+
}
39+
2140
/**
2241
* Builds a toolbox API URL for a sandbox-scoped endpoint.
2342
*/

0 commit comments

Comments
 (0)