diff --git a/.roomote/environments/roomote.yaml b/.roomote/environments/roomote.yaml index 09170738a..6a7a2d7fc 100644 --- a/.roomote/environments/roomote.yaml +++ b/.roomote/environments/roomote.yaml @@ -20,6 +20,13 @@ 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 +# 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 @@ -27,6 +34,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..97f8654ae 100644 --- a/apps/docs/environments/definition.mdx +++ b/apps/docs/environments/definition.mdx @@ -153,6 +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 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). | @@ -452,6 +454,36 @@ oidc: Each `token_file` must be an absolute path and must be unique across targets. +## Nested deployments + +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 +``` + +- `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 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..11184bf8c 100644 --- a/apps/web/src/components/settings/environments/EnvironmentPreview.tsx +++ b/apps/web/src/components/settings/environments/EnvironmentPreview.tsx @@ -409,6 +409,27 @@ function getAdvancedItems(config: EnvironmentConfig) { ); } + if (config.inherit_compute || config.inherit_source_control) { + items.push( +
+
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} +
, + ); + } + 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..e535a52e8 100644 --- a/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts +++ b/apps/web/src/components/settings/environments/YamlEnvironmentEditor.test.ts @@ -195,4 +195,27 @@ describe('configToYaml', () => { }); expect(yaml).toContain('oidc:'); }); + + 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(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 f13169b85..f405a5fa2 100644 --- a/apps/web/src/components/settings/environments/yaml-utils.ts +++ b/apps/web/src/components/settings/environments/yaml-utils.ts @@ -99,6 +99,14 @@ export function configToYaml(config: EnvironmentConfig): string { cleanConfig.oidc = config.oidc; } + if (config.inherit_compute !== undefined) { + 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 6a7b64b83..45c6d346b 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_DEPLOYMENT_ENV: nestedComputeEnv, + R_MODEL: 'roomote/openai/outer-model', + }); + + await setup({ + mode: 'directDispatch', + workspace: { + ...environmentWorkspaceOptions, + userEnvVars: { + FOO: 'bar', + R_NESTED_DEPLOYMENT_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_DEPLOYMENT_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__/snapshot.test.ts b/apps/worker/src/commands/__tests__/snapshot.test.ts index 559d1c3b8..6f4e14265 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_DEPLOYMENT_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_DEPLOYMENT_ENV: nestedComputeEnv }), + undefined, + expect.objectContaining({ omitInheritedModelRuntimeEnvFromShell: true }), + ); + expect(mockSetup).toHaveBeenCalledWith( + expect.objectContaining({ + workspace: expect.objectContaining({ + envVars: expect.objectContaining({ + R_NESTED_DEPLOYMENT_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/__tests__/utils.test.ts b/apps/worker/src/commands/__tests__/utils.test.ts index 31d578733..c5e4c200d 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_DEPLOYMENT_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..c253bbc45 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_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, + parseNestedDeploymentEnv, } 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 nestedDeploymentEnv = + parseNestedDeploymentEnv(envVars[NESTED_DEPLOYMENT_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_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) && @@ -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 (nestedDeploymentEnv) { + Object.assign(nestedEnvironmentEnvVars, nestedDeploymentEnv); + } + 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 nestedDeploymentEnv = parseNestedDeploymentEnv( + runtimeEnv[NESTED_DEPLOYMENT_ENV_VAR_NAME], + ); + if ( + SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME in runtimeEnv || + NESTED_DEPLOYMENT_ENV_VAR_NAME in runtimeEnv + ) { delete runtimeEnv[SANDBOX_OPENROUTER_API_KEY_ENV_VAR_NAME]; + delete runtimeEnv[NESTED_DEPLOYMENT_ENV_VAR_NAME]; workerEnv.setRuntimeEnv(runtimeEnv); } @@ -204,11 +228,16 @@ export async function setup({ ? buildEnvironmentWorkspaceEnvVars( inheritedWorkspaceEnvVars, sandboxOpenRouterApiKey ?? workerEnv.sandboxOpenRouterApiKey, + nestedDeploymentEnv, ) : inheritedWorkspaceEnvVars, userEnvVars: isEnvironmentWorkspace && workspaceOpts.userEnvVars - ? buildEnvironmentWorkspaceEnvVars(workspaceOpts.userEnvVars) + ? buildEnvironmentWorkspaceEnvVars( + workspaceOpts.userEnvVars, + undefined, + nestedDeploymentEnv, + ) : workspaceOpts.userEnvVars, }; let result: PrepareWorkspaceResult | undefined; 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); diff --git a/apps/worker/src/commands/utils/env-vars.ts b/apps/worker/src/commands/utils/env-vars.ts index d3f3b165f..516cdc687 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_DEPLOYMENT_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_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 52402414e..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,6 +4116,7 @@ describe('runTask', () => { } as never, envVars: { SANDBOX_OPENROUTER_API_KEY: 'dequeued-sandbox-key', + R_NESTED_DEPLOYMENT_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_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 b28d784e1..ceec31eab 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_DEPLOYMENT_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_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 cc44190d2..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 @@ -12,6 +12,10 @@ const { mockCreateTaskRunBitbucketCredentials, mockResolveSandboxModelRuntimeEnv, mockTaskRunsFindFirst, + mockEnvironmentsFindFirst, + mockEnvironmentVariablesSelectWhere, + mockResolveDefaultComputeProvider, + mockResolveComputeProviderEnvValues, mockNotifySourceRunOnSettle, mockCaptureTaskSettled, } = vi.hoisted(() => ({ @@ -25,6 +29,12 @@ const { mockCreateTaskRunBitbucketCredentials: vi.fn(), 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(), mockCaptureTaskSettled: vi.fn(), })); @@ -43,15 +53,28 @@ vi.mock('@roomote/db/server', () => ({ taskRuns: { findFirst: (...args: unknown[]) => mockTaskRunsFindFirst(...args), }, + environments: { + findFirst: (...args: unknown[]) => mockEnvironmentsFindFirst(...args), + }, }, 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[]) => + mockResolveComputeProviderEnvValues(...args), repositories: { fullName: 'repositories.fullName', sourceControlProvider: 'repositories.sourceControlProvider', @@ -1008,6 +1031,171 @@ 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', + }, + { nestedDeploymentEnvironmentId: 'env-nested' }, + ); + + expect(envVars).not.toHaveProperty('MODAL_TOKEN_SECRET'); + expect(envVars.MY_APP_CONFIG).toBe('value'); + 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', + }); + expect(mockResolveComputeProviderEnvValues).toHaveBeenCalledWith( + 'modal', + expect.objectContaining({ runtimeEnv: expect.any(Object) }), + ); + }); + + 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' }, + { nestedDeploymentEnvironmentId: 'env-plain' }, + ); + + expect(envVars).not.toHaveProperty('R_NESTED_DEPLOYMENT_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' }, + { 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:/, ''), + ); + + 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 () => { + mockResolveSandboxModelRuntimeEnv.mockResolvedValueOnce({}); + mockEnvironmentsFindFirst.mockClear(); + + const envVars = await fetchResolvedRuntimeEnvVars({ + R_NESTED_DEPLOYMENT_ENV: '{"DEFAULT_COMPUTE_PROVIDER":"modal"}', + MY_APP_CONFIG: 'value', + }); + + expect(envVars).not.toHaveProperty('R_NESTED_DEPLOYMENT_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..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,6 +259,7 @@ describe('dequeueResumeTaskRun', () => { { sourceControlProvider: ['gitlab', 'github'], includeSandboxOpenRouterApiKey: true, + 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 ba15240d7..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,9 +3,15 @@ import { DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES, DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, INFERENCE_GATEWAY_KEYS_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, + buildNestedSourceControlEnv, + mergeNestedDeploymentEnv, + serializeNestedDeploymentEnv, parseInferenceGatewayKeys, parseModelProviderEnvKeys, RunStatus, @@ -20,8 +26,12 @@ import { import { type TaskRun, db, + environments, + environmentVariables, taskRuns, markTaskStartParallelCountEndedAt, + resolveComputeProviderEnvValues, + resolveDefaultComputeProvider, resolveSandboxModelRuntimeEnv, resolveWorkspaceSourceControlProvider, resolveWorkspaceSourceControlHost, @@ -30,9 +40,11 @@ import { stringifyDecryptedEnvVarValue, syncTaskStateFromRuns, eq, + inArray, sql, } from '@roomote/db/server'; import { captureTaskSettled } from '@roomote/telemetry/server'; +import { Env } from '@roomote/env'; import { decryptSecrets } from '@roomote/db/encryption'; import { createTaskRunWorkerGitHubTokenWithMetadata, @@ -263,11 +275,123 @@ function redactModelRuntimeManagedEnvVars( ); } +/** + * 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 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 (!inheritCompute && !inheritSourceControl) { + return null; + } + + 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, + }), + }); + } + + 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( deploymentEnvVars?: Record, options?: { sourceControlProvider?: SourceControlProvider | SourceControlProvider[]; includeSandboxOpenRouterApiKey?: boolean; + /** + * Environment the task runs in. When it opted in with `inherit_compute`, + * the result carries NESTED_DEPLOYMENT_ENV_VAR_NAME for the nested app. + */ + nestedDeploymentEnvironmentId?: string; }, ): Promise> { const envVars = @@ -288,15 +412,25 @@ export async function fetchResolvedRuntimeEnvVars( ), ); + const nestedComputeEnvVar = options?.nestedDeploymentEnvironmentId + ? await resolveNestedDeploymentEnvVar(options.nestedDeploymentEnvironmentId) + : null; + const environmentTaskEnvVars = nestedComputeEnvVar + ? { + ...resolvedEnvVars, + [NESTED_DEPLOYMENT_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..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,6 +428,7 @@ export const dequeueResumeTaskRun = async ( includeSandboxOpenRouterApiKey: Boolean( 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 5503928a5..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,6 +571,8 @@ export const dequeueTaskRun = async ( includeSandboxOpenRouterApiKey: Boolean( 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 f800ef19f..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,6 +45,7 @@ export async function fetchSnapshotEnv( const deploymentEnvVars = await fetchResolvedRuntimeEnvVars(undefined, { sourceControlProvider: sourceControlProviders, includeSandboxOpenRouterApiKey: Boolean(taskRun.payload.environmentId), + nestedDeploymentEnvironmentId: taskRun.payload.environmentId, }); const sourceControlToken = await createSourceControlTokenForTaskRun( 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 68265e601..d4bb5f97b 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_DEPLOYMENT_ENV_VAR_NAME } from './nested-deployment-env'; 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_DEPLOYMENT_ENV_VAR_NAME, ], ); diff --git a/packages/types/src/environment-config.ts b/packages/types/src/environment-config.ts index 51ccd52a8..ba8b41765 100644 --- a/packages/types/src/environment-config.ts +++ b/packages/types/src/environment-config.ts @@ -736,6 +736,24 @@ 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(), + /** + * 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 407742f77..e9c7cba6e 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-deployment-env'; export * from './inference-provider-retry'; export * from './model-provider-config'; export * from './openai-compatible-providers'; 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; +}