Problem
Passing timeout: 0 or a negative value (e.g. timeout: -10) to the sandbox create, resume, or fork API endpoints is accepted without error. The sandbox is created successfully but immediately expires — the orchestrator's background cleanup loop finds its EndTime already in the past (or at now) and kills it before the caller can do anything with it.
A unit test that demonstrates this returns 404 — not 400:
--- FAIL: TestPostSandboxes_InvalidTimeout (4.14s)
sandbox_create_test.go:1171:
Error Trace: sandbox_create_test.go:1171
Error: Not equal:
expected: 400
actual : 404
Test: TestPostSandboxes_InvalidTimeout
The 404 is from the template-not-found path, showing the request advanced past input validation entirely. When the template does exist, the request succeeds with 201 and the sandbox is immediately dead.
Root Cause
All three handlers — PostSandboxes, PostSandboxesSandboxIDResume, and PostSandboxesSandboxIDFork — share the same validation pattern:
// packages/api/internal/handlers/sandbox_create.go:L156
// packages/api/internal/handlers/sandbox_resume.go:L59
// packages/api/internal/handlers/sandbox_fork.go:L65
timeout := sandbox.SandboxTimeoutDefault
if body.Timeout != nil {
timeout = time.Duration(*body.Timeout) * time.Second
if timeout > time.Duration(teamInfo.Limits.MaxLengthHours)*time.Hour {
a.sendAPIStoreError(c, http.StatusBadRequest, ...)
return
}
}
The only guard is an upper-bound check (timeout > MaxLengthHours). Since -10s < 24h and 0s < 24h, both pass silently. The resulting time.Duration is zero or negative, so EndTime = now + timeout is in the past or at now by the time the sandbox starts.
Compare to PostSandboxesSandboxIDTimeout (the keepalive endpoint), which correctly guards the other direction:
| Handler |
Lower-bound check |
Upper-bound check |
PostSandboxes |
❌ missing |
✅ present |
PostSandboxesSandboxIDResume |
❌ missing |
✅ present |
PostSandboxesSandboxIDFork |
❌ missing |
✅ present |
PostSandboxesSandboxIDTimeout |
✅ if body.Timeout < 0 |
— |
The OpenAPI spec already declares minimum: 0 on NewSandbox.timeout, so this is also a spec/implementation mismatch.
Reproduction Steps
- Start a local API with
make local-infra and make run-local from packages/api/.
- Send a sandbox-create request with a non-positive timeout:
curl -X POST http://localhost:3000/sandboxes \
-H 'X-API-Key: <key>' \
-H 'Content-Type: application/json' \
-d '{"templateID": "<valid-id>", "timeout": -10}'
- Observe: response is
201 Created. The sandbox is created.
- Immediately call
GET /sandboxes/<id> — the sandbox is already gone (killed by the expiration loop) or has an EndTime in the past.
Unit-test reproduction (no running infra required):
func TestPostSandboxes_InvalidTimeout(t *testing.T) {
// ... setup store with DB + Redis (see existing test helpers) ...
invalidTimeout := int32(-10)
body, _ := json.Marshal(api.PostSandboxesJSONRequestBody{
TemplateID: "<existing-template-id>",
Timeout: &invalidTimeout,
})
// ... POST /sandboxes ...
require.Equal(t, http.StatusBadRequest, recorder.Code) // FAILS: actual is 201 or 404
}
Technical Context
- Files affected:
packages/api/internal/handlers/sandbox_create.go L156–L164
packages/api/internal/handlers/sandbox_resume.go L59–L67
packages/api/internal/handlers/sandbox_fork.go L65–L73
- Subsystem: Control Plane REST API — sandbox lifecycle
- Impact: Medium — silently accepted invalid input produces sandboxes that die immediately; confusing for SDK users and hard to diagnose without knowing to check
EndTime
Proposed Changes
| # |
Change |
File(s) Affected |
Complexity |
| 1 |
Add if *body.Timeout <= 0 guard before upper-bound check, return 400 Bad Request with message "Timeout must be greater than 0" |
sandbox_create.go, sandbox_resume.go, sandbox_fork.go |
Low |
| 2 |
Add unit test cases asserting 400 for timeout: 0 and timeout: -10 |
sandbox_create_test.go |
Low |
Problem
Passing
timeout: 0or a negative value (e.g.timeout: -10) to the sandbox create, resume, or fork API endpoints is accepted without error. The sandbox is created successfully but immediately expires — the orchestrator's background cleanup loop finds itsEndTimealready in the past (or atnow) and kills it before the caller can do anything with it.A unit test that demonstrates this returns
404— not400:The
404is from the template-not-found path, showing the request advanced past input validation entirely. When the template does exist, the request succeeds with201and the sandbox is immediately dead.Root Cause
All three handlers —
PostSandboxes,PostSandboxesSandboxIDResume, andPostSandboxesSandboxIDFork— share the same validation pattern:The only guard is an upper-bound check (
timeout > MaxLengthHours). Since-10s < 24hand0s < 24h, both pass silently. The resultingtime.Durationis zero or negative, soEndTime = now + timeoutis in the past or atnowby the time the sandbox starts.Compare to
PostSandboxesSandboxIDTimeout(the keepalive endpoint), which correctly guards the other direction:PostSandboxesPostSandboxesSandboxIDResumePostSandboxesSandboxIDForkPostSandboxesSandboxIDTimeoutif body.Timeout < 0The OpenAPI spec already declares
minimum: 0onNewSandbox.timeout, so this is also a spec/implementation mismatch.Reproduction Steps
make local-infraandmake run-localfrompackages/api/.201 Created. The sandbox is created.GET /sandboxes/<id>— the sandbox is already gone (killed by the expiration loop) or has anEndTimein the past.Unit-test reproduction (no running infra required):
Technical Context
packages/api/internal/handlers/sandbox_create.goL156–L164packages/api/internal/handlers/sandbox_resume.goL59–L67packages/api/internal/handlers/sandbox_fork.goL65–L73EndTimeProposed Changes
if *body.Timeout <= 0guard before upper-bound check, return400 Bad Requestwith message"Timeout must be greater than 0"sandbox_create.go,sandbox_resume.go,sandbox_fork.go400fortimeout: 0andtimeout: -10sandbox_create_test.go