Files
PaperClipAI/packages/shared/src/validators/agent.ts
T
Nicky LeachandPaperclip ad4f0b5867 Fix Codex API key authentication in tests and runs (#13260)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent runtime settings can bind organization secrets to an adapter
environment
> - Paperclip redacts plain environment values when it returns a saved
agent to the UI
> - A saved-agent test sent the redacted `CODEX_HOME` value back to the
server
> - Codex ACP also received the API key without an ACP API-key
authentication request
> - This pull request restores saved environment values for tests and
selects API-key authentication for Codex ACP runs
> - The benefit is that Codex agents can test and run with an
organization-scoped OpenAI API key

## Linked Issues or Issue Description

**What happened?**

Testing a saved Codex agent sent `***REDACTED***` as `CODEX_HOME`.
Secret normalization rejected that placeholder. Remote Codex ACP runs
received `OPENAI_API_KEY`, but session creation stopped with
`Authentication required`.

**Expected behavior**

Paperclip must use the saved `CODEX_HOME` value when it tests an
existing agent. Codex ACP must select API-key authentication when
`OPENAI_API_KEY` is available.

**Steps to reproduce**

1. Create an organization-scoped secret named `OPENAI_API_KEY`.
2. Give a Codex agent access to the secret.
3. Save the agent runtime settings.
4. Test the saved agent again.
5. Run the agent in a remote sandbox through ACP.

**Paperclip version or commit**

Reproduced on master before commit
`68c17709d7c051a804a416263e2e08920f1dfcb1`.

**Deployment mode**

Self-hosted server with a remote sandbox environment.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Codex.

## What Changed

- Send the saved agent ID with adapter environment tests.
- Restore redacted plain environment values from the saved agent before
test-time secret resolution.
- Select the Codex ACP `api-key` authentication method when
`OPENAI_API_KEY` is present.
- Add focused regression coverage for saved-agent tests and remote ACP
launch configuration.

## Verification

- `pnpm --filter @paperclipai/adapter-utils exec vitest run
src/acpx-engine/execute.test.ts`
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/agent-adapter-validation-routes.test.ts`
- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/test-agent-setup.test.ts`
- `pnpm -r typecheck`
- `pnpm test:run`
- `pnpm build`
- `git diff --check`

## Risks

- Low risk. The test route reads saved configuration only when the
request supplies a compatible agent ID and the caller can update that
agent.
- The Codex ACP change applies only when `OPENAI_API_KEY` exists and no
explicit `DEFAULT_AUTH_REQUEST` exists.
- There are no schema migrations or telemetry changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with `gpt-5`. The context-window size is not exposed in
this runtime. The model used reasoning, repository search, file editing,
command execution, and test execution.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 13:58:29 -07:00

284 lines
11 KiB
TypeScript

import { z } from "zod";
import {
AGENT_ICON_NAMES,
AGENT_ROLES,
AGENT_STATUSES,
INBOX_MINE_ISSUE_STATUS_FILTER,
} from "../constants.js";
import { agentAdapterTypeSchema } from "../adapter-type.js";
import { envConfigSchema } from "./secret.js";
import { trustAuthorizationPolicySchema, trustPresetSchema } from "./trust-policy.js";
import { agentDesiredSkillSelectionSchema } from "./adapter-skills.js";
import { objectWithoutDefaults } from "./partial.js";
export const agentPermissionsSchema = z.object({
// No schema default: the server derives the default (enabled unless the
// permissions record marks the agent low-trust) when the field is omitted.
canCreateAgents: z.boolean().optional(),
canCreateSkills: z.boolean().optional().default(true),
trustPreset: trustPresetSchema.optional(),
authorizationPolicy: trustAuthorizationPolicySchema.optional(),
}).catchall(z.unknown());
export const agentInstructionsBundleModeSchema = z.enum(["managed", "external"]);
export const updateAgentInstructionsBundleSchema = z.object({
mode: agentInstructionsBundleModeSchema.optional(),
rootPath: z.string().trim().min(1).nullable().optional(),
entryFile: z.string().trim().min(1).optional(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
});
export type UpdateAgentInstructionsBundle = z.infer<typeof updateAgentInstructionsBundleSchema>;
export const upsertAgentInstructionsFileSchema = z.object({
path: z.string().trim().min(1),
content: z.string(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
});
export type UpsertAgentInstructionsFile = z.infer<typeof upsertAgentInstructionsFileSchema>;
const adapterConfigSchema = z.record(z.string(), z.unknown()).superRefine((value, ctx) => {
const envValue = value.env;
if (envValue === undefined) return;
const parsed = envConfigSchema.safeParse(envValue);
if (!parsed.success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "adapterConfig.env must be a map of valid env bindings",
path: ["env"],
});
}
});
export const createAgentInstructionsBundleSchema = z.object({
entryFile: z.string().trim().min(1).optional(),
files: z.record(z.string(), z.string()).refine((files) => Object.keys(files).length > 0, {
message: "instructionsBundle.files must contain at least one file",
}),
});
export const agentRuntimeConfigSchema = z.object({
debug: z.object({
providerTrace: z.literal("raw").optional(),
}).strict().optional(),
}).catchall(z.unknown()).superRefine((value, ctx) => {
if (Object.prototype.hasOwnProperty.call(value, "modelProfiles")) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["modelProfiles"],
message: "runtimeConfig.modelProfiles is no longer supported",
});
}
});
export const createAgentSchema = z.object({
name: z.string().min(1),
role: z.enum(AGENT_ROLES).optional().default("general"),
title: z.string().optional().nullable(),
icon: z.enum(AGENT_ICON_NAMES).optional().nullable(),
reportsTo: z.string().guid().optional().nullable(),
capabilities: z.string().optional().nullable(),
desiredSkills: z.array(agentDesiredSkillSelectionSchema).optional(),
adapterType: agentAdapterTypeSchema,
adapterConfig: adapterConfigSchema.optional().default({}),
instructionsBundle: createAgentInstructionsBundleSchema.optional(),
runtimeConfig: agentRuntimeConfigSchema.optional().default({}),
defaultEnvironmentId: z.string().guid().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
permissions: agentPermissionsSchema.optional(),
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
// The optional stored-session claim from a completed Claude login session. It
// is the non-secret `storedSessionId`; it carries no token. The agent-create
// transaction consumes it as the one-time stored-session claim.
storedSessionId: z.string().min(1).max(256).optional(),
// The optional apply-existing flag. When true, the caller binds the fixed
// Claude OAuth token reference to the owner stored value with no new login
// round trip. The server permits the no-claim bind only for a user actor and
// only when that owner already has a stored value. It carries no token.
applyStoredClaudeLogin: z.boolean().optional(),
// Narrow intent flag set by the onboarding wizard when it hires the very first
// agent (the chief of staff). It is not an agent column: the server consumes
// it to seed the server-owned chief-of-staff persona over the agent's entry
// instruction file instead of the generic default, and honors it only for
// board-authored requests. Mirrors onboardingFirstTask on issue create.
onboardingFirstAgent: z.boolean().optional(),
});
export type CreateAgent = z.infer<typeof createAgentSchema>;
export const builtInAgentProvisionSchema = z.object({
adapterType: agentAdapterTypeSchema.optional(),
adapterConfig: adapterConfigSchema.optional(),
budgetMonthlyCents: z.number().int().nonnegative().optional(),
}).strict();
export type BuiltInAgentProvision = z.infer<typeof builtInAgentProvisionSchema>;
export const builtInAgentEmptyMutationSchema = z.object({}).strict().default({});
export type BuiltInAgentEmptyMutation = z.infer<typeof builtInAgentEmptyMutationSchema>;
export const builtInAgentResetSchema = z.object({
resources: z.array(z.enum(["agent", "instructions", "skill", "routine"])).optional(),
}).strict().default({});
export type BuiltInAgentReset = z.infer<typeof builtInAgentResetSchema>;
export const createAgentHireSchema = createAgentSchema.extend({
sourceIssueId: z.string().guid().optional().nullable(),
sourceIssueIds: z.array(z.string().guid()).optional(),
});
export type CreateAgentHire = z.infer<typeof createAgentHireSchema>;
export const updateAgentSchema = objectWithoutDefaults(
createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true }),
)
.partial()
.extend({
permissions: z.never().optional(),
replaceAdapterConfig: z.boolean().optional(),
status: z.enum(AGENT_STATUSES).optional(),
spentMonthlyCents: z.number().int().nonnegative().optional(),
});
export type UpdateAgent = z.infer<typeof updateAgentSchema>;
export const updateAgentInstructionsPathSchema = z.object({
path: z.string().trim().min(1).nullable(),
adapterConfigKey: z.string().trim().min(1).optional(),
});
export type UpdateAgentInstructionsPath = z.infer<typeof updateAgentInstructionsPathSchema>;
export const taskBridgeAgentKeyScopeSchema = z.object({
kind: z.literal("task_bridge"),
projectId: z.string().guid().optional().nullable(),
projectIds: z.array(z.string().guid()).max(50).optional(),
parentIssueId: z.string().guid().optional().nullable(),
parentIssueIds: z.array(z.string().guid()).max(50).optional(),
allowedAssigneeAgentIds: z.array(z.string().guid()).max(50).optional(),
}).strict().superRefine((value, ctx) => {
const hasProjectBoundary = Boolean(value.projectId) || Boolean(value.projectIds?.length);
const hasParentBoundary = Boolean(value.parentIssueId) || Boolean(value.parentIssueIds?.length);
if (!hasProjectBoundary && !hasParentBoundary) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "task_bridge keys require at least one project or parent issue boundary",
path: ["projectId"],
});
}
});
export const standardAgentKeyScopeSchema = z.object({
kind: z.literal("standard"),
}).strict();
export const skillTestAgentKeyScopeSchema = z.object({
kind: z.literal("skill_test"),
issueId: z.string().guid(),
}).strict();
export const agentApiKeyScopeSchema = z.union([
standardAgentKeyScopeSchema,
taskBridgeAgentKeyScopeSchema,
skillTestAgentKeyScopeSchema,
]);
export type AgentApiKeyScope = z.infer<typeof agentApiKeyScopeSchema>;
export type TaskBridgeAgentKeyScope = z.infer<typeof taskBridgeAgentKeyScopeSchema>;
export type SkillTestAgentKeyScope = z.infer<typeof skillTestAgentKeyScopeSchema>;
export function normalizeAgentApiKeyScope(value: unknown): AgentApiKeyScope {
const parsed = agentApiKeyScopeSchema.safeParse(value);
return parsed.success ? parsed.data : { kind: "standard" };
}
export const createAgentKeySchema = z.object({
name: z.string().min(1).default("default"),
scope: agentApiKeyScopeSchema.optional().default({ kind: "standard" }),
});
export type CreateAgentKey = z.infer<typeof createAgentKeySchema>;
export const agentMineInboxQuerySchema = z.object({
userId: z.string().trim().min(1),
status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER),
});
export type AgentMineInboxQuery = z.infer<typeof agentMineInboxQuerySchema>;
export const wakeAgentSchema = z.object({
source: z
.enum(["timer", "assignment", "on_demand", "automation"])
.optional()
.default("on_demand"),
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),
reason: z.string().optional().nullable(),
/** Select an exact failed run; its chat request and actor are server-derived. */
failedRunId: z.string().uuid().optional(),
payload: z.record(z.string(), z.unknown()).optional().nullable(),
idempotencyKey: z.string().optional().nullable(),
forceFreshSession: z.preprocess(
(value) => (value === null ? undefined : value),
z.boolean().optional().default(false),
),
debug: z
.object({
providerTrace: z.literal("raw"),
})
.strict()
.optional(),
});
export type WakeAgent = z.infer<typeof wakeAgentSchema>;
export const resetAgentSessionSchema = z.object({
taskKey: z.string().min(1).optional().nullable(),
});
export type ResetAgentSession = z.infer<typeof resetAgentSessionSchema>;
export const testAdapterEnvironmentSchema = z.object({
/** Saved agent whose redacted environment entries are restored for this probe. */
agentId: z.string().guid().optional(),
/** One-shot provider keys for a probe. Never persist these in agent config. */
testCredentials: z.object({
ANTHROPIC_API_KEY: z.string().max(16384),
OPENAI_API_KEY: z.string().max(16384),
OPENROUTER_API_KEY: z.string().max(16384),
GEMINI_API_KEY: z.string().max(16384),
XAI_API_KEY: z.string().max(16384),
GROQ_API_KEY: z.string().max(16384),
OPENCODE_API_KEY: z.string().max(16384),
CURSOR_API_KEY: z.string().max(16384),
KIMI_MODEL_API_KEY: z.string().max(16384),
API_SERVER_KEY: z.string().max(16384),
ZAI_API_KEY: z.string().max(16384),
KIMI_API_KEY: z.string().max(16384),
MINIMAX_API_KEY: z.string().max(16384),
}).partial().strict().optional(),
adapterConfig: adapterConfigSchema.optional().default({}),
/**
* Optional environment to run the adapter test inside. When omitted, the
* test runs against the local Paperclip host. When provided and the
* environment is non-local (SSH/sandbox), the test probes are executed
* inside that environment so the result reflects real agent execution.
*/
environmentId: z.string().guid().optional().nullable(),
});
export type TestAdapterEnvironment = z.infer<typeof testAdapterEnvironmentSchema>;
export const updateAgentPermissionsSchema = z.object({
canCreateAgents: z.boolean(),
canCreateSkills: z.boolean().optional(),
canAssignTasks: z.boolean(),
trustPreset: trustPresetSchema.optional(),
authorizationPolicy: trustAuthorizationPolicySchema.optional(),
});
export type UpdateAgentPermissions = z.infer<typeof updateAgentPermissionsSchema>;