Skip to content

Commit 6bd708c

Browse files
Bill Leoutsakoscursoragent
authored andcommitted
fix(pi): scope the sandbox lifetime cap to E2B and harden the job-log path
Deriving PI_TIMEOUT_MS from the E2B lifetime applied it to every provider, so a Daytona Create PR run lost its ~90-minute agent turn to a ceiling Daytona does not have — it stops on inactivity instead. The reserve now only applies when the provider imposes an absolute lifetime. A configured PI_SANDBOX_LIFETIME_MS below the clone and finalize reserves left no positive remainder for the turn, so E2B could reap the sandbox before the push. Such a value is raised to a floor rather than rejected: a module-scope throw on a config typo would take down every path that imports this, not just Pi. github_job_logs returns its response body verbatim, so unlike its siblings that parse a typed shape, a coordinate carrying URL syntax turned a bearer-authenticated request into a general read. Path segments are now escaped and the job id checked. Also corrects the plan's rollup field path: GraphQL's CheckRun has no output object, and isRequired is Boolean!, so stage 2 needs neither the nested path nor an unknown-required branch. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 785d372 commit 6bd708c

8 files changed

Lines changed: 233 additions & 27 deletions

File tree

.agents/plans/pi-babysit-mode.plan.md

Lines changed: 9 additions & 7 deletions
Large diffs are not rendered by default.

apps/sim/executor/handlers/pi/cloud-shared.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it } from 'vitest'
5-
import { PI_SANDBOX_MAX_LIFETIME_MS } from '@/lib/execution/remote-sandbox/pi-lifetime'
5+
import {
6+
PI_SANDBOX_MAX_LIFETIME_MS,
7+
PI_SANDBOX_MIN_LIFETIME_MS,
8+
} from '@/lib/execution/remote-sandbox/pi-lifetime'
69
import {
710
CLONE_TIMEOUT_MS,
811
FINALIZE_TIMEOUT_MS,
12+
MIN_PI_TIMEOUT_MS,
913
PI_TIMEOUT_MS,
1014
} from '@/executor/handlers/pi/cloud-shared'
1115

@@ -21,4 +25,13 @@ describe('PI_TIMEOUT_MS', () => {
2125
)
2226
expect(PI_TIMEOUT_MS).toBeGreaterThan(0)
2327
})
28+
29+
it('keeps the lifetime floor above the reserves it exists to protect', () => {
30+
// The floor lives next to the lifetime and the reserves live here, so
31+
// without this they can drift until a permitted lifetime leaves the agent
32+
// turn with nothing but its own minimum.
33+
expect(PI_SANDBOX_MIN_LIFETIME_MS).toBeGreaterThanOrEqual(
34+
CLONE_TIMEOUT_MS + 2 * FINALIZE_TIMEOUT_MS + MIN_PI_TIMEOUT_MS
35+
)
36+
})
2437
})

apps/sim/executor/handlers/pi/cloud-shared.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const PUSH_ERROR_MAX = 1000
2525
* Such a run can still finish, since those ceilings are pessimistic, so the floor
2626
* leaves a short turn rather than refusing one.
2727
*/
28-
const MIN_PI_TIMEOUT_MS = 60 * 1000
28+
export const MIN_PI_TIMEOUT_MS = 60 * 1000
2929

3030
/**
3131
* How long one Pi CLI invocation may run. The platform's max execution timeout
@@ -41,14 +41,23 @@ const MIN_PI_TIMEOUT_MS = 60 * 1000
4141
* What is reserved is each command's timeout ceiling, not its measured elapsed
4242
* time — a clone takes seconds in practice — so this is a budget that adds up,
4343
* not a guarantee that the sandbox outlives the run.
44+
*
45+
* The reserve only applies when the provider imposes an absolute lifetime, which
46+
* is E2B alone. Daytona stops on inactivity, so subtracting E2B's ceiling there
47+
* would cut the agent turn to fit a limit Daytona does not have.
4448
*/
45-
export const PI_TIMEOUT_MS = Math.min(
46-
getMaxExecutionTimeout(),
47-
Math.max(
48-
resolvePiSandboxLifetimeMs() - CLONE_TIMEOUT_MS - 2 * FINALIZE_TIMEOUT_MS,
49-
MIN_PI_TIMEOUT_MS
50-
)
51-
)
49+
const piSandboxLifetimeMs = resolvePiSandboxLifetimeMs()
50+
51+
export const PI_TIMEOUT_MS =
52+
piSandboxLifetimeMs === undefined
53+
? getMaxExecutionTimeout()
54+
: Math.min(
55+
getMaxExecutionTimeout(),
56+
Math.max(
57+
piSandboxLifetimeMs - CLONE_TIMEOUT_MS - 2 * FINALIZE_TIMEOUT_MS,
58+
MIN_PI_TIMEOUT_MS
59+
)
60+
)
5261

5362
/**
5463
* Marker carrying a digest of the cloned repository's git config. A clone script

apps/sim/lib/execution/remote-sandbox/conformance.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,10 @@ import {
7676
SIM_RESULT_PREFIX,
7777
withPiSandbox,
7878
} from '@/lib/execution/remote-sandbox'
79-
import { PI_SANDBOX_MAX_LIFETIME_MS } from '@/lib/execution/remote-sandbox/pi-lifetime'
79+
import {
80+
PI_SANDBOX_MAX_LIFETIME_MS,
81+
PI_SANDBOX_MIN_LIFETIME_MS,
82+
} from '@/lib/execution/remote-sandbox/pi-lifetime'
8083

8184
type Provider = 'e2b' | 'daytona'
8285
const PROVIDERS: Provider[] = ['e2b', 'daytona']
@@ -483,13 +486,24 @@ describe('Pi sandbox lifetime', () => {
483486
expect(mockE2BCreate.mock.calls[0][1].timeoutMs).toBe(PI_SANDBOX_MAX_LIFETIME_MS)
484487
})
485488

486-
it('honours a configured lifetime below the ceiling', async () => {
489+
it('honours a configured lifetime between the floor and the ceiling', async () => {
490+
useProvider('e2b')
491+
mockEnv.PI_SANDBOX_LIFETIME_MS = '2700000'
492+
493+
await withPiSandbox(async () => undefined)
494+
495+
expect(mockE2BCreate.mock.calls[0][1].timeoutMs).toBe(2_700_000)
496+
})
497+
498+
it('raises a configured lifetime too short to clone, run, and push in', async () => {
487499
useProvider('e2b')
500+
// Ten minutes is the clone reserve on its own, so the turn and the push would
501+
// race a sandbox E2B may already have reaped.
488502
mockEnv.PI_SANDBOX_LIFETIME_MS = '600000'
489503

490504
await withPiSandbox(async () => undefined)
491505

492-
expect(mockE2BCreate.mock.calls[0][1].timeoutMs).toBe(600_000)
506+
expect(mockE2BCreate.mock.calls[0][1].timeoutMs).toBe(PI_SANDBOX_MIN_LIFETIME_MS)
493507
})
494508

495509
it('leaves the short-lived sandbox kinds on the E2B default', async () => {
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
/**
7+
* The resolver reads configuration at import, so each case re-imports the module
8+
* with its own mocked environment rather than mutating shared state.
9+
*/
10+
async function resolveWith(options: {
11+
provider?: string
12+
lifetimeMs?: string
13+
}): Promise<{ lifetime: number | undefined; min: number; max: number }> {
14+
vi.resetModules()
15+
vi.doMock('@/lib/core/config/env', () => ({
16+
env: {
17+
PI_SANDBOX_LIFETIME_MS: options.lifetimeMs,
18+
SANDBOX_PROVIDER: options.provider,
19+
},
20+
}))
21+
22+
const mod = await import('@/lib/execution/remote-sandbox/pi-lifetime')
23+
return {
24+
lifetime: mod.resolvePiSandboxLifetimeMs(),
25+
min: mod.PI_SANDBOX_MIN_LIFETIME_MS,
26+
max: mod.PI_SANDBOX_MAX_LIFETIME_MS,
27+
}
28+
}
29+
30+
beforeEach(() => {
31+
vi.resetModules()
32+
})
33+
34+
describe('resolvePiSandboxLifetimeMs', () => {
35+
it('defaults to the sub-hour cap on E2B', async () => {
36+
const { lifetime, max } = await resolveWith({})
37+
38+
expect(lifetime).toBe(max)
39+
})
40+
41+
it('has no lifetime to report when the provider stops on inactivity', async () => {
42+
// Daytona has no absolute lifetime, so reporting E2B's would cut the agent
43+
// turn to fit a ceiling that does not apply — the regression this prevents.
44+
const { lifetime } = await resolveWith({ provider: 'daytona' })
45+
46+
expect(lifetime).toBeUndefined()
47+
})
48+
49+
it('ignores a configured lifetime entirely on that provider', async () => {
50+
const { lifetime } = await resolveWith({ provider: 'daytona', lifetimeMs: '600000' })
51+
52+
expect(lifetime).toBeUndefined()
53+
})
54+
55+
it('lets a configured value lower the lifetime', async () => {
56+
const { lifetime, min, max } = await resolveWith({ lifetimeMs: String(45 * 60 * 1000) })
57+
58+
expect(lifetime).toBe(45 * 60 * 1000)
59+
expect(lifetime!).toBeGreaterThan(min)
60+
expect(lifetime!).toBeLessThan(max)
61+
})
62+
63+
it('refuses to be raised above the cap', async () => {
64+
// A Hobby key rejects a create above one hour, so an over-large override
65+
// would otherwise fail every Pi run rather than lengthening one.
66+
const { lifetime, max } = await resolveWith({ lifetimeMs: String(6 * 60 * 60 * 1000) })
67+
68+
expect(lifetime).toBe(max)
69+
})
70+
71+
it('raises a lifetime too short for a run to finish in', async () => {
72+
// Ten minutes is consumed by the clone reserve alone, leaving the turn and
73+
// the push to race a sandbox that may already be reaped.
74+
const { lifetime, min } = await resolveWith({ lifetimeMs: String(10 * 60 * 1000) })
75+
76+
expect(lifetime).toBe(min)
77+
})
78+
79+
it.each(['', 'soon', '0', '-1'])('falls back to the cap for %o', async (value) => {
80+
const { lifetime, max } = await resolveWith({ lifetimeMs: value })
81+
82+
expect(lifetime).toBe(max)
83+
})
84+
})

apps/sim/lib/execution/remote-sandbox/pi-lifetime.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,20 @@
44
* against the same number without importing the provider SDKs.
55
*/
66

7+
import { createLogger } from '@sim/logger'
78
import { env } from '@/lib/core/config/env'
89

10+
const logger = createLogger('PiSandboxLifetime')
11+
12+
/**
13+
* Read from `env` rather than the `env-flags` gate, and normalized the same way
14+
* `remote-sandbox/index.ts` normalizes it, so this module keeps the independence
15+
* its header describes: no provider adapters, no barrel, no config gate.
16+
*/
17+
function isLifetimeProvider(): boolean {
18+
return (env.SANDBOX_PROVIDER?.toLowerCase() ?? 'e2b') === 'e2b'
19+
}
20+
921
/**
1022
* E2B documents a one-hour maximum sandbox lifetime for Hobby accounts (24 hours
1123
* for Pro) and rejects a create above it. The cap sits strictly below that hour:
@@ -15,20 +27,54 @@ import { env } from '@/lib/core/config/env'
1527
export const PI_SANDBOX_MAX_LIFETIME_MS = 59 * 60 * 1000
1628

1729
/**
18-
* The lifetime requested for a Pi sandbox, always clamped to
19-
* {@link PI_SANDBOX_MAX_LIFETIME_MS}. Defaults to the cap: a run that finishes
20-
* kills its sandbox explicitly, so for the normal path the lifetime is a ceiling
30+
* The shortest lifetime a Pi run can actually complete in, and the floor an
31+
* override is raised to.
32+
*
33+
* A Pi backend brackets the agent turn with a clone and two finalize commands,
34+
* and caps the turn itself against whatever is left. Below this, that arithmetic
35+
* has no positive remainder: the reserves alone exhaust the lifetime, so E2B
36+
* could reap the sandbox before the turn or the push finished. Raising the value
37+
* is better than rejecting it — a module-scope throw on a config typo would take
38+
* down every execution path that imports this, not just Pi.
39+
*
40+
* `cloud-shared.test.ts` asserts this stays at or above the backends' own
41+
* reserves, so the two cannot drift apart silently.
42+
*/
43+
export const PI_SANDBOX_MIN_LIFETIME_MS = 31 * 60 * 1000
44+
45+
/**
46+
* The lifetime requested for a Pi sandbox, or `undefined` when the selected
47+
* provider has no such concept.
48+
*
49+
* Only E2B takes an absolute lifetime. Daytona stops on inactivity instead
50+
* (`autoStopInterval`, refreshed by activity), so there is no ceiling for a Pi
51+
* command to reserve against — and deriving one anyway would shorten Daytona's
52+
* agent turn to fit a limit that does not apply to it.
53+
*
54+
* For E2B it defaults to {@link PI_SANDBOX_MAX_LIFETIME_MS}: a run that finishes
55+
* kills its sandbox explicitly, so on the normal path the lifetime is a ceiling
2156
* rather than a budget. It is not entirely free — if the web process dies
2257
* mid-run the orphaned sandbox now bills until this ceiling instead of the SDK's
2358
* five minutes — but five minutes is short enough to kill live runs, which is
2459
* the bug this replaces.
2560
*
26-
* `PI_SANDBOX_LIFETIME_MS` lowers it (a Pro account can raise the constant, but
27-
* the env var may only reduce it, so a misconfigured value cannot make every
28-
* create fail on a Hobby key).
61+
* `PI_SANDBOX_LIFETIME_MS` may only lower it, and only as far as
62+
* {@link PI_SANDBOX_MIN_LIFETIME_MS}, so a misconfigured value can neither make
63+
* every create fail on a Hobby key nor leave a run without time to push.
2964
*/
30-
export function resolvePiSandboxLifetimeMs(): number {
65+
export function resolvePiSandboxLifetimeMs(): number | undefined {
66+
if (!isLifetimeProvider()) return undefined
67+
3168
const configured = Number.parseInt(env.PI_SANDBOX_LIFETIME_MS ?? '', 10)
3269
if (!Number.isFinite(configured) || configured <= 0) return PI_SANDBOX_MAX_LIFETIME_MS
70+
71+
if (configured < PI_SANDBOX_MIN_LIFETIME_MS) {
72+
logger.warn('PI_SANDBOX_LIFETIME_MS is below the minimum a Pi run can finish in; raising it', {
73+
configured,
74+
using: PI_SANDBOX_MIN_LIFETIME_MS,
75+
})
76+
return PI_SANDBOX_MIN_LIFETIME_MS
77+
}
78+
3379
return Math.min(configured, PI_SANDBOX_MAX_LIFETIME_MS)
3480
}

apps/sim/tools/github/job_logs.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,28 @@ describe('github_job_logs', () => {
2323
expect(url).toBe('https://api.github.com/repos/octo/demo/actions/jobs/42/logs')
2424
})
2525

26+
it('escapes coordinates so they cannot redirect the authenticated request', () => {
27+
const url = (jobLogsTool.request.url as (params: JobLogsParams) => string)({
28+
...BASE_PARAMS,
29+
owner: '../../orgs/secret',
30+
repo: 'demo?ref=x',
31+
})
32+
33+
expect(url).toBe(
34+
'https://api.github.com/repos/..%2F..%2Forgs%2Fsecret/demo%3Fref%3Dx/actions/jobs/42/logs'
35+
)
36+
})
37+
38+
it('rejects a job id that is not a positive integer', () => {
39+
const url = jobLogsTool.request.url as (params: JobLogsParams) => string
40+
41+
expect(() => url({ ...BASE_PARAMS, job_id: 0 })).toThrow(/job_id must be a positive integer/)
42+
expect(() => url({ ...BASE_PARAMS, job_id: 1.5 })).toThrow(/job_id must be a positive integer/)
43+
expect(() => url({ ...BASE_PARAMS, job_id: '9/../..' as unknown as number })).toThrow(
44+
/job_id must be a positive integer/
45+
)
46+
})
47+
2648
it('returns a short log whole', async () => {
2749
const result = await jobLogsTool.transformResponse!(logResponse('boom\n'), BASE_PARAMS)
2850

apps/sim/tools/github/job_logs.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,22 @@ function resolveMaxCharacters(value: number | undefined): number {
1212
return requested
1313
}
1414

15+
/**
16+
* Every path segment is escaped or checked before it reaches the URL.
17+
*
18+
* Raw interpolation is the prevailing shape among the GitHub tools here, but it
19+
* costs more in this one: the response body is returned verbatim as `logs`
20+
* instead of being parsed into a fixed shape, so a coordinate carrying URL syntax
21+
* would turn a bearer-authenticated request into a general read of whatever
22+
* endpoint it reached. Siblings that parse a typed response fail closed instead.
23+
*/
24+
function jobLogsPath(owner: string, repo: string, jobId: number): string {
25+
if (!Number.isSafeInteger(jobId) || jobId < 1) {
26+
throw new Error('job_id must be a positive integer')
27+
}
28+
return `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${jobId}/logs`
29+
}
30+
1531
/**
1632
* The tail is what matters: a failing job reports its error at the end.
1733
*
@@ -76,7 +92,7 @@ export const jobLogsTool: ToolConfig<JobLogsParams, JobLogsResponse> = {
7692
// The per-job endpoint, not the run-level zip archive. GitHub answers with a
7793
// 302 to a short-lived blob URL that carries its own signature.
7894
url: (params) =>
79-
`https://api.github.com/repos/${params.owner}/${params.repo}/actions/jobs/${params.job_id}/logs`,
95+
`https://api.github.com/repos/${jobLogsPath(params.owner, params.repo, params.job_id)}`,
8096
method: 'GET',
8197
headers: (params) => ({
8298
Accept: 'application/vnd.github+json',

0 commit comments

Comments
 (0)