From 49bf4dff971613923b4bcea8c49855b410b8c2d4 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:36:35 -0400 Subject: [PATCH 1/5] feat: environments can inherit the deployment compute provider for nested Roomote --- .roomote/environments/roomote.yaml | 9 ++ apps/docs/environments/definition.mdx | 25 +++ .../environments/EnvironmentPreview.tsx | 12 ++ .../YamlEnvironmentEditor.test.ts | 15 ++ .../settings/environments/yaml-utils.ts | 4 + .../src/commands/__tests__/setup.test.ts | 81 ++++++++++ .../src/commands/__tests__/utils.test.ts | 10 ++ apps/worker/src/commands/setup.ts | 33 +++- apps/worker/src/commands/utils/env-vars.ts | 4 + .../__tests__/dequeue-helpers.test.ts | 91 +++++++++++ .../__tests__/dequeue-resume-task-run.test.ts | 1 + .../server/lib/task-runs/dequeue-helpers.ts | 55 ++++++- .../lib/task-runs/dequeue-resume-task-run.ts | 1 + .../server/lib/task-runs/dequeue-task-run.ts | 1 + .../lib/task-runs/fetch-snapshot-env.ts | 1 + .../src/__tests__/nested-compute.test.ts | 148 ++++++++++++++++++ packages/types/src/control-plane-env-vars.ts | 4 + packages/types/src/environment-config.ts | 10 ++ packages/types/src/index.ts | 1 + packages/types/src/nested-compute.ts | 115 ++++++++++++++ 20 files changed, 615 insertions(+), 6 deletions(-) create mode 100644 packages/types/src/__tests__/nested-compute.test.ts create mode 100644 packages/types/src/nested-compute.ts diff --git a/.roomote/environments/roomote.yaml b/.roomote/environments/roomote.yaml index 09170738a..7e9492d5d 100644 --- a/.roomote/environments/roomote.yaml +++ b/.roomote/environments/roomote.yaml @@ -20,6 +20,10 @@ description: (Redis, API, BullMQ, controller) plus a local Mintlify preview of the public docs site (apps/docs) on the `docs` port. initialUrl: http://127.0.0.1:3000/auth/dev-login +# Forward this deployment's compute provider credentials so the nested Roomote +# controller can spawn its own task sandboxes (see "Nested compute" in the +# environment definition docs). +inherit_compute: true ports: - name: web port: 3000 @@ -27,6 +31,11 @@ ports: primary: true - name: docs port: 3333 + # Unproxied so the nested controller's sandboxes can reach the nested API + # directly; the controller command below reads it from ROOMOTE_API_HOST. + - name: api + port: 13001 + proxied: false services: - postgres16 repositories: diff --git a/apps/docs/environments/definition.mdx b/apps/docs/environments/definition.mdx index a8b40594c..f27780241 100644 --- a/apps/docs/environments/definition.mdx +++ b/apps/docs/environments/definition.mdx @@ -153,6 +153,7 @@ agentInstructions: | | `docker_projects` | list | no | Existing Compose or Dockerfile projects to build and start. See [Docker projects](#docker-projects). | | `ports` | list | no | Named preview ports. See [Ports](#ports). | | `oidc` | map | no | Sandbox OIDC targets. See [OIDC](#oidc). | +| `inherit_compute` | boolean | no | Forward the deployment's compute provider credentials into tasks. See [Nested compute](#nested-compute). | | `mcpServers` | map | no | Custom MCP servers for this environment. See [MCP servers](#mcp-servers). | | `skills` | map | no | Installable skills by `owner/repo`. See [Skills](#skills). | | `manualSkills` | list | no | Inline skills defined in the environment. See [Skills](#skills). | @@ -452,6 +453,30 @@ oidc: Each `token_file` must be an absolute path and must be unique across targets. +## Nested compute + +Roomote strips every compute provider credential from task sandboxes. That is +the right default, but it means a Roomote instance running _inside_ an +environment (for example Roomote's own development environment) cannot spawn +task sandboxes of its own. Set `inherit_compute: true` to forward the +deployment's default compute provider and its credentials into tasks in this +environment: + +```yaml +inherit_compute: true +``` + +Tasks then see `DEFAULT_COMPUTE_PROVIDER` plus that provider's variables (for +example `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or the Roomote Cloud token +pair), exactly as the deployment resolves them. Local Docker cannot be nested +and is never forwarded. + +Every task in the environment can read the forwarded values, so enable this +only for environments you trust with the deployment's compute credentials. The +nested instance also needs to be reachable by the sandboxes it spawns: expose +its API on an unproxied port (`proxied: false`) so `ROOMOTE__HOST` is a +direct machine URL those sandboxes can call. + ## Write a definition with a coding agent Because this page fully describes the schema, you can have a local coding agent diff --git a/apps/web/src/components/settings/environments/EnvironmentPreview.tsx b/apps/web/src/components/settings/environments/EnvironmentPreview.tsx index 2e4ae260e..b2ad2874a 100644 --- a/apps/web/src/components/settings/environments/EnvironmentPreview.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentPreview.tsx @@ -409,6 +409,18 @@ function getAdvancedItems(config: EnvironmentConfig) { ); } + if (config.inherit_compute) { + items.push( +
+
Nested compute
+
+ Tasks receive this deployment's compute provider credentials so a + nested Roomote instance can spawn its own sandboxes. +
+
, + ); + } + if (config.skills && Object.keys(config.skills).length > 0) { items.push(
diff --git a/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts b/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts index e4b16d33d..41b5312b0 100644 --- a/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts +++ b/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts @@ -195,4 +195,19 @@ describe('configToYaml', () => { }); expect(yaml).toContain('oidc:'); }); + + it('preserves inherit_compute when serializing environment config', () => { + const config: EnvironmentConfig = { + name: 'Nested Roomote', + repositories: [{ repository: 'Roomote/example-app' }], + inherit_compute: true, + }; + + const parsed = YAML.parse(configToYaml(config)); + + expect(parsed.inherit_compute).toBe(true); + expect( + YAML.parse(configToYaml({ ...config, inherit_compute: undefined })), + ).not.toHaveProperty('inherit_compute'); + }); }); diff --git a/apps/web/src/components/settings/environments/yaml-utils.ts b/apps/web/src/components/settings/environments/yaml-utils.ts index f13169b85..0ecd5d967 100644 --- a/apps/web/src/components/settings/environments/yaml-utils.ts +++ b/apps/web/src/components/settings/environments/yaml-utils.ts @@ -99,6 +99,10 @@ export function configToYaml(config: EnvironmentConfig): string { cleanConfig.oidc = config.oidc; } + if (config.inherit_compute !== undefined) { + cleanConfig.inherit_compute = config.inherit_compute; + } + if (config.ports && config.ports.length > 0) { cleanConfig.ports = config.ports; } diff --git a/apps/worker/src/commands/__tests__/setup.test.ts b/apps/worker/src/commands/__tests__/setup.test.ts index 6a7b64b83..ed373cb8d 100644 --- a/apps/worker/src/commands/__tests__/setup.test.ts +++ b/apps/worker/src/commands/__tests__/setup.test.ts @@ -462,6 +462,87 @@ describe('setup mode behavior', () => { }); }); + it('expands forwarded compute config into the nested environment env only', async () => { + const nestedComputeEnv = JSON.stringify({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + }); + mockGetRuntimeEnv.mockReturnValueOnce({ + R_NESTED_COMPUTE_ENV: nestedComputeEnv, + R_MODEL: 'roomote/openai/outer-model', + }); + + await setup({ + mode: 'directDispatch', + workspace: { + ...environmentWorkspaceOptions, + userEnvVars: { + FOO: 'bar', + R_NESTED_COMPUTE_ENV: nestedComputeEnv, + }, + }, + logger, + workerEnv: mockWorkerEnv, + }); + + // The raw forwarding value leaves the worker runtime env. + expect(mockSetRuntimeEnv).toHaveBeenCalledWith({ + R_MODEL: 'roomote/openai/outer-model', + }); + + const expectedNestedEnv = { + BASE: 'base', + FOO: 'bar', + OPENROUTER_API_KEY: 'sandbox-openrouter-key', + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + }; + expect(mockInitializeWorkspaceRepositories).toHaveBeenCalledWith( + logger, + expect.objectContaining({ + envVars: expectedNestedEnv, + userEnvVars: { + FOO: 'bar', + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + }, + }), + ); + expect(mockSetUserEnv).toHaveBeenCalledWith(expectedNestedEnv); + }); + + it('leaves forwarded compute config out of repository workspaces', async () => { + const nestedComputeEnv = JSON.stringify({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + }); + mockGetRuntimeEnv.mockReturnValueOnce({ + R_NESTED_COMPUTE_ENV: nestedComputeEnv, + }); + + await setup({ + mode: 'directDispatch', + workspace: workspaceOptions, + logger, + workerEnv: mockWorkerEnv, + }); + + expect(mockSetRuntimeEnv).toHaveBeenCalledWith({}); + expect(mockInitializeWorkspaceRepositories).toHaveBeenCalledWith( + logger, + expect.objectContaining({ + envVars: expect.not.objectContaining({ + MODAL_TOKEN_ID: expect.anything(), + DEFAULT_COMPUTE_PROVIDER: expect.anything(), + }), + }), + ); + }); + it('retains explicit environment values that initially equal runtime values', async () => { mockGetRuntimeEnv.mockReturnValueOnce({ R_VISION_MODEL: 'openai/shared-model', diff --git a/apps/worker/src/commands/__tests__/utils.test.ts b/apps/worker/src/commands/__tests__/utils.test.ts index 31d578733..fa62fa2e9 100644 --- a/apps/worker/src/commands/__tests__/utils.test.ts +++ b/apps/worker/src/commands/__tests__/utils.test.ts @@ -146,6 +146,16 @@ describe('injectEnvVars', () => { ).toEqual({ FOO: 'bar', R_MODEL: 'openai/nested-model' }); }); + it('keeps the raw compute forwarding value out of environment shell env', () => { + expect( + buildEnvironmentShellEnvVars({ + FOO: 'bar', + R_NESTED_COMPUTE_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', + DEFAULT_COMPUTE_PROVIDER: 'modal', + }), + ).toEqual({ FOO: 'bar', DEFAULT_COMPUTE_PROVIDER: 'modal' }); + }); + it('writes explicit nested model values over inherited deployment values', async () => { await injectEnvVars( { diff --git a/apps/worker/src/commands/setup.ts b/apps/worker/src/commands/setup.ts index 6111f8dff..c119c4e6a 100644 --- a/apps/worker/src/commands/setup.ts +++ b/apps/worker/src/commands/setup.ts @@ -13,12 +13,14 @@ import { DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES, DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, + NESTED_COMPUTE_ENV_VAR_NAME, OPENCODE_AUTH_CONTENT_ENV_VAR_NAME, SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME, TASK_MODEL_CONTEXT_WINDOWS_ENV_VAR_NAME, TASK_MODEL_COSTS_ENV_VAR_NAME, TaskPayloadKind, parseModelProviderEnvKeys, + parseNestedComputeEnv, } from '@roomote/types'; import { ExecutionError } from '../command-executor'; @@ -127,10 +129,14 @@ const INHERITED_MODEL_PROVIDER_ENV_VAR_NAMES: ReadonlySet = new Set([ function buildEnvironmentWorkspaceEnvVars( envVars: Record, launcherSandboxOpenRouterApiKey?: string, + launcherNestedComputeEnv?: Record | null, ): Record { const sandboxOpenRouterApiKey = envVars[SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME] ?? launcherSandboxOpenRouterApiKey; + const nestedComputeEnv = + parseNestedComputeEnv(envVars[NESTED_COMPUTE_ENV_VAR_NAME]) ?? + launcherNestedComputeEnv; const configuredProviderEnvVarNames = new Set( parseModelProviderEnvKeys(envVars.R_MODEL_ENV_KEYS), ); @@ -140,6 +146,7 @@ function buildEnvironmentWorkspaceEnvVars( if ( value !== undefined && name !== SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME && + name !== NESTED_COMPUTE_ENV_VAR_NAME && !name.startsWith('R_INFERENCE_GATEWAY_') && !INHERITED_MODEL_RUNTIME_ENV_VAR_NAMES.has(name) && !INHERITED_MODEL_PROVIDER_ENV_VAR_NAMES.has(name) && @@ -153,6 +160,13 @@ function buildEnvironmentWorkspaceEnvVars( nestedEnvironmentEnvVars.OPENROUTER_API_KEY = sandboxOpenRouterApiKey; } + // `inherit_compute` environments: expand the launcher's compute forwarding + // blob into the real provider names so a nested Roomote controller can + // spawn sandboxes with the outer deployment's provider. + if (nestedComputeEnv) { + Object.assign(nestedEnvironmentEnvVars, nestedComputeEnv); + } + return nestedEnvironmentEnvVars; } @@ -186,8 +200,18 @@ export async function setup({ workerEnv.refreshSystemEnv(process.env); const runtimeEnv = workerEnv.getRuntimeEnv(); - if (SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME in runtimeEnv) { + // Launcher-only source names never stay in the worker runtime env: the + // sandbox OpenRouter key maps to OPENROUTER_API_KEY and the nested compute + // blob expands into provider names, both for the nested app only. + const nestedComputeEnv = parseNestedComputeEnv( + runtimeEnv[NESTED_COMPUTE_ENV_VAR_NAME], + ); + if ( + SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME in runtimeEnv || + NESTED_COMPUTE_ENV_VAR_NAME in runtimeEnv + ) { delete runtimeEnv[SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME]; + delete runtimeEnv[NESTED_COMPUTE_ENV_VAR_NAME]; workerEnv.setRuntimeEnv(runtimeEnv); } @@ -204,11 +228,16 @@ export async function setup({ ? buildEnvironmentWorkspaceEnvVars( inheritedWorkspaceEnvVars, sandboxOpenRouterApiKey ?? workerEnv.sandboxOpenRouterApiKey, + nestedComputeEnv, ) : inheritedWorkspaceEnvVars, userEnvVars: isEnvironmentWorkspace && workspaceOpts.userEnvVars - ? buildEnvironmentWorkspaceEnvVars(workspaceOpts.userEnvVars) + ? buildEnvironmentWorkspaceEnvVars( + workspaceOpts.userEnvVars, + undefined, + nestedComputeEnv, + ) : workspaceOpts.userEnvVars, }; let result: PrepareWorkspaceResult | undefined; diff --git a/apps/worker/src/commands/utils/env-vars.ts b/apps/worker/src/commands/utils/env-vars.ts index d3f3b165f..d4e116970 100644 --- a/apps/worker/src/commands/utils/env-vars.ts +++ b/apps/worker/src/commands/utils/env-vars.ts @@ -6,6 +6,7 @@ import { buildPreviewProxyUrl, CODE_SERVER_NAMED_PORT, getSourceControlTokenEnvVars, + NESTED_COMPUTE_ENV_VAR_NAME, portNameToSlug, PRODUCT_NAME, TASK_MODEL_ROLE_DESCRIPTORS, @@ -41,6 +42,9 @@ export const INHERITED_MODEL_RUNTIME_ENV_VAR_NAMES: ReadonlySet = ]), 'R_MODEL_ENV_KEYS', 'ROOMOTE_MODEL_ENV_KEYS', + // Launcher-only source name; setup expands it into the nested app's env, + // so the raw value is left out of the shell files. + NESTED_COMPUTE_ENV_VAR_NAME, ]); export function buildEnvironmentShellEnvVars( diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts index cc44190d2..8c76de995 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts @@ -12,6 +12,9 @@ const { mockCreateTaskRunBitbucketCredentials, mockResolveSandboxModelRuntimeEnv, mockTaskRunsFindFirst, + mockEnvironmentsFindFirst, + mockResolveDefaultComputeProvider, + mockResolveComputeProviderEnvValues, mockNotifySourceRunOnSettle, mockCaptureTaskSettled, } = vi.hoisted(() => ({ @@ -25,6 +28,9 @@ const { mockCreateTaskRunBitbucketCredentials: vi.fn(), mockResolveSandboxModelRuntimeEnv: vi.fn(), mockTaskRunsFindFirst: vi.fn(), + mockEnvironmentsFindFirst: vi.fn(), + mockResolveDefaultComputeProvider: vi.fn(), + mockResolveComputeProviderEnvValues: vi.fn(), mockNotifySourceRunOnSettle: vi.fn(), mockCaptureTaskSettled: vi.fn(), })); @@ -43,6 +49,9 @@ vi.mock('@roomote/db/server', () => ({ taskRuns: { findFirst: (...args: unknown[]) => mockTaskRunsFindFirst(...args), }, + environments: { + findFirst: (...args: unknown[]) => mockEnvironmentsFindFirst(...args), + }, }, select: () => ({ from: () => ({ @@ -52,6 +61,11 @@ vi.mock('@roomote/db/server', () => ({ transaction: vi.fn(), }, taskRuns: { id: 'taskRuns.id' }, + environments: { id: 'environments.id' }, + resolveDefaultComputeProvider: (...args: unknown[]) => + mockResolveDefaultComputeProvider(...args), + resolveComputeProviderEnvValues: (...args: unknown[]) => + mockResolveComputeProviderEnvValues(...args), repositories: { fullName: 'repositories.fullName', sourceControlProvider: 'repositories.sourceControlProvider', @@ -1008,6 +1022,83 @@ describe('fetchResolvedRuntimeEnvVars', () => { expect(envVars.MY_APP_CONFIG).toBe('value'); }); + it('forwards the deployment compute config for inherit_compute environments', async () => { + mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({}); + mockEnvironmentsFindFirst.mockResolvedValueOnce({ + config: { inherit_compute: true }, + }); + mockResolveDefaultComputeProvider.mockResolvedValueOnce('modal'); + mockResolveComputeProviderEnvValues.mockResolvedValueOnce({ + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + }); + + const envVars = await fetchResolvedRuntimeEnvVars( + { + // Stored under its real name: still stripped from the sandbox. + MODAL_TOKEN_SECRET: 'stored-secret', + MY_APP_CONFIG: 'value', + }, + { nestedComputeEnvironmentId: 'env-nested' }, + ); + + expect(envVars).not.toHaveProperty('MODAL_TOKEN_SECRET'); + expect(envVars.MY_APP_CONFIG).toBe('value'); + expect(JSON.parse(envVars.R_NESTED_COMPUTE_ENV!)).toEqual({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + }); + expect(mockResolveComputeProviderEnvValues).toHaveBeenCalledWith('modal'); + }); + + it('withholds compute config when the environment did not opt in', async () => { + mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({}); + mockResolveDefaultComputeProvider.mockClear(); + mockEnvironmentsFindFirst.mockResolvedValueOnce({ + config: { name: 'Plain' }, + }); + + const envVars = await fetchResolvedRuntimeEnvVars( + { MY_APP_CONFIG: 'value' }, + { nestedComputeEnvironmentId: 'env-plain' }, + ); + + expect(envVars).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); + expect(mockResolveDefaultComputeProvider).not.toHaveBeenCalled(); + }); + + it('withholds compute config when the deployment provider cannot be nested', async () => { + mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({}); + mockEnvironmentsFindFirst.mockResolvedValueOnce({ + config: { inherit_compute: true }, + }); + mockResolveDefaultComputeProvider.mockResolvedValueOnce('docker'); + mockResolveComputeProviderEnvValues.mockResolvedValueOnce({}); + + const envVars = await fetchResolvedRuntimeEnvVars( + { MY_APP_CONFIG: 'value' }, + { nestedComputeEnvironmentId: 'env-docker' }, + ); + + expect(envVars).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); + }); + + it('strips an operator-stored forwarding value for tasks without an environment', async () => { + mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({}); + mockEnvironmentsFindFirst.mockClear(); + + const envVars = await fetchResolvedRuntimeEnvVars({ + R_NESTED_COMPUTE_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', + MY_APP_CONFIG: 'value', + }); + + expect(envVars).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); + expect(mockEnvironmentsFindFirst).not.toHaveBeenCalled(); + }); + it('mirrors resolved model env to legacy ROOMOTE_* aliases for pre-rename snapshot workers', async () => { mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({ R_MODEL: 'anthropic/claude-test', diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts index 6de921b13..d8eda4b00 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts @@ -259,6 +259,7 @@ describe('dequeueResumeTaskRun', () => { { sourceControlProvider: ['gitlab', 'github'], includeSandboxOpenRouterApiKey: true, + nestedComputeEnvironmentId: 'env-1', }, ); expect(result?.harnessInstructions).toBe('preserved instructions'); diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index ba15240d7..2fa9e11e6 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -3,9 +3,12 @@ import { DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES, DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, INFERENCE_GATEWAY_KEYS_ENV_VAR_NAME, + NESTED_COMPUTE_ENV_VAR_NAME, OPENCODE_AUTH_CONTENT_ENV_VAR_NAME, SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME, TASK_MODEL_CONTEXT_WINDOWS_ENV_VAR_NAME, + buildNestedComputeEnv, + serializeNestedComputeEnv, parseInferenceGatewayKeys, parseModelProviderEnvKeys, RunStatus, @@ -20,8 +23,11 @@ import { import { type TaskRun, db, + environments, taskRuns, markTaskStartParallelCountEndedAt, + resolveComputeProviderEnvValues, + resolveDefaultComputeProvider, resolveSandboxModelRuntimeEnv, resolveWorkspaceSourceControlProvider, resolveWorkspaceSourceControlHost, @@ -263,11 +269,45 @@ function redactModelRuntimeManagedEnvVars( ); } +/** + * Resolves the compute forwarding value for an environment that opted in with + * `inherit_compute: true`: the deployment's default compute provider plus the + * resolved setup-catalog values for it, serialized under a single name that + * the worker expands for the nested Roomote app. Returns null when the + * environment did not opt in, the provider cannot be nested, or the provider + * is not fully configured. + */ +async function resolveNestedComputeEnvVar( + environmentId: string, +): Promise { + const environment = await db.query.environments.findFirst({ + where: eq(environments.id, environmentId), + columns: { config: true }, + }); + + if (environment?.config?.inherit_compute !== true) { + return null; + } + + const provider = await resolveDefaultComputeProvider(); + const nestedComputeEnv = buildNestedComputeEnv({ + provider, + resolvedEnvValues: await resolveComputeProviderEnvValues(provider), + }); + + return nestedComputeEnv ? serializeNestedComputeEnv(nestedComputeEnv) : null; +} + export async function fetchResolvedRuntimeEnvVars( deploymentEnvVars?: Record, options?: { sourceControlProvider?: SourceControlProvider | SourceControlProvider[]; includeSandboxOpenRouterApiKey?: boolean; + /** + * Environment the task runs in. When it opted in with `inherit_compute`, + * the result carries NESTED_COMPUTE_ENV_VAR_NAME for the nested app. + */ + nestedComputeEnvironmentId?: string; }, ): Promise> { const envVars = @@ -288,15 +328,22 @@ export async function fetchResolvedRuntimeEnvVars( ), ); + const nestedComputeEnvVar = options?.nestedComputeEnvironmentId + ? await resolveNestedComputeEnvVar(options.nestedComputeEnvironmentId) + : null; + const environmentTaskEnvVars = nestedComputeEnvVar + ? { ...resolvedEnvVars, [NESTED_COMPUTE_ENV_VAR_NAME]: nestedComputeEnvVar } + : resolvedEnvVars; + if (options?.includeSandboxOpenRouterApiKey) { - return resolvedEnvVars; + return environmentTaskEnvVars; } - if (!(SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME in resolvedEnvVars)) { - return resolvedEnvVars; + if (!(SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME in environmentTaskEnvVars)) { + return environmentTaskEnvVars; } - const ordinaryTaskEnvVars = { ...resolvedEnvVars }; + const ordinaryTaskEnvVars = { ...environmentTaskEnvVars }; delete ordinaryTaskEnvVars[SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME]; return ordinaryTaskEnvVars; } diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts index 14210dec6..58240d035 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts @@ -428,6 +428,7 @@ export const dequeueResumeTaskRun = async ( includeSandboxOpenRouterApiKey: Boolean( result.taskRun.payload.environmentId, ), + nestedComputeEnvironmentId: result.taskRun.payload.environmentId, }); } catch (error) { const message = diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts index 5503928a5..68a7ee2b1 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts @@ -571,6 +571,7 @@ export const dequeueTaskRun = async ( includeSandboxOpenRouterApiKey: Boolean( txResult.taskRun.payload.environmentId, ), + nestedComputeEnvironmentId: txResult.taskRun.payload.environmentId, }), }); } catch (error) { diff --git a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts index f800ef19f..4efeb3488 100644 --- a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts +++ b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts @@ -45,6 +45,7 @@ export async function fetchSnapshotEnv( const deploymentEnvVars = await fetchResolvedRuntimeEnvVars(undefined, { sourceControlProvider: sourceControlProviders, includeSandboxOpenRouterApiKey: Boolean(taskRun.payload.environmentId), + nestedComputeEnvironmentId: taskRun.payload.environmentId, }); const sourceControlToken = await createSourceControlTokenForTaskRun( diff --git a/packages/types/src/__tests__/nested-compute.test.ts b/packages/types/src/__tests__/nested-compute.test.ts new file mode 100644 index 000000000..8853bf004 --- /dev/null +++ b/packages/types/src/__tests__/nested-compute.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; + +import { CONTROL_PLANE_ENV_VAR_NAMES } from '../control-plane-env-vars'; +import { environmentConfigSchema } from '../environment-config'; +import { + NESTED_COMPUTE_ENV_VAR_NAME, + buildNestedComputeEnv, + parseNestedComputeEnv, + serializeNestedComputeEnv, +} from '../nested-compute'; + +describe('buildNestedComputeEnv', () => { + it('forwards the default provider and its populated catalog fields', () => { + expect( + buildNestedComputeEnv({ + provider: 'modal', + resolvedEnvValues: { + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + MODAL_REGIONS: ' ', + UNRELATED: 'ignored', + }, + }), + ).toEqual({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + }); + }); + + it('forwards the managed provider with its broker settings', () => { + expect( + buildNestedComputeEnv({ + provider: 'roomote', + resolvedEnvValues: { + ROOMOTE_CLOUD_TOKEN_ID: 'tenant', + ROOMOTE_CLOUD_TOKEN_SECRET: 'rbk_key', + ROOMOTE_CLOUD_BACKEND: 'broker', + ROOMOTE_CLOUD_BROKER_URL: 'https://broker.example', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker@sha256:abc', + }, + }), + ).toEqual({ + DEFAULT_COMPUTE_PROVIDER: 'roomote', + ROOMOTE_CLOUD_TOKEN_ID: 'tenant', + ROOMOTE_CLOUD_TOKEN_SECRET: 'rbk_key', + ROOMOTE_CLOUD_BACKEND: 'broker', + ROOMOTE_CLOUD_BROKER_URL: 'https://broker.example', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker@sha256:abc', + }); + }); + + it('returns null when a required field is missing', () => { + expect( + buildNestedComputeEnv({ + provider: 'modal', + resolvedEnvValues: { MODAL_TOKEN_ID: 'ak-id' }, + }), + ).toBeNull(); + }); + + it('never nests Local Docker', () => { + expect( + buildNestedComputeEnv({ provider: 'docker', resolvedEnvValues: {} }), + ).toBeNull(); + }); +}); + +describe('parseNestedComputeEnv', () => { + it('round-trips a serialized env map', () => { + const env = { + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + }; + + expect(parseNestedComputeEnv(serializeNestedComputeEnv(env))).toEqual(env); + }); + + it('rejects blank, malformed, and non-object input', () => { + expect(parseNestedComputeEnv(undefined)).toBeNull(); + expect(parseNestedComputeEnv(' ')).toBeNull(); + expect(parseNestedComputeEnv('{not json')).toBeNull(); + expect(parseNestedComputeEnv('["MODAL_TOKEN_ID"]')).toBeNull(); + expect(parseNestedComputeEnv('null')).toBeNull(); + }); + + it('rejects non-string values and invalid env var names', () => { + expect( + parseNestedComputeEnv( + JSON.stringify({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 1, + }), + ), + ).toBeNull(); + expect( + parseNestedComputeEnv( + JSON.stringify({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + 'BAD NAME; rm -rf': 'x', + }), + ), + ).toBeNull(); + }); + + it('rejects an unknown or missing provider', () => { + expect( + parseNestedComputeEnv(JSON.stringify({ MODAL_TOKEN_ID: 'ak-id' })), + ).toBeNull(); + expect( + parseNestedComputeEnv( + JSON.stringify({ DEFAULT_COMPUTE_PROVIDER: 'mainframe' }), + ), + ).toBeNull(); + }); +}); + +describe('nested compute wiring', () => { + it('reserves the forwarding name from the generic environment editor', () => { + expect(CONTROL_PLANE_ENV_VAR_NAMES.has(NESTED_COMPUTE_ENV_VAR_NAME)).toBe( + true, + ); + }); + + it('accepts inherit_compute on an environment definition', () => { + const result = environmentConfigSchema.safeParse({ + name: 'Nested Roomote', + repositories: [{ repository: 'acme/app' }], + inherit_compute: true, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.inherit_compute).toBe(true); + }); + + it('rejects a non-boolean inherit_compute', () => { + expect( + environmentConfigSchema.safeParse({ + name: 'Nested Roomote', + repositories: [{ repository: 'acme/app' }], + inherit_compute: 'yes', + }).success, + ).toBe(false); + }); +}); diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index 68265e601..e28e28d44 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -2,6 +2,7 @@ import { COMMS_PROVIDER_ENV_VAR_NAMES } from './setup-auth-config'; import { COMPUTE_PROVIDER_ENV_VAR_NAMES } from './setup-compute-config'; import { SETUP_SOURCE_CONTROL_PROVIDER_CATALOG } from './setup-source-control-config'; import { OPENCODE_AUTH_CONTENT_ENV_VAR_NAME } from './chatgpt-subscription'; +import { NESTED_COMPUTE_ENV_VAR_NAME } from './nested-compute'; import { DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, @@ -154,6 +155,9 @@ export const CONTROL_PLANE_ENV_VAR_NAMES: ReadonlySet = new Set( // Hosting-managed Roomote inference is served only through the inference // gateway, never configured through the generic environment editor. ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, + // Launcher-built compute forwarding for `inherit_compute` environments. + // Never operator-set; the dequeue path injects it after this denylist. + NESTED_COMPUTE_ENV_VAR_NAME, ], ); diff --git a/packages/types/src/environment-config.ts b/packages/types/src/environment-config.ts index 51ccd52a8..807bbfdf9 100644 --- a/packages/types/src/environment-config.ts +++ b/packages/types/src/environment-config.ts @@ -736,6 +736,16 @@ export const environmentConfigSchema = z * token_file: /home/roomote/.roomote/oidc/custom/token */ oidc: environmentOidcSchema.optional(), + /** + * Forward the deployment's own compute provider configuration + * (`DEFAULT_COMPUTE_PROVIDER` plus that provider's credentials) into this + * environment so a nested Roomote instance running inside it can spawn + * its own task sandboxes. Compute credentials are otherwise stripped from + * every sandbox. Every task in the environment can read the forwarded + * values, so enable this only for environments you trust with them. + * Local Docker cannot be nested and is never forwarded. + */ + inherit_compute: z.boolean().optional(), /** * Named preview ports for human-facing application URLs. * Each port gets an authenticated shareable URL in diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 407742f77..9281cb152 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,6 +49,7 @@ export * from './llm-usage'; export * from './bedrock-opencode-provider'; export * from './inference-gateway'; export * from './sandbox-preview-inference'; +export * from './nested-compute'; export * from './inference-provider-retry'; export * from './model-provider-config'; export * from './openai-compatible-providers'; diff --git a/packages/types/src/nested-compute.ts b/packages/types/src/nested-compute.ts new file mode 100644 index 000000000..2b64d354f --- /dev/null +++ b/packages/types/src/nested-compute.ts @@ -0,0 +1,115 @@ +import { + isComputeProvider, + type ComputeProvider, +} from './compute-providers/compute-provider'; +import { + getSetupComputeProvider, + isRequiredComputeField, +} from './setup-compute-config'; + +/** POSIX env var name: letters, digits, underscores; no leading digit. */ +const VALID_ENV_VAR_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** + * Launcher-only JSON env var that carries the outer deployment's compute + * provider configuration into environment workspaces that opted in with + * `inherit_compute: true`. Every compute credential name is a reserved + * control-plane name that is stripped before a sandbox sees it, so a nested + * Roomote instance running inside an environment could never spawn its own + * sandboxes. This var travels under a non-reserved name and the worker expands + * it back into the real names for the nested app only. + */ +export const NESTED_COMPUTE_ENV_VAR_NAME = 'R_NESTED_COMPUTE_ENV' as const; + +/** + * Providers that cannot be nested: Local Docker needs the host's Docker + * socket, which is not reachable from inside a sandbox. + */ +const NON_NESTABLE_COMPUTE_PROVIDERS: ReadonlySet = new Set([ + 'docker', +]); + +export interface NestedComputeEnvInput { + provider: ComputeProvider; + /** Resolved setup-catalog env values for `provider` (process env + saved). */ + resolvedEnvValues: Partial>; +} + +/** + * Builds the env map a nested Roomote instance needs to spawn sandboxes with + * the outer deployment's provider: `DEFAULT_COMPUTE_PROVIDER` plus every + * populated setup-catalog field for that provider. Returns null when the + * provider cannot be nested or a required field is missing, so callers never + * forward a half-configured provider. + */ +export function buildNestedComputeEnv( + input: NestedComputeEnvInput, +): Record | null { + if (NON_NESTABLE_COMPUTE_PROVIDERS.has(input.provider)) { + return null; + } + + const descriptor = getSetupComputeProvider(input.provider); + const env: Record = { + DEFAULT_COMPUTE_PROVIDER: input.provider, + }; + + for (const field of descriptor.fields) { + const value = input.resolvedEnvValues[field.envVarName]?.trim(); + + if (value) { + env[field.envVarName] = value; + } else if (isRequiredComputeField(field)) { + return null; + } + } + + return env; +} + +export function serializeNestedComputeEnv(env: Record): string { + return JSON.stringify(env); +} + +/** + * Parses the launcher-provided JSON. Anything that is not a flat object of + * valid env var names to strings, or that names a provider Roomote does not + * know, is ignored rather than expanded into the nested shell. + */ +export function parseNestedComputeEnv( + raw: string | undefined, +): Record | null { + if (!raw?.trim()) { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + const env: Record = {}; + + for (const [name, value] of Object.entries(parsed)) { + if (typeof value !== 'string' || !VALID_ENV_VAR_NAME.test(name)) { + return null; + } + + env[name] = value; + } + + const provider = env.DEFAULT_COMPUTE_PROVIDER; + + if (!provider || !isComputeProvider(provider)) { + return null; + } + + return env; +} From 5095a13e1dae21fef9cf7ef09208190b49d7f8d5 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:51:22 -0400 Subject: [PATCH 2/5] fix: keep the nested compute value out of the snapshot shell env --- .../src/commands/__tests__/snapshot.test.ts | 36 +++++++++++++++++++ apps/worker/src/commands/snapshot.ts | 9 ++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/worker/src/commands/__tests__/snapshot.test.ts b/apps/worker/src/commands/__tests__/snapshot.test.ts index 559d1c3b8..36ce043e3 100644 --- a/apps/worker/src/commands/__tests__/snapshot.test.ts +++ b/apps/worker/src/commands/__tests__/snapshot.test.ts @@ -113,6 +113,42 @@ describe('snapshot', () => { expect(EXPLICIT_SNAPSHOT_TIMEOUT_MS).toBe(10 * 60 * 1_000); }); + it('projects snapshot shell env like an environment task and hands the nested compute value to setup', async () => { + const nestedComputeEnv = JSON.stringify({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + }); + mockFetchSnapshotEnv.mockResolvedValue({ + envVars: { + PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.run', + R_NESTED_COMPUTE_ENV: nestedComputeEnv, + }, + gitHubToken: 'gh-token', + taskId: 'task-42', + }); + + await snapshot({ runId: 42, environmentId: 'env-1', sandboxId: 'sb-1' }); + + // The shell projection omits launcher-only values (covered by the + // buildEnvironmentShellEnvVars tests); setup expands the raw value into + // the nested environment env. + expect(mockInjectEnvVars).toHaveBeenCalledWith( + expect.objectContaining({ R_NESTED_COMPUTE_ENV: nestedComputeEnv }), + undefined, + expect.objectContaining({ omitInheritedModelRuntimeEnvFromShell: true }), + ); + expect(mockSetup).toHaveBeenCalledWith( + expect.objectContaining({ + workspace: expect.objectContaining({ + envVars: expect.objectContaining({ + R_NESTED_COMPUTE_ENV: nestedComputeEnv, + }), + }), + }), + ); + }); + it('treats the failure cleanup status write as a best-effort no-op when it succeeds idempotently', async () => { const result = await snapshot({ runId: 42, diff --git a/apps/worker/src/commands/snapshot.ts b/apps/worker/src/commands/snapshot.ts index f5ad71d92..a183e51a9 100644 --- a/apps/worker/src/commands/snapshot.ts +++ b/apps/worker/src/commands/snapshot.ts @@ -80,7 +80,14 @@ export async function snapshot({ // Write source-control tokens under ~/.roomote and set up shell env files // so file-backed credential helpers can authenticate git operations. - await injectEnvVars(envVars, undefined, { sourceControlToken }); + // Snapshot setup is always an environment workspace, so the shell file + // gets the same projection as task setup: outer model transport stays + // out, and the launcher-only nested compute value is expanded by setup + // rather than written raw. + await injectEnvVars(envVars, undefined, { + sourceControlToken, + omitInheritedModelRuntimeEnvFromShell: true, + }); const environmentConfig = await findRuntimeEnvironmentConfig(environmentId); const taskRun = await sdk.taskRuns.findFirstById(runId); From e19d0a5a664780fa1c04854bef368c5ecba5beb2 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:10:39 -0400 Subject: [PATCH 3/5] fix: resolve nested compute values from the validated Env --- .../lib/task-runs/__tests__/dequeue-helpers.test.ts | 5 ++++- packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts index 8c76de995..bc8f07a77 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts @@ -1051,7 +1051,10 @@ describe('fetchResolvedRuntimeEnvVars', () => { MODAL_TOKEN_SECRET: 'as-secret', MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', }); - expect(mockResolveComputeProviderEnvValues).toHaveBeenCalledWith('modal'); + expect(mockResolveComputeProviderEnvValues).toHaveBeenCalledWith( + 'modal', + expect.objectContaining({ runtimeEnv: expect.any(Object) }), + ); }); it('withholds compute config when the environment did not opt in', async () => { diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index 2fa9e11e6..fed5df38f 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -39,6 +39,7 @@ import { sql, } from '@roomote/db/server'; import { captureTaskSettled } from '@roomote/telemetry/server'; +import { Env } from '@roomote/env'; import { decryptSecrets } from '@roomote/db/encryption'; import { createTaskRunWorkerGitHubTokenWithMetadata, @@ -290,9 +291,14 @@ async function resolveNestedComputeEnvVar( } const provider = await resolveDefaultComputeProvider(); + // The validated Env carries the resolved NODE_ENV/APP_ENV and worker image + // that the derived Modal base image depends on; the raw process env does + // not, and a missing base image ref would drop the whole forward. const nestedComputeEnv = buildNestedComputeEnv({ provider, - resolvedEnvValues: await resolveComputeProviderEnvValues(provider), + resolvedEnvValues: await resolveComputeProviderEnvValues(provider, { + runtimeEnv: Env, + }), }); return nestedComputeEnv ? serializeNestedComputeEnv(nestedComputeEnv) : null; From 4f8f2609e88327c8a151269d53c9c0c9320287d3 Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:21:05 -0400 Subject: [PATCH 4/5] fix: keep the nested compute value out of the harness process env --- apps/worker/src/run-task/__tests__/run-task.test.ts | 4 ++++ apps/worker/src/run-task/run-task.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/worker/src/run-task/__tests__/run-task.test.ts b/apps/worker/src/run-task/__tests__/run-task.test.ts index 52402414e..f8db477a1 100644 --- a/apps/worker/src/run-task/__tests__/run-task.test.ts +++ b/apps/worker/src/run-task/__tests__/run-task.test.ts @@ -4116,6 +4116,7 @@ describe('runTask', () => { } as never, envVars: { SANDBOX_OPENROUTER_API_KEY: 'dequeued-sandbox-key', + R_NESTED_COMPUTE_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', }, workspacePath: '/tmp/workspace', prompt: '', @@ -4183,6 +4184,9 @@ describe('runTask', () => { expect( createHarnessMock.mock.calls.at(-1)?.[0]?.runtimeEnv, ).not.toHaveProperty('SANDBOX_OPENROUTER_API_KEY'); + expect( + createHarnessMock.mock.calls.at(-1)?.[0]?.runtimeEnv, + ).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); }); it('isolates the task runtime HOME while keeping packaged skill sourcing on the worker HOME', async () => { diff --git a/apps/worker/src/run-task/run-task.ts b/apps/worker/src/run-task/run-task.ts index b28d784e1..81a4105f7 100644 --- a/apps/worker/src/run-task/run-task.ts +++ b/apps/worker/src/run-task/run-task.ts @@ -18,6 +18,7 @@ import { getSlackThreadTsFromTaskPayload, getTaskReportConsumerFromPayload, isCommunicationProvider, + NESTED_COMPUTE_ENV_VAR_NAME, SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME, SANDBOX_SERVER_PORT, SANDBOX_TIMEOUT_MS, @@ -714,9 +715,14 @@ export const runTask = async ({ githubTokenRefreshInterval: undefined, }; + // Launcher-only source names never reach the harness process env: the + // sandbox OpenRouter key and the nested compute forwarding value are + // both consumed by setup and expanded for the nested app instead. const taskEnvVars = Object.fromEntries( Object.entries(envVars).filter( - ([name]) => name !== SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME, + ([name]) => + name !== SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME && + name !== NESTED_COMPUTE_ENV_VAR_NAME, ), ); const unsanitizedEnv = workerEnv From 18494061fa43a0e2faab518962724a7b966ba14e Mon Sep 17 00:00:00 2001 From: Matt Rubens <2600+mrubens@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:56:17 -0400 Subject: [PATCH 5/5] feat: environments can also inherit the deployment source-control providers --- .roomote/environments/roomote.yaml | 3 + apps/docs/environments/definition.mdx | 43 ++-- .../environments/EnvironmentPreview.tsx | 23 +- .../YamlEnvironmentEditor.test.ts | 16 +- .../settings/environments/yaml-utils.ts | 4 + .../src/commands/__tests__/setup.test.ts | 6 +- .../src/commands/__tests__/snapshot.test.ts | 6 +- .../src/commands/__tests__/utils.test.ts | 2 +- apps/worker/src/commands/setup.ts | 26 +- apps/worker/src/commands/utils/env-vars.ts | 4 +- .../src/run-task/__tests__/run-task.test.ts | 4 +- apps/worker/src/run-task/run-task.ts | 4 +- .../__tests__/dequeue-helpers.test.ts | 112 ++++++++- .../__tests__/dequeue-resume-task-run.test.ts | 2 +- .../server/lib/task-runs/dequeue-helpers.ts | 133 ++++++++-- .../lib/task-runs/dequeue-resume-task-run.ts | 2 +- .../server/lib/task-runs/dequeue-task-run.ts | 3 +- .../lib/task-runs/fetch-snapshot-env.ts | 2 +- .../src/__tests__/nested-compute.test.ts | 148 ----------- .../__tests__/nested-deployment-env.test.ts | 234 ++++++++++++++++++ packages/types/src/control-plane-env-vars.ts | 4 +- packages/types/src/environment-config.ts | 8 + packages/types/src/index.ts | 2 +- packages/types/src/nested-compute.ts | 115 --------- packages/types/src/nested-deployment-env.ts | 190 ++++++++++++++ 25 files changed, 736 insertions(+), 360 deletions(-) delete mode 100644 packages/types/src/__tests__/nested-compute.test.ts create mode 100644 packages/types/src/__tests__/nested-deployment-env.test.ts delete mode 100644 packages/types/src/nested-compute.ts create mode 100644 packages/types/src/nested-deployment-env.ts diff --git a/.roomote/environments/roomote.yaml b/.roomote/environments/roomote.yaml index 7e9492d5d..6a7a2d7fc 100644 --- a/.roomote/environments/roomote.yaml +++ b/.roomote/environments/roomote.yaml @@ -24,6 +24,9 @@ initialUrl: http://127.0.0.1:3000/auth/dev-login # controller can spawn its own task sandboxes (see "Nested compute" in the # environment definition docs). inherit_compute: true +# Also forward the configured source-control providers (GitHub App fields) so +# the nested instance can clone repositories and mint its own repo tokens. +inherit_source_control: true ports: - name: web port: 3000 diff --git a/apps/docs/environments/definition.mdx b/apps/docs/environments/definition.mdx index f27780241..97f8654ae 100644 --- a/apps/docs/environments/definition.mdx +++ b/apps/docs/environments/definition.mdx @@ -153,7 +153,8 @@ agentInstructions: | | `docker_projects` | list | no | Existing Compose or Dockerfile projects to build and start. See [Docker projects](#docker-projects). | | `ports` | list | no | Named preview ports. See [Ports](#ports). | | `oidc` | map | no | Sandbox OIDC targets. See [OIDC](#oidc). | -| `inherit_compute` | boolean | no | Forward the deployment's compute provider credentials into tasks. See [Nested compute](#nested-compute). | +| `inherit_compute` | boolean | no | Forward the deployment's compute provider configuration into tasks. See [Nested deployments](#nested-deployments). | +| `inherit_source_control` | boolean | no | Forward the deployment's source-control provider configuration into tasks. See [Nested deployments](#nested-deployments). | | `mcpServers` | map | no | Custom MCP servers for this environment. See [MCP servers](#mcp-servers). | | `skills` | map | no | Installable skills by `owner/repo`. See [Skills](#skills). | | `manualSkills` | list | no | Inline skills defined in the environment. See [Skills](#skills). | @@ -453,29 +454,35 @@ oidc: Each `token_file` must be an absolute path and must be unique across targets. -## Nested compute +## Nested deployments -Roomote strips every compute provider credential from task sandboxes. That is -the right default, but it means a Roomote instance running _inside_ an -environment (for example Roomote's own development environment) cannot spawn -task sandboxes of its own. Set `inherit_compute: true` to forward the -deployment's default compute provider and its credentials into tasks in this -environment: +Roomote strips its own provider configuration from task sandboxes: compute +credentials and source-control app secrets never reach an agent. That is the +right default, but it means a Roomote instance running _inside_ an environment +(for example Roomote's own development environment) cannot spawn task +sandboxes of its own or clone repositories. Two opt-in flags forward the +deployment's configuration into tasks in this environment: ```yaml inherit_compute: true +inherit_source_control: true ``` -Tasks then see `DEFAULT_COMPUTE_PROVIDER` plus that provider's variables (for -example `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or the Roomote Cloud token -pair), exactly as the deployment resolves them. Local Docker cannot be nested -and is never forwarded. - -Every task in the environment can read the forwarded values, so enable this -only for environments you trust with the deployment's compute credentials. The -nested instance also needs to be reachable by the sandboxes it spawns: expose -its API on an unproxied port (`proxied: false`) so `ROOMOTE__HOST` is a -direct machine URL those sandboxes can call. +- `inherit_compute` forwards `DEFAULT_COMPUTE_PROVIDER` plus that provider's + variables (for example `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or the + Roomote Cloud token pair), exactly as the deployment resolves them. Local + Docker cannot be nested and is never forwarded. +- `inherit_source_control` forwards every fully configured source-control + provider (for example the GitHub App slug, App ID, private key, OAuth client, + and webhook secret). Partially configured providers are skipped. The nested + instance then acts as the same app on the same repositories, while webhooks + keep arriving at the outer deployment. + +Every task in the environment can read the forwarded values, so enable these +only for environments you trust with the deployment's credentials. The nested +instance also needs to be reachable by the sandboxes it spawns: expose its API +on an unproxied port (`proxied: false`) so `ROOMOTE__HOST` is a direct +machine URL those sandboxes can call. ## Write a definition with a coding agent diff --git a/apps/web/src/components/settings/environments/EnvironmentPreview.tsx b/apps/web/src/components/settings/environments/EnvironmentPreview.tsx index b2ad2874a..11184bf8c 100644 --- a/apps/web/src/components/settings/environments/EnvironmentPreview.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentPreview.tsx @@ -409,14 +409,23 @@ function getAdvancedItems(config: EnvironmentConfig) { ); } - if (config.inherit_compute) { + if (config.inherit_compute || config.inherit_source_control) { items.push( -
-
Nested compute
-
- Tasks receive this deployment's compute provider credentials so a - nested Roomote instance can spawn its own sandboxes. -
+
+
Nested deployment
+ {config.inherit_compute ? ( +
+ Tasks receive this deployment's compute provider configuration + so a nested Roomote instance can spawn its own sandboxes. +
+ ) : null} + {config.inherit_source_control ? ( +
+ Tasks receive this deployment's source-control provider + configuration so a nested Roomote instance can reach its + repositories. +
+ ) : null}
, ); } diff --git a/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts b/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts index 41b5312b0..e535a52e8 100644 --- a/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts +++ b/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts @@ -196,18 +196,26 @@ describe('configToYaml', () => { expect(yaml).toContain('oidc:'); }); - it('preserves inherit_compute when serializing environment config', () => { + it('preserves the inherit flags when serializing environment config', () => { const config: EnvironmentConfig = { name: 'Nested Roomote', repositories: [{ repository: 'Roomote/example-app' }], inherit_compute: true, + inherit_source_control: true, }; const parsed = YAML.parse(configToYaml(config)); expect(parsed.inherit_compute).toBe(true); - expect( - YAML.parse(configToYaml({ ...config, inherit_compute: undefined })), - ).not.toHaveProperty('inherit_compute'); + expect(parsed.inherit_source_control).toBe(true); + const cleared = YAML.parse( + configToYaml({ + ...config, + inherit_compute: undefined, + inherit_source_control: undefined, + }), + ); + expect(cleared).not.toHaveProperty('inherit_compute'); + expect(cleared).not.toHaveProperty('inherit_source_control'); }); }); diff --git a/apps/web/src/components/settings/environments/yaml-utils.ts b/apps/web/src/components/settings/environments/yaml-utils.ts index 0ecd5d967..f405a5fa2 100644 --- a/apps/web/src/components/settings/environments/yaml-utils.ts +++ b/apps/web/src/components/settings/environments/yaml-utils.ts @@ -103,6 +103,10 @@ export function configToYaml(config: EnvironmentConfig): string { cleanConfig.inherit_compute = config.inherit_compute; } + if (config.inherit_source_control !== undefined) { + cleanConfig.inherit_source_control = config.inherit_source_control; + } + if (config.ports && config.ports.length > 0) { cleanConfig.ports = config.ports; } diff --git a/apps/worker/src/commands/__tests__/setup.test.ts b/apps/worker/src/commands/__tests__/setup.test.ts index ed373cb8d..45c6d346b 100644 --- a/apps/worker/src/commands/__tests__/setup.test.ts +++ b/apps/worker/src/commands/__tests__/setup.test.ts @@ -469,7 +469,7 @@ describe('setup mode behavior', () => { MODAL_TOKEN_SECRET: 'as-secret', }); mockGetRuntimeEnv.mockReturnValueOnce({ - R_NESTED_COMPUTE_ENV: nestedComputeEnv, + R_NESTED_DEPLOYMENT_ENV: nestedComputeEnv, R_MODEL: 'roomote/openai/outer-model', }); @@ -479,7 +479,7 @@ describe('setup mode behavior', () => { ...environmentWorkspaceOptions, userEnvVars: { FOO: 'bar', - R_NESTED_COMPUTE_ENV: nestedComputeEnv, + R_NESTED_DEPLOYMENT_ENV: nestedComputeEnv, }, }, logger, @@ -521,7 +521,7 @@ describe('setup mode behavior', () => { MODAL_TOKEN_SECRET: 'as-secret', }); mockGetRuntimeEnv.mockReturnValueOnce({ - R_NESTED_COMPUTE_ENV: nestedComputeEnv, + R_NESTED_DEPLOYMENT_ENV: nestedComputeEnv, }); await setup({ diff --git a/apps/worker/src/commands/__tests__/snapshot.test.ts b/apps/worker/src/commands/__tests__/snapshot.test.ts index 36ce043e3..6f4e14265 100644 --- a/apps/worker/src/commands/__tests__/snapshot.test.ts +++ b/apps/worker/src/commands/__tests__/snapshot.test.ts @@ -122,7 +122,7 @@ describe('snapshot', () => { mockFetchSnapshotEnv.mockResolvedValue({ envVars: { PREVIEW_PROXY_BASE_URL: 'https://preview.roomote.run', - R_NESTED_COMPUTE_ENV: nestedComputeEnv, + R_NESTED_DEPLOYMENT_ENV: nestedComputeEnv, }, gitHubToken: 'gh-token', taskId: 'task-42', @@ -134,7 +134,7 @@ describe('snapshot', () => { // buildEnvironmentShellEnvVars tests); setup expands the raw value into // the nested environment env. expect(mockInjectEnvVars).toHaveBeenCalledWith( - expect.objectContaining({ R_NESTED_COMPUTE_ENV: nestedComputeEnv }), + expect.objectContaining({ R_NESTED_DEPLOYMENT_ENV: nestedComputeEnv }), undefined, expect.objectContaining({ omitInheritedModelRuntimeEnvFromShell: true }), ); @@ -142,7 +142,7 @@ describe('snapshot', () => { expect.objectContaining({ workspace: expect.objectContaining({ envVars: expect.objectContaining({ - R_NESTED_COMPUTE_ENV: nestedComputeEnv, + R_NESTED_DEPLOYMENT_ENV: nestedComputeEnv, }), }), }), diff --git a/apps/worker/src/commands/__tests__/utils.test.ts b/apps/worker/src/commands/__tests__/utils.test.ts index fa62fa2e9..c5e4c200d 100644 --- a/apps/worker/src/commands/__tests__/utils.test.ts +++ b/apps/worker/src/commands/__tests__/utils.test.ts @@ -150,7 +150,7 @@ describe('injectEnvVars', () => { expect( buildEnvironmentShellEnvVars({ FOO: 'bar', - R_NESTED_COMPUTE_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', + R_NESTED_DEPLOYMENT_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', DEFAULT_COMPUTE_PROVIDER: 'modal', }), ).toEqual({ FOO: 'bar', DEFAULT_COMPUTE_PROVIDER: 'modal' }); diff --git a/apps/worker/src/commands/setup.ts b/apps/worker/src/commands/setup.ts index c119c4e6a..c253bbc45 100644 --- a/apps/worker/src/commands/setup.ts +++ b/apps/worker/src/commands/setup.ts @@ -13,14 +13,14 @@ import { DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES, DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, - NESTED_COMPUTE_ENV_VAR_NAME, + NESTED_DEPLOYMENT_ENV_VAR_NAME, OPENCODE_AUTH_CONTENT_ENV_VAR_NAME, SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME, TASK_MODEL_CONTEXT_WINDOWS_ENV_VAR_NAME, TASK_MODEL_COSTS_ENV_VAR_NAME, TaskPayloadKind, parseModelProviderEnvKeys, - parseNestedComputeEnv, + parseNestedDeploymentEnv, } from '@roomote/types'; import { ExecutionError } from '../command-executor'; @@ -134,8 +134,8 @@ function buildEnvironmentWorkspaceEnvVars( const sandboxOpenRouterApiKey = envVars[SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME] ?? launcherSandboxOpenRouterApiKey; - const nestedComputeEnv = - parseNestedComputeEnv(envVars[NESTED_COMPUTE_ENV_VAR_NAME]) ?? + const nestedDeploymentEnv = + parseNestedDeploymentEnv(envVars[NESTED_DEPLOYMENT_ENV_VAR_NAME]) ?? launcherNestedComputeEnv; const configuredProviderEnvVarNames = new Set( parseModelProviderEnvKeys(envVars.R_MODEL_ENV_KEYS), @@ -146,7 +146,7 @@ function buildEnvironmentWorkspaceEnvVars( if ( value !== undefined && name !== SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME && - name !== NESTED_COMPUTE_ENV_VAR_NAME && + name !== NESTED_DEPLOYMENT_ENV_VAR_NAME && !name.startsWith('R_INFERENCE_GATEWAY_') && !INHERITED_MODEL_RUNTIME_ENV_VAR_NAMES.has(name) && !INHERITED_MODEL_PROVIDER_ENV_VAR_NAMES.has(name) && @@ -163,8 +163,8 @@ function buildEnvironmentWorkspaceEnvVars( // `inherit_compute` environments: expand the launcher's compute forwarding // blob into the real provider names so a nested Roomote controller can // spawn sandboxes with the outer deployment's provider. - if (nestedComputeEnv) { - Object.assign(nestedEnvironmentEnvVars, nestedComputeEnv); + if (nestedDeploymentEnv) { + Object.assign(nestedEnvironmentEnvVars, nestedDeploymentEnv); } return nestedEnvironmentEnvVars; @@ -203,15 +203,15 @@ export async function setup({ // Launcher-only source names never stay in the worker runtime env: the // sandbox OpenRouter key maps to OPENROUTER_API_KEY and the nested compute // blob expands into provider names, both for the nested app only. - const nestedComputeEnv = parseNestedComputeEnv( - runtimeEnv[NESTED_COMPUTE_ENV_VAR_NAME], + const nestedDeploymentEnv = parseNestedDeploymentEnv( + runtimeEnv[NESTED_DEPLOYMENT_ENV_VAR_NAME], ); if ( SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME in runtimeEnv || - NESTED_COMPUTE_ENV_VAR_NAME in runtimeEnv + NESTED_DEPLOYMENT_ENV_VAR_NAME in runtimeEnv ) { delete runtimeEnv[SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME]; - delete runtimeEnv[NESTED_COMPUTE_ENV_VAR_NAME]; + delete runtimeEnv[NESTED_DEPLOYMENT_ENV_VAR_NAME]; workerEnv.setRuntimeEnv(runtimeEnv); } @@ -228,7 +228,7 @@ export async function setup({ ? buildEnvironmentWorkspaceEnvVars( inheritedWorkspaceEnvVars, sandboxOpenRouterApiKey ?? workerEnv.sandboxOpenRouterApiKey, - nestedComputeEnv, + nestedDeploymentEnv, ) : inheritedWorkspaceEnvVars, userEnvVars: @@ -236,7 +236,7 @@ export async function setup({ ? buildEnvironmentWorkspaceEnvVars( workspaceOpts.userEnvVars, undefined, - nestedComputeEnv, + nestedDeploymentEnv, ) : workspaceOpts.userEnvVars, }; diff --git a/apps/worker/src/commands/utils/env-vars.ts b/apps/worker/src/commands/utils/env-vars.ts index d4e116970..516cdc687 100644 --- a/apps/worker/src/commands/utils/env-vars.ts +++ b/apps/worker/src/commands/utils/env-vars.ts @@ -6,7 +6,7 @@ import { buildPreviewProxyUrl, CODE_SERVER_NAMED_PORT, getSourceControlTokenEnvVars, - NESTED_COMPUTE_ENV_VAR_NAME, + NESTED_DEPLOYMENT_ENV_VAR_NAME, portNameToSlug, PRODUCT_NAME, TASK_MODEL_ROLE_DESCRIPTORS, @@ -44,7 +44,7 @@ export const INHERITED_MODEL_RUNTIME_ENV_VAR_NAMES: ReadonlySet = 'ROOMOTE_MODEL_ENV_KEYS', // Launcher-only source name; setup expands it into the nested app's env, // so the raw value is left out of the shell files. - NESTED_COMPUTE_ENV_VAR_NAME, + NESTED_DEPLOYMENT_ENV_VAR_NAME, ]); export function buildEnvironmentShellEnvVars( diff --git a/apps/worker/src/run-task/__tests__/run-task.test.ts b/apps/worker/src/run-task/__tests__/run-task.test.ts index f8db477a1..065f8eae5 100644 --- a/apps/worker/src/run-task/__tests__/run-task.test.ts +++ b/apps/worker/src/run-task/__tests__/run-task.test.ts @@ -4116,7 +4116,7 @@ describe('runTask', () => { } as never, envVars: { SANDBOX_OPENROUTER_API_KEY: 'dequeued-sandbox-key', - R_NESTED_COMPUTE_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', + R_NESTED_DEPLOYMENT_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', }, workspacePath: '/tmp/workspace', prompt: '', @@ -4186,7 +4186,7 @@ describe('runTask', () => { ).not.toHaveProperty('SANDBOX_OPENROUTER_API_KEY'); expect( createHarnessMock.mock.calls.at(-1)?.[0]?.runtimeEnv, - ).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); + ).not.toHaveProperty('R_NESTED_DEPLOYMENT_ENV'); }); it('isolates the task runtime HOME while keeping packaged skill sourcing on the worker HOME', async () => { diff --git a/apps/worker/src/run-task/run-task.ts b/apps/worker/src/run-task/run-task.ts index 81a4105f7..ceec31eab 100644 --- a/apps/worker/src/run-task/run-task.ts +++ b/apps/worker/src/run-task/run-task.ts @@ -18,7 +18,7 @@ import { getSlackThreadTsFromTaskPayload, getTaskReportConsumerFromPayload, isCommunicationProvider, - NESTED_COMPUTE_ENV_VAR_NAME, + NESTED_DEPLOYMENT_ENV_VAR_NAME, SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME, SANDBOX_SERVER_PORT, SANDBOX_TIMEOUT_MS, @@ -722,7 +722,7 @@ export const runTask = async ({ Object.entries(envVars).filter( ([name]) => name !== SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME && - name !== NESTED_COMPUTE_ENV_VAR_NAME, + name !== NESTED_DEPLOYMENT_ENV_VAR_NAME, ), ); const unsanitizedEnv = workerEnv diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts index bc8f07a77..4539e82df 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-helpers.test.ts @@ -13,6 +13,7 @@ const { mockResolveSandboxModelRuntimeEnv, mockTaskRunsFindFirst, mockEnvironmentsFindFirst, + mockEnvironmentVariablesSelectWhere, mockResolveDefaultComputeProvider, mockResolveComputeProviderEnvValues, mockNotifySourceRunOnSettle, @@ -29,6 +30,9 @@ const { mockResolveSandboxModelRuntimeEnv: vi.fn(), mockTaskRunsFindFirst: vi.fn(), mockEnvironmentsFindFirst: vi.fn(), + mockEnvironmentVariablesSelectWhere: vi.fn< + (...args: unknown[]) => Promise> + >(async () => []), mockResolveDefaultComputeProvider: vi.fn(), mockResolveComputeProviderEnvValues: vi.fn(), mockNotifySourceRunOnSettle: vi.fn(), @@ -55,13 +59,18 @@ vi.mock('@roomote/db/server', () => ({ }, select: () => ({ from: () => ({ - where: async () => [], + where: (...args: unknown[]) => + mockEnvironmentVariablesSelectWhere(...args), }), }), transaction: vi.fn(), }, taskRuns: { id: 'taskRuns.id' }, environments: { id: 'environments.id' }, + environmentVariables: { + name: 'environmentVariables.name', + value: 'environmentVariables.value', + }, resolveDefaultComputeProvider: (...args: unknown[]) => mockResolveDefaultComputeProvider(...args), resolveComputeProviderEnvValues: (...args: unknown[]) => @@ -1040,12 +1049,12 @@ describe('fetchResolvedRuntimeEnvVars', () => { MODAL_TOKEN_SECRET: 'stored-secret', MY_APP_CONFIG: 'value', }, - { nestedComputeEnvironmentId: 'env-nested' }, + { nestedDeploymentEnvironmentId: 'env-nested' }, ); expect(envVars).not.toHaveProperty('MODAL_TOKEN_SECRET'); expect(envVars.MY_APP_CONFIG).toBe('value'); - expect(JSON.parse(envVars.R_NESTED_COMPUTE_ENV!)).toEqual({ + expect(JSON.parse(envVars.R_NESTED_DEPLOYMENT_ENV!)).toEqual({ DEFAULT_COMPUTE_PROVIDER: 'modal', MODAL_TOKEN_ID: 'ak-id', MODAL_TOKEN_SECRET: 'as-secret', @@ -1066,10 +1075,10 @@ describe('fetchResolvedRuntimeEnvVars', () => { const envVars = await fetchResolvedRuntimeEnvVars( { MY_APP_CONFIG: 'value' }, - { nestedComputeEnvironmentId: 'env-plain' }, + { nestedDeploymentEnvironmentId: 'env-plain' }, ); - expect(envVars).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); + expect(envVars).not.toHaveProperty('R_NESTED_DEPLOYMENT_ENV'); expect(mockResolveDefaultComputeProvider).not.toHaveBeenCalled(); }); @@ -1083,10 +1092,95 @@ describe('fetchResolvedRuntimeEnvVars', () => { const envVars = await fetchResolvedRuntimeEnvVars( { MY_APP_CONFIG: 'value' }, - { nestedComputeEnvironmentId: 'env-docker' }, + { nestedDeploymentEnvironmentId: 'env-docker' }, + ); + + expect(envVars).not.toHaveProperty('R_NESTED_DEPLOYMENT_ENV'); + }); + + it('forwards fully configured source-control providers for inherit_source_control environments', async () => { + const githubAppEnv: Record = { + R_GITHUB_APP_SLUG: 'roomote-test', + R_GITHUB_APP_ID: '12345', + R_GITHUB_APP_PRIVATE_KEY: 'pem', + R_GITHUB_CLIENT_ID: 'Iv1.abc', + R_GITHUB_CLIENT_SECRET: 'client-secret', + R_GITHUB_WEBHOOK_SECRET: 'webhook-secret', + }; + mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({}); + mockResolveDefaultComputeProvider.mockClear(); + mockEnvironmentsFindFirst.mockResolvedValueOnce({ + config: { inherit_source_control: true }, + }); + // The saved deployment env holds the GitHub App plus a half-configured + // Gitea provider; only the complete provider is forwarded. + mockEnvironmentVariablesSelectWhere.mockResolvedValueOnce([ + ...Object.entries(githubAppEnv).map(([name, value]) => ({ + name, + value: `enc:${value}`, + })), + { name: 'GITEA_BASE_URL', value: 'enc:https://gitea.example' }, + ]); + mockDecryptSecrets.mockImplementation(async (value: unknown) => + String(value).replace(/^enc:/, ''), ); - expect(envVars).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); + try { + const envVars = await fetchResolvedRuntimeEnvVars( + { + // Stored under its real name: still stripped from the sandbox. + R_GITHUB_APP_PRIVATE_KEY: 'stored-pem', + MY_APP_CONFIG: 'value', + }, + { nestedDeploymentEnvironmentId: 'env-scm' }, + ); + + expect(envVars).not.toHaveProperty('R_GITHUB_APP_PRIVATE_KEY'); + expect(JSON.parse(envVars.R_NESTED_DEPLOYMENT_ENV!)).toEqual( + githubAppEnv, + ); + expect(mockResolveDefaultComputeProvider).not.toHaveBeenCalled(); + } finally { + mockDecryptSecrets.mockReset(); + } + }); + + it('merges compute and source-control forwards when both flags are set', async () => { + mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({}); + mockEnvironmentsFindFirst.mockResolvedValueOnce({ + config: { inherit_compute: true, inherit_source_control: true }, + }); + mockResolveDefaultComputeProvider.mockResolvedValueOnce('modal'); + mockResolveComputeProviderEnvValues.mockResolvedValueOnce({ + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + }); + mockEnvironmentVariablesSelectWhere.mockResolvedValueOnce([ + { name: 'GITEA_BASE_URL', value: 'https://gitea.example' }, + { name: 'GITEA_CLIENT_ID', value: 'gitea-client' }, + { name: 'GITEA_CLIENT_SECRET', value: 'gitea-secret' }, + ]); + mockDecryptSecrets.mockImplementation(async (value: unknown) => value); + + try { + const envVars = await fetchResolvedRuntimeEnvVars( + { MY_APP_CONFIG: 'value' }, + { nestedDeploymentEnvironmentId: 'env-both' }, + ); + + expect(JSON.parse(envVars.R_NESTED_DEPLOYMENT_ENV!)).toEqual({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + GITEA_BASE_URL: 'https://gitea.example', + GITEA_CLIENT_ID: 'gitea-client', + GITEA_CLIENT_SECRET: 'gitea-secret', + }); + } finally { + mockDecryptSecrets.mockReset(); + } }); it('strips an operator-stored forwarding value for tasks without an environment', async () => { @@ -1094,11 +1188,11 @@ describe('fetchResolvedRuntimeEnvVars', () => { mockEnvironmentsFindFirst.mockClear(); const envVars = await fetchResolvedRuntimeEnvVars({ - R_NESTED_COMPUTE_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', + R_NESTED_DEPLOYMENT_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', MY_APP_CONFIG: 'value', }); - expect(envVars).not.toHaveProperty('R_NESTED_COMPUTE_ENV'); + expect(envVars).not.toHaveProperty('R_NESTED_DEPLOYMENT_ENV'); expect(mockEnvironmentsFindFirst).not.toHaveBeenCalled(); }); diff --git a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts index d8eda4b00..94c27378d 100644 --- a/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts +++ b/packages/sdk/src/server/lib/task-runs/__tests__/dequeue-resume-task-run.test.ts @@ -259,7 +259,7 @@ describe('dequeueResumeTaskRun', () => { { sourceControlProvider: ['gitlab', 'github'], includeSandboxOpenRouterApiKey: true, - nestedComputeEnvironmentId: 'env-1', + nestedDeploymentEnvironmentId: 'env-1', }, ); expect(result?.harnessInstructions).toBe('preserved instructions'); diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index fed5df38f..45e82622b 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -3,12 +3,15 @@ import { DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES, DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, INFERENCE_GATEWAY_KEYS_ENV_VAR_NAME, - NESTED_COMPUTE_ENV_VAR_NAME, + NESTED_DEPLOYMENT_ENV_VAR_NAME, OPENCODE_AUTH_CONTENT_ENV_VAR_NAME, SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME, TASK_MODEL_CONTEXT_WINDOWS_ENV_VAR_NAME, + NESTED_SOURCE_CONTROL_ENV_VAR_NAMES, buildNestedComputeEnv, - serializeNestedComputeEnv, + buildNestedSourceControlEnv, + mergeNestedDeploymentEnv, + serializeNestedDeploymentEnv, parseInferenceGatewayKeys, parseModelProviderEnvKeys, RunStatus, @@ -24,6 +27,7 @@ import { type TaskRun, db, environments, + environmentVariables, taskRuns, markTaskStartParallelCountEndedAt, resolveComputeProviderEnvValues, @@ -36,6 +40,7 @@ import { stringifyDecryptedEnvVarValue, syncTaskStateFromRuns, eq, + inArray, sql, } from '@roomote/db/server'; import { captureTaskSettled } from '@roomote/telemetry/server'; @@ -271,37 +276,110 @@ function redactModelRuntimeManagedEnvVars( } /** - * Resolves the compute forwarding value for an environment that opted in with - * `inherit_compute: true`: the deployment's default compute provider plus the - * resolved setup-catalog values for it, serialized under a single name that - * the worker expands for the nested Roomote app. Returns null when the - * environment did not opt in, the provider cannot be nested, or the provider - * is not fully configured. + * Resolves deployment env values by name the way the control plane does: + * process env first, then the encrypted deployment env in one batched read. */ -async function resolveNestedComputeEnvVar( +async function resolveDeploymentEnvValues( + names: readonly string[], +): Promise>> { + const resolved: Partial> = {}; + const missing: string[] = []; + + for (const name of names) { + const runtimeValue = (Env as Partial>)[name]; + const value = + typeof runtimeValue === 'string' ? runtimeValue.trim() : undefined; + + if (value) { + resolved[name] = value; + } else { + missing.push(name); + } + } + + if (missing.length === 0) { + return resolved; + } + + const encryptedEnvVars = await db + .select({ + name: environmentVariables.name, + value: environmentVariables.value, + }) + .from(environmentVariables) + .where(inArray(environmentVariables.name, missing)); + + for (const envVar of encryptedEnvVars) { + const decryptedValue = await decryptSecrets(envVar.value); + + if (decryptedValue === null) { + continue; + } + + const value = stringifyDecryptedEnvVarValue(decryptedValue).trim(); + + if (value) { + resolved[envVar.name] = value; + } + } + + return resolved; +} + +/** + * Resolves the forwarding value for an environment that opted in with + * `inherit_compute` and/or `inherit_source_control`: the deployment's default + * compute provider with its resolved setup-catalog values, and the fully + * configured source-control providers with theirs, serialized under a single + * name that the worker expands for the nested Roomote app. Returns null when + * the environment did not opt in or nothing resolved to a complete provider. + */ +async function resolveNestedDeploymentEnvVar( environmentId: string, ): Promise { const environment = await db.query.environments.findFirst({ where: eq(environments.id, environmentId), columns: { config: true }, }); + const inheritCompute = environment?.config?.inherit_compute === true; + const inheritSourceControl = + environment?.config?.inherit_source_control === true; - if (environment?.config?.inherit_compute !== true) { + if (!inheritCompute && !inheritSourceControl) { return null; } - const provider = await resolveDefaultComputeProvider(); - // The validated Env carries the resolved NODE_ENV/APP_ENV and worker image - // that the derived Modal base image depends on; the raw process env does - // not, and a missing base image ref would drop the whole forward. - const nestedComputeEnv = buildNestedComputeEnv({ - provider, - resolvedEnvValues: await resolveComputeProviderEnvValues(provider, { - runtimeEnv: Env, - }), - }); + let nestedComputeEnv: Record | null = null; + + if (inheritCompute) { + const provider = await resolveDefaultComputeProvider(); + // The validated Env carries the resolved NODE_ENV/APP_ENV and worker + // image that the derived Modal base image depends on; the raw process + // env does not, and a missing base image ref would drop the forward. + nestedComputeEnv = buildNestedComputeEnv({ + provider, + resolvedEnvValues: await resolveComputeProviderEnvValues(provider, { + runtimeEnv: Env, + }), + }); + } - return nestedComputeEnv ? serializeNestedComputeEnv(nestedComputeEnv) : null; + const nestedSourceControlEnv = inheritSourceControl + ? buildNestedSourceControlEnv({ + resolvedEnvValues: await resolveDeploymentEnvValues( + NESTED_SOURCE_CONTROL_ENV_VAR_NAMES, + ), + }) + : null; + + const nestedDeploymentEnv = mergeNestedDeploymentEnv( + nestedComputeEnv, + nestedSourceControlEnv, + ); + + return nestedDeploymentEnv + ? serializeNestedDeploymentEnv(nestedDeploymentEnv) + : null; } export async function fetchResolvedRuntimeEnvVars( @@ -311,9 +389,9 @@ export async function fetchResolvedRuntimeEnvVars( includeSandboxOpenRouterApiKey?: boolean; /** * Environment the task runs in. When it opted in with `inherit_compute`, - * the result carries NESTED_COMPUTE_ENV_VAR_NAME for the nested app. + * the result carries NESTED_DEPLOYMENT_ENV_VAR_NAME for the nested app. */ - nestedComputeEnvironmentId?: string; + nestedDeploymentEnvironmentId?: string; }, ): Promise> { const envVars = @@ -334,11 +412,14 @@ export async function fetchResolvedRuntimeEnvVars( ), ); - const nestedComputeEnvVar = options?.nestedComputeEnvironmentId - ? await resolveNestedComputeEnvVar(options.nestedComputeEnvironmentId) + const nestedComputeEnvVar = options?.nestedDeploymentEnvironmentId + ? await resolveNestedDeploymentEnvVar(options.nestedDeploymentEnvironmentId) : null; const environmentTaskEnvVars = nestedComputeEnvVar - ? { ...resolvedEnvVars, [NESTED_COMPUTE_ENV_VAR_NAME]: nestedComputeEnvVar } + ? { + ...resolvedEnvVars, + [NESTED_DEPLOYMENT_ENV_VAR_NAME]: nestedComputeEnvVar, + } : resolvedEnvVars; if (options?.includeSandboxOpenRouterApiKey) { diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts index 58240d035..9dae80593 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-resume-task-run.ts @@ -428,7 +428,7 @@ export const dequeueResumeTaskRun = async ( includeSandboxOpenRouterApiKey: Boolean( result.taskRun.payload.environmentId, ), - nestedComputeEnvironmentId: result.taskRun.payload.environmentId, + nestedDeploymentEnvironmentId: result.taskRun.payload.environmentId, }); } catch (error) { const message = diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts index 68a7ee2b1..17d7c24dd 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-task-run.ts @@ -571,7 +571,8 @@ export const dequeueTaskRun = async ( includeSandboxOpenRouterApiKey: Boolean( txResult.taskRun.payload.environmentId, ), - nestedComputeEnvironmentId: txResult.taskRun.payload.environmentId, + nestedDeploymentEnvironmentId: + txResult.taskRun.payload.environmentId, }), }); } catch (error) { diff --git a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts index 4efeb3488..b74c02e8b 100644 --- a/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts +++ b/packages/sdk/src/server/lib/task-runs/fetch-snapshot-env.ts @@ -45,7 +45,7 @@ export async function fetchSnapshotEnv( const deploymentEnvVars = await fetchResolvedRuntimeEnvVars(undefined, { sourceControlProvider: sourceControlProviders, includeSandboxOpenRouterApiKey: Boolean(taskRun.payload.environmentId), - nestedComputeEnvironmentId: taskRun.payload.environmentId, + nestedDeploymentEnvironmentId: taskRun.payload.environmentId, }); const sourceControlToken = await createSourceControlTokenForTaskRun( diff --git a/packages/types/src/__tests__/nested-compute.test.ts b/packages/types/src/__tests__/nested-compute.test.ts deleted file mode 100644 index 8853bf004..000000000 --- a/packages/types/src/__tests__/nested-compute.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { CONTROL_PLANE_ENV_VAR_NAMES } from '../control-plane-env-vars'; -import { environmentConfigSchema } from '../environment-config'; -import { - NESTED_COMPUTE_ENV_VAR_NAME, - buildNestedComputeEnv, - parseNestedComputeEnv, - serializeNestedComputeEnv, -} from '../nested-compute'; - -describe('buildNestedComputeEnv', () => { - it('forwards the default provider and its populated catalog fields', () => { - expect( - buildNestedComputeEnv({ - provider: 'modal', - resolvedEnvValues: { - MODAL_TOKEN_ID: 'ak-id', - MODAL_TOKEN_SECRET: 'as-secret', - MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', - MODAL_REGIONS: ' ', - UNRELATED: 'ignored', - }, - }), - ).toEqual({ - DEFAULT_COMPUTE_PROVIDER: 'modal', - MODAL_TOKEN_ID: 'ak-id', - MODAL_TOKEN_SECRET: 'as-secret', - MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', - }); - }); - - it('forwards the managed provider with its broker settings', () => { - expect( - buildNestedComputeEnv({ - provider: 'roomote', - resolvedEnvValues: { - ROOMOTE_CLOUD_TOKEN_ID: 'tenant', - ROOMOTE_CLOUD_TOKEN_SECRET: 'rbk_key', - ROOMOTE_CLOUD_BACKEND: 'broker', - ROOMOTE_CLOUD_BROKER_URL: 'https://broker.example', - MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker@sha256:abc', - }, - }), - ).toEqual({ - DEFAULT_COMPUTE_PROVIDER: 'roomote', - ROOMOTE_CLOUD_TOKEN_ID: 'tenant', - ROOMOTE_CLOUD_TOKEN_SECRET: 'rbk_key', - ROOMOTE_CLOUD_BACKEND: 'broker', - ROOMOTE_CLOUD_BROKER_URL: 'https://broker.example', - MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker@sha256:abc', - }); - }); - - it('returns null when a required field is missing', () => { - expect( - buildNestedComputeEnv({ - provider: 'modal', - resolvedEnvValues: { MODAL_TOKEN_ID: 'ak-id' }, - }), - ).toBeNull(); - }); - - it('never nests Local Docker', () => { - expect( - buildNestedComputeEnv({ provider: 'docker', resolvedEnvValues: {} }), - ).toBeNull(); - }); -}); - -describe('parseNestedComputeEnv', () => { - it('round-trips a serialized env map', () => { - const env = { - DEFAULT_COMPUTE_PROVIDER: 'modal', - MODAL_TOKEN_ID: 'ak-id', - MODAL_TOKEN_SECRET: 'as-secret', - }; - - expect(parseNestedComputeEnv(serializeNestedComputeEnv(env))).toEqual(env); - }); - - it('rejects blank, malformed, and non-object input', () => { - expect(parseNestedComputeEnv(undefined)).toBeNull(); - expect(parseNestedComputeEnv(' ')).toBeNull(); - expect(parseNestedComputeEnv('{not json')).toBeNull(); - expect(parseNestedComputeEnv('["MODAL_TOKEN_ID"]')).toBeNull(); - expect(parseNestedComputeEnv('null')).toBeNull(); - }); - - it('rejects non-string values and invalid env var names', () => { - expect( - parseNestedComputeEnv( - JSON.stringify({ - DEFAULT_COMPUTE_PROVIDER: 'modal', - MODAL_TOKEN_ID: 1, - }), - ), - ).toBeNull(); - expect( - parseNestedComputeEnv( - JSON.stringify({ - DEFAULT_COMPUTE_PROVIDER: 'modal', - 'BAD NAME; rm -rf': 'x', - }), - ), - ).toBeNull(); - }); - - it('rejects an unknown or missing provider', () => { - expect( - parseNestedComputeEnv(JSON.stringify({ MODAL_TOKEN_ID: 'ak-id' })), - ).toBeNull(); - expect( - parseNestedComputeEnv( - JSON.stringify({ DEFAULT_COMPUTE_PROVIDER: 'mainframe' }), - ), - ).toBeNull(); - }); -}); - -describe('nested compute wiring', () => { - it('reserves the forwarding name from the generic environment editor', () => { - expect(CONTROL_PLANE_ENV_VAR_NAMES.has(NESTED_COMPUTE_ENV_VAR_NAME)).toBe( - true, - ); - }); - - it('accepts inherit_compute on an environment definition', () => { - const result = environmentConfigSchema.safeParse({ - name: 'Nested Roomote', - repositories: [{ repository: 'acme/app' }], - inherit_compute: true, - }); - - expect(result.success).toBe(true); - expect(result.success && result.data.inherit_compute).toBe(true); - }); - - it('rejects a non-boolean inherit_compute', () => { - expect( - environmentConfigSchema.safeParse({ - name: 'Nested Roomote', - repositories: [{ repository: 'acme/app' }], - inherit_compute: 'yes', - }).success, - ).toBe(false); - }); -}); diff --git a/packages/types/src/__tests__/nested-deployment-env.test.ts b/packages/types/src/__tests__/nested-deployment-env.test.ts new file mode 100644 index 000000000..bc34d4e15 --- /dev/null +++ b/packages/types/src/__tests__/nested-deployment-env.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest'; + +import { CONTROL_PLANE_ENV_VAR_NAMES } from '../control-plane-env-vars'; +import { environmentConfigSchema } from '../environment-config'; +import { + NESTED_DEPLOYMENT_ENV_VAR_NAME, + NESTED_SOURCE_CONTROL_ENV_VAR_NAMES, + buildNestedComputeEnv, + buildNestedSourceControlEnv, + mergeNestedDeploymentEnv, + parseNestedDeploymentEnv, + serializeNestedDeploymentEnv, +} from '../nested-deployment-env'; + +const GITHUB_APP_ENV = { + R_GITHUB_APP_SLUG: 'roomote-test', + R_GITHUB_APP_ID: '12345', + R_GITHUB_APP_PRIVATE_KEY: '-----BEGIN RSA PRIVATE KEY-----\nabc\n-----END', + R_GITHUB_CLIENT_ID: 'Iv1.abc', + R_GITHUB_CLIENT_SECRET: 'client-secret', + R_GITHUB_WEBHOOK_SECRET: 'webhook-secret', +}; + +describe('buildNestedComputeEnv', () => { + it('forwards the default provider and its populated catalog fields', () => { + expect( + buildNestedComputeEnv({ + provider: 'modal', + resolvedEnvValues: { + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + MODAL_REGIONS: ' ', + UNRELATED: 'ignored', + }, + }), + ).toEqual({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker:develop', + }); + }); + + it('forwards the managed provider with its broker settings', () => { + expect( + buildNestedComputeEnv({ + provider: 'roomote', + resolvedEnvValues: { + ROOMOTE_CLOUD_TOKEN_ID: 'tenant', + ROOMOTE_CLOUD_TOKEN_SECRET: 'rbk_key', + ROOMOTE_CLOUD_BACKEND: 'broker', + ROOMOTE_CLOUD_BROKER_URL: 'https://broker.example', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker@sha256:abc', + }, + }), + ).toEqual({ + DEFAULT_COMPUTE_PROVIDER: 'roomote', + ROOMOTE_CLOUD_TOKEN_ID: 'tenant', + ROOMOTE_CLOUD_TOKEN_SECRET: 'rbk_key', + ROOMOTE_CLOUD_BACKEND: 'broker', + ROOMOTE_CLOUD_BROKER_URL: 'https://broker.example', + MODAL_BASE_IMAGE_REF: 'ghcr.io/roocodeinc/roomote-worker@sha256:abc', + }); + }); + + it('returns null when a required field is missing', () => { + expect( + buildNestedComputeEnv({ + provider: 'modal', + resolvedEnvValues: { MODAL_TOKEN_ID: 'ak-id' }, + }), + ).toBeNull(); + }); + + it('never nests Local Docker', () => { + expect( + buildNestedComputeEnv({ provider: 'docker', resolvedEnvValues: {} }), + ).toBeNull(); + }); +}); + +describe('buildNestedSourceControlEnv', () => { + it('forwards every fully configured provider', () => { + expect( + buildNestedSourceControlEnv({ + resolvedEnvValues: { + ...GITHUB_APP_ENV, + GITEA_BASE_URL: 'https://gitea.example', + GITEA_CLIENT_ID: 'gitea-client', + GITEA_CLIENT_SECRET: 'gitea-secret', + UNRELATED: 'ignored', + }, + }), + ).toEqual({ + ...GITHUB_APP_ENV, + GITEA_BASE_URL: 'https://gitea.example', + GITEA_CLIENT_ID: 'gitea-client', + GITEA_CLIENT_SECRET: 'gitea-secret', + }); + }); + + it('skips a provider that is only partially configured', () => { + expect( + buildNestedSourceControlEnv({ + resolvedEnvValues: { + ...GITHUB_APP_ENV, + GITEA_BASE_URL: 'https://gitea.example', + }, + }), + ).toEqual(GITHUB_APP_ENV); + }); + + it('returns null when no provider is configured', () => { + expect( + buildNestedSourceControlEnv({ + resolvedEnvValues: { R_GITHUB_APP_ID: '12345' }, + }), + ).toBeNull(); + }); + + it('covers the GitHub App fields in the resolved name list', () => { + for (const name of Object.keys(GITHUB_APP_ENV)) { + expect(NESTED_SOURCE_CONTROL_ENV_VAR_NAMES).toContain(name); + } + }); +}); + +describe('mergeNestedDeploymentEnv', () => { + it('merges populated parts and drops empty ones', () => { + expect( + mergeNestedDeploymentEnv({ DEFAULT_COMPUTE_PROVIDER: 'modal' }, null, { + R_GITHUB_APP_ID: '12345', + }), + ).toEqual({ DEFAULT_COMPUTE_PROVIDER: 'modal', R_GITHUB_APP_ID: '12345' }); + expect(mergeNestedDeploymentEnv(null, undefined)).toBeNull(); + }); +}); + +describe('parseNestedDeploymentEnv', () => { + it('round-trips a serialized env map', () => { + const env = { + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 'ak-id', + MODAL_TOKEN_SECRET: 'as-secret', + R_GITHUB_APP_ID: '12345', + }; + + expect(parseNestedDeploymentEnv(serializeNestedDeploymentEnv(env))).toEqual( + env, + ); + }); + + it('accepts a source-control-only map', () => { + expect(parseNestedDeploymentEnv(JSON.stringify(GITHUB_APP_ENV))).toEqual( + GITHUB_APP_ENV, + ); + }); + + it('rejects blank, malformed, empty, and non-object input', () => { + expect(parseNestedDeploymentEnv(undefined)).toBeNull(); + expect(parseNestedDeploymentEnv(' ')).toBeNull(); + expect(parseNestedDeploymentEnv('{not json')).toBeNull(); + expect(parseNestedDeploymentEnv('["MODAL_TOKEN_ID"]')).toBeNull(); + expect(parseNestedDeploymentEnv('null')).toBeNull(); + expect(parseNestedDeploymentEnv('{}')).toBeNull(); + }); + + it('rejects non-string values and invalid env var names', () => { + expect( + parseNestedDeploymentEnv( + JSON.stringify({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + MODAL_TOKEN_ID: 1, + }), + ), + ).toBeNull(); + expect( + parseNestedDeploymentEnv( + JSON.stringify({ + DEFAULT_COMPUTE_PROVIDER: 'modal', + 'BAD NAME; rm -rf': 'x', + }), + ), + ).toBeNull(); + }); + + it('rejects an unknown compute provider', () => { + expect( + parseNestedDeploymentEnv( + JSON.stringify({ DEFAULT_COMPUTE_PROVIDER: 'mainframe' }), + ), + ).toBeNull(); + }); +}); + +describe('nested deployment wiring', () => { + it('reserves the forwarding name from the generic environment editor', () => { + expect( + CONTROL_PLANE_ENV_VAR_NAMES.has(NESTED_DEPLOYMENT_ENV_VAR_NAME), + ).toBe(true); + }); + + it('accepts the inherit flags on an environment definition', () => { + const result = environmentConfigSchema.safeParse({ + name: 'Nested Roomote', + repositories: [{ repository: 'acme/app' }], + inherit_compute: true, + inherit_source_control: true, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.inherit_compute).toBe(true); + expect(result.success && result.data.inherit_source_control).toBe(true); + }); + + it('rejects non-boolean inherit flags', () => { + const base = { + name: 'Nested Roomote', + repositories: [{ repository: 'acme/app' }], + }; + + expect( + environmentConfigSchema.safeParse({ ...base, inherit_compute: 'yes' }) + .success, + ).toBe(false); + expect( + environmentConfigSchema.safeParse({ + ...base, + inherit_source_control: 'yes', + }).success, + ).toBe(false); + }); +}); diff --git a/packages/types/src/control-plane-env-vars.ts b/packages/types/src/control-plane-env-vars.ts index e28e28d44..d4bb5f97b 100644 --- a/packages/types/src/control-plane-env-vars.ts +++ b/packages/types/src/control-plane-env-vars.ts @@ -2,7 +2,7 @@ import { COMMS_PROVIDER_ENV_VAR_NAMES } from './setup-auth-config'; import { COMPUTE_PROVIDER_ENV_VAR_NAMES } from './setup-compute-config'; import { SETUP_SOURCE_CONTROL_PROVIDER_CATALOG } from './setup-source-control-config'; import { OPENCODE_AUTH_CONTENT_ENV_VAR_NAME } from './chatgpt-subscription'; -import { NESTED_COMPUTE_ENV_VAR_NAME } from './nested-compute'; +import { NESTED_DEPLOYMENT_ENV_VAR_NAME } from './nested-deployment-env'; import { DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, @@ -157,7 +157,7 @@ export const CONTROL_PLANE_ENV_VAR_NAMES: ReadonlySet = new Set( ROOMOTE_INFERENCE_API_KEY_ENV_VAR_NAME, // Launcher-built compute forwarding for `inherit_compute` environments. // Never operator-set; the dequeue path injects it after this denylist. - NESTED_COMPUTE_ENV_VAR_NAME, + NESTED_DEPLOYMENT_ENV_VAR_NAME, ], ); diff --git a/packages/types/src/environment-config.ts b/packages/types/src/environment-config.ts index 807bbfdf9..ba8b41765 100644 --- a/packages/types/src/environment-config.ts +++ b/packages/types/src/environment-config.ts @@ -746,6 +746,14 @@ export const environmentConfigSchema = z * Local Docker cannot be nested and is never forwarded. */ inherit_compute: z.boolean().optional(), + /** + * Forward the deployment's configured source-control providers (for + * example the GitHub App fields) into this environment so a nested Roomote + * instance can reach its repositories and mint its own repo tokens. Same + * trust caveat as `inherit_compute`: every task in the environment can + * read the forwarded values. + */ + inherit_source_control: z.boolean().optional(), /** * Named preview ports for human-facing application URLs. * Each port gets an authenticated shareable URL in diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9281cb152..e9c7cba6e 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,7 +49,7 @@ export * from './llm-usage'; export * from './bedrock-opencode-provider'; export * from './inference-gateway'; export * from './sandbox-preview-inference'; -export * from './nested-compute'; +export * from './nested-deployment-env'; export * from './inference-provider-retry'; export * from './model-provider-config'; export * from './openai-compatible-providers'; diff --git a/packages/types/src/nested-compute.ts b/packages/types/src/nested-compute.ts deleted file mode 100644 index 2b64d354f..000000000 --- a/packages/types/src/nested-compute.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { - isComputeProvider, - type ComputeProvider, -} from './compute-providers/compute-provider'; -import { - getSetupComputeProvider, - isRequiredComputeField, -} from './setup-compute-config'; - -/** POSIX env var name: letters, digits, underscores; no leading digit. */ -const VALID_ENV_VAR_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/; - -/** - * Launcher-only JSON env var that carries the outer deployment's compute - * provider configuration into environment workspaces that opted in with - * `inherit_compute: true`. Every compute credential name is a reserved - * control-plane name that is stripped before a sandbox sees it, so a nested - * Roomote instance running inside an environment could never spawn its own - * sandboxes. This var travels under a non-reserved name and the worker expands - * it back into the real names for the nested app only. - */ -export const NESTED_COMPUTE_ENV_VAR_NAME = 'R_NESTED_COMPUTE_ENV' as const; - -/** - * Providers that cannot be nested: Local Docker needs the host's Docker - * socket, which is not reachable from inside a sandbox. - */ -const NON_NESTABLE_COMPUTE_PROVIDERS: ReadonlySet = new Set([ - 'docker', -]); - -export interface NestedComputeEnvInput { - provider: ComputeProvider; - /** Resolved setup-catalog env values for `provider` (process env + saved). */ - resolvedEnvValues: Partial>; -} - -/** - * Builds the env map a nested Roomote instance needs to spawn sandboxes with - * the outer deployment's provider: `DEFAULT_COMPUTE_PROVIDER` plus every - * populated setup-catalog field for that provider. Returns null when the - * provider cannot be nested or a required field is missing, so callers never - * forward a half-configured provider. - */ -export function buildNestedComputeEnv( - input: NestedComputeEnvInput, -): Record | null { - if (NON_NESTABLE_COMPUTE_PROVIDERS.has(input.provider)) { - return null; - } - - const descriptor = getSetupComputeProvider(input.provider); - const env: Record = { - DEFAULT_COMPUTE_PROVIDER: input.provider, - }; - - for (const field of descriptor.fields) { - const value = input.resolvedEnvValues[field.envVarName]?.trim(); - - if (value) { - env[field.envVarName] = value; - } else if (isRequiredComputeField(field)) { - return null; - } - } - - return env; -} - -export function serializeNestedComputeEnv(env: Record): string { - return JSON.stringify(env); -} - -/** - * Parses the launcher-provided JSON. Anything that is not a flat object of - * valid env var names to strings, or that names a provider Roomote does not - * know, is ignored rather than expanded into the nested shell. - */ -export function parseNestedComputeEnv( - raw: string | undefined, -): Record | null { - if (!raw?.trim()) { - return null; - } - - let parsed: unknown; - - try { - parsed = JSON.parse(raw); - } catch { - return null; - } - - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return null; - } - - const env: Record = {}; - - for (const [name, value] of Object.entries(parsed)) { - if (typeof value !== 'string' || !VALID_ENV_VAR_NAME.test(name)) { - return null; - } - - env[name] = value; - } - - const provider = env.DEFAULT_COMPUTE_PROVIDER; - - if (!provider || !isComputeProvider(provider)) { - return null; - } - - return env; -} diff --git a/packages/types/src/nested-deployment-env.ts b/packages/types/src/nested-deployment-env.ts new file mode 100644 index 000000000..e480b6592 --- /dev/null +++ b/packages/types/src/nested-deployment-env.ts @@ -0,0 +1,190 @@ +import { + isComputeProvider, + type ComputeProvider, +} from './compute-providers/compute-provider'; +import { + getSetupComputeProvider, + isRequiredComputeField, +} from './setup-compute-config'; +import { SETUP_SOURCE_CONTROL_PROVIDER_CATALOG } from './setup-source-control-config'; + +/** + * Launcher-only JSON env var that carries selected deployment configuration + * into environment workspaces that opted in with `inherit_compute` and/or + * `inherit_source_control`. Compute credentials and source-control app + * secrets are reserved control-plane names that are stripped before a sandbox + * sees them, so a nested Roomote instance running inside an environment could + * never spawn its own sandboxes or reach its repositories. The forwarded map + * travels under this single non-reserved name and the worker expands it back + * into the real names for the nested app only. + */ +export const NESTED_DEPLOYMENT_ENV_VAR_NAME = + 'R_NESTED_DEPLOYMENT_ENV' as const; + +/** POSIX env var name: letters, digits, underscores; no leading digit. */ +const VALID_ENV_VAR_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +/** + * Providers that cannot be nested: Local Docker needs the host's Docker + * socket, which is not reachable from inside a sandbox. + */ +const NON_NESTABLE_COMPUTE_PROVIDERS: ReadonlySet = new Set([ + 'docker', +]); + +export interface NestedComputeEnvInput { + provider: ComputeProvider; + /** Resolved setup-catalog env values for `provider` (process env + saved). */ + resolvedEnvValues: Partial>; +} + +/** + * Builds the env map a nested Roomote instance needs to spawn sandboxes with + * the outer deployment's provider: `DEFAULT_COMPUTE_PROVIDER` plus every + * populated setup-catalog field for that provider. Returns null when the + * provider cannot be nested or a required field is missing, so callers never + * forward a half-configured provider. + */ +export function buildNestedComputeEnv( + input: NestedComputeEnvInput, +): Record | null { + if (NON_NESTABLE_COMPUTE_PROVIDERS.has(input.provider)) { + return null; + } + + const descriptor = getSetupComputeProvider(input.provider); + const env: Record = { + DEFAULT_COMPUTE_PROVIDER: input.provider, + }; + + for (const field of descriptor.fields) { + const value = input.resolvedEnvValues[field.envVarName]?.trim(); + + if (value) { + env[field.envVarName] = value; + } else if (isRequiredComputeField(field)) { + return null; + } + } + + return env; +} + +/** + * Every setup-catalog env var name across all source-control providers; the + * launcher resolves these (process env first, then the encrypted deployment + * env) before building the nested source-control map. + */ +export const NESTED_SOURCE_CONTROL_ENV_VAR_NAMES: readonly string[] = + SETUP_SOURCE_CONTROL_PROVIDER_CATALOG.flatMap((descriptor) => + descriptor.fields.map((field) => field.envVarName), + ); + +export interface NestedSourceControlEnvInput { + /** Resolved values for NESTED_SOURCE_CONTROL_ENV_VAR_NAMES. */ + resolvedEnvValues: Partial>; +} + +/** + * Builds the env map a nested Roomote instance needs to talk to the outer + * deployment's source-control providers: every populated setup-catalog field + * of each provider whose required fields are all present. Providers that are + * only partially configured are skipped rather than forwarded half-done. + * Returns null when no provider is fully configured. + */ +export function buildNestedSourceControlEnv( + input: NestedSourceControlEnvInput, +): Record | null { + const env: Record = {}; + + for (const descriptor of SETUP_SOURCE_CONTROL_PROVIDER_CATALOG) { + const providerEnv: Record = {}; + let complete = true; + + for (const field of descriptor.fields) { + const value = input.resolvedEnvValues[field.envVarName]?.trim(); + + if (value) { + providerEnv[field.envVarName] = value; + } else if (field.required !== false) { + complete = false; + break; + } + } + + if (complete) { + Object.assign(env, providerEnv); + } + } + + return Object.keys(env).length > 0 ? env : null; +} + +/** Merges forwarded parts, ignoring the ones that resolved to nothing. */ +export function mergeNestedDeploymentEnv( + ...parts: Array | null | undefined> +): Record | null { + const env: Record = {}; + + for (const part of parts) { + if (part) { + Object.assign(env, part); + } + } + + return Object.keys(env).length > 0 ? env : null; +} + +export function serializeNestedDeploymentEnv( + env: Record, +): string { + return JSON.stringify(env); +} + +/** + * Parses the launcher-provided JSON. Anything that is not a non-empty flat + * object of valid env var names to strings, or that names a compute provider + * Roomote does not know, is ignored rather than expanded into the nested + * shell. + */ +export function parseNestedDeploymentEnv( + raw: string | undefined, +): Record | null { + if (!raw?.trim()) { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + const env: Record = {}; + + for (const [name, value] of Object.entries(parsed)) { + if (typeof value !== 'string' || !VALID_ENV_VAR_NAME.test(name)) { + return null; + } + + env[name] = value; + } + + if (Object.keys(env).length === 0) { + return null; + } + + const provider = env.DEFAULT_COMPUTE_PROVIDER; + + if (provider !== undefined && !isComputeProvider(provider)) { + return null; + } + + return env; +}