Skip to content

Commit 7d3a1a4

Browse files
committed
feat(mothership): grep-pipe on sim_cli + persistent per-chat session sandbox
sim_cli accepts a trailing | grep (native, sim-side, chainable; flags -i -v -c -n -E -m N), validated before the CLI runs so a mutating command never executes and then fails on a malformed pipe. The mothership code sandbox (run_code/run_function) is now persistent per chat: a session lease in the remote-sandbox layer reconnects to the chat's live E2B sandbox (metadata key mothership-chat:<chatId>), holds a 20-minute idle TTL refreshed per execution, is never metered or abort-killed, and stamps sandboxSession: created|reused on every result. Fresh sessions bootstrap the sim CLI detached; the delegation token is minted sim-side per execution and injected into the exec env only, so a stopped sandbox never holds a live credential. Fixes found by e2e, each with regression tests: transformResponse dropped sandboxSession (field whitelist), and run_code timeouts sent as strings skipped the seconds conversion into z.coerce (a "90"s request armed a 90ms abort). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent 95fbf8f commit 7d3a1a4

17 files changed

Lines changed: 914 additions & 21 deletions

File tree

‎apps/sim/lib/api/contracts/hotspots.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,11 @@ export const functionExecuteBodySchema = z
221221
isCustomTool: z.boolean().optional().default(false),
222222
/** Workspace sandbox whose dependency set this execution runs against. */
223223
sandboxId: z.string().optional(),
224+
/**
225+
* Reusable session-sandbox identity (one per Mothership chat). Honored only
226+
* for trusted Mothership executions; workspace callers cannot opt in.
227+
*/
228+
sandboxSessionKey: z.string().optional(),
224229
/** `all` (default) or `selected`; see mountedSecrets. */
225230
secretScope: z.enum(['all', 'selected']).optional(),
226231
/** Secret names this execution may read when secretScope is `selected`. */

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,7 @@ export const env = createEnv({
567567
E2B_FUNCTION_TEMPLATE_ID: z.string().refine(isImmutableE2BTemplateRef, { message: `E2B_FUNCTION_TEMPLATE_ID ${IMMUTABLE_E2B_TEMPLATE_REF_ERROR}` }).optional(), // Immutable dedicated E2B build for Function JavaScript/Python/Shell and workspace sandbox layers; no Mothership fallback
568568
E2B_FUNCTION_TEMPLATE_GENERATION: z.string().refine(isValidSandboxReleaseGeneration, { message: `E2B_FUNCTION_TEMPLATE_GENERATION ${SANDBOX_RELEASE_GENERATION_ERROR}` }).optional(), // Monotonic release epoch printed by the Function E2B builder
569569
MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Mothership code-tool template; never a Function-base fallback
570+
MOTHERSHIP_SANDBOX_CLI_ENDPOINT: z.string().optional(), // Sim API base the sandboxed sim CLI calls back to; defaults to NEXT_PUBLIC_APP_URL (set when the public URL is not reachable from the sandbox network)
570571
MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path
571572
E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Create PR, its Babysit continuation, and Review Code)
572573
PI_SANDBOX_LIFETIME_MS: z.string().optional(), // Lower the Pi sandbox lifetime (ms) below the default; E2B caps a sandbox at 1h on Hobby accounts and 24h on Pro

‎apps/sim/lib/execution/remote-sandbox/e2b.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ function templateFor(kind: SandboxKind, imageRef?: string): string {
306306
return functionTemplateRef()
307307
}
308308

309+
/** Metadata key that tags a sandbox with its owning session for reconnection. */
310+
const E2B_SESSION_METADATA_KEY = 'simSessionKey'
311+
309312
class E2BSandboxHandle implements SandboxHandle {
310313
private killed = false
311314
private killPromise: Promise<void> | null = null
@@ -320,6 +323,10 @@ class E2BSandboxHandle implements SandboxHandle {
320323
return this.sandbox.sandboxId
321324
}
322325

326+
async extendLifetime(lifetimeMs: number): Promise<void> {
327+
await this.sandbox.setTimeout(e2bTimeoutMs(lifetimeMs))
328+
}
329+
323330
async runCode(
324331
code: string,
325332
options: {
@@ -899,6 +906,9 @@ export const e2bProvider: SandboxProvider = {
899906
const createOptions = {
900907
apiKey,
901908
...(effectiveLifetimeMs !== undefined ? { timeoutMs: effectiveLifetimeMs } : {}),
909+
...(options?.sessionKey
910+
? { metadata: { [E2B_SESSION_METADATA_KEY]: options.sessionKey } }
911+
: {}),
902912
}
903913

904914
const { Sandbox } = await import('@e2b/code-interpreter')
@@ -914,4 +924,31 @@ export const e2bProvider: SandboxProvider = {
914924
: undefined
915925
)
916926
},
927+
928+
async findSessionSandbox(
929+
key: string,
930+
options: { language?: CodeLanguage }
931+
): Promise<SandboxHandle | null> {
932+
const apiKey = env.E2B_API_KEY
933+
if (!apiKey) return null
934+
const { Sandbox } = await import('@e2b/code-interpreter')
935+
try {
936+
const paginator = Sandbox.list({
937+
apiKey,
938+
query: { metadata: { [E2B_SESSION_METADATA_KEY]: key }, state: ['running'] },
939+
})
940+
const candidates = await paginator.nextItems()
941+
if (candidates.length === 0) return null
942+
// Oldest first: when a race created two sandboxes for one session, every
943+
// later execution adopts the same one and the stragglers idle out.
944+
candidates.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime())
945+
const sandbox = await Sandbox.connect(candidates[0].sandboxId, { apiKey })
946+
return new E2BSandboxHandle(sandbox, options.language ?? CodeLanguage.Python)
947+
} catch (error) {
948+
// A sandbox reaped between list and connect is an ordinary miss, and any
949+
// other lookup failure degrades to a fresh create rather than an error.
950+
logger.info('E2B session sandbox lookup missed', { key, error: getErrorMessage(error) })
951+
return null
952+
}
953+
},
917954
}

‎apps/sim/lib/execution/remote-sandbox/index.ts‎

Lines changed: 148 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isTimeoutAbortReason,
1313
} from '@/lib/core/execution-limits'
1414
import { recordSandboxTeardownFailure } from '@/lib/core/execution-limits/metrics'
15+
import { runDetached } from '@/lib/core/utils/background'
1516
import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime'
1617
import { SANDBOX_SYSTEM_PATH } from '@/lib/execution/remote-sandbox/cli-tools.server'
1718
import {
@@ -57,6 +58,7 @@ import type {
5758
SandboxPrivateInput,
5859
SandboxProvider,
5960
SandboxProviderId,
61+
SandboxSessionRequest,
6062
SandboxShellExecutionRequest,
6163
} from '@/lib/execution/remote-sandbox/types'
6264

@@ -108,6 +110,119 @@ async function createSandbox(
108110
}
109111
}
110112

113+
/**
114+
* How long a session sandbox survives between executions before the provider
115+
* reaps it. Refreshed on every acquire and release, so the clock measures idle
116+
* time, not total lifetime.
117+
*/
118+
const SESSION_SANDBOX_IDLE_MS = 20 * 60_000
119+
120+
/** Budget for a session's one-time bootstrap command (e.g. a CLI install). */
121+
const SESSION_BOOTSTRAP_TIMEOUT_MS = 120_000
122+
123+
interface SandboxLease {
124+
created: CreatedSandbox
125+
/** Set when this execution runs in a session sandbox; absent for one-shot. */
126+
session?: 'created' | 'reused'
127+
/** One-shot: kills the sandbox. Session: refreshes its idle deadline. */
128+
release(): Promise<void>
129+
}
130+
131+
/**
132+
* Acquires the sandbox an execution runs in: reconnects to the caller's live
133+
* session sandbox, creates and bootstraps a fresh one under the session tag, or
134+
* falls back to the classic one-shot create.
135+
*
136+
* A session lease never binds the abort signal to teardown — cancelling one
137+
* execution must not destroy state the next turn builds on — and is never
138+
* metered, because session sandboxes are server-owned rather than billed to
139+
* workspace compute. Providers without session support degrade to one-shot.
140+
*/
141+
async function leaseSandbox(
142+
kind: SandboxKind,
143+
options: CreateSandboxOptions,
144+
selected: ResolvedSandbox | null,
145+
signal: AbortSignal,
146+
meterUsage: boolean | undefined,
147+
session: SandboxSessionRequest | undefined
148+
): Promise<SandboxLease> {
149+
const provider = resolveProvider()
150+
const sessionCapable = Boolean(session && !meterUsage && provider.findSessionSandbox)
151+
152+
if (session && sessionCapable) {
153+
const refresh = async (sandbox: SandboxHandle) => {
154+
try {
155+
await sandbox.extendLifetime?.(SESSION_SANDBOX_IDLE_MS)
156+
} catch (error) {
157+
logger.warn('Failed to refresh session sandbox lifetime', {
158+
sandboxId: sandbox.sandboxId,
159+
error: getErrorMessage(error),
160+
})
161+
}
162+
}
163+
try {
164+
const existing = await provider.findSessionSandbox?.(session.key, {
165+
...(options.language ? { language: options.language } : {}),
166+
})
167+
if (existing) {
168+
await refresh(existing)
169+
return {
170+
created: { sandbox: existing, providerId: provider.id, startedAtMs: Date.now() },
171+
session: 'reused',
172+
release: () => refresh(existing),
173+
}
174+
}
175+
} catch (error) {
176+
logger.warn('Session sandbox lookup failed; creating a fresh one', {
177+
key: session.key,
178+
error: getErrorMessage(error),
179+
})
180+
}
181+
const created = await createSelectedSandbox(
182+
kind,
183+
{ ...options, lifetimeMs: SESSION_SANDBOX_IDLE_MS, sessionKey: session.key },
184+
selected,
185+
signal,
186+
false
187+
)
188+
const bootstrapCommand = session.bootstrapCommand
189+
if (bootstrapCommand) {
190+
// Detached: the bootstrap (a CLI install, until the images bake it in)
191+
// can outlast the caller's execution budget, and the first execution of a
192+
// chat must not pay for it. The narrow race — the very first execution
193+
// using the bootstrapped tool before the install lands — resolves on
194+
// retry against the by-then bootstrapped sandbox.
195+
runDetached('session-sandbox-bootstrap', async () => {
196+
const bootstrap = await created.sandbox.runCommand(bootstrapCommand, {
197+
timeoutMs: SESSION_BOOTSTRAP_TIMEOUT_MS,
198+
rootUser: true,
199+
})
200+
if (bootstrap.exitCode !== 0) {
201+
logger.warn('Session sandbox bootstrap exited non-zero', {
202+
sandboxId: created.sandbox.sandboxId,
203+
exitCode: bootstrap.exitCode,
204+
})
205+
}
206+
})
207+
}
208+
return {
209+
created,
210+
session: 'created',
211+
release: () => refresh(created.sandbox),
212+
}
213+
}
214+
215+
const created = await createSelectedSandbox(kind, options, selected, signal, meterUsage)
216+
const abortBinding = bindSandboxAbort(created.sandbox, created.providerId, signal)
217+
return {
218+
created,
219+
release: async () => {
220+
abortBinding.detach()
221+
await abortBinding.cleanup()
222+
},
223+
}
224+
}
225+
111226
/**
112227
* Creates a sandbox, turning "that image is gone" into a rebuild rather than a
113228
* failure the author has to resolve by hand.
@@ -813,7 +928,7 @@ async function executeInSandboxWithinBudget(
813928
})
814929
throwIfAborted(signal)
815930

816-
const created = await createSelectedSandbox(
931+
const lease = await leaseSandbox(
817932
kind,
818933
{
819934
language,
@@ -822,18 +937,20 @@ async function executeInSandboxWithinBudget(
822937
},
823938
selected,
824939
signal,
825-
req.meterUsage
940+
req.meterUsage,
941+
req.session
826942
)
943+
const created = lease.created
827944
const sandbox = created.sandbox
828945
const sandboxId = sandbox.sandboxId
829-
const abortBinding = bindSandboxAbort(sandbox, created.providerId, signal)
946+
const sessionField = lease.session ? { sandboxSession: lease.session } : {}
830947
let billableResult: SandboxExecutionResult | undefined
831948
let billableOutputError: unknown
832949

833950
try {
834951
throwIfAborted(signal)
835-
// Inside the try so a failed install or mount still kills the sandbox via the
836-
// finally below. Dependencies land before the inputs so user code and its
952+
// Inside the try so a failed install or mount still releases the sandbox via
953+
// the finally below. Dependencies land before the inputs so user code and its
837954
// mounts always see a complete environment.
838955
//
839956
await provisionWithinBudget(sandbox, selected, signal)
@@ -846,10 +963,13 @@ async function executeInSandboxWithinBudget(
846963
)
847964
const executionEnvironment = {
848965
...selected?.envs,
966+
...req.session?.envs,
849967
...privateInputEnvironment,
850968
}
851969
const hasExecutionEnvironment =
852-
selected?.envs !== undefined || Object.keys(privateInputEnvironment).length > 0
970+
selected?.envs !== undefined ||
971+
req.session?.envs !== undefined ||
972+
Object.keys(privateInputEnvironment).length > 0
853973

854974
let execution: SandboxCodeResult
855975
try {
@@ -878,6 +998,7 @@ async function executeInSandboxWithinBudget(
878998
stdout: execution.error.traceback || errorMessage,
879999
error: errorMessage,
8801000
sandboxId,
1001+
...sessionField,
8811002
}
8821003
if (execution.providerFailure !== 'provider_limit') billableResult = executionResult
8831004
return executionResult
@@ -906,13 +1027,15 @@ async function executeInSandboxWithinBudget(
9061027
stdout: cleanedStdout,
9071028
error: SIM_RESULT_CORRUPTED_ERROR,
9081029
sandboxId,
1030+
...sessionField,
9091031
}
9101032
}
9111033

9121034
billableResult = {
9131035
result: extraction.result,
9141036
stdout: cleanedStdout,
9151037
sandboxId,
1038+
...sessionField,
9161039
}
9171040
try {
9181041
const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles(
@@ -950,8 +1073,7 @@ async function executeInSandboxWithinBudget(
9501073
if (cost && billableOutputError) {
9511074
attachTrustedSandboxOutputCost(billableOutputError, cost)
9521075
}
953-
abortBinding.detach()
954-
await abortBinding.cleanup()
1076+
await lease.release()
9551077
}
9561078
}
9571079

@@ -978,24 +1100,26 @@ async function executeShellInSandboxWithinBudget(
9781100
})
9791101
throwIfAborted(signal)
9801102

981-
const created = await createSelectedSandbox(
1103+
const lease = await leaseSandbox(
9821104
kind,
9831105
{ imageRef: selected?.imageRef, lifetimeMs: remainingSandboxBudgetMs(signal) },
9841106
selected,
9851107
signal,
986-
req.meterUsage
1108+
req.meterUsage,
1109+
req.session
9871110
)
1111+
const created = lease.created
9881112
const sandbox = created.sandbox
9891113
const sandboxId = sandbox.sandboxId
990-
const abortBinding = bindSandboxAbort(sandbox, created.providerId, signal)
1114+
const sessionField = lease.session ? { sandboxSession: lease.session } : {}
9911115
let billableResult: SandboxExecutionResult | undefined
9921116
let billableOutputError: unknown
9931117

9941118
try {
9951119
throwIfAborted(signal)
996-
// Inside the try so a failed install or mount still kills the sandbox via the
997-
// finally below. The install shares the caller's budget rather than adding to
998-
// it — see the note in `executeInSandbox`.
1120+
// Inside the try so a failed install or mount still releases the sandbox via
1121+
// the finally below. The install shares the caller's budget rather than adding
1122+
// to it — see the note in `executeInSandbox`.
9991123
await provisionWithinBudget(sandbox, selected, signal)
10001124
await writeSandboxInputs(sandbox, req.sandboxFiles, {
10011125
rootUser: true,
@@ -1014,6 +1138,7 @@ async function executeShellInSandboxWithinBudget(
10141138
envs: {
10151139
...selected?.envs,
10161140
...envs,
1141+
...req.session?.envs,
10171142
PATH: selected?.envs?.PATH ?? SANDBOX_SYSTEM_PATH,
10181143
...privateInputEnvironment,
10191144
},
@@ -1041,7 +1166,13 @@ async function executeShellInSandboxWithinBudget(
10411166
sandboxId,
10421167
exitCode: result.exitCode,
10431168
})
1044-
const executionResult = { result: null, stdout, error: errorMessage, sandboxId }
1169+
const executionResult = {
1170+
result: null,
1171+
stdout,
1172+
error: errorMessage,
1173+
sandboxId,
1174+
...sessionField,
1175+
}
10451176
if (result.providerFailure !== 'provider_limit') billableResult = executionResult
10461177
return executionResult
10471178
}
@@ -1056,6 +1187,7 @@ async function executeShellInSandboxWithinBudget(
10561187
result: parsed,
10571188
stdout: extraction.cleanedStdout,
10581189
sandboxId,
1190+
...sessionField,
10591191
}
10601192
try {
10611193
const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles(
@@ -1093,8 +1225,7 @@ async function executeShellInSandboxWithinBudget(
10931225
if (cost && billableOutputError) {
10941226
attachTrustedSandboxOutputCost(billableOutputError, cost)
10951227
}
1096-
abortBinding.detach()
1097-
await abortBinding.cleanup()
1228+
await lease.release()
10981229
}
10991230
}
11001231

0 commit comments

Comments
 (0)