Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 11 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,17 @@ jobs:
shellcheck scripts/build-lambda-microvm-artifact.sh

- name: Validate runner Dockerfile
run: >-
docker buildx build --check
--platform linux/arm64
--target lambda-microvm-runner
-f api/Dockerfile
.
run: |
docker buildx build --check \
--platform linux/arm64 \
--target lambda-microvm-runner \
-f api/Dockerfile \
.
docker buildx build --check \
--platform linux/arm64 \
--target lambda-microvm-app-host \
-f api/Dockerfile \
.

- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
Expand Down
14 changes: 13 additions & 1 deletion api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ RUN rm -f /usr/bin/nsenter /usr/bin/unshare /usr/bin/chroot /usr/sbin/chroot \
2>/dev/null || true

COPY api/src/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
COPY api/src/hosted-app-launcher.sh /usr/local/bin/codeapi-hosted-app-launcher
RUN chmod +x ./entrypoint.sh /usr/local/bin/codeapi-hosted-app-launcher

# ============================================================================
# Stage 2b: AWS Lambda MicroVM container base image
Expand All @@ -169,6 +170,17 @@ ENV PORT=8080 \
EXPOSE 8080/tcp
ENTRYPOINT ["/sandbox_api/entrypoint.sh"]

# Dedicated resident-process host. This image deliberately keeps the runner
# control listener on 8080 and exposes one fixed user-app port separately.
# `/execute` is disabled when hosted-app mode is enabled; the control plane
# restores a checkpoint and starts the app through `/api/v2/hosted-app/start`.
FROM lambda-microvm-runner AS lambda-microvm-app-host

ENV SANDBOX_HOSTED_APPS_ENABLED=true \
SANDBOX_HOSTED_APP_PORT=3000

EXPOSE 3000/tcp

# ============================================================================
# Stage 3: Build the Rust launcher binary (Fedora for libkrun ABI)
# ============================================================================
Expand Down
88 changes: 88 additions & 0 deletions api/src/api/hosted-app.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
import express from 'express';
import type { Server } from 'node:http';
import { config } from '../config';
import { resetSessionWorkspaceStateForTests } from '../session-workspace';
import v2Router from './v2';

let server: Server;
let baseUrl: string;
const savedHostedAppsEnabled = config.hosted_apps_enabled;
const savedSessionWorkspaceEnabled = config.session_workspace_enabled;

beforeAll(async () => {
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use('/api/v2', v2Router);
await new Promise<void>(resolve => {
server = app.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
baseUrl = `http://127.0.0.1:${typeof address === 'object' && address ? address.port : 0}`;
});

afterEach(() => {
config.hosted_apps_enabled = false;
config.session_workspace_enabled = false;
resetSessionWorkspaceStateForTests();
});

afterAll(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
config.hosted_apps_enabled = savedHostedAppsEnabled;
config.session_workspace_enabled = savedSessionWorkspaceEnabled;
resetSessionWorkspaceStateForTests();
});

const post = (path: string, body: unknown = {}) => fetch(`${baseUrl}/api/v2${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

describe('hosted-app route isolation', () => {
test('ordinary runner images do not expose hosted-app controls', async () => {
config.hosted_apps_enabled = false;
config.session_workspace_enabled = true;

const response = await post('/hosted-app/start');

expect(response.status).toBe(404);
expect(await response.json()).toEqual({ message: 'Not Found' });
});

test('dedicated app hosts require the authenticated runtime-session binding', async () => {
config.hosted_apps_enabled = true;
config.session_workspace_enabled = true;

const response = await post('/hosted-app/start');

expect(response.status).toBe(409);
expect(await response.json()).toEqual({ message: 'Missing runtime session header' });
});

test('dedicated app hosts refuse ordinary execution requests', async () => {
config.hosted_apps_enabled = true;
config.session_workspace_enabled = true;

const response = await post('/execute', {});

expect(response.status).toBe(404);
expect(await response.json()).toEqual({ message: 'Not Found' });
});

test('status is session-bound and reports absence without starting a process', async () => {
config.hosted_apps_enabled = true;
config.session_workspace_enabled = true;

const response = await fetch(`${baseUrl}/api/v2/hosted-app/status`, {
headers: { 'X-Runtime-Session-Id': 'rt_hosted_demo' },
});

expect(response.status).toBe(404);
expect(await response.json()).toEqual({
error: 'hosted_app_not_running',
message: 'No hosted app has been started',
});
});
});
8 changes: 7 additions & 1 deletion api/src/api/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import express, { Router, type Request, type Response } from 'express';
import { logger } from '../logger';
import { bindSessionWorkspace, parseSessionBinding, unbindSessionWorkspace } from '../session-workspace';
import { hostedAppSupervisor } from '../hosted-app';

/**
* AWS Lambda MicroVM hook endpoints. The platform POSTs to
Expand Down Expand Up @@ -97,7 +98,12 @@ 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'));
/* Stop before resetting the workspace so a resident process cannot race the
* recursive session cleanup. The platform does not need to wait for this
* best-effort cleanup before destroying the whole MicroVM. */
void hostedAppSupervisor.shutdown()
.then(() => unbindSessionWorkspace())
.catch((err) => logger.error({ err }, 'Failed to stop hosted app on terminate'));
return res.status(200).json({ hook: 'terminate', status: 'ok' });
});

Expand Down
83 changes: 83 additions & 0 deletions api/src/api/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import {
pruneInputCache,
storeCachedInputs,
} from '../session-inputs';
import {
HostedAppError,
hostedAppSupervisor,
} from '../hosted-app';

const router = express.Router();
const SYNTHETIC_PRINCIPAL_SOURCE = 'synthetic_test';
Expand Down Expand Up @@ -388,6 +392,16 @@ router.use((req: Request, res: Response, next: NextFunction) => {
next();
});

/* A hosted-app image is a dedicated process host, not an execution runner.
* Keeping `/execute` structurally unavailable prevents a resident app and an
* NsJail job from sharing the pinned session UID/workspace concurrently. */
router.use('/execute', (_req: Request, res: Response, next: NextFunction) => {
if (config.hosted_apps_enabled) {
return res.status(404).json({ message: 'Not Found' });
}
next();
});

/** Replay PTC payloads (user code + tool definitions + inlined
* `_ptc_history.json` + pyplot assets) can far exceed Express's default
* ~100kb body limit. The parser is installed *here* rather than globally
Expand Down Expand Up @@ -686,6 +700,75 @@ router.post('/session/restore', (req: Request, res: Response, next: NextFunction
}
return restoreSessionCheckpoint(req, res).catch(next);
});

function requireHostedAppTarget(
_req: Request,
res: Response,
next: NextFunction,
): Response | void {
if (!config.hosted_apps_enabled) {
return res.status(404).json({ message: 'Not Found' });
}
next();
}

function hostedAppFailure(
error: unknown,
res: Response,
next: NextFunction,
): Response | void {
if (error instanceof HostedAppError) {
return res.status(error.status).json({ error: error.code, message: error.message });
}
next(error);
}

/* Dedicated Lambda MicroVM resident-server adapter. The control plane first
* restores an immutable session checkpoint into this VM, then starts exactly
* one foreground process. Preview traffic uses a separate AWS token restricted
* to `hosted_app_port`; these control routes stay on the runner port. */
router.post(
'/hosted-app/start',
requireHostedAppTarget,
express.json({ limit: '64kb' }),
(req: Request, res: Response, next: NextFunction) => {
const failure = bindSessionFromHeader(req);
if (failure) return res.status(failure.status).json(failure.body);
return hostedAppSupervisor.start(req.body)
.then(status => res.status(200).json(status))
.catch(error => hostedAppFailure(error, res, next));
},
);

router.get(
'/hosted-app/status',
requireHostedAppTarget,
(req: Request, res: Response) => {
const failure = bindSessionFromHeader(req);
if (failure) return res.status(failure.status).json(failure.body);
const status = hostedAppSupervisor.status();
if (!status) {
return res.status(404).json({
error: 'hosted_app_not_running',
message: 'No hosted app has been started',
});
}
return res.status(200).json(status);
},
);

router.post(
'/hosted-app/stop',
requireHostedAppTarget,
express.json({ limit: '1kb' }),
(req: Request, res: Response, next: NextFunction) => {
const failure = bindSessionFromHeader(req);
if (failure) return res.status(failure.status).json(failure.body);
return hostedAppSupervisor.stop()
.then(status => status ? res.status(200).json(status) : res.status(204).send())
.catch(next);
},
);
/**
* Input delivery for backends whose sandbox cannot reach the file server.
*
Expand Down
25 changes: 25 additions & 0 deletions api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,31 @@ export const config = {
* session mode. An enabled runner additionally binds each request to a
* workspace through the authenticated X-Runtime-Session-Id header. */
session_workspace_enabled: (process.env.SANDBOX_SESSION_WORKSPACE_ENABLED ?? 'false') === 'true',
/**
* Enables the Lambda-only hosted-app runner surface. This must only be set
* on a dedicated app-host MicroVM image: user application processes share
* that VM's network namespace and are therefore intentionally never started
* by the ordinary stateless/session execution runner.
*/
hosted_apps_enabled: (process.env.SANDBOX_HOSTED_APPS_ENABLED ?? 'false') === 'true',
hosted_app_port: safeInt(process.env.SANDBOX_HOSTED_APP_PORT, 3000),
hosted_app_start_timeout_ms: safeInt(
process.env.SANDBOX_HOSTED_APP_START_TIMEOUT_MS,
30_000,
),
hosted_app_stop_timeout_ms: safeInt(
process.env.SANDBOX_HOSTED_APP_STOP_TIMEOUT_MS,
5_000,
),
hosted_app_log_max_bytes: safeInt(
process.env.SANDBOX_HOSTED_APP_LOG_MAX_BYTES,
64 * 1024,
),
hosted_app_memory_max_bytes: safeInt(
process.env.SANDBOX_HOSTED_APP_MEMORY_MAX_BYTES,
2 * 1024 * 1024 * 1024,
),
hosted_app_pids_max: safeInt(process.env.SANDBOX_HOSTED_APP_PIDS_MAX, 128),
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
26 changes: 26 additions & 0 deletions api/src/hosted-app-launcher.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail

if [ "$#" -lt 5 ]; then
echo "usage: hosted-app-launcher <cgroup> <uid> <gid> <command> [args...]" >&2
exit 64
fi

CGROUP_PATH="$1"
APP_UID="$2"
APP_GID="$3"
shift 3

# This wrapper starts as root, joins the root-owned cgroup before any user code
# can fork, then irreversibly drops identity and capabilities. App descendants
# inherit the cgroup even if they daemonize or create a new process group.
printf '%s' "$$" > "${CGROUP_PATH}/cgroup.procs"
exec /usr/bin/setpriv \
--no-new-privs \
--reuid "$APP_UID" \
--regid "$APP_GID" \
--clear-groups \
--inh-caps=-all \
--ambient-caps=-all \
--bounding-set=-all \
-- "$@"
Loading