mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Low-trust agents may read only the projects in their allowed scope. > - Workspace setup correctly stops a task outside that scope. > - The task previously showed a generic failure, and agent settings did not offer an additive repair. > - This pull request explains the denial and lets authorized operators add projects while preserving other restrictions. > - The saved task then offers an explicit retry. Saving access does not start work. ## Linked Issues or Issue Description **What happened?** A project access denial appeared as an ordinary failed attempt. Operators could not add a second allowed project from agent settings. **Expected behavior** The task should explain the denied access and link to a working permission editor. The editor should preserve existing scope and restrictions, enforce operator authority, and retain drafts after a failed save. **Steps to reproduce** 1. Assign a low-trust agent to a task whose project is outside its allowed projects. 2. Let workspace setup deny project access. 3. Open the task, follow Manage project access, add the project, save, reload, and return to the task. 4. Confirm that the saved message remains and Retry task is a separate action. ## What Changed - Record a typed, non-retryable project denial before provider dispatch. Project denials do not enter automatic continuation or provider comment policy. - Project current and historical setup denials into a safe task notice. Keep unrelated failures, prior assignees, and approval/pause/budget gates separate. Recheck current project authority, including standard trust and root-issue grants. Accepted retries keep their working state. - Add board-only project-access reads and additive saves under the agent row lock. Enforce company scope, agent-management authority, pending-approval restrictions, and an audit record for new additions. - Add the production notice and searchable multi-project editor, including read-only, saving, cancellation, and failed-save states. Preserve the return-to-task query through canonical agent redirects. - Register board-only project-access routes in OpenAPI with a strict request schema. - Add production Storybook examples and API/persistence/concurrency regressions. Retain the approved design snapshot and update the behavior maps and implementation contract. ## Verification - Passed: `pnpm -r typecheck`, `pnpm build`, `pnpm build-storybook`, `pnpm check:token-gates`, `node scripts/check-maps.mjs`, and `node scripts/storybook-catalog.mjs`. - Passed: 11 permission/projection tests and 30 run-completion tests against an isolated PostgreSQL database. These cover historical failures, concurrent additions, idempotent audit, preserved restrictions, foreign projects, read-only users, agent self-grants, and no automatic wakeup. - Passed: all 27 OpenAPI route tests, including route coverage and the new board-only request/response contract. - Browser: the real app displayed a historical denial; Manage project access opened the editor; saving retained the original project and survived reload; Return to task showed the ready notice. Saving created one audit event and zero runs. Explicit Retry returned 202 and reached the separate sandbox requirement, which remained enforced. No external provider was used. - All five production Storybook interaction checks pass: blocked task, save, cancellation, failed-save recovery, and read-only settings. These use fixture callbacks; durable authorization is covered separately above. - `pnpm test:run` was attempted locally but stopped after repeated embedded PostgreSQL startup failures caused by macOS semaphore exhaustion. Full-suite CI is required before merge. The separate UI suite passed all 9,095 tests. The 41 focused server regressions also passed after rebasing onto the latest master. A further 26 recovery-classification tests pass, including a red-to-green regression for automatic continuation after a project denial. ## Risks - No schema migration. The new endpoint only adds same-company project IDs and preserves the rest of the stored policy. - The historical notice recognizes one exact setup denial message. It does not display arbitrary run errors or disclose a different source project's identity. - The retry still passes all current admission and isolation checks. The notice does not promise that other execution requirements are satisfied. - Related open work: #13309 covers inherited secret restrictions; #11234 covers the broader repository catalog. This change does not replace either policy surface. ## Model Used OpenAI Codex (GPT-6), with code execution and browser tools. The precise runtime model ID 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 #` 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 (focused server and full UI suites; full-suite infrastructure limit above) - [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>
334 lines
14 KiB
TypeScript
334 lines
14 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 addAgentProjectAccessSchema = z.object({
|
|
projectIds: z.array(z.string().uuid()).min(1).max(100),
|
|
}).strict();
|
|
|
|
export const resolveAgentInstructionCandidateSchema = z.object({
|
|
baseRevisionId: z.string().uuid().nullable(),
|
|
content: z.string().max(1024 * 1024),
|
|
}).strict();
|
|
export type ResolveAgentInstructionCandidate = z.infer<typeof resolveAgentInstructionCandidateSchema>;
|