Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
55 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
77aff8c
fix: address Codex review round 10 of stateful session mechanics
danny-avila Jul 9, 2026
eb90f11
fix: address Codex review round 11 (tar close-listener races, fresh-r…
danny-avila Jul 9, 2026
16e6304
fix(runner): truncate stderr overflow instead of SIGKILL + surface ki…
danny-avila Jul 14, 2026
263cafd
feat(runner): boot-time SANDBOX_WARMUP_COMMAND hook + 30min idle default
danny-avila Jul 14, 2026
c198047
fix(lambda-microvm): retry launch once when a MicroVM dies during boot
danny-avila Jul 14, 2026
7b76e69
docs(runner): correct SANDBOX_OUTPUT_MAX_SIZE semantics
danny-avila Jul 15, 2026
12d3374
feat: push-model input file delivery for session VMs
danny-avila Jul 17, 2026
944c92f
feat(file-server): optional FILE_SERVER_HOST bind address
danny-avila Jul 19, 2026
6648237
fix: harden push delivery, checkpoint restore, and session lifecycle
danny-avila Jul 22, 2026
eba3f24
fix: close remaining session-delivery and fencing gaps
danny-avila Jul 23, 2026
82bb551
fix(runner): make re-delivery non-destructive independent of control-…
danny-avila Jul 23, 2026
e666861
fix(runner): symlink-safe, validate-then-commit delivery merge
danny-avila Jul 23, 2026
d6f613f
refactor: deliver inputs through the runner's own priming path
danny-avila Jul 23, 2026
dd7b468
fix(runner): parse the input-probe body
danny-avila Jul 23, 2026
51694a2
fix(runner): keep the input cache outside the workspace root
danny-avila Jul 23, 2026
fb6a214
fix: align the input-cache digest across control plane and runner
danny-avila Jul 23, 2026
5c7a6e8
fix: a cached object's destination belongs to the requesting ref
danny-avila Jul 23, 2026
f2097cb
fix: harden lambda microvm sessions and delivery
danny-avila Jul 23, 2026
fcace15
ci: make artifact guard shellcheck-safe
danny-avila Jul 23, 2026
d8d9f11
ci: lock terraform provider platforms
danny-avila Jul 23, 2026
b4a0e6b
fix: close lambda microvm review gaps
danny-avila Jul 24, 2026
d00b13b
fix: normalize minio ssl flag
danny-avila Jul 24, 2026
94e1ea8
fix: close late session review findings
danny-avila Jul 24, 2026
e370796
fix: harden checkpoint rollback safety
danny-avila Jul 24, 2026
5c85e74
fix: consume checkpoint streams through eof
danny-avila Jul 24, 2026
eedb3b0
fix: preserve checkpoint transaction boundaries
danny-avila Jul 24, 2026
0a39f43
fix: prevent session launch token collisions
danny-avila Jul 28, 2026
d300434
fix: harden runtime session identity and config
danny-avila Jul 28, 2026
554fc12
fix: recover runtime session delivery failures
danny-avila Jul 28, 2026
7fa3d3a
fix: harden stateful session execution
danny-avila Jul 28, 2026
104ff33
fix: close stateful session review gaps
danny-avila Jul 28, 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) {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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;
23 changes: 23 additions & 0 deletions api/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ 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,
getBoundSessionWorkspace,
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 +137,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 +179,9 @@ function getJob(

validateConstraints(body, rt);

const binding = parseSessionBindingFromHeader(runtimeSessionHeader);
if (binding) bindSessionWorkspace(binding);

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

Expand Down Expand Up @@ -238,6 +250,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 +336,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 +456,12 @@ 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. */
router.get('/session/checkpoint', (_req: Request, res: Response) => streamSessionCheckpoint(res));
router.post('/session/restore', (req: Request, res: Response) => restoreSessionCheckpoint(req, res));

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
9 changes: 9 additions & 0 deletions api/src/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ set -e

echo "Starting NsJail sandbox API..."

# Raise the RLIMIT_NOFILE hard cap 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. No-op where the limit is already higher
# (e.g. Docker's ~1M default) or where CAP_SYS_RESOURCE is unavailable.
ulimit -Hn 65536 2>/dev/null || true
ulimit -Sn 65536 2>/dev/null || true
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

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