diff --git a/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts b/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts index e53170205..42b08c176 100644 --- a/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts +++ b/apps/controller/src/compute-providers/__tests__/spawn-modal-worker.test.ts @@ -133,7 +133,7 @@ describe('spawnModalWorker', () => { ); }); - it('uses Modal VM sandboxes for environments with container projects', async () => { + it('rejects environments with container projects before starting Modal', async () => { mockGetNamedPortsForTaskRun.mockResolvedValue({ namedPorts: [{ name: 'SANDBOX_SERVER', port: 7777 }], environmentSnapshotId: undefined, @@ -149,28 +149,27 @@ describe('spawnModalWorker', () => { }, }); - await spawnModalWorker( - mockTaskRun({ - payloadKind: TaskPayloadKind.StandardTask, - payload: { repo: 'test/repo', environmentId: 'env_123' }, - }), - 'auth_token', - { - deploymentSlug: 'roomote', - modalTokenId: 'token-id', - modalTokenSecret: 'token-secret', - modalBaseImageRef: 'image-ref', - modalTimeoutMs: 60_000, - }, + await expect( + spawnModalWorker( + mockTaskRun({ + payloadKind: TaskPayloadKind.StandardTask, + payload: { repo: 'test/repo', environmentId: 'env_123' }, + }), + 'auth_token', + { + deploymentSlug: 'roomote', + modalTokenId: 'token-id', + modalTokenSecret: 'token-secret', + modalBaseImageRef: 'image-ref', + modalTimeoutMs: 60_000, + }, + ), + ).rejects.toThrow( + 'Modal does not currently support Docker Compose or Dockerfile projects', ); - expect(mockCreateComputeProviderClient).toHaveBeenCalledWith( - expect.objectContaining({ - provider: 'modal', - config: expect.objectContaining({ vmRuntime: true }), - }), - ); - expect(mockCreateModalMachine).toHaveBeenCalled(); + expect(mockCreateComputeProviderClient).not.toHaveBeenCalled(); + expect(mockCreateModalMachine).not.toHaveBeenCalled(); }); it('primes environment OIDC before launching a fresh Modal worker when the environment defines OIDC targets', async () => { diff --git a/apps/controller/src/compute-providers/spawn-modal-worker.ts b/apps/controller/src/compute-providers/spawn-modal-worker.ts index 5b928e3af..86c807f7f 100644 --- a/apps/controller/src/compute-providers/spawn-modal-worker.ts +++ b/apps/controller/src/compute-providers/spawn-modal-worker.ts @@ -161,7 +161,11 @@ export async function spawnModalWorker( const { namedPorts, environmentSnapshotId, environmentConfig } = await getNamedPortsForTaskRun(taskRun); - const needsVmRuntime = Boolean(environmentConfig?.container_projects?.length); + if (environmentConfig?.container_projects?.length) { + throw new NonRetryableSpawnError( + 'Modal does not currently support Docker Compose or Dockerfile projects. Choose E2B, Daytona, Blaxel, or Local Docker for this environment.', + ); + } const shouldEnableAuthBypass = shouldEnableAuthBypassForTaskRun({ environmentConfig, @@ -252,7 +256,6 @@ export async function spawnModalWorker( const configuredResources = resolveConfiguredComputeProviderResources({ provider: 'modal', }); - const modalConfig = { tokenId: modalTokenId, tokenSecret: modalTokenSecret, @@ -269,7 +272,6 @@ export async function spawnModalWorker( ...(modalEcrOidcRoleArn ? { ecrOidcRoleArn: modalEcrOidcRoleArn } : {}), ...(modalEcrRegion ? { ecrRegion: modalEcrRegion } : {}), ...(parsedModalRegions ? { regions: parsedModalRegions } : {}), - ...(needsVmRuntime ? { vmRuntime: true } : {}), ...(configuredResources.configuredCpuCores !== null ? { cpu: configuredResources.configuredCpuCores } : {}), diff --git a/apps/dev/src/index.ts b/apps/dev/src/index.ts index 7ddeea898..64081e4de 100644 --- a/apps/dev/src/index.ts +++ b/apps/dev/src/index.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { execa } from 'execa'; import ora from 'ora'; import { PRODUCT_NAME } from '@roomote/types'; @@ -22,6 +23,33 @@ import { // routing in deploy/caddy/Caddyfile. const CADDY_DEV_PORT = 18080; +const DEVELOPMENT_WORKER_IMAGE_REPOSITORY = 'ghcr.io/roocodeinc/roomote-worker'; + +async function configureHostedDevelopmentWorkerImage(): Promise { + if (process.env.ROOMOTE_DEVELOPMENT_WORKER_IMAGE_REF) { + return; + } + + try { + const { stdout } = await execa('git', ['rev-parse', 'origin/develop'], { + cwd: process.cwd(), + }); + // The publish workflow tags images with exactly the first eight SHA + // characters. Git's --short=8 means "at least eight" and may lengthen an + // ambiguous abbreviation, which would select a tag that was never pushed. + const imageRef = `${DEVELOPMENT_WORKER_IMAGE_REPOSITORY}:develop-${stdout.trim().slice(0, 8)}`; + + process.env.ROOMOTE_DEVELOPMENT_WORKER_IMAGE_REF = imageRef; + // Modal consumes its base image directly instead of provisioning a named + // artifact. Preserve an operator override, otherwise pin it to the same + // immutable development image as E2B, Daytona, and Blaxel. + process.env.MODAL_BASE_IMAGE_REF ||= imageRef; + } catch { + // Source archives and shallow checkouts may not have origin/develop. The + // shared resolver retains the mutable :develop fallback for those cases. + } +} + class LocalDevStarter { static async run(options: ScriptOptions): Promise { try { @@ -35,6 +63,8 @@ class LocalDevStarter { await EnvService.checkEnvVars(); + await configureHostedDevelopmentWorkerImage(); + await WatchmanService.checkInstalled(); await PM2Service.checkInstalled(); await PM2Service.stopServices({ diff --git a/apps/worker/Dockerfile b/apps/worker/Dockerfile index 39fc50961..eac204741 100644 --- a/apps/worker/Dockerfile +++ b/apps/worker/Dockerfile @@ -254,7 +254,7 @@ RUN sudo apt-get update \ # Shared with WORKER_RUNTIME_SCHEMA_VERSION in @roomote/types. Local builds # pass this explicitly; the default keeps direct/release Docker builds labeled. -ARG ROOMOTE_WORKER_RUNTIME_SCHEMA_VERSION=2 +ARG ROOMOTE_WORKER_RUNTIME_SCHEMA_VERSION=3 LABEL dev.roomote.worker-image.schema-version="${ROOMOTE_WORKER_RUNTIME_SCHEMA_VERSION}" WORKDIR /sandbox diff --git a/packages/compute-providers/src/blaxel/build-blaxel-image.test.ts b/packages/compute-providers/src/blaxel/build-blaxel-image.test.ts index c21840ad1..a1badafd7 100644 --- a/packages/compute-providers/src/blaxel/build-blaxel-image.test.ts +++ b/packages/compute-providers/src/blaxel/build-blaxel-image.test.ts @@ -33,7 +33,7 @@ describe('Blaxel worker image provisioning', () => { it('derives a deterministic resource name from the Blaxel image hash', () => { expect(deriveBlaxelWorkerImageName('ghcr.io/roomote/worker:v1')).toBe( - 'roomote-worker-abc123-r2', + 'roomote-worker-abc123-r3', ); }); @@ -49,7 +49,7 @@ describe('Blaxel worker image provisioning', () => { imageRef: 'ghcr.io/roomote/worker:v1', }), ).resolves.toEqual({ - imageName: 'roomote-worker-abc123-r2', + imageName: 'roomote-worker-abc123-r3', imageRef: 'sandbox/roomote-worker:version', }); @@ -58,8 +58,8 @@ describe('Blaxel worker image provisioning', () => { workspace: 'workspace', }); expect(mockBuild).toHaveBeenCalledWith( - expect.objectContaining({ name: 'roomote-worker-abc123-r2' }), + expect.objectContaining({ name: 'roomote-worker-abc123-r3' }), ); - expect(mockDelete).toHaveBeenCalledWith('roomote-worker-abc123-r2'); + expect(mockDelete).toHaveBeenCalledWith('roomote-worker-abc123-r3'); }); }); diff --git a/packages/compute-providers/src/e2b/build-e2b-template.test.ts b/packages/compute-providers/src/e2b/build-e2b-template.test.ts new file mode 100644 index 000000000..5102e34f2 --- /dev/null +++ b/packages/compute-providers/src/e2b/build-e2b-template.test.ts @@ -0,0 +1,71 @@ +const { mockBuild, mockFromImage, mockRunCmd, mockSetUser, mockSetWorkdir } = + vi.hoisted(() => ({ + mockBuild: vi.fn(), + mockFromImage: vi.fn(), + mockRunCmd: vi.fn(), + mockSetUser: vi.fn(), + mockSetWorkdir: vi.fn(), + })); + +vi.mock('e2b', () => { + const builder = { + fromImage: mockFromImage, + setUser: mockSetUser, + setWorkdir: mockSetWorkdir, + runCmd: mockRunCmd, + }; + + for (const method of Object.values(builder)) { + method.mockReturnValue(builder); + } + + return { + Template: Object.assign( + vi.fn(() => builder), + { build: mockBuild }, + ), + }; +}); + +import { buildE2bWorkerTemplate } from './build-e2b-template'; + +describe('buildE2bWorkerTemplate', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockBuild.mockResolvedValue({ + templateId: 'template-id', + buildId: 'build-id', + tags: ['default'], + }); + }); + + it('validates as root and restores the Roomote runtime user', async () => { + await buildE2bWorkerTemplate({ + apiKey: 'e2b-key', + imageRef: 'ghcr.io/roomote/worker:develop', + registryUsername: 'registry-user', + registryPassword: 'registry-password', + }); + + expect(mockFromImage).toHaveBeenCalledWith( + 'ghcr.io/roomote/worker:develop', + { username: 'registry-user', password: 'registry-password' }, + ); + expect(mockSetUser).toHaveBeenNthCalledWith(1, 'root'); + expect(mockSetUser).toHaveBeenNthCalledWith(2, 'roomote'); + expect(mockSetWorkdir).toHaveBeenCalledWith('/home/roomote'); + expect(mockRunCmd).toHaveBeenCalledWith('/usr/bin/docker compose version'); + const runCommandOrder = mockRunCmd.mock.invocationCallOrder[0]; + const rootUserOrder = mockSetUser.mock.invocationCallOrder[0]; + const roomoteUserOrder = mockSetUser.mock.invocationCallOrder[1]; + const setWorkdirOrder = mockSetWorkdir.mock.invocationCallOrder[0]; + + expect(runCommandOrder).toBeDefined(); + expect(rootUserOrder).toBeDefined(); + expect(roomoteUserOrder).toBeDefined(); + expect(setWorkdirOrder).toBeDefined(); + expect(rootUserOrder!).toBeLessThan(runCommandOrder!); + expect(runCommandOrder!).toBeLessThan(roomoteUserOrder!); + expect(roomoteUserOrder!).toBeLessThan(setWorkdirOrder!); + }); +}); diff --git a/packages/compute-providers/src/e2b/build-e2b-template.ts b/packages/compute-providers/src/e2b/build-e2b-template.ts index 2c8995edf..0e99de6d0 100644 --- a/packages/compute-providers/src/e2b/build-e2b-template.ts +++ b/packages/compute-providers/src/e2b/build-e2b-template.ts @@ -93,7 +93,14 @@ export async function buildE2bWorkerTemplate( ? { username: registryUsername, password: registryPassword } : undefined, ) - .runCmd('sudo docker compose version'); + // E2B doesn't preserve a custom image's Dockerfile USER for template + // steps, and its build environment can reject sudo even for an image user + // that has passwordless sudo. Validate the baked-in Docker CLI directly as + // root, then restore Roomote's intended runtime identity. + .setUser('root') + .runCmd('/usr/bin/docker compose version') + .setUser('roomote') + .setWorkdir('/home/roomote'); const buildInfo = await Template.build(template, templateRef, { apiKey, diff --git a/packages/compute-providers/src/worker-artifact-version.test.ts b/packages/compute-providers/src/worker-artifact-version.test.ts index 88acda2a0..364bedb56 100644 --- a/packages/compute-providers/src/worker-artifact-version.test.ts +++ b/packages/compute-providers/src/worker-artifact-version.test.ts @@ -4,13 +4,13 @@ import { deriveE2bWorkerTemplateRef } from './e2b'; describe('hosted worker artifact versioning', () => { it('includes the runtime schema in E2B template refs', () => { expect(deriveE2bWorkerTemplateRef('ghcr.io/roomote/worker:v1.2.3')).toBe( - 'roomote-worker:v1.2.3-r2', + 'roomote-worker:v1.2.3-r3', ); }); it('includes the runtime schema in Daytona snapshot names', () => { expect( deriveDaytonaWorkerSnapshotName('ghcr.io/roomote/worker:v1.2.3'), - ).toBe('roomote-worker-v1.2.3-r2'); + ).toBe('roomote-worker-v1.2.3-r3'); }); }); diff --git a/packages/types/src/__tests__/compute-provider-capabilities.test.ts b/packages/types/src/__tests__/compute-provider-capabilities.test.ts index b495290f7..91853b34d 100644 --- a/packages/types/src/__tests__/compute-provider-capabilities.test.ts +++ b/packages/types/src/__tests__/compute-provider-capabilities.test.ts @@ -1,7 +1,7 @@ import { getComputeProviderCapabilities } from '../compute-providers/capabilities'; describe('compute provider capabilities', () => { - it.each(['docker', 'modal', 'daytona', 'e2b', 'blaxel'] as const)( + it.each(['docker', 'daytona', 'e2b', 'blaxel'] as const)( 'marks %s as supporting container projects', (provider) => { expect( @@ -9,4 +9,10 @@ describe('compute provider capabilities', () => { ).toBe(true); }, ); + + it('marks Modal as not supporting container projects', () => { + expect( + getComputeProviderCapabilities('modal').supportsContainerProjects, + ).toBe(false); + }); }); diff --git a/packages/types/src/compute-providers/capabilities.ts b/packages/types/src/compute-providers/capabilities.ts index e9710f030..1e9c171fb 100644 --- a/packages/types/src/compute-providers/capabilities.ts +++ b/packages/types/src/compute-providers/capabilities.ts @@ -45,7 +45,7 @@ export const MODAL_CAPABILITIES: ComputeProviderCapabilities = { supportsStandbyResume: false, supportsResume: true, supportsFileWrite: true, - supportsContainerProjects: true, + supportsContainerProjects: false, }; export const DAYTONA_CAPABILITIES: ComputeProviderCapabilities = { diff --git a/packages/types/src/setup-compute-config.test.ts b/packages/types/src/setup-compute-config.test.ts index f01a1ecb0..a23debef7 100644 --- a/packages/types/src/setup-compute-config.test.ts +++ b/packages/types/src/setup-compute-config.test.ts @@ -574,6 +574,21 @@ describe('buildSetupComputeStatus', () => { expect(status.setupSatisfied).toBe(true); }); + it('reports the immutable development worker image used for provisioning', () => { + const status = buildSetupComputeStatus({ + runtimeEnv: { + NODE_ENV: 'development', + ROOMOTE_DEVELOPMENT_WORKER_IMAGE_REF: + 'ghcr.io/roocodeinc/roomote-worker:develop-62a69ba7', + }, + }); + + expect(status.workerImage).toMatchObject({ + hostedImageRef: 'ghcr.io/roocodeinc/roomote-worker:develop-62a69ba7', + hostedReady: true, + }); + }); + it('reports provider infrastructure availability for the picker', () => { const withWorkerImage = buildSetupComputeStatus({ runtimeEnv: { @@ -802,6 +817,16 @@ describe('resolveDerivedModalBaseImageRef', () => { }), ).toBe(DEVELOPMENT_MODAL_BASE_IMAGE_REF); }); + + it('uses the immutable development worker image selected by the dev launcher', () => { + expect( + resolveDerivedModalBaseImageRef({ + NODE_ENV: 'development', + ROOMOTE_DEVELOPMENT_WORKER_IMAGE_REF: + 'ghcr.io/roocodeinc/roomote-worker:develop-62a69ba7', + }), + ).toBe('ghcr.io/roocodeinc/roomote-worker:develop-62a69ba7'); + }); }); describe('resolveEffectiveModalBaseImageRef', () => { diff --git a/packages/types/src/setup-compute-config.ts b/packages/types/src/setup-compute-config.ts index f711f5dc0..12c6ee19c 100644 --- a/packages/types/src/setup-compute-config.ts +++ b/packages/types/src/setup-compute-config.ts @@ -571,6 +571,9 @@ const DEFAULT_WORKER_IMAGE_REPOSITORY = 'ghcr.io/roocodeinc/roomote-worker'; export const DEVELOPMENT_MODAL_BASE_IMAGE_REF = 'ghcr.io/roocodeinc/roomote-worker:develop'; +const ROOMOTE_DEVELOPMENT_WORKER_IMAGE_REF = + 'ROOMOTE_DEVELOPMENT_WORKER_IMAGE_REF'; + /** * Derives the published worker image ref for the running app release: * `:${RELEASE_VERSION}`, where the repo defaults to the official GHCR @@ -638,9 +641,14 @@ export function resolveDerivedModalBaseImageRef( return derivedFromWorkerImage; } - return isDevelopmentRuntime(runtimeEnv) - ? DEVELOPMENT_MODAL_BASE_IMAGE_REF - : null; + if (!isDevelopmentRuntime(runtimeEnv)) { + return null; + } + + return ( + runtimeEnv[ROOMOTE_DEVELOPMENT_WORKER_IMAGE_REF]?.trim() || + DEVELOPMENT_MODAL_BASE_IMAGE_REF + ); } /** @@ -721,14 +729,7 @@ export function buildSetupComputeStatus(input: { // are ignored so they cannot stick above release-derived images. Only a // registry-qualified ref is hosted-ready; a bare local tag is not pullable // by hosted providers. - const explicitWorkerImage = runtimeEnv.DOCKER_WORKER_IMAGE?.trim() || null; - const effectiveWorkerImage = - explicitWorkerImage ?? deriveWorkerImageFromReleaseVersion(runtimeEnv); - const hostedWorkerImageRef = - deriveModalBaseImageRefDefault(effectiveWorkerImage) ?? - (isDevelopmentRuntime(runtimeEnv) - ? DEVELOPMENT_MODAL_BASE_IMAGE_REF - : null); + const hostedWorkerImageRef = resolveDerivedModalBaseImageRef(runtimeEnv); const derivedModalBaseImageRef = hostedWorkerImageRef; const workerImage: SetupComputeWorkerImageStatus = { diff --git a/packages/types/src/worker-runtime-version.ts b/packages/types/src/worker-runtime-version.ts index 2276181ab..d88a88062 100644 --- a/packages/types/src/worker-runtime-version.ts +++ b/packages/types/src/worker-runtime-version.ts @@ -3,7 +3,7 @@ * into Roomote worker images. Bump this when an existing image tag or hosted * provider artifact must be rebuilt because the runtime contract changed. */ -export const WORKER_RUNTIME_SCHEMA_VERSION = 2; +export const WORKER_RUNTIME_SCHEMA_VERSION = 3; /** Stable string used in Docker labels and provider-side resource names. */ export const WORKER_RUNTIME_SCHEMA_TAG = `r${WORKER_RUNTIME_SCHEMA_VERSION}`;