Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7619a39
refactor: extract SandboxBackend seam from worker sandbox dispatch
danny-avila Jul 5, 2026
63aacf6
feat: derive runtime session ids and add Redis session registry (dark…
danny-avila Jul 5, 2026
9f7ee1a
feat: Lambda MicroVM client wrapper, test fake, and op throttle
danny-avila Jul 5, 2026
4d8d7e0
feat: Lambda MicroVM image target, lifecycle hooks, and stateless bac…
danny-avila Jul 5, 2026
ac4fdef
feat: persistent session workspace for stateful MicroVM sessions
danny-avila Jul 5, 2026
f80700e
feat: backend session orchestration (find-or-launch + warm-VM reuse)
danny-avila Jul 5, 2026
60fdf3b
feat: session workspace checkpoint/restore for perceived statefulness…
danny-avila Jul 5, 2026
a38933a
feat: auto-checkpoint session workspaces to S3 with restore on relaunch
danny-avila Jul 6, 2026
a71fcfb
feat: hookless per-request session binding via X-Runtime-Session-Id h…
danny-avila Jul 7, 2026
b4d7856
docs: Lambda MicroVM stateful sessions runbook + Terraform + image he…
danny-avila Jul 7, 2026
3c7e9d3
fix: address review findings (lock TTL, symlink chown, fence ordering…
danny-avila Jul 7, 2026
02cbdf1
fix(docs): address review findings in Lambda MicroVM IaC + guide
danny-avila Jul 7, 2026
8e2da55
fix(docs): region-unique bucket names + correct checkpoint-cred guidance
danny-avila Jul 7, 2026
daa43b0
fix: address Codex review of stateful session mechanics
danny-avila Jul 7, 2026
59f6fae
fix: address Macroscope review of the Codex-fix commit
danny-avila Jul 7, 2026
e0dd912
fix: address Codex review round 2 of stateful session mechanics
danny-avila Jul 7, 2026
06bbb17
fix: address Codex review round 3 of stateful session mechanics
danny-avila Jul 7, 2026
78673a2
fix: address Codex review round 4 + Macroscope of stateful sessions
danny-avila Jul 7, 2026
2f75eff
fix: address Codex review round 5 of stateful session mechanics
danny-avila Jul 8, 2026
299a77b
fix: address Codex review round 6 of stateful session mechanics
danny-avila Jul 8, 2026
11bc26c
fix: address Codex review round 7 of stateful session mechanics
danny-avila Jul 8, 2026
61ba32e
docs(lambda-microvm): guard the build-log path against the wrong "fix"
danny-avila Jul 8, 2026
a53ff5f
fix: address Codex review round 8 of stateful session mechanics
danny-avila Jul 8, 2026
7871287
fix: address Codex review round 9 of stateful session mechanics
danny-avila Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,27 @@ RUN rm -f /usr/bin/nsenter /usr/bin/unshare /usr/bin/chroot /usr/sbin/chroot \
COPY api/src/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh

# ============================================================================
# Stage 2b: AWS Lambda MicroVM container base image
#
# Lambda MicroVM IS the VM boundary: no libkrun launcher, no guest kernel.
# This target is pushed to a same-account ECR repo and referenced by the
# FROM line of the code-artifact Dockerfile that Lambda builds on top of
# the AL2023 MicroVM base image (see scripts/build-lambda-microvm-artifact.sh).
# Inbound traffic defaults to port 8080; lifecycle/build hooks are served
# by the same app at /aws/lambda-microvms/runtime/v1/*.
# ============================================================================
FROM sandbox-build AS lambda-microvm-runner

COPY --from=package-builder /pkgs /pkgs

ENV PORT=8080 \
SANDBOX_PACKAGES_DIRECTORY=/pkgs \
SANDBOX_SESSION_WORKSPACE_ENABLED=true

EXPOSE 8080/tcp
ENTRYPOINT ["/sandbox_api/entrypoint.sh"]

# ============================================================================
# Stage 3: Build the Rust launcher binary (Fedora for libkrun ABI)
# ============================================================================
Expand Down
46 changes: 46 additions & 0 deletions api/src/api/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, test } from 'bun:test';
import {
LIFECYCLE_HOOK_BASE_PATH,
applyRunHook,
getMicrovmRunContext,
resetMicrovmRunContextForTests,
} from './lifecycle';

afterEach(resetMicrovmRunContextForTests);

describe('MicroVM /run hook context', () => {
test('captures microvmId and runHookPayload from the platform body', () => {
const context = applyRunHook({
microvmId: 'mvm-0123',
runHookPayload: '{"runtime_session_id":"rt_x"}',
});
expect(context.microvmId).toBe('mvm-0123');
expect(context.runHookPayload).toBe('{"runtime_session_id":"rt_x"}');
expect(getMicrovmRunContext()).toBe(context);
});

test('first run wins: a different microvmId does not overwrite the context', () => {
applyRunHook({ microvmId: 'mvm-first', runHookPayload: 'a' });
applyRunHook({ microvmId: 'mvm-second', runHookPayload: 'b' });
expect(getMicrovmRunContext()?.microvmId).toBe('mvm-first');
expect(getMicrovmRunContext()?.runHookPayload).toBe('a');
});

test('retries with the same microvmId are idempotent', () => {
const first = applyRunHook({ microvmId: 'mvm-0123' });
const second = applyRunHook({ microvmId: 'mvm-0123' });
expect(second).toBe(first);
});

test('tolerates malformed and empty bodies', () => {
expect(applyRunHook(undefined).microvmId).toBeUndefined();
resetMicrovmRunContextForTests();
expect(applyRunHook('not-an-object').microvmId).toBeUndefined();
resetMicrovmRunContextForTests();
expect(applyRunHook({ microvmId: 42, runHookPayload: {} }).microvmId).toBeUndefined();
});

test('hook base path matches the AWS well-known prefix', () => {
expect(LIFECYCLE_HOOK_BASE_PATH).toBe('/aws/lambda-microvms/runtime/v1');
});
});
94 changes: 94 additions & 0 deletions api/src/api/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import express, { Router, type Request, type Response } from 'express';
import { logger } from '../logger';
import { bindSessionWorkspace, parseSessionBinding, unbindSessionWorkspace } from '../session-workspace';

/**
* AWS Lambda MicroVM hook endpoints. The platform POSTs to
* `/aws/lambda-microvms/runtime/v1/<hook>` on the configured hook port:
*
* /ready, /validate image build hooks (200 = proceed, 503 = retry)
* /run after start; body {microvmId, runHookPayload};
* external traffic only flows after this returns 200
* /resume, /suspend, /terminate lifecycle transitions
*
* Phase 1-2: structured no-ops that log and 200. /suspend and /terminate
* are the Phase 3 checkpoint-flush attachment points; /run captures the
* per-VM payload for later runtime-session wiring.
*/

export const LIFECYCLE_HOOK_BASE_PATH = '/aws/lambda-microvms/runtime/v1';

export interface MicrovmRunContext {
microvmId?: string;
runHookPayload?: string;
receivedAt: number;
}

let runContext: MicrovmRunContext | undefined;

export function getMicrovmRunContext(): MicrovmRunContext | undefined {
return runContext;
}

export function resetMicrovmRunContextForTests(): void {
runContext = undefined;
}

/** First /run wins; retries with the same microvmId are idempotent, a
* different id is logged and ignored. Always 200 — a non-200 would fail
* the platform's lifecycle operation. */
export function applyRunHook(body: unknown): MicrovmRunContext {
const parsed = (typeof body === 'object' && body !== null ? body : {}) as {
microvmId?: unknown;
runHookPayload?: unknown;
};
const microvmId = typeof parsed.microvmId === 'string' ? parsed.microvmId : undefined;
const runHookPayload = typeof parsed.runHookPayload === 'string' ? parsed.runHookPayload : undefined;

if (runContext == null) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium api/lifecycle.ts:48

applyRunHook permanently locks in the very first /run body, even when it is malformed or missing both microvmId and runHookPayload. Line 48 stores { microvmId: undefined, runHookPayload: undefined } in runContext, so any later retry with the real values falls through the else if branch (both ids are undefined, so they don't mismatch) and never re-initializes or binds the session workspace. A transient bad first payload thus leaves the VM with an empty runContext for its whole lifetime. Consider gating the else if so retries with real values overwrite an uninitialized runContext — e.g. only treat the existing context as locked when it has a non-null microvmId.

-  if (runContext == null) {
+  if (runContext == null || runContext.microvmId == null) {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @api/src/api/lifecycle.ts around line 48:

`applyRunHook` permanently locks in the very first `/run` body, even when it is malformed or missing both `microvmId` and `runHookPayload`. Line 48 stores `{ microvmId: undefined, runHookPayload: undefined }` in `runContext`, so any later retry with the real values falls through the `else if` branch (both ids are `undefined`, so they don't mismatch) and never re-initializes or binds the session workspace. A transient bad first payload thus leaves the VM with an empty `runContext` for its whole lifetime. Consider gating the `else if` so retries with real values overwrite an uninitialized `runContext` — e.g. only treat the existing context as locked when it has a non-null `microvmId`.

runContext = { microvmId, runHookPayload, receivedAt: Date.now() };
const binding = parseSessionBinding(runHookPayload);
if (binding) {
bindSessionWorkspace(binding);
logger.info({ runtimeSessionId: binding.runtimeSessionId }, 'Bound persistent session workspace');
}
} else if (runContext.microvmId != null && microvmId != null && runContext.microvmId !== microvmId) {
logger.warn(
{ existing: runContext.microvmId, incoming: microvmId },
'Ignoring /run hook for a different microvmId',
);
}
return runContext;
}

const lifecycleRouter = Router();

function ackHook(hook: string) {
return (_req: Request, res: Response): Response => {
logger.info({ hook }, 'MicroVM lifecycle hook invoked');
return res.status(200).json({ hook, status: 'ok' });
};
}

lifecycleRouter.post('/ready', ackHook('ready'));
lifecycleRouter.post('/validate', ackHook('validate'));

lifecycleRouter.post('/run', express.json({ limit: '32kb' }), (req: Request, res: Response) => {
const context = applyRunHook(req.body);
logger.info(
{ hook: 'run', microvmId: context.microvmId, hasPayload: context.runHookPayload != null },
'MicroVM lifecycle hook invoked',
);
return res.status(200).json({ hook: 'run', status: 'ok' });
});

lifecycleRouter.post('/resume', ackHook('resume'));
lifecycleRouter.post('/suspend', ackHook('suspend'));

lifecycleRouter.post('/terminate', (_req: Request, res: Response) => {
logger.info({ hook: 'terminate' }, 'MicroVM lifecycle hook invoked');
void unbindSessionWorkspace().catch((err) => logger.error({ err }, 'Failed to unbind session workspace on terminate'));
return res.status(200).json({ hook: 'terminate', status: 'ok' });
});

export default lifecycleRouter;
53 changes: 53 additions & 0 deletions api/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { activeSandboxExecutions, recordSandboxExecution } from '../metrics';
import { classifySandboxSafeError } from '../safe-error';
import { withSpan } from '../telemetry';
import { checkSandboxWorkspaceHealth } from '../workspace-isolation';
import {
RUNTIME_SESSION_ID_HEADER,
bindSessionWorkspace,
parseSessionBindingFromHeader,
} from '../session-workspace';
import { streamSessionCheckpoint, restoreSessionCheckpoint } from '../session-checkpoint';

const router = express.Router();
const SYNTHETIC_PRINCIPAL_SOURCE = 'synthetic_test';
Expand Down Expand Up @@ -130,6 +136,7 @@ function getJob(
egressGrantToken?: string,
toolCallSocketEnabled = false,
isSynthetic = false,
runtimeSessionHeader?: string | string[],
): Job {
const {
session_id, language, version, args, stdin, files,
Expand Down Expand Up @@ -171,6 +178,14 @@ function getJob(

validateConstraints(body, rt);

/* Session mode is per-request opt-in: only run in the persistent workspace
* when THIS request carried a valid X-Runtime-Session-Id. A headerless or
* malformed-header request must NOT inherit a previously bound session, or it
* would reuse that session's files/UID (defense-in-depth — the backend always
* sends the header for a session VM, so this only guards stray requests). */
const binding = parseSessionBindingFromHeader(runtimeSessionHeader);
const session = binding ? bindSessionWorkspace(binding) ?? null : null;

return new Job({
session_id: session_id ?? null,
runtime: rt,
Expand All @@ -194,6 +209,7 @@ function getJob(
egress_grant: egressGrantToken,
tool_call_socket_enabled: toolCallSocketEnabled,
is_synthetic: isSynthetic,
session,
});
}

Expand Down Expand Up @@ -238,6 +254,8 @@ function manifestErrorStatus(error: ExecutionManifestError): number {

router.use((req: Request, res: Response, next: NextFunction) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
/* Checkpoint restore streams a tar.gz body, not JSON. */
if (req.path === '/session/restore') return next();
if (!req.headers['content-type']?.startsWith('application/json')) {
return res.status(415).json({ message: 'requests must be of type application/json' });
}
Expand Down Expand Up @@ -322,6 +340,7 @@ router.post('/execute', express.json({ limit: config.execute_body_limit }), asyn
tokenFromBodyOrHeader(req.body, 'egress_grant', req.header(EGRESS_GRANT_HEADER) ?? undefined),
toolCallSocketEnabled,
verifiedManifest?.principal_source === SYNTHETIC_PRINCIPAL_SOURCE,
req.headers[RUNTIME_SESSION_ID_HEADER],
);
metricsLanguage = job.runtime.language;
markActiveExecution();
Expand Down Expand Up @@ -441,4 +460,38 @@ router.get('/runtimes', (_req: Request, res: Response) => {
return res.status(200).json(runtimes);
});

/* Session workspace checkpoint / restore — control-plane driven, session-mode
* only. GET streams a tar.gz of the whole workspace (captures state the
* file-ref path drops: installed packages, chDB dirs, unsupported-extension
* files); POST replaces the workspace from one. No body parser on restore:
* the handler consumes the raw request stream. */
/* Bind the session from the header before checkpoint/restore. These run BEFORE
* the first /execute on a relaunched VM, so in the hookless design nothing else
* has bound the workspace yet; without this the handlers 409 and a real restore
* silently continues with an empty workspace (checkpoint state lost on expiry).
* Returns false (→ fail closed) when this request carries no valid header, so a
* headerless/malformed request never operates on a stale prior session. */
function bindSessionFromHeader(req: Request): boolean {
const binding = parseSessionBindingFromHeader(req.headers[RUNTIME_SESSION_ID_HEADER]);
if (!binding) return false;
bindSessionWorkspace(binding);
return true;
}

/* Express 4 (pinned) does NOT auto-forward rejected route-handler promises, so
* `.catch(next)` is required or a rejection (e.g. session.ownership()) hangs the
* request and surfaces as an unhandled rejection instead of a 5xx. */
router.get('/session/checkpoint', (req: Request, res: Response, next: NextFunction) => {
if (!bindSessionFromHeader(req)) {
return res.status(409).json({ message: 'Missing runtime session header' });
}
return streamSessionCheckpoint(res).catch(next);
});
router.post('/session/restore', (req: Request, res: Response, next: NextFunction) => {
if (!bindSessionFromHeader(req)) {
return res.status(409).json({ message: 'Missing runtime session header' });
}
return restoreSessionCheckpoint(req, res).catch(next);
});

export default router;
6 changes: 6 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ export const config = {
run_memory_limit: Number(process.env.SANDBOX_RUN_MEMORY_LIMIT ?? -1),
max_concurrent_jobs: safeInt(process.env.SANDBOX_MAX_CONCURRENT_JOBS, 8),
per_job_uids: (process.env.SANDBOX_PER_JOB_UIDS ?? 'true') === 'true',
/* Image-level enable for persistent session workspaces (stateful sessions).
* Only the Lambda MicroVM runner target sets this true; the K8s
* sandbox-runner image leaves it false so it is structurally incapable of
* session mode regardless of any /run payload. A VM additionally opts in
* per-launch via the /run runHookPayload. */
session_workspace_enabled: (process.env.SANDBOX_SESSION_WORKSPACE_ENABLED ?? 'false') === 'true',
job_uid_base: safeInt(process.env.SANDBOX_JOB_UID_BASE, 200000),
job_gid_base: safeInt(process.env.SANDBOX_JOB_GID_BASE, 200000),
job_uid_count: safeInt(
Expand Down
17 changes: 17 additions & 0 deletions api/src/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ set -e

echo "Starting NsJail sandbox API..."

# Raise the RLIMIT_NOFILE limits so per-job nsjail can set its own soft
# limit (SANDBOX_MAX_OPEN_FILES) without EPERM. The AWS Lambda MicroVM base
# image ships a 1024 hard cap, below the sandbox default of 2048; nsjail
# runs the child with keep_caps:false, so the hard limit must already be
# high before the API starts. Only ever RAISE, never lower: `ulimit -n`
# clamps downward too, so an unconditional set would shrink Docker's ~1M
# default (and any SANDBOX_MAX_OPEN_FILES above 65536) instead of no-op'ing.
raise_nofile() {
local kind="$1" want=65536 cur
cur="$(ulimit "$kind" 2>/dev/null || echo unlimited)"
if [ "$cur" != "unlimited" ] && [ "$cur" -lt "$want" ] 2>/dev/null; then
ulimit "$kind" "$want" 2>/dev/null || true
fi
}
raise_nofile -Hn # hard first, so the soft raise below is permitted
raise_nofile -Sn

SANDBOX_USE_CGROUPV2="${SANDBOX_USE_CGROUPV2:-true}"
SANDBOX_REMOVE_UMOUNT_AFTER_STARTUP="${SANDBOX_REMOVE_UMOUNT_AFTER_STARTUP:-true}"
NSJAIL_CONFIG_SOURCE="${NSJAIL_CONFIG:-/sandbox_api/config/sandbox.cfg}"
Expand Down
2 changes: 2 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { initializeSandboxWorkspaceIsolation, startWorkspaceReaper } from './wor
import { httpMetricsMiddleware, metricsHandler } from './metrics';
import { positiveInt, shutdownTelemetry, traceHttpRequest } from './telemetry';
import v2Router from './api/v2';
import lifecycleRouter, { LIFECYCLE_HOOK_BASE_PATH } from './api/lifecycle';

const app = express();

Expand All @@ -29,6 +30,7 @@ loadPackages(config.packages_directory);

logger.info('Registering routes');
app.get('/metrics', metricsHandler);
app.use(LIFECYCLE_HOOK_BASE_PATH, lifecycleRouter);
app.use('/api/v2', v2Router);

app.get('/', (_req, res) => {
Expand Down
Loading