From fdcd414fa24bbc573a4e3323542626542d8f635a Mon Sep 17 00:00:00 2001 From: Roomote Date: Tue, 4 Aug 2026 21:46:24 +0000 Subject: [PATCH 1/3] fix: scope environment tools to setup tasks --- .../__tests__/environment-write-auth.test.ts | 41 ++++ .../__tests__/recordVerification.test.ts | 41 ++++ .../environments/createEnvironment.ts | 42 ++++ .../environments/recordVerification.ts | 19 +- .../environments/updateEnvironment.ts | 6 + .../tasks/__tests__/launchTask.test.ts | 37 ++++ apps/api/src/handlers/tasks/launchTask.ts | 7 + .../trpc/commands/environments/index.test.ts | 2 + .../src/trpc/commands/environments/index.ts | 2 + .../trpc/commands/preview-settings/index.ts | 1 + .../task-preview-status.test.ts | 9 +- .../src/trpc/commands/setup-new/index.test.ts | 1 + apps/web/src/trpc/commands/setup-new/index.ts | 3 + .../src/trpc/commands/task-runs/index.test.ts | 17 ++ apps/web/src/trpc/commands/task-runs/index.ts | 22 +- apps/web/src/trpc/routers/_app.ts | 6 +- .../__tests__/environment-management.test.ts | 101 +++++++++ apps/worker/src/environment-management.ts | 42 ++++ .../__tests__/tool-descriptions.test.ts | 26 ++- .../src/mcp/roomote-mcp-server/index.ts | 195 +++++++++--------- apps/worker/src/run-task/run-task.ts | 56 ++--- apps/worker/src/run-task/types.ts | 3 +- .../server/lib/task-runs/dequeue-task-run.ts | 2 + packages/types/src/environment-management.ts | 86 ++++++++ packages/types/src/index.ts | 1 + packages/types/src/task-runs.ts | 7 + 26 files changed, 641 insertions(+), 134 deletions(-) create mode 100644 apps/worker/src/__tests__/environment-management.test.ts create mode 100644 apps/worker/src/environment-management.ts create mode 100644 packages/types/src/environment-management.ts diff --git a/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts b/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts index 3b57e0768..86950310d 100644 --- a/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts +++ b/apps/api/src/handlers/environments/__tests__/environment-write-auth.test.ts @@ -106,6 +106,13 @@ describe.each([ beforeEach(() => { vi.clearAllMocks(); mockUsersFindFirst.mockResolvedValue({ role: 'admin', deletedAt: null }); + mockTaskRunFindFirst.mockResolvedValue({ + payloadKind: 'standard', + payload: { + environmentManagementMode: method === 'POST' ? 'create' : 'update', + }, + task: { workflow: 'setup_onboarding' }, + }); }); it('rejects a deployment-principal run token with no live acting user', async () => { @@ -213,6 +220,34 @@ describe.each([ expect(response.status).toBe(403); expect(mockUsersFindFirst).toHaveBeenCalledOnce(); }); + + it.each([ + ['ordinary task', 'standard', {}], + [ + 'snapshot resume', + 'snapshot_resume', + { environmentManagementMode: method === 'POST' ? 'create' : 'update' }, + ], + ])( + 'rejects an admin run token from an %s', + async (_label, payloadKind, payload) => { + mockTaskRunFindFirst + .mockResolvedValueOnce({ actingUserId: 'user-live-admin' }) + .mockResolvedValueOnce({ + payloadKind, + payload, + task: { workflow: 'standard' }, + }); + + const app = createApp(deploymentRunToken()); + const response = await app.request(invalidBodyRequest(method, path)); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ + error: 'This task is not allowed to perform that environment action.', + }); + }, + ); }); describe.each([ @@ -273,6 +308,12 @@ describe('createEnvironment attribution', () => { // attachEnvironmentIdToTaskRun payload sync, which can no-op. mockTaskRunFindFirst .mockResolvedValueOnce({ actingUserId: 'user-live' }) + .mockResolvedValueOnce({ + payloadKind: 'standard', + payload: { environmentManagementMode: 'create' }, + task: { workflow: 'setup_onboarding' }, + }) + .mockResolvedValueOnce(null) .mockResolvedValueOnce(null); const app = createApp({ diff --git a/apps/api/src/handlers/environments/__tests__/recordVerification.test.ts b/apps/api/src/handlers/environments/__tests__/recordVerification.test.ts index cbaf26258..eb89d4584 100644 --- a/apps/api/src/handlers/environments/__tests__/recordVerification.test.ts +++ b/apps/api/src/handlers/environments/__tests__/recordVerification.test.ts @@ -79,7 +79,9 @@ describe('recordVerification', () => { it('records a successful verification for a task marked to verify the environment', async () => { mockTaskRunFindFirst.mockResolvedValueOnce({ taskId: 'task-abc', + payloadKind: 'standard', payload: { verifiesEnvironmentId: 'env-1' }, + task: { workflow: 'standard' }, }); const app = createApp(runToken()); @@ -106,7 +108,9 @@ describe('recordVerification', () => { it('authorizes the environment-setup task via the environmentDefinitionId marker', async () => { mockTaskRunFindFirst.mockResolvedValueOnce({ taskId: 'task-setup', + payloadKind: 'standard', payload: { environmentDefinitionId: 'env-1' }, + task: { workflow: 'setup_onboarding' }, }); const app = createApp(runToken()); @@ -125,10 +129,45 @@ describe('recordVerification', () => { ); }); + it.each([ + [ + 'ordinary task', + 'standard', + { environmentDefinitionId: 'env-1' }, + 'standard', + ], + [ + 'snapshot resume', + 'snapshot_resume', + { environmentManagementMode: 'verify', verifiesEnvironmentId: 'env-1' }, + 'standard', + ], + ])( + 'rejects verification from an %s', + async (_label, payloadKind, payload, workflow) => { + mockTaskRunFindFirst.mockResolvedValueOnce({ + taskId: 'task-denied', + payloadKind, + payload, + task: { workflow }, + }); + + const app = createApp(runToken()); + const response = await app.request( + verificationRequest('env-1', { success: true }), + ); + + expect(response.status).toBe(403); + expect(mockRecordEnvironmentVerification).not.toHaveBeenCalled(); + }, + ); + it('rejects a task that is not authorized for the target environment', async () => { mockTaskRunFindFirst.mockResolvedValueOnce({ taskId: 'task-other', + payloadKind: 'standard', payload: { verifiesEnvironmentId: 'env-other' }, + task: { workflow: 'standard' }, }); const app = createApp(runToken()); @@ -159,7 +198,9 @@ describe('recordVerification', () => { it('returns 409 when the verification attempt is superseded', async () => { mockTaskRunFindFirst.mockResolvedValueOnce({ taskId: 'task-stale', + payloadKind: 'standard', payload: { verifiesEnvironmentId: 'env-1' }, + task: { workflow: 'standard' }, }); mockRecordEnvironmentVerification.mockResolvedValueOnce({ recorded: false, diff --git a/apps/api/src/handlers/environments/createEnvironment.ts b/apps/api/src/handlers/environments/createEnvironment.ts index 937e96379..6b6ffc67a 100644 --- a/apps/api/src/handlers/environments/createEnvironment.ts +++ b/apps/api/src/handlers/environments/createEnvironment.ts @@ -13,12 +13,15 @@ import { users, } from '@roomote/db/server'; import { + type EnvironmentManagementAction, type TaskPayload, + canPerformEnvironmentManagementAction, environmentConfigSchema, getAmbiguousEnvironmentRepositoryError, getDuplicateEnvironmentRepositoryConfigError, getMissingEnvironmentRepositoryError, getEnvironmentRepositoryInstallationError, + resolveEnvironmentManagementMode, } from '@roomote/types'; import { captureActivationEnvironmentSaved } from '@roomote/telemetry/server'; @@ -35,6 +38,8 @@ export const EVAL_ENVIRONMENT_WRITE_ERROR = 'isEval is reserved for internal eval environments.'; export const ENVIRONMENT_ADMIN_REQUIRED_ERROR = 'Admin access is required to create or update environments.'; +export const ENVIRONMENT_TASK_CAPABILITY_ERROR = + 'This task is not allowed to perform that environment action.'; type PostgresErrorLike = { code?: string; @@ -140,6 +145,39 @@ export async function canAdministerEnvironments( return user?.role === 'admin' && user.deletedAt == null; } +export async function canTaskRunManageEnvironments( + auth: McpAuth, + action: EnvironmentManagementAction, +): Promise { + if (auth.authContext.tokenType !== 'run') { + return true; + } + + const runId = extractRunId(auth); + if (!runId) { + return false; + } + + const taskRun = await db.query.taskRuns.findFirst({ + where: eq(taskRuns.id, runId), + columns: { payload: true, payloadKind: true }, + with: { task: { columns: { workflow: true } } }, + }); + + if (!taskRun) { + return false; + } + + return canPerformEnvironmentManagementAction( + resolveEnvironmentManagementMode({ + payloadKind: taskRun.payloadKind, + payload: taskRun.payload, + workflow: taskRun.task.workflow, + }), + action, + ); +} + /** * When environment creation/update is triggered by a running task run, persist * the resulting environment id on that job payload so the UI can resolve @@ -234,6 +272,10 @@ export async function createEnvironment( return c.json({ error: ENVIRONMENT_ADMIN_REQUIRED_ERROR }, 403); } + if (!(await canTaskRunManageEnvironments(auth, 'create'))) { + return c.json({ error: ENVIRONMENT_TASK_CAPABILITY_ERROR }, 403); + } + let body: unknown; try { diff --git a/apps/api/src/handlers/environments/recordVerification.ts b/apps/api/src/handlers/environments/recordVerification.ts index dfa624b33..e0d2096a7 100644 --- a/apps/api/src/handlers/environments/recordVerification.ts +++ b/apps/api/src/handlers/environments/recordVerification.ts @@ -7,7 +7,11 @@ import { recordEnvironmentVerification, taskRuns, } from '@roomote/db/server'; -import { getEnvironmentDefinitionIdFromPayload } from '@roomote/types'; +import { + canPerformEnvironmentManagementAction, + getEnvironmentDefinitionIdFromPayload, + resolveEnvironmentManagementMode, +} from '@roomote/types'; import type { Variables } from '../../types'; import type { McpAuth } from '../mcp/middleware'; @@ -81,7 +85,8 @@ async function resolveVerificationCaller( const taskRun = await db.query.taskRuns.findFirst({ where: eq(taskRuns.id, runId), - columns: { taskId: true, payload: true }, + columns: { taskId: true, payload: true, payloadKind: true }, + with: { task: { columns: { workflow: true } } }, }); if (!taskRun?.taskId) { @@ -93,6 +98,16 @@ async function resolveVerificationCaller( } const payload = taskRun.payload as Record; + const mode = resolveEnvironmentManagementMode({ + payloadKind: taskRun.payloadKind, + payload, + workflow: taskRun.task.workflow, + }); + + if (!canPerformEnvironmentManagementAction(mode, 'record_verification')) { + return null; + } + const marker = payload.verifiesEnvironmentId; const authorizedEnvironmentId = diff --git a/apps/api/src/handlers/environments/updateEnvironment.ts b/apps/api/src/handlers/environments/updateEnvironment.ts index 016aac983..baf64415a 100644 --- a/apps/api/src/handlers/environments/updateEnvironment.ts +++ b/apps/api/src/handlers/environments/updateEnvironment.ts @@ -21,9 +21,11 @@ import { logHandlerError } from '../utils'; import { DUPLICATE_ENVIRONMENT_NAME_ERROR, ENVIRONMENT_ADMIN_REQUIRED_ERROR, + ENVIRONMENT_TASK_CAPABILITY_ERROR, EVAL_ENVIRONMENT_WRITE_ERROR, attachEnvironmentIdToTaskRun, canAdministerEnvironments, + canTaskRunManageEnvironments, getEnvironmentRepositoryConfigError, isEnvironmentNameUniqueViolation, resolveCallingVerificationTaskId, @@ -50,6 +52,10 @@ export async function updateEnvironment( return c.json({ error: ENVIRONMENT_ADMIN_REQUIRED_ERROR }, 403); } + if (!(await canTaskRunManageEnvironments(auth, 'update'))) { + return c.json({ error: ENVIRONMENT_TASK_CAPABILITY_ERROR }, 403); + } + const id = c.req.param('id'); if (!id) { diff --git a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts index ba8dd7cde..b3f7c7d87 100644 --- a/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/launchTask.test.ts @@ -441,6 +441,40 @@ describe('launchTask', () => { expect(mockEnqueueTask).not.toHaveBeenCalled(); }); + it.each([ + ['create', undefined], + ['update', '6f1f3f0a-9f5e-4d2a-8f4e-1a2b3c4d5e6f'], + ] as const)( + 'stamps %s capability on environment-definition tasks', + async (mode, environmentId) => { + mockEnqueueTask.mockResolvedValue({ id: 106, taskId: 'task-env' }); + mockRepositoriesFindMany.mockResolvedValue([ + { fullName: 'octo/github-repo', installationId: 1 }, + ]); + mockResolveWorkspaceRepositoryProviders.mockResolvedValue({ + 'octo/github-repo': 'github', + }); + + const app = createApp(authContext); + const response = await app.request( + new Request('http://localhost/tasks', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'environment-definition', + selectedRepositories: ['octo/github-repo'], + ...(environmentId ? { environmentId } : {}), + }), + }), + ); + + expect(response.status).toBe(200); + expect(mockEnqueueTask.mock.calls[0]?.[0].task.payload).toEqual( + expect.objectContaining({ environmentManagementMode: mode }), + ); + }, + ); + it('allows non-admin members to launch standard tasks', async () => { mockGetMembershipRole.mockResolvedValue('org:member'); mockEnqueueTask.mockResolvedValue({ id: 102, taskId: 'task-member' }); @@ -456,5 +490,8 @@ describe('launchTask', () => { expect(response.status).toBe(200); expect(mockEnqueueTask).toHaveBeenCalledTimes(1); + expect(mockEnqueueTask.mock.calls[0]?.[0].task.payload).not.toHaveProperty( + 'environmentManagementMode', + ); }); }); diff --git a/apps/api/src/handlers/tasks/launchTask.ts b/apps/api/src/handlers/tasks/launchTask.ts index caaa32abb..8626aa05e 100644 --- a/apps/api/src/handlers/tasks/launchTask.ts +++ b/apps/api/src/handlers/tasks/launchTask.ts @@ -267,6 +267,13 @@ export async function launchTask( const basePayload = { ...workspacePayload, + ...(requestedType === 'environment-definition' + ? { + environmentManagementMode: body.environmentId + ? ('update' as const) + : ('create' as const), + } + : {}), ...(sourceControlProvider ? { sourceControlProvider } : {}), branch: body.branch, sha: body.sha, diff --git a/apps/web/src/trpc/commands/environments/index.test.ts b/apps/web/src/trpc/commands/environments/index.test.ts index f2297c035..c6896a57d 100644 --- a/apps/web/src/trpc/commands/environments/index.test.ts +++ b/apps/web/src/trpc/commands/environments/index.test.ts @@ -176,6 +176,7 @@ describe('startEnvironmentDefinitionTaskCommand', () => { type: TaskPayloadKind.StandardTask, payload: expect.objectContaining({ selectedRepositories: ['acme/web', 'acme/api'], + environmentManagementMode: 'create', }), }), initiator: { kind: 'user', userId: 'user-1' }, @@ -313,6 +314,7 @@ describe('retryEnvironmentVerificationCommand', () => { payload: expect.objectContaining({ environmentId: 'env-1', verifiesEnvironmentId: 'env-1', + environmentManagementMode: 'verify', }), }), workflow: 'standard', diff --git a/apps/web/src/trpc/commands/environments/index.ts b/apps/web/src/trpc/commands/environments/index.ts index 204ed725a..ac9e0902d 100644 --- a/apps/web/src/trpc/commands/environments/index.ts +++ b/apps/web/src/trpc/commands/environments/index.ts @@ -937,6 +937,7 @@ export async function startEnvironmentDefinitionTaskCommand( type: TaskPayloadKind.StandardTask, payload: { ...workspacePayload, + environmentManagementMode: input.environmentId ? 'update' : 'create', ...(input.environmentId ? { environmentDefinitionId: input.environmentId } : {}), @@ -1107,6 +1108,7 @@ export async function retryEnvironmentVerificationCommand( repo: ALL_REPOSITORIES, environmentId: environment.id, verifiesEnvironmentId: environment.id, + environmentManagementMode: 'verify', description: prompt, }, }, diff --git a/apps/web/src/trpc/commands/preview-settings/index.ts b/apps/web/src/trpc/commands/preview-settings/index.ts index 4136aa17d..6e920c8f7 100644 --- a/apps/web/src/trpc/commands/preview-settings/index.ts +++ b/apps/web/src/trpc/commands/preview-settings/index.ts @@ -665,6 +665,7 @@ export async function startPreviewSetupTaskCommand( repo: ALL_REPOSITORIES, environmentId: environment.id, environmentDefinitionId: environment.id, + environmentManagementMode: 'update', description: prompt, }, }, diff --git a/apps/web/src/trpc/commands/preview-settings/task-preview-status.test.ts b/apps/web/src/trpc/commands/preview-settings/task-preview-status.test.ts index b6e110f5b..b624a0f15 100644 --- a/apps/web/src/trpc/commands/preview-settings/task-preview-status.test.ts +++ b/apps/web/src/trpc/commands/preview-settings/task-preview-status.test.ts @@ -417,10 +417,17 @@ describe('startPreviewSetupTaskCommand', () => { const enqueueInput = vi.mocked(enqueueTask).mock.calls.at(-1)?.[0] as { title: string; - task: { payload: { description: string; environmentId?: string } }; + task: { + payload: { + description: string; + environmentId?: string; + environmentManagementMode?: string; + }; + }; }; expect(enqueueInput.title).toMatch(/^Fix live previews: /); expect(enqueueInput.task.payload.environmentId).toBe(environment.id); + expect(enqueueInput.task.payload.environmentManagementMode).toBe('update'); // Repair prompts are standalone coding-task briefs, not $environment-setup // skill invocations: the skill prohibits application source changes. expect( diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts index da71d0341..7204e8dbd 100644 --- a/apps/web/src/trpc/commands/setup-new/index.test.ts +++ b/apps/web/src/trpc/commands/setup-new/index.test.ts @@ -1678,6 +1678,7 @@ describe('setup-new onboarding task start command', () => { communicationProvider: 'discord', communicationChannelId: 'dm-channel-1', communicationMessageId: 'dm-message-1', + environmentManagementMode: 'create', }), }), }), diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts index 35e19a143..5da72b1d6 100644 --- a/apps/web/src/trpc/commands/setup-new/index.ts +++ b/apps/web/src/trpc/commands/setup-new/index.ts @@ -2764,6 +2764,7 @@ export async function startSetupNewOnboardingTaskCommand( type: TaskPayloadKind.StandardTask, payload: { ...workspacePayload, + environmentManagementMode: 'create', ...(setupSourceControlProvider ? { sourceControlProvider: setupSourceControlProvider } : {}), @@ -2852,6 +2853,7 @@ export async function startSetupNewOnboardingTaskCommand( type: TaskPayloadKind.StandardTask, payload: { ...workspacePayload, + environmentManagementMode: 'create', ...(setupSourceControlProvider ? { sourceControlProvider: setupSourceControlProvider } : {}), @@ -2933,6 +2935,7 @@ export async function startSetupNewOnboardingTaskCommand( type: TaskPayloadKind.SlackAppMention, payload: { ...workspacePayload, + environmentManagementMode: 'create', ...(setupSourceControlProvider ? { sourceControlProvider: setupSourceControlProvider } : {}), diff --git a/apps/web/src/trpc/commands/task-runs/index.test.ts b/apps/web/src/trpc/commands/task-runs/index.test.ts index 15fee8b0b..8d13bddaa 100644 --- a/apps/web/src/trpc/commands/task-runs/index.test.ts +++ b/apps/web/src/trpc/commands/task-runs/index.test.ts @@ -219,6 +219,23 @@ describe('createStandardTaskRunCommand', () => { ); }); + it('strips privileged environment-management markers from manual tasks', async () => { + await createStandardTaskRunCommand(auth, { + payload: { + repo: 'acme/app', + description: 'Ordinary task', + environmentDefinitionId: '7bb91386-6282-4c98-9b31-0eb181116822', + environmentManagementMode: 'create', + verifiesEnvironmentId: '7bb91386-6282-4c98-9b31-0eb181116822', + }, + }); + + const payload = mockEnqueueTask.mock.calls[0]?.[0].task.payload; + expect(payload).not.toHaveProperty('environmentDefinitionId'); + expect(payload).not.toHaveProperty('environmentManagementMode'); + expect(payload).not.toHaveProperty('verifiesEnvironmentId'); + }); + it('allows an all-repositories launch without an environment', async () => { const result = await createStandardTaskRunCommand(auth, { payload: { diff --git a/apps/web/src/trpc/commands/task-runs/index.ts b/apps/web/src/trpc/commands/task-runs/index.ts index 4bf343023..40b0bc78f 100644 --- a/apps/web/src/trpc/commands/task-runs/index.ts +++ b/apps/web/src/trpc/commands/task-runs/index.ts @@ -339,6 +339,13 @@ export async function createStandardTaskRunCommand( }; } + const { + environmentDefinitionId: _environmentDefinitionId, + environmentManagementMode: _environmentManagementMode, + verifiesEnvironmentId: _verifiesEnvironmentId, + ...payload + } = input.payload; + const evalSelection = resolveEvalHarnessSelection({ harness: input.harness, model: input.model, @@ -348,9 +355,8 @@ export async function createStandardTaskRunCommand( throw new Error(evalSelection.error); } - const selectedRepositoryFullNames = getManualTaskRepositoryFullNames( - input.payload, - ); + const selectedRepositoryFullNames = + getManualTaskRepositoryFullNames(payload); const availableRepositories = selectedRepositoryFullNames.length === 0 ? [] @@ -359,26 +365,24 @@ export async function createStandardTaskRunCommand( selectedRepositoryFullNames.includes(repository.fullName), ); const sourceControlProvider = - input.payload.sourceControlProvider ?? + payload.sourceControlProvider ?? resolveSelectedRepositorySourceControlProvider( selectedRepositories, selectedRepositoryFullNames, ) ?? - (await resolveEnvironmentSourceControlProvider( - input.payload.environmentId, - )); + (await resolveEnvironmentSourceControlProvider(payload.environmentId)); const task: StandardTask = { harness: evalSelection.harness ?? input.harness, computeProvider: input.computeProvider, type: TaskPayloadKind.StandardTask, payload: { - ...input.payload, + ...payload, ...(sourceControlProvider ? { sourceControlProvider } : {}), ...(evalSelection.harnessModelOverrides ? { harnessModelOverrides: { - ...(input.payload.harnessModelOverrides ?? {}), + ...(payload.harnessModelOverrides ?? {}), ...evalSelection.harnessModelOverrides, }, } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 302958fd5..66f7c5aed 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -394,7 +394,11 @@ import { } from '../commands/product-releases'; import { getStatuspageIncident } from '@roomote/slack'; -const standardTaskPayloadSchema = standardTaskSchema.shape.payload; +const standardTaskPayloadSchema = standardTaskSchema.shape.payload.omit({ + environmentDefinitionId: true, + environmentManagementMode: true, + verifiesEnvironmentId: true, +}); const stateRecordSchema = z.record(z.string()); function assertAdmin(auth: { isAdmin: boolean }) { diff --git a/apps/worker/src/__tests__/environment-management.test.ts b/apps/worker/src/__tests__/environment-management.test.ts new file mode 100644 index 000000000..66828eef6 --- /dev/null +++ b/apps/worker/src/__tests__/environment-management.test.ts @@ -0,0 +1,101 @@ +import { TaskPayloadKind } from '@roomote/types'; + +import { + applyEnvironmentManagementRuntimeEnv, + buildEnvironmentManagementRuntimeEnv, + getEnvironmentManagementActions, + resolveEnvironmentManagementMode, +} from '../environment-management'; + +describe('environment management capabilities', () => { + it.each([ + ['create', ['create', 'update', 'record_verification']], + ['update', ['update', 'record_verification']], + ['verify', ['record_verification']], + ] as const)('maps %s capability to its actions', (mode, actions) => { + expect(getEnvironmentManagementActions(mode)).toEqual(actions); + }); + + it('uses the explicit trusted task capability', () => { + expect( + resolveEnvironmentManagementMode({ + payloadKind: TaskPayloadKind.StandardTask, + payload: { environmentManagementMode: 'update' }, + workflow: 'standard', + }), + ).toBe('update'); + }); + + it('builds the restricted worker runtime environment', () => { + expect( + buildEnvironmentManagementRuntimeEnv({ + payloadKind: TaskPayloadKind.StandardTask, + payload: { environmentManagementMode: 'create' }, + }), + ).toEqual({ ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE: 'create' }); + }); + + it('removes an untrusted environment-management override', () => { + expect( + applyEnvironmentManagementRuntimeEnv( + { + PATH: '/usr/bin', + ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE: 'create', + }, + { + payloadKind: TaskPayloadKind.StandardTask, + payload: {}, + workflow: 'standard', + }, + ), + ).toEqual({ PATH: '/usr/bin' }); + }); + + it.each([ + [{ verifiesEnvironmentId: 'env-1' }, 'verify'], + [{ environmentDefinitionId: 'env-1' }, 'update'], + [{ projectDefinitionEnvironmentId: 'env-1' }, 'update'], + [{ repo: 'acme/app' }, 'create'], + ] as const)( + 'supports an already-queued setup payload', + (payload, expected) => { + expect( + resolveEnvironmentManagementMode({ + payloadKind: TaskPayloadKind.StandardTask, + payload, + workflow: 'setup_onboarding', + }), + ).toBe(expected); + }, + ); + + it('omits environment management from ordinary tasks', () => { + expect( + resolveEnvironmentManagementMode({ + payloadKind: TaskPayloadKind.StandardTask, + payload: { environmentId: 'env-1' }, + workflow: 'standard', + }), + ).toBeNull(); + }); + + it('does not infer update capability from markers on ordinary tasks', () => { + expect( + resolveEnvironmentManagementMode({ + payloadKind: TaskPayloadKind.StandardTask, + payload: { environmentDefinitionId: 'env-1' }, + workflow: 'standard', + }), + ).toBeNull(); + }); + + it('does not implicitly inherit setup capability on snapshot resume', () => { + expect( + resolveEnvironmentManagementMode({ + payloadKind: TaskPayloadKind.SnapshotResume, + payload: { environmentManagementMode: 'create' }, + workflow: 'setup_onboarding', + }), + ).toBeNull(); + }); +}); diff --git a/apps/worker/src/environment-management.ts b/apps/worker/src/environment-management.ts new file mode 100644 index 000000000..e004ce35e --- /dev/null +++ b/apps/worker/src/environment-management.ts @@ -0,0 +1,42 @@ +import { + environmentManagementActions, + isEnvironmentManagementMode, + resolveEnvironmentManagementMode, +} from '@roomote/types'; + +export { + environmentManagementActions, + resolveEnvironmentManagementMode, +} from '@roomote/types'; + +export const ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE = + 'ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE'; + +export function buildEnvironmentManagementRuntimeEnv( + input: Parameters[0], +): Record { + const mode = resolveEnvironmentManagementMode(input); + + return mode ? { [ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE]: mode } : {}; +} + +export function applyEnvironmentManagementRuntimeEnv( + runtimeEnv: Record, + input: Parameters[0], +): Record { + const trustedRuntimeEnv = { ...runtimeEnv }; + delete trustedRuntimeEnv[ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE]; + + return { + ...trustedRuntimeEnv, + ...buildEnvironmentManagementRuntimeEnv(input), + }; +} + +export function getEnvironmentManagementActions( + value = process.env[ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE], +) { + return isEnvironmentManagementMode(value) + ? environmentManagementActions[value] + : null; +} diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index 206440ef4..55d2c6932 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -69,6 +69,7 @@ async function importRoomoteMcpServer( // the runner (e.g. when this suite itself runs inside a Roomote task) so // tests only see what they opt into. delete process.env.ROOMOTE_TASK_ID; + delete process.env.ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE; Object.assign(process.env, envOverrides); @@ -753,7 +754,9 @@ describe('roomote MCP tool descriptions', () => { }); it('serializes manage_environments definition as a plain string schema, not a union', async () => { - const { registeredTools } = await importRoomoteMcpServer(); + const { registeredTools } = await importRoomoteMcpServer({ + ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE: 'create', + }); const envTool = getRegisteredTool(registeredTools, 'manage_environments'); const definitionField = envTool.config.inputSchema .definition as unknown as z.ZodType; @@ -770,6 +773,27 @@ describe('roomote MCP tool descriptions', () => { expect(definitionField.description).toContain('YAML or JSON string'); }); + it.each([ + ['create', ['create', 'update', 'record_verification']], + ['update', ['update', 'record_verification']], + ['verify', ['record_verification']], + ])('restricts environment management for %s tasks', async (mode, actions) => { + const { registeredTools } = await importRoomoteMcpServer({ + ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE: mode, + }); + const envTool = getRegisteredTool(registeredTools, 'manage_environments'); + + expect(getInputSchemaField(envTool, 'action').options).toEqual(actions); + }); + + it('omits environment management from ordinary tasks', async () => { + const { registeredTools } = await importRoomoteMcpServer(); + + expect( + registeredTools.some((tool) => tool.name === 'manage_environments'), + ).toBe(false); + }); + it('documents hidden investigation context on task suggestions', () => { const source = readFileSync( path.resolve(thisDirPath, '../index.ts'), diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index 152544c1e..38b0cd9cc 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -23,6 +23,7 @@ import { initWorkerSentry, installWorkerFatalProcessHandlers, } from '../../monitoring/sentry.js'; +import { getEnvironmentManagementActions } from '../../environment-management.js'; import { handleCreatePlan } from './create-plan.js'; import { handleUpload } from './upload.js'; @@ -896,96 +897,110 @@ roomoteMcpServer.registerTool( }, ); -roomoteMcpServer.registerTool( - 'manage_environments', - { - title: 'Manage Environments', - description: `Create or update ${PRODUCT_NAME} environments, or record an environment verification result.`, - inputSchema: { - action: z - .enum(['create', 'update', 'record_verification']) - .describe('The environment action to perform'), - definition: z - .string() - .optional() - .describe( - 'Environment definition as a YAML or JSON string. Must satisfy EnvironmentConfig (e.g., include name and repositories). Required for "create" and "update".', - ), - environmentId: z - .string() - .optional() - .describe( - 'Existing environment ID. Required for "update" and "record_verification".', - ), - format: z - .enum(['auto', 'json', 'yaml']) - .optional() - .describe( - 'How to parse definition when it is a string. Defaults to auto (JSON first, then YAML).', - ), - name: z - .string() - .optional() - .describe('Optional name override applied after parsing definition.'), - description: z - .string() - .optional() - .describe( - 'Optional description override applied after parsing definition.', - ), - success: z - .boolean() - .optional() - .describe( - 'For "record_verification": whether the environment verification succeeded.', - ), - error: z - .string() - .optional() - .describe( - 'For "record_verification" with success=false: a short, user-safe failure message. Never include secrets or full environment YAML.', - ), - }, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, +const allowedEnvironmentManagementActions = getEnvironmentManagementActions(); + +if (allowedEnvironmentManagementActions) { + roomoteMcpServer.registerTool( + 'manage_environments', + { + title: 'Manage Environments', + description: `Create or update ${PRODUCT_NAME} environments, or record an environment verification result.`, + inputSchema: { + action: z + .enum(allowedEnvironmentManagementActions) + .describe('The environment action to perform'), + definition: z + .string() + .optional() + .describe( + 'Environment definition as a YAML or JSON string. Must satisfy EnvironmentConfig (e.g., include name and repositories). Required for "create" and "update".', + ), + environmentId: z + .string() + .optional() + .describe( + 'Existing environment ID. Required for "update" and "record_verification".', + ), + format: z + .enum(['auto', 'json', 'yaml']) + .optional() + .describe( + 'How to parse definition when it is a string. Defaults to auto (JSON first, then YAML).', + ), + name: z + .string() + .optional() + .describe('Optional name override applied after parsing definition.'), + description: z + .string() + .optional() + .describe( + 'Optional description override applied after parsing definition.', + ), + success: z + .boolean() + .optional() + .describe( + 'For "record_verification": whether the environment verification succeeded.', + ), + error: z + .string() + .optional() + .describe( + 'For "record_verification" with success=false: a short, user-safe failure message. Never include secrets or full environment YAML.', + ), + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, }, - }, - async (params): Promise => { - const config = getRoomoteConfig(); - if (!config) { - return errorResult('ROOMOTE_CLOUD_TOKEN environment variable not set'); - } + async (params): Promise => { + const config = getRoomoteConfig(); + if (!config) { + return errorResult('ROOMOTE_CLOUD_TOKEN environment variable not set'); + } - if (params.action === 'record_verification') { - if (typeof params.success !== 'boolean') { - return errorResult( - 'success (boolean) is required for record_verification', + if (params.action === 'record_verification') { + if (typeof params.success !== 'boolean') { + return errorResult( + 'success (boolean) is required for record_verification', + ); + } + + return handleRecordVerification( + { + environmentId: params.environmentId ?? '', + success: params.success, + error: params.error, + }, + config, ); } - return handleRecordVerification( - { - environmentId: params.environmentId ?? '', - success: params.success, - error: params.error, - }, - config, - ); - } + if (params.definition === undefined) { + return errorResult( + `definition is required for action "${params.action}"`, + ); + } - if (params.definition === undefined) { - return errorResult( - `definition is required for action "${params.action}"`, - ); - } + if (params.action === 'update') { + return handleUpdateEnvironment( + { + environmentId: params.environmentId ?? '', + definition: params.definition, + format: params.format, + name: params.name, + description: params.description, + }, + config, + ); + } - if (params.action === 'update') { - return handleUpdateEnvironment( + return handleCreateEnvironment( { - environmentId: params.environmentId ?? '', definition: params.definition, format: params.format, name: params.name, @@ -993,19 +1008,9 @@ roomoteMcpServer.registerTool( }, config, ); - } - - return handleCreateEnvironment( - { - definition: params.definition, - format: params.format, - name: params.name, - description: params.description, - }, - config, - ); - }, -); + }, + ); +} if (shouldRegisterEnvVarRequestTool()) { roomoteMcpServer.registerTool( diff --git a/apps/worker/src/run-task/run-task.ts b/apps/worker/src/run-task/run-task.ts index 0410e741d..23db57689 100644 --- a/apps/worker/src/run-task/run-task.ts +++ b/apps/worker/src/run-task/run-task.ts @@ -44,6 +44,7 @@ import { import { recordChatTurnStart } from '../mcp/roomote-mcp-server/chat-reply-satisfaction'; import { recordSandboxPromptSlackTurnStart } from '../sandbox-server/procedures/slackReplyTurnTracking'; import { type IntegrationMcpOptions } from '../commands/setup/setup-mcps'; +import { applyEnvironmentManagementRuntimeEnv } from '../environment-management'; import type { EnvironmentSetupSettledOutcome, @@ -729,30 +730,37 @@ export const runTask = async ({ ), ); - const runtimeEnv: Record = { - ...deploymentEnvVars, - ...sanitizedEnv, - ...openCodeHarnessEnv, - R_APP_URL: workerEnv.roomoteAppUrl, - ROOMOTE_PLATFORM_API_URL: workerEnv.trpcUrl, - ROOMOTE_WORKSPACE_PATH: workspacePath, - ROOMOTE_CLOUD_TOKEN: workerEnv.authToken, - ROOMOTE_TASK_ID: taskRun.taskId, - AGENT_BROWSER_SESSION: taskRun.taskId, - ROOMOTE_TASK_TYPE: taskRun.payloadKind, - ...(unsanitizedEnv.ROOMOTE_AUTH_BYPASS_VALUE && { - ROOMOTE_AUTH_BYPASS_VALUE: unsanitizedEnv.ROOMOTE_AUTH_BYPASS_VALUE, - }), - ...(unsanitizedEnv.ROOMOTE_AUTH_BYPASS_HEADER_NAME && { - ROOMOTE_AUTH_BYPASS_HEADER_NAME: - unsanitizedEnv.ROOMOTE_AUTH_BYPASS_HEADER_NAME, - }), - // Consumed (and removed) by generateOpenCodeConfig, which registers the - // hidden proof-runner subagent only when a browser surface exists. - ...(environmentConfig?.initialUrl && { - ROOMOTE_PROOF_BROWSER_TARGET: environmentConfig.initialUrl, - }), - }; + const runtimeEnv = applyEnvironmentManagementRuntimeEnv( + { + ...deploymentEnvVars, + ...sanitizedEnv, + ...openCodeHarnessEnv, + R_APP_URL: workerEnv.roomoteAppUrl, + ROOMOTE_PLATFORM_API_URL: workerEnv.trpcUrl, + ROOMOTE_WORKSPACE_PATH: workspacePath, + ROOMOTE_CLOUD_TOKEN: workerEnv.authToken, + ROOMOTE_TASK_ID: taskRun.taskId, + AGENT_BROWSER_SESSION: taskRun.taskId, + ROOMOTE_TASK_TYPE: taskRun.payloadKind, + ...(unsanitizedEnv.ROOMOTE_AUTH_BYPASS_VALUE && { + ROOMOTE_AUTH_BYPASS_VALUE: unsanitizedEnv.ROOMOTE_AUTH_BYPASS_VALUE, + }), + ...(unsanitizedEnv.ROOMOTE_AUTH_BYPASS_HEADER_NAME && { + ROOMOTE_AUTH_BYPASS_HEADER_NAME: + unsanitizedEnv.ROOMOTE_AUTH_BYPASS_HEADER_NAME, + }), + // Consumed (and removed) by generateOpenCodeConfig, which registers the + // hidden proof-runner subagent only when a browser surface exists. + ...(environmentConfig?.initialUrl && { + ROOMOTE_PROOF_BROWSER_TARGET: environmentConfig.initialUrl, + }), + }, + { + payloadKind: taskRun.payloadKind, + payload: taskRun.payload, + workflow: task?.workflow, + }, + ); // Strip credentials for disabled providers as defense in depth, even if a // stale worker/dequeue payload still contains them. for (const envVarName of DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES) { diff --git a/apps/worker/src/run-task/types.ts b/apps/worker/src/run-task/types.ts index be257b75a..413286bc0 100644 --- a/apps/worker/src/run-task/types.ts +++ b/apps/worker/src/run-task/types.ts @@ -64,7 +64,8 @@ export interface BackgroundEnvironmentSetupNotifier { type TaskChannelBindings = Pick< DequeuedTaskRun['task'], 'slackChannelId' | 'slackThreadTs' | 'linearSessionId' ->; +> & + Partial>; type Todo = { id: string; 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 990c37b6a..6ff2ac6a8 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 @@ -44,6 +44,7 @@ import { resolveSlackTaskRunRouting } from './slack-task-run-routing'; export type DequeuedTaskContext = { id: string; title: string; + workflow: Task['workflow']; /** The initial task prompt (tasks.prompt); per-attempt prompt is top-level. */ prompt: string | null; harnessInstructions: string | null; @@ -59,6 +60,7 @@ export function buildDequeuedTaskContext(task: Task): DequeuedTaskContext { return { id: task.id, title: task.title, + workflow: task.workflow, prompt: task.prompt ?? null, harnessInstructions: task.harnessInstructions ?? null, requestedWorkKind: task.requestedWorkKind, diff --git a/packages/types/src/environment-management.ts b/packages/types/src/environment-management.ts new file mode 100644 index 000000000..3b0405d29 --- /dev/null +++ b/packages/types/src/environment-management.ts @@ -0,0 +1,86 @@ +import { getEnvironmentDefinitionIdFromPayload } from './environment-definition-tasks'; + +export const environmentManagementModes = [ + 'create', + 'update', + 'verify', +] as const; + +export type EnvironmentManagementMode = + (typeof environmentManagementModes)[number]; + +export type EnvironmentManagementAction = + | 'create' + | 'update' + | 'record_verification'; + +export const environmentManagementActions = { + // Creation tasks may need to revise the new definition after their spawned + // verification task reports a fixable setup problem. + create: ['create', 'update', 'record_verification'], + update: ['update', 'record_verification'], + verify: ['record_verification'], +} as const satisfies Record< + EnvironmentManagementMode, + readonly EnvironmentManagementAction[] +>; + +export function isEnvironmentManagementMode( + value: unknown, +): value is EnvironmentManagementMode { + return environmentManagementModes.includes( + value as EnvironmentManagementMode, + ); +} + +export function resolveEnvironmentManagementMode(input: { + payloadKind: string; + payload: unknown; + workflow?: string; +}): EnvironmentManagementMode | null { + if ( + !input.payload || + typeof input.payload !== 'object' || + Array.isArray(input.payload) + ) { + return null; + } + + const payload = input.payload as Record; + + if (input.payloadKind === 'snapshot_resume') { + return null; + } + + if (isEnvironmentManagementMode(payload.environmentManagementMode)) { + return payload.environmentManagementMode; + } + + if (typeof payload.verifiesEnvironmentId === 'string') { + return 'verify'; + } + + if ( + input.workflow === 'setup_onboarding' && + getEnvironmentDefinitionIdFromPayload(payload) + ) { + return 'update'; + } + + if (input.workflow === 'setup_onboarding' && payload.environmentId == null) { + return 'create'; + } + + return null; +} + +export function canPerformEnvironmentManagementAction( + mode: EnvironmentManagementMode | null, + action: EnvironmentManagementAction, +): boolean { + return mode + ? environmentManagementActions[mode].some( + (allowedAction) => allowedAction === action, + ) + : false; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 524d23fd4..98e2e1538 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -21,6 +21,7 @@ export * from './constants'; export * from './deploy-marker'; export * from './deployment-access-policy'; export * from './environment-config'; +export * from './environment-management'; export * from './reserved-mcp-env-vars'; export * from './environment-definition-tasks'; export * from './eval-harness-selection'; diff --git a/packages/types/src/task-runs.ts b/packages/types/src/task-runs.ts index 2d4ef0bcf..53fed0342 100644 --- a/packages/types/src/task-runs.ts +++ b/packages/types/src/task-runs.ts @@ -15,6 +15,7 @@ import { SANDBOX_SNAPSHOT_EXPIRY_MS } from './compute-providers/worker-runtime'; import { prActions } from './cloud-agents'; import { ALL_REPOSITORIES } from './constants'; import { sourceControlProviderSchema } from './source-control'; +import { environmentManagementModes } from './environment-management'; import { resolveTaskModelIdAlias } from './task-models'; /** @@ -899,6 +900,12 @@ const sharedTaskPayloadSchema = z.object({ */ verifiesEnvironmentId: z.string().uuid().optional(), + /** + * Narrows the environment-management actions exposed to this task. This is + * stamped by trusted launch paths and converted to worker runtime policy. + */ + environmentManagementMode: z.enum(environmentManagementModes).optional(), + /** * Optional validated repository subset to prepare when `repo` is the * ALL_REPOSITORIES sentinel. This allows multi-repo tasks to start a shared From c6f1c435c4b10d37d485e6b81dacc6de01d20d95 Mon Sep 17 00:00:00 2001 From: Roomote Date: Tue, 4 Aug 2026 21:47:38 +0000 Subject: [PATCH 2/3] chore: keep environment policy helpers internal --- apps/worker/src/environment-management.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/worker/src/environment-management.ts b/apps/worker/src/environment-management.ts index e004ce35e..d08e6bb7c 100644 --- a/apps/worker/src/environment-management.ts +++ b/apps/worker/src/environment-management.ts @@ -4,12 +4,9 @@ import { resolveEnvironmentManagementMode, } from '@roomote/types'; -export { - environmentManagementActions, - resolveEnvironmentManagementMode, -} from '@roomote/types'; +export { resolveEnvironmentManagementMode } from '@roomote/types'; -export const ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE = +const ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE = 'ROOMOTE_ENVIRONMENT_MANAGEMENT_MODE'; export function buildEnvironmentManagementRuntimeEnv( From e45f5695d0c1e7b7b8a6f30f8a3ae1d355705ef0 Mon Sep 17 00:00:00 2001 From: Roomote Date: Tue, 4 Aug 2026 23:28:40 +0000 Subject: [PATCH 3/3] fix: reject untrusted environment capability launches --- .../sdk/src/server/routers/task-runs.test.ts | 65 +++++++++++++++++++ packages/sdk/src/server/routers/task-runs.ts | 20 +++++- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/server/routers/task-runs.test.ts b/packages/sdk/src/server/routers/task-runs.test.ts index c46b45b8b..cef985e02 100644 --- a/packages/sdk/src/server/routers/task-runs.test.ts +++ b/packages/sdk/src/server/routers/task-runs.test.ts @@ -635,6 +635,71 @@ describe('taskRunsRouter queue message guards', () => { }); }); + it('rejects user-authenticated environment management launches', async () => { + await expect( + createAuthCaller().enqueue({ + task: { + type: TaskPayloadKind.StandardTask, + payload: { + repo: 'acme/api', + description: 'Manage the environment', + environmentManagementMode: 'create', + }, + }, + initiator: { kind: 'user', userId: 'user-1' }, + workflow: 'standard', + surface: 'api', + trigger: 'manual', + }), + ).rejects.toThrow( + 'Environment management launches must use a trusted server launch path.', + ); + + expect(mockEnqueueTask).not.toHaveBeenCalled(); + }); + + it('rejects indirect environment management markers and workflows', async () => { + const commonInput = { + task: { + type: TaskPayloadKind.StandardTask, + payload: { + repo: 'acme/api', + description: 'Manage the environment', + }, + }, + initiator: { kind: 'user' as const, userId: 'user-1' }, + surface: 'api' as const, + trigger: 'manual' as const, + }; + + await expect( + createAuthCaller().enqueue({ + ...commonInput, + task: { + ...commonInput.task, + payload: { + ...commonInput.task.payload, + verifiesEnvironmentId: '3e675f73-5f32-41c4-b90d-7f9fbcf42831', + }, + }, + workflow: 'standard', + }), + ).rejects.toThrow( + 'Environment management launches must use a trusted server launch path.', + ); + + await expect( + createAuthCaller().enqueue({ + ...commonInput, + workflow: 'setup_onboarding', + }), + ).rejects.toThrow( + 'Environment management launches must use a trusted server launch path.', + ); + + expect(mockEnqueueTask).not.toHaveBeenCalled(); + }); + it('rejects launches without an initiator', async () => { await expect( createAuthCaller().enqueue({ diff --git a/packages/sdk/src/server/routers/task-runs.ts b/packages/sdk/src/server/routers/task-runs.ts index dae3efb45..3d8bece07 100644 --- a/packages/sdk/src/server/routers/task-runs.ts +++ b/packages/sdk/src/server/routers/task-runs.ts @@ -224,6 +224,24 @@ const freshEnqueueInputSchema = z.object({ prLinkage: taskPrLinkageSchema.optional(), }); +const userFreshEnqueueInputSchema = freshEnqueueInputSchema.superRefine( + (input, ctx) => { + const payload = input.task.payload as Record; + + if ( + input.workflow === 'setup_onboarding' || + payload.environmentManagementMode !== undefined || + payload.verifiesEnvironmentId !== undefined + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Environment management launches must use a trusted server launch path.', + }); + } + }, +); + /** * A resume attaches a new run to the source run's task. It never creates a * task and never re-attributes; the resumer becomes the run's actingUserId. @@ -234,7 +252,7 @@ const resumeEnqueueInputSchema = z.object({ }); const enqueueTaskInputSchema = z.union([ - freshEnqueueInputSchema, + userFreshEnqueueInputSchema, resumeEnqueueInputSchema, ]);