diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index cc561080d80..bfb66751746 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -54,7 +54,7 @@ export const createAgentInstructionsBundleSchema = z.object({ const agentModelProfileConfigSchema = z.object({ enabled: z.boolean().optional(), label: z.string().trim().min(1).optional(), - adapterConfig: z.record(z.unknown()), + adapterConfig: adapterConfigSchema, }).strict(); export const agentRuntimeConfigSchema = z.object({ diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index c3c13e79b66..4117aa423c5 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -140,6 +140,26 @@ describe("issue validators", () => { expect(parsed.runtimeConfig.heartbeat).toEqual({ enabled: true }); }); + it("validates cheap model profile env bindings like top-level adapter config", () => { + const parsed = createAgentSchema.safeParse({ + name: "Coder", + adapterType: "codex_local", + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + env: { + API_TOKEN: 123, + }, + }, + }, + }, + }, + }); + + expect(parsed.success).toBe(false); + }); + it("rejects unknown agent runtime model profile keys", () => { const parsed = createAgentSchema.safeParse({ name: "Coder", diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 11f039af1db..85d4f1801f4 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -496,6 +496,165 @@ describe.sequential("agent permission routes", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("blocks agent-authenticated self-updates that set cheap-profile host-executed workspace commands", async () => { + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + }); + + const app = await createApp({ + type: "agent", + agentId, + companyId, + source: "agent_key", + runId: "run-1", + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "touch /tmp/paperclip-rce", + }, + }, + }, + }, + }, + })); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("host-executed workspace commands"); + expect(res.body.error).toContain( + "runtimeConfig.modelProfiles.cheap.adapterConfig.workspaceStrategy.provisionCommand", + ); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + + it("allows board updates that set cheap-profile workspace commands", async () => { + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const runtimeConfig = { + modelProfiles: { + cheap: { + adapterConfig: { + workspaceStrategy: { + type: "git_worktree", + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + }, + }, + }; + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ runtimeConfig })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + agentId, + expect.objectContaining({ runtimeConfig }), + expect.anything(), + ); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "agent.updated", + })); + }); + + it("normalizes cheap-profile env bindings through the adapter config secret pipeline", async () => { + mockAgentService.getById.mockResolvedValue({ + ...baseAgent, + adapterType: "codex_local", + }); + mockSecretService.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => ({ + ...config, + env: { + API_TOKEN: { + type: "secret_ref", + secretId: "33333333-3333-4333-8333-333333333333", + version: "latest", + }, + }, + })); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}`) + .send({ + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + model: "gpt-5.3-codex-spark", + env: { + API_TOKEN: { + type: "secret_ref", + secretId: "33333333-3333-4333-8333-333333333333", + version: "latest", + }, + }, + }, + }, + }, + }, + })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockSecretService.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + model: "gpt-5.3-codex-spark", + env: expect.any(Object), + }), + { strictMode: false }, + ); + expect(mockAgentService.update).toHaveBeenCalledWith( + agentId, + expect.objectContaining({ + runtimeConfig: { + modelProfiles: { + cheap: { + adapterConfig: { + model: "gpt-5.3-codex-spark", + env: { + API_TOKEN: { + type: "secret_ref", + secretId: "33333333-3333-4333-8333-333333333333", + version: "latest", + }, + }, + }, + }, + }, + }, + }), + expect.anything(), + ); + }); + it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => { const app = await createApp({ type: "agent", diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8cf4c64820b..3fb95edafdf 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -711,6 +711,99 @@ export function agentRoutes( return normalizedRuntimeConfig; } + function listRuntimeModelProfileAdapterConfigs(runtimeConfig: unknown): Array<{ + profileKey: string; + profile: Record; + adapterConfig: Record; + path: string; + }> { + const runtimeRecord = asRecord(runtimeConfig); + const modelProfiles = asRecord(runtimeRecord?.modelProfiles); + if (!modelProfiles) return []; + + const entries: Array<{ + profileKey: string; + profile: Record; + adapterConfig: Record; + path: string; + }> = []; + for (const [profileKey, rawProfile] of Object.entries(modelProfiles)) { + const profile = asRecord(rawProfile); + const adapterConfig = asRecord(profile?.adapterConfig); + if (!profile || !adapterConfig) continue; + entries.push({ + profileKey, + profile, + adapterConfig, + path: `runtimeConfig.modelProfiles.${profileKey}.adapterConfig`, + }); + } + return entries; + } + + function assertNoAgentRuntimeConfigAdapterConfigMutation(req: Request, runtimeConfig: unknown) { + for (const entry of listRuntimeModelProfileAdapterConfigs(runtimeConfig)) { + assertNoAgentAdapterConfigMutation(req, entry.adapterConfig, entry.path); + } + } + + async function normalizeMediatedAdapterConfigForPersistence(input: { + companyId: string; + adapterType: string | null | undefined; + adapterConfig: Record; + constraintAdapterConfig?: Record; + }): Promise> { + const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + input.companyId, + input.adapterConfig, + { strictMode: strictSecretsMode }, + ); + await assertAdapterConfigConstraints( + input.companyId, + input.adapterType, + input.constraintAdapterConfig + ? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig } + : normalizedAdapterConfig, + ); + return normalizedAdapterConfig; + } + + async function normalizeRuntimeConfigAdapterConfigsForPersistence( + companyId: string, + adapterType: string, + runtimeConfig: Record, + baseAdapterConfig: Record, + ): Promise> { + const entries = listRuntimeModelProfileAdapterConfigs(runtimeConfig); + if (entries.length === 0) return runtimeConfig; + const adapterModelProfiles = await listAdapterModelProfiles(adapterType); + + const normalizedRuntimeConfig = { ...runtimeConfig }; + const modelProfiles = asRecord(runtimeConfig.modelProfiles) ?? {}; + const normalizedModelProfiles = { ...modelProfiles }; + normalizedRuntimeConfig.modelProfiles = normalizedModelProfiles; + + for (const entry of entries) { + const adapterProfile = adapterModelProfiles.find((profile) => profile.key === entry.profileKey); + const adapterDefaultConfig = asRecord(adapterProfile?.adapterConfig) ?? {}; + const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ + companyId, + adapterType, + adapterConfig: entry.adapterConfig, + constraintAdapterConfig: { + ...baseAdapterConfig, + ...adapterDefaultConfig, + }, + }); + normalizedModelProfiles[entry.profileKey] = { + ...entry.profile, + adapterConfig: normalizedAdapterConfig, + }; + } + + return normalizedRuntimeConfig; + } + function generateEd25519PrivateKeyPem(): string { const { privateKey } = generateKeyPairSync("ed25519"); return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); @@ -867,15 +960,34 @@ export function agentRoutes( function assertNoAgentInstructionsConfigMutation( req: Request, adapterConfig: Record | null | undefined, + path = "adapterConfig", ) { if (req.actor.type !== "agent" || !adapterConfig) return; - const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined); + const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS + .filter((key) => adapterConfig[key] !== undefined) + .map((key) => `${path}.${key}`); if (changedSensitiveKeys.length === 0) return; throw forbidden( `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`, ); } + function adapterConfigTouchesInstructionsConfig(adapterConfig: Record) { + return KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => adapterConfig[key] !== undefined); + } + + function assertNoAgentAdapterConfigMutation( + req: Request, + adapterConfig: Record, + path = "adapterConfig", + ) { + assertNoAgentInstructionsConfigMutation(req, adapterConfig, path); + assertNoAgentHostWorkspaceCommandMutation( + req, + collectAgentAdapterWorkspaceCommandPaths(adapterConfig, path), + ); + } + function summarizeAgentUpdateDetails(patch: Record) { const changedTopLevelKeys = Object.keys(patch).sort(); const details: Record = { changedTopLevelKeys }; @@ -1633,21 +1745,16 @@ export function agentRoutes( ...hireInput } = req.body; hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType); + const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record; assertNoNewAgentLegacyPromptTemplate( hireInput.adapterType, - (hireInput.adapterConfig ?? {}) as Record, - ); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig), - ); - assertNoAgentInstructionsConfigMutation( - req, - (hireInput.adapterConfig ?? {}) as Record, + rawHireAdapterConfig, ); + assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig); + assertNoAgentRuntimeConfigAdapterConfigMutation(req, hireInput.runtimeConfig); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, - ((hireInput.adapterConfig ?? {}) as Record), + rawHireAdapterConfig, ); const desiredSkillAssignment = await resolveDesiredSkillAssignment( companyId, @@ -1655,20 +1762,21 @@ export function agentRoutes( requestedAdapterConfig, Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); - const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, - desiredSkillAssignment.adapterConfig, - { strictMode: strictSecretsMode }, - ); - await assertAdapterConfigConstraints( + adapterType: hireInput.adapterType, + adapterConfig: desiredSkillAssignment.adapterConfig, + }); + const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( companyId, hireInput.adapterType, + normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig), normalizedAdapterConfig, ); const normalizedHireInput = { ...hireInput, adapterConfig: normalizedAdapterConfig, - runtimeConfig: normalizeNewAgentRuntimeConfig(hireInput.runtimeConfig), + runtimeConfig: normalizedRuntimeConfig, }; const company = await db @@ -1823,21 +1931,16 @@ export function agentRoutes( ...createInput } = req.body; createInput.adapterType = assertKnownAdapterType(createInput.adapterType); + const rawCreateAdapterConfig = (createInput.adapterConfig ?? {}) as Record; assertNoNewAgentLegacyPromptTemplate( createInput.adapterType, - (createInput.adapterConfig ?? {}) as Record, - ); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig), - ); - assertNoAgentInstructionsConfigMutation( - req, - (createInput.adapterConfig ?? {}) as Record, + rawCreateAdapterConfig, ); + assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig); + assertNoAgentRuntimeConfigAdapterConfigMutation(req, createInput.runtimeConfig); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( createInput.adapterType, - ((createInput.adapterConfig ?? {}) as Record), + rawCreateAdapterConfig, ); const desiredSkillAssignment = await resolveDesiredSkillAssignment( companyId, @@ -1845,14 +1948,15 @@ export function agentRoutes( requestedAdapterConfig, Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined, ); - const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( + const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ companyId, - desiredSkillAssignment.adapterConfig, - { strictMode: strictSecretsMode }, - ); - await assertAdapterConfigConstraints( + adapterType: createInput.adapterType, + adapterConfig: desiredSkillAssignment.adapterConfig, + }); + const normalizedRuntimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( companyId, createInput.adapterType, + normalizeNewAgentRuntimeConfig(createInput.runtimeConfig), normalizedAdapterConfig, ); await assertAgentEnvironmentSelection(companyId, createInput.adapterType, createInput.defaultEnvironmentId); @@ -1864,7 +1968,7 @@ export function agentRoutes( const createdAgent = await svc.create(companyId, { ...createInput, adapterConfig: normalizedAdapterConfig, - runtimeConfig: normalizeNewAgentRuntimeConfig(createInput.runtimeConfig), + runtimeConfig: normalizedRuntimeConfig, status: "idle", spentMonthlyCents: 0, lastHeartbeatAt: null, @@ -2239,14 +2343,8 @@ export function agentRoutes( res.status(422).json({ error: "adapterConfig must be an object" }); return; } - assertNoAgentInstructionsConfigMutation(req, adapterConfig); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(adapterConfig), - ); - const changingInstructionsConfig = Object.keys(adapterConfig).some((key) => - KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]), - ); + assertNoAgentAdapterConfigMutation(req, adapterConfig); + const changingInstructionsConfig = adapterConfigTouchesInstructionsConfig(adapterConfig); if (changingInstructionsConfig) { await assertCanManageInstructionsPath(req, existing); } @@ -2256,6 +2354,16 @@ export function agentRoutes( const requestedAdapterType = hasOwn(patchData, "adapterType") ? assertKnownAdapterType(patchData.adapterType as string | null | undefined) : existing.adapterType; + let requestedRuntimeConfig: Record | null = null; + if (hasOwn(patchData, "runtimeConfig")) { + const runtimeConfig = asRecord(patchData.runtimeConfig); + if (!runtimeConfig) { + res.status(422).json({ error: "runtimeConfig must be an object" }); + return; + } + assertNoAgentRuntimeConfigAdapterConfigMutation(req, runtimeConfig); + requestedRuntimeConfig = runtimeConfig; + } const touchesAdapterConfiguration = hasOwn(patchData, "adapterType") || hasOwn(patchData, "adapterConfig"); @@ -2301,19 +2409,20 @@ export function agentRoutes( requestedAdapterType, rawEffectiveAdapterConfig, ); - const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( - existing.companyId, - effectiveAdapterConfig, - { strictMode: strictSecretsMode }, - ); + const normalizedEffectiveAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ + companyId: existing.companyId, + adapterType: requestedAdapterType, + adapterConfig: effectiveAdapterConfig, + }); patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig); } - if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { - const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; - await assertAdapterConfigConstraints( + if (requestedRuntimeConfig) { + const baseAdapterConfig = asRecord(patchData.adapterConfig) ?? asRecord(existing.adapterConfig) ?? {}; + patchData.runtimeConfig = await normalizeRuntimeConfigAdapterConfigsForPersistence( existing.companyId, requestedAdapterType, - effectiveAdapterConfig, + requestedRuntimeConfig, + baseAdapterConfig, ); } if (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId")) { diff --git a/server/src/routes/workspace-command-authz.ts b/server/src/routes/workspace-command-authz.ts index e7999d11d5a..6c56e9d9231 100644 --- a/server/src/routes/workspace-command-authz.ts +++ b/server/src/routes/workspace-command-authz.ts @@ -47,11 +47,14 @@ export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: s ); } -export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] { +export function collectAgentAdapterWorkspaceCommandPaths( + adapterConfig: unknown, + prefix = "adapterConfig", +): string[] { if (!isRecord(adapterConfig)) return []; return collectWorkspaceStrategyCommandPaths( adapterConfig.workspaceStrategy, - "adapterConfig.workspaceStrategy", + `${prefix}.workspaceStrategy`, ); }