Files
PaperClipAI/packages/shared/src/validators/agent.ts
T
DottaandPaperclip 0eb2c964a7 fix(agents): allow runner qualification with agent configuration permission (#17448)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent owners configure the harness and computer used for that work.
> - A legacy runner upgrade must first qualify an unverified sandbox.
> - The upgrade called an instance-admin probe, so a company owner with
agent configuration permission received HTTP 403.
> - This pull request qualifies the saved computer through the agent's
existing configuration authorization.
> - Company owners can complete the upgrade while general instance
environment management stays restricted.

## Linked Issues or Issue Description

Refs #17361. Related: #17122.

**What happened?**

Upgrade runner displayed for an existing Claude agent on a Daytona
Paperclip Computer. Clicking it returned “Instance admin access
required” for a cloud company owner. The agent remained on the legacy
runner.

**Expected behavior**

A caller permitted to update the agent can qualify that agent's saved
computer and upgrade. Instance-admin access is not required for this
agent-scoped operation.

**Steps to reproduce**

1. Sign in as a cloud company owner with `agents:configure` and no
instance-admin role.
2. Open an existing legacy Claude agent on an unqualified Daytona
snapshot.
3. Select Upgrade runner.

## What Changed

- Add `POST /api/agents/{id}/qualify-runner` with the existing
`agent_config:update` guard.
- Reuse the server's effective environment resolver. The submitted
environment ID only detects stale state; it cannot select a different
computer.
- Preserve company binding checks, check binding again at lease
acquisition, and record agent-scoped audit activity.
- Return qualification status and the lease receipt without
instance-level provider metadata or raw provider errors.
- Use the new endpoint for the existing-agent upgrade. Keep the generic
environment probe restricted to instance admins.
- Disable Upgrade runner when an unsaved computer change resolves to a
different target, with a save-first explanation. Other draft settings
still accompany an upgrade.
- Add real-database authorization regression tests and update the API
contract and access maps.

## Verification

- Red: the existing UI called the instance-wide probe and failed the new
agent-scoped call assertions in eight upgrade cases.
- Green: 349 related route, provider qualification, runner selection,
and UI tests passed.
- Green: 12 authorization cases passed with real PostgreSQL, real HTTP
routes, real saved role grants, and a simulated provider boot. They
cover the cloud owner, removed grants, foreign agent/computer, inherited
targets, stale target, invalid body, archived/local target, redacted
failure, audit scope, and unchanged agent settings.
- Review regression: two cases first reproduced the enabled upgrade
button after an unsaved computer change; both now pass, along with all
168 related UI tests. Qualified/unqualified computers and restoring the
saved selection are covered.
- All 26 OpenAPI cases passed; the 12 real-database authorization cases
also passed after rebase.
- The host exhausted PostgreSQL semaphores. For the 12 cases, a
temporary local-only setup used disposable PostgreSQL 18 in Docker
instead of the embedded database launcher. The committed tests use the
normal embedded database helper. No authorization decisions were mocked.
- `pnpm -r typecheck`, `pnpm build`, `pnpm check:token-gates`, map
validation, module-boundary validation, and `git diff --check` passed.
- `pnpm test:run` was attempted but did not establish a full local pass:
the host could not start embedded PostgreSQL clusters. All current-head
CI gates passed, including the full suite in CI. The commitperclip
review workflow timed out before reporting any executed steps on its
first attempt and passed on one retry.
- Fresh staging acceptance remains pending deployment. Verify persisted
native configuration and a successful runtime test on the existing
Daytona computer after merge. Earlier live Daytona proof from #17361 is
separate evidence.

## Risks

- Qualification can start a fresh, temporary sandbox. It uses the
existing probe lifecycle and cleanup contract.
- The new path must remain scoped to the saved agent and its company.
Denial and redaction tests cover that boundary. General instance probe
authorization is unchanged.
- No migration, role change, credential change, or automatic agent
upgrade is included.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, code execution, and
browser tools. The exact deployed model variant and context-window size
were not exposed in this session.

## 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-10-24 10:31:40 -07:00

330 lines
13 KiB
TypeScript

import { agentAppearanceSchema } from "../agent-appearance.js";
import { aiRuntimeConnectionBindingSchema as aiConnectionBindingSchema } from "../ai-connection-router.js";
import { z } from "zod";
import {
AGENT_ICON_NAMES,
AGENT_ROLES,
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().min(1).max(512),
content: z.string().max(1024 * 1024),
baseRevisionId: z.string().uuid().nullable().optional(),
baseHash: z.string().regex(/^[a-f0-9]{64}$/).nullable().optional(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
}).strict();
export const restoreAgentInstructionSchema = z.object({
path: z.string().min(1).max(512),
revisionId: z.string().uuid(),
baseRevisionId: z.string().uuid(),
}).strict();
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({
aiConnection: aiConnectionBindingSchema.optional(),
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({
setupId: z.string().guid().optional(),
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(),
appearance: agentAppearanceSchema.optional(),
reportsTo: z.string().guid().optional().nullable(),
capabilities: z.string().optional().nullable(),
desiredSkills: z.array(agentDesiredSkillSelectionSchema).optional(),
adapterType: agentAdapterTypeSchema,
runner: z.enum(["auto", "paperclip", "legacy"]).optional(),
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(),
runner: z.enum(["auto", "paperclip", "legacy"]).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.omit({ setupId: true }).extend({
setupAgentId: z.string().guid().optional(),
sourceIssueId: z.string().guid().optional().nullable(),
sourceIssueIds: z.array(z.string().guid()).optional(),
// Agent-authored hires may explicitly request the caller's native runner
// settings. The server consumes this intent; it is never an agent column.
inheritRuntimeFrom: z.literal("caller").optional(),
});
export type CreateAgentHire = z.infer<typeof createAgentHireSchema>;
// Narrow intent used by roster bulk edits. The server merges only these fields
// into the latest configuration and rejects stale previews.
export const agentRuntimeSettingsPatchSchema = z.object({
harness: z.string().min(1),
model: z.string().trim().min(1).max(512).optional(),
aiConnection: aiConnectionBindingSchema.optional(),
expected: z.object({
model: z.string().nullable(),
aiConnection: aiConnectionBindingSchema.nullable(),
}).strict(),
}).strict().refine(value => value.model !== undefined || value.aiConnection !== undefined, {
message: "Choose a connection or default model to change",
});
export type AgentRuntimeSettingsPatch = z.infer<typeof agentRuntimeSettingsPatchSchema>;
export const updateAgentSchema = objectWithoutDefaults(
createAgentSchema.omit({ permissions: true, onboardingFirstAgent: true, setupId: true }),
)
.partial()
.extend({
permissions: z.never().optional(),
runtimeSettings: agentRuntimeSettingsPatchSchema.optional(),
replaceAdapterConfig: z.boolean().optional(),
status: z.enum(["paused", "idle", "terminated"]).optional(),
spentMonthlyCents: z.never().optional(),
});
export type UpdateAgent = z.infer<typeof updateAgentSchema>;
export const qualifyAgentRunnerSchema = z.object({
expectedEnvironmentId: z.string().uuid(),
}).strict();
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({
runner: z.enum(["auto", "paperclip", "legacy"]).optional(),
aiConnection: aiConnectionBindingSchema.optional(),
/** 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>;
export const resolveAgentInstructionCandidateSchema = z.object({
baseRevisionId: z.string().uuid().nullable(),
content: z.string().max(1024 * 1024),
}).strict();
export type ResolveAgentInstructionCandidate = z.infer<typeof resolveAgentInstructionCandidateSchema>;