Guard codex_local agents from shared OpenAI key (#8272)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The `codex_local` adapter runs local Codex CLI processes and builds their environment from persisted agent config plus host process env. > - A host-level `OPENAI_API_KEY` or shared Codex auth home can silently make new agents spend through shared credentials. > - Existing agents can be repaired manually, but new and updated agents need a persistent guard at the agent configuration boundary. > - This pull request isolates new and updated `codex_local` agents with per-agent `CODEX_HOME` and an empty `OPENAI_API_KEY` override. > - The benefit is that future agent creation or adapter updates cannot silently fall back to shared OpenAI credentials. ## Linked Issues or Issue Description Paperclip work item: [ZOL-5477](/ZOL/issues/ZOL-5477). No matching GitHub issue exists, so the bug is described inline following `.github/ISSUE_TEMPLATE/bug_report.yml`. **Pre-submission checklist** - [x] I have searched existing open and closed issues and this is not a duplicate. - [x] I am on the latest `master` commit for this PR branch. - [x] I have confirmed the error originates in Paperclip's `codex_local` adapter configuration boundary, not in a provider outage. **What happened?** New or updated `codex_local` agents could inherit a host-level `OPENAI_API_KEY` or use a shared Codex home when their adapter config did not explicitly isolate those values. That made it possible for future agents or manual adapter edits to silently fall back to shared OpenAI credentials. **Expected behavior** Creating, hiring, or updating a `codex_local` agent should either persist isolated per-agent configuration or reject unsafe shared Codex home configuration with a clear 422 response. The guard must not print secret values. **Steps to reproduce** 1. Create or update a `codex_local` agent without an explicit `adapterConfig.env.OPENAI_API_KEY` override. 2. Run it on a host where the Paperclip server process has `OPENAI_API_KEY` set. 3. Observe that the adapter process can inherit the host key unless Paperclip persists a blocking empty override. 4. Set `adapterConfig.env.CODEX_HOME` to a shared path such as `~/.codex` or the company-level `codex-home`. 5. Observe that the old code allowed the shared auth home instead of returning a validation error. **Paperclip version or commit** - Reproduced by inspection against `master` before this PR. **Deployment mode** - Local dev / self-hosted server with `codex_local` agents. **Installation method** - Built from source. **Agent adapter(s) involved** - Codex. **Database mode** - Not database-related. **Access context** - Board and agent configuration paths. **Relevant logs or output** - No secret-bearing logs included. **Relevant config** - Unsafe shape: missing `adapterConfig.env.OPENAI_API_KEY`, or shared `adapterConfig.env.CODEX_HOME`. - Fixed shape: per-agent `CODEX_HOME` plus empty `OPENAI_API_KEY` override. **Additional context** Related PR search for `codex_local OPENAI_API_KEY CODEX_HOME` found: - #3681 `fix: preserve managed Codex auth and repo-root env loading` - #5621 `fix: copy worktree codex auth locally` Those are adjacent auth-handling changes, but they do not add the agent create/update guard implemented here. **Privacy checklist** - [x] I have reviewed all pasted output for PII, usernames, file paths, API keys, tokens, company names, and redacted where necessary. ## What Changed - Added a `codex_local` config guard in agent create, hire, and update routes. - The guard assigns `adapterConfig.env.CODEX_HOME` to `companies/<companyId>/agents/<agentId>/codex-home` when missing. - The guard persists `adapterConfig.env.OPENAI_API_KEY = ""` when missing, preventing host env inheritance. - Shared `CODEX_HOME` values for the company codex-home, host `$CODEX_HOME`, or `~/.codex` now fail with a 422 error. - Added route tests for create, hire, update, and rejected shared host Codex home. - Updated `codex_local` and development docs to describe the per-agent home contract. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-adapter-validation-routes.test.ts` - `pnpm exec vitest run server/src/__tests__/agent-skills-routes.test.ts` - `pnpm typecheck` - `git diff --check upstream/master...HEAD` - `gh pr list --repo paperclipai/paperclip --state all --search "codex_local OPENAI_API_KEY CODEX_HOME" --limit 20 --json number,title,state,url` - `rg -n "codex|OPENAI_API_KEY|CODEX_HOME|adapter" ROADMAP.md` returned no roadmap overlap. ## Risks - Existing legacy `codex_local` agents with shared `CODEX_HOME` will get a clear 422 when their adapter config is updated until the shared path is replaced. This is intentional because silent fallback is the bug being guarded. - Low migration risk: no database migration and no secret values are printed or persisted beyond the empty override. ## Model Used - OpenAI GPT-5.5 Codex, Codex coding-agent session with repository tool use. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Paperclip - Issue: [ZOL-5477](/ZOL/issues/ZOL-5477) - Owner: Разработчик (`6625498c-66c9-429f-b578-4463ddc3ba16`) - Status: waiting reviewer - Next action: merge after approval and green CI --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
committed by
GitHub
parent
04173b341d
commit
5320a44088
@@ -1,4 +1,6 @@
|
||||
import express from "express";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import request from "supertest";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
@@ -6,6 +8,7 @@ import type { ServerAdapterModule } from "../adapters/index.js";
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
@@ -24,6 +27,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
|
||||
const mockSecretService = vi.hoisted(() => ({
|
||||
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
|
||||
resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record<string, unknown>) => ({ config })),
|
||||
syncEnvBindingsForTarget: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentInstructionsService = vi.hoisted(() => ({
|
||||
@@ -80,6 +84,10 @@ vi.mock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
vi.mock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
agentService: () => mockAgentService,
|
||||
@@ -100,6 +108,10 @@ function registerModuleMocks() {
|
||||
vi.doMock("../services/instance-settings.js", () => ({
|
||||
instanceSettingsService: () => mockInstanceSettingsService,
|
||||
}));
|
||||
|
||||
vi.doMock("../services/secrets.js", () => ({
|
||||
secretService: () => mockSecretService,
|
||||
}));
|
||||
}
|
||||
|
||||
const externalAdapter: ServerAdapterModule = {
|
||||
@@ -202,8 +214,12 @@ describe("agent routes adapter validation", () => {
|
||||
mockAccessService.ensureMembership.mockResolvedValue(undefined);
|
||||
mockAccessService.setPrincipalPermission.mockResolvedValue(undefined);
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockSecretService.syncEnvBindingsForTarget.mockResolvedValue(undefined);
|
||||
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(async (agent: { adapterConfig: unknown }) => ({
|
||||
adapterConfig: agent.adapterConfig,
|
||||
}));
|
||||
mockAgentService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
id: String(input.id ?? "11111111-1111-4111-8111-111111111111"),
|
||||
companyId: "company-1",
|
||||
name: String(input.name ?? "Agent"),
|
||||
urlKey: "agent",
|
||||
@@ -226,6 +242,34 @@ describe("agent routes adapter validation", () => {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}));
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
companyId: "company-1",
|
||||
name: "Codex",
|
||||
urlKey: "codex",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
|
||||
...(await mockAgentService.getById()),
|
||||
...patch,
|
||||
}));
|
||||
await unregisterTestAdapter("external_test");
|
||||
await unregisterTestAdapter(missingAdapterType);
|
||||
});
|
||||
@@ -253,6 +297,92 @@ describe("agent routes adapter validation", () => {
|
||||
expect(res.body.adapterType).toBe("external_test");
|
||||
});
|
||||
|
||||
it("adds isolated CODEX_HOME and empty OPENAI_API_KEY override when creating codex_local agents", async () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Codex Agent",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
const createInput = mockAgentService.create.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
const agentId = String(createInput.id);
|
||||
expect(agentId).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
const adapterConfig = createInput.adapterConfig as Record<string, unknown>;
|
||||
const env = adapterConfig.env as Record<string, unknown>;
|
||||
expect(env.OPENAI_API_KEY).toBe("");
|
||||
expect(env.CODEX_HOME).toContain(`/companies/company-1/agents/${agentId}/codex-home`);
|
||||
expect(String(env.CODEX_HOME)).not.toContain("/companies/company-1/codex-home");
|
||||
});
|
||||
|
||||
it("adds isolated CODEX_HOME and empty OPENAI_API_KEY override when updating codex_local agents", async () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.patch("/api/agents/11111111-1111-4111-8111-111111111111")
|
||||
.send({
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
const patch = mockAgentService.update.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
const adapterConfig = patch.adapterConfig as Record<string, unknown>;
|
||||
const env = adapterConfig.env as Record<string, unknown>;
|
||||
expect(env.OPENAI_API_KEY).toBe("");
|
||||
expect(env.CODEX_HOME).toContain(
|
||||
"/companies/company-1/agents/11111111-1111-4111-8111-111111111111/codex-home",
|
||||
);
|
||||
expect(String(env.CODEX_HOME)).not.toContain("/companies/company-1/codex-home");
|
||||
});
|
||||
|
||||
it("rejects codex_local agents configured with the shared host Codex home", async () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Shared Codex",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
CODEX_HOME: path.join(os.homedir(), ".codex"),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(String(res.body.error)).toContain("codex_local agents must use an isolated adapterConfig.env.CODEX_HOME");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects codex_local agents configured with the ~/.codex alias", async () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.post("/api/companies/company-1/agents")
|
||||
.send({
|
||||
name: "Shared Codex Alias",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {
|
||||
env: {
|
||||
CODEX_HOME: "~/.codex",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(422);
|
||||
expect(String(res.body.error)).toContain("codex_local agents must use an isolated adapterConfig.env.CODEX_HOME");
|
||||
expect(mockAgentService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unknown adapter types even when schema accepts arbitrary strings", async () => {
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
|
||||
@@ -63,6 +63,13 @@ const mockAdapter = vi.hoisted(() => ({
|
||||
syncSkills: vi.fn(),
|
||||
}));
|
||||
|
||||
function expectResponseId(value: unknown): string {
|
||||
expect(value).toEqual(expect.any(String));
|
||||
expect(value).not.toBe("");
|
||||
expect(value).not.toBe("undefined");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
vi.mock("@paperclipai/shared/telemetry", () => ({
|
||||
trackAgentCreated: mockTrackAgentCreated,
|
||||
trackErrorHandlerCrash: vi.fn(),
|
||||
@@ -546,6 +553,7 @@ describe.sequential("agent skill routes", () => {
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
const createdAgentId = expectResponseId(res.body.id);
|
||||
expect(mockAgentService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
@@ -559,7 +567,7 @@ describe.sequential("agent skill routes", () => {
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
agentId: createdAgentId,
|
||||
agentRole: "engineer",
|
||||
}),
|
||||
);
|
||||
@@ -576,6 +584,7 @@ describe.sequential("agent skill routes", () => {
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
const createdAgentId = expectResponseId(res.body.id);
|
||||
expect(res.body).toMatchObject({
|
||||
role: "security",
|
||||
});
|
||||
@@ -588,7 +597,7 @@ describe.sequential("agent skill routes", () => {
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
agentId: "11111111-1111-4111-8111-111111111111",
|
||||
agentId: createdAgentId,
|
||||
agentRole: "security",
|
||||
}),
|
||||
);
|
||||
@@ -610,13 +619,15 @@ describe.sequential("agent skill routes", () => {
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
const createdAgentId = expectResponseId(res.body.id);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
createdAgentId,
|
||||
expect.objectContaining({
|
||||
adapterConfig: expect.objectContaining({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
|
||||
instructionsRootPath: `/tmp/${createdAgentId}/instructions`,
|
||||
instructionsFilePath: `/tmp/${createdAgentId}/instructions/AGENTS.md`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -658,9 +669,10 @@ describe.sequential("agent skill routes", () => {
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
const createdAgentId = expectResponseId(res.body.id);
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
id: createdAgentId,
|
||||
role: "ceo",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
@@ -685,10 +697,11 @@ describe.sequential("agent skill routes", () => {
|
||||
}));
|
||||
|
||||
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
|
||||
const createdAgentId = expectResponseId(res.body.id);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
id: createdAgentId,
|
||||
role: "engineer",
|
||||
adapterType: "claude_local",
|
||||
}),
|
||||
@@ -811,6 +824,10 @@ describe.sequential("agent skill routes", () => {
|
||||
});
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
|
||||
| { payload?: { agentId?: string; adapterConfig?: Record<string, unknown> } }
|
||||
| undefined;
|
||||
const hiredAgentId = expectResponseId(approvalInput?.payload?.agentId);
|
||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
@@ -818,14 +835,12 @@ describe.sequential("agent skill routes", () => {
|
||||
adapterConfig: expect.objectContaining({
|
||||
instructionsBundleMode: "managed",
|
||||
instructionsEntryFile: "AGENTS.md",
|
||||
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
|
||||
instructionsRootPath: `/tmp/${hiredAgentId}/instructions`,
|
||||
instructionsFilePath: `/tmp/${hiredAgentId}/instructions/AGENTS.md`,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
|
||||
| { payload?: { adapterConfig?: Record<string, unknown> } }
|
||||
| undefined;
|
||||
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db";
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
LOW_TRUST_REVIEW_PRESET,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
resolvePaperclipInstanceRootForAdapter,
|
||||
readPaperclipSkillSyncPreference,
|
||||
writePaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -957,6 +959,14 @@ export function agentRoutes(
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function asEnvBindingString(value: unknown): string | null {
|
||||
const direct = asNonEmptyString(value);
|
||||
if (direct) return direct;
|
||||
const record = asRecord(value);
|
||||
if (record?.type !== "plain") return null;
|
||||
return asNonEmptyString(record.value);
|
||||
}
|
||||
|
||||
function preserveInstructionsBundleConfig(
|
||||
existingAdapterConfig: Record<string, unknown>,
|
||||
nextAdapterConfig: Record<string, unknown>,
|
||||
@@ -1133,6 +1143,64 @@ export function agentRoutes(
|
||||
return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() };
|
||||
}
|
||||
|
||||
function codexLocalAgentHome(companyId: string, agentId: string): string {
|
||||
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
|
||||
homeDir: asNonEmptyString(process.env.PAPERCLIP_HOME) ?? undefined,
|
||||
instanceId: asNonEmptyString(process.env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
||||
env: process.env,
|
||||
});
|
||||
return path.resolve(instanceRoot, "companies", companyId, "agents", agentId, "codex-home");
|
||||
}
|
||||
|
||||
function normalizeCodexLocalHomePath(rawHome: string): string {
|
||||
if (rawHome === "~") return path.resolve(os.homedir());
|
||||
if (rawHome.startsWith("~/") || rawHome.startsWith("~\\")) {
|
||||
return path.resolve(path.join(os.homedir(), rawHome.slice(2)));
|
||||
}
|
||||
return path.resolve(rawHome);
|
||||
}
|
||||
|
||||
function assertCodexLocalHomeIsNotShared(companyId: string, configuredHome: string) {
|
||||
const instanceRoot = resolvePaperclipInstanceRootForAdapter({
|
||||
homeDir: asNonEmptyString(process.env.PAPERCLIP_HOME) ?? undefined,
|
||||
instanceId: asNonEmptyString(process.env.PAPERCLIP_INSTANCE_ID) ?? undefined,
|
||||
env: process.env,
|
||||
});
|
||||
const normalizedHome = normalizeCodexLocalHomePath(configuredHome);
|
||||
const sharedHomes = [
|
||||
path.resolve(instanceRoot, "companies", companyId, "codex-home"),
|
||||
path.resolve(path.join(os.homedir(), ".codex")),
|
||||
];
|
||||
const hostCodexHome = asNonEmptyString(process.env.CODEX_HOME);
|
||||
if (hostCodexHome) {
|
||||
sharedHomes.push(normalizeCodexLocalHomePath(hostCodexHome));
|
||||
}
|
||||
if (!sharedHomes.some((sharedHome) => sharedHome === normalizedHome)) return;
|
||||
throw unprocessable(
|
||||
"codex_local agents must use an isolated adapterConfig.env.CODEX_HOME; shared company codex-home or host Codex auth home is not allowed",
|
||||
);
|
||||
}
|
||||
|
||||
function applyCodexLocalIsolationGuard(
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (adapterType !== "codex_local") return adapterConfig;
|
||||
const env = asRecord(adapterConfig.env) ? { ...(adapterConfig.env as Record<string, unknown>) } : {};
|
||||
const configuredHome = asEnvBindingString(env.CODEX_HOME);
|
||||
if (configuredHome) {
|
||||
assertCodexLocalHomeIsNotShared(companyId, configuredHome);
|
||||
} else {
|
||||
env.CODEX_HOME = codexLocalAgentHome(companyId, agentId);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY")) {
|
||||
env.OPENAI_API_KEY = "";
|
||||
}
|
||||
return { ...adapterConfig, env };
|
||||
}
|
||||
|
||||
function applyCreateDefaultsByAdapterType(
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
@@ -2158,9 +2226,15 @@ export function agentRoutes(
|
||||
);
|
||||
assertNoAgentAdapterConfigMutation(req, rawHireAdapterConfig);
|
||||
assertNoAgentRuntimeConfigAdapterConfigMutation(req, hireInput.runtimeConfig);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
const hiredAgentId = randomUUID();
|
||||
const requestedAdapterConfig = applyCodexLocalIsolationGuard(
|
||||
companyId,
|
||||
hiredAgentId,
|
||||
hireInput.adapterType,
|
||||
rawHireAdapterConfig,
|
||||
applyCreateDefaultsByAdapterType(
|
||||
hireInput.adapterType,
|
||||
rawHireAdapterConfig,
|
||||
),
|
||||
);
|
||||
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
|
||||
companyId,
|
||||
@@ -2198,6 +2272,7 @@ export function agentRoutes(
|
||||
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
||||
const status = requiresApproval ? "pending_approval" : "idle";
|
||||
const createdAgent = await svc.create(companyId, {
|
||||
id: hiredAgentId,
|
||||
...normalizedHireInput,
|
||||
status,
|
||||
spentMonthlyCents: 0,
|
||||
@@ -2344,9 +2419,15 @@ export function agentRoutes(
|
||||
);
|
||||
assertNoAgentAdapterConfigMutation(req, rawCreateAdapterConfig);
|
||||
assertNoAgentRuntimeConfigAdapterConfigMutation(req, createInput.runtimeConfig);
|
||||
const requestedAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
const agentId = randomUUID();
|
||||
const requestedAdapterConfig = applyCodexLocalIsolationGuard(
|
||||
companyId,
|
||||
agentId,
|
||||
createInput.adapterType,
|
||||
rawCreateAdapterConfig,
|
||||
applyCreateDefaultsByAdapterType(
|
||||
createInput.adapterType,
|
||||
rawCreateAdapterConfig,
|
||||
),
|
||||
);
|
||||
const desiredSkillAssignment = await resolveDesiredSkillAssignment(
|
||||
companyId,
|
||||
@@ -2372,6 +2453,7 @@ export function agentRoutes(
|
||||
});
|
||||
|
||||
const createdAgent = await svc.create(companyId, {
|
||||
id: agentId,
|
||||
...createInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
runtimeConfig: normalizedRuntimeConfig,
|
||||
@@ -2820,9 +2902,14 @@ export function agentRoutes(
|
||||
rawEffectiveAdapterConfig,
|
||||
);
|
||||
}
|
||||
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
const effectiveAdapterConfig = applyCodexLocalIsolationGuard(
|
||||
existing.companyId,
|
||||
existing.id,
|
||||
requestedAdapterType,
|
||||
rawEffectiveAdapterConfig,
|
||||
applyCreateDefaultsByAdapterType(
|
||||
requestedAdapterType,
|
||||
rawEffectiveAdapterConfig,
|
||||
),
|
||||
);
|
||||
const normalizedEffectiveAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({
|
||||
companyId: existing.companyId,
|
||||
|
||||
Reference in New Issue
Block a user