Fix nested adapter config mediation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-30 14:22:03 -05:00
1 parent 3e8c37a82e
commit bc3a17b109
5 files changed
+344 -53

No files matched your search

+1 -1
View File
@@ -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({
@@ -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",
@@ -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",
+159 -50
View File
@@ -711,6 +711,99 @@ export function agentRoutes(
return normalizedRuntimeConfig;
}
function listRuntimeModelProfileAdapterConfigs(runtimeConfig: unknown): Array<{
profileKey: string;
profile: Record<string, unknown>;
adapterConfig: Record<string, unknown>;
path: string;
}> {
const runtimeRecord = asRecord(runtimeConfig);
const modelProfiles = asRecord(runtimeRecord?.modelProfiles);
if (!modelProfiles) return [];
const entries: Array<{
profileKey: string;
profile: Record<string, unknown>;
adapterConfig: Record<string, unknown>;
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<string, unknown>;
constraintAdapterConfig?: Record<string, unknown>;
}): Promise<Record<string, unknown>> {
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<string, unknown>,
baseAdapterConfig: Record<string, unknown>,
): Promise<Record<string, unknown>> {
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<string, unknown> | 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<string, unknown>) {
return KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => adapterConfig[key] !== undefined);
}
function assertNoAgentAdapterConfigMutation(
req: Request,
adapterConfig: Record<string, unknown>,
path = "adapterConfig",
) {
assertNoAgentInstructionsConfigMutation(req, adapterConfig, path);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(adapterConfig, path),
);
}
function summarizeAgentUpdateDetails(patch: Record<string, unknown>) {
const changedTopLevelKeys = Object.keys(patch).sort();
const details: Record<string, unknown> = { changedTopLevelKeys };
@@ -1633,21 +1745,16 @@ export function agentRoutes(
...hireInput
} = req.body;
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
const rawHireAdapterConfig = (hireInput.adapterConfig ?? {}) as Record<string, unknown>;
assertNoNewAgentLegacyPromptTemplate(
hireInput.adapterType,
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
);
assertNoAgentInstructionsConfigMutation(
req,
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
rawHireAdapterConfig,
);
assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig);
assertNoAgentRuntimeConfigAdapterConfigMutation(req, hireInput.runtimeConfig);
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
hireInput.adapterType,
((hireInput.adapterConfig ?? {}) as Record<string, unknown>),
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<string, unknown>;
assertNoNewAgentLegacyPromptTemplate(
createInput.adapterType,
(createInput.adapterConfig ?? {}) as Record<string, unknown>,
);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig),
);
assertNoAgentInstructionsConfigMutation(
req,
(createInput.adapterConfig ?? {}) as Record<string, unknown>,
rawCreateAdapterConfig,
);
assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig);
assertNoAgentRuntimeConfigAdapterConfigMutation(req, createInput.runtimeConfig);
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
createInput.adapterType,
((createInput.adapterConfig ?? {}) as Record<string, unknown>),
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<string, unknown> | 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")) {
+5 -2
View File
@@ -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`,
);
}