[codex] harden authenticated routes and issue editor reliability (#3741)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The control plane depends on authenticated routes enforcing company
boundaries and role permissions correctly
> - This branch also touches the issue detail and markdown editing flows
operators use while handling advisory and triage work
> - Partial issue cache seeds and fragile rich-editor parsing could
leave important issue content missing or blank at the moment an operator
needed it
> - Blocked issues becoming actionable again should wake their assignee
automatically instead of silently staying idle
> - This pull request rebases the advisory follow-up branch onto current
`master`, hardens authenticated route authorization, and carries the
issue-detail/editor reliability fixes forward with regression tests
> - The benefit is tighter authz on sensitive routes plus more reliable
issue/advisory editing and wakeup behavior on top of the latest base

## What Changed

- Hardened authenticated route authorization across agent, activity,
approval, access, project, plugin, health, execution-workspace,
portability, and related server paths, with new cross-tenant and
runtime-authz regression coverage.
- Switched issue detail queries from `initialData` to placeholder-based
hydration so list/quicklook seeds still refetch full issue bodies.
- Normalized advisory-style HTML images before mounting the markdown
editor and strengthened fallback behavior when the rich editor silently
fails or rejects the content.
- Woke assigned agents when blocked issues move back to `todo`, with
route coverage for reopen and unblock transitions.
- Rebasing note: this branch now sits cleanly on top of the latest
`master` tip used for the PR base.

## Verification

- `pnpm exec vitest run ui/src/lib/issueDetailQuery.test.tsx
ui/src/components/MarkdownEditor.test.tsx
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/activity-routes.test.ts
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts`
- Confirmed `pnpm-lock.yaml` is not part of the PR diff.
- Rebased the branch onto current `public-gh/master` before publishing.

## Risks

- Broad authz tightening may expose existing flows that were relying on
permissive board or agent access and now need explicit grants.
- Markdown editor fallback changes could affect focus or rendering in
edge-case content that mixes HTML-like advisory markup with normal
markdown.
- This verification was intentionally scoped to touched regressions and
did not run the full repository suite.

## Model Used

- OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment
with tool use for terminal, git, and GitHub operations. The exact
runtime model identifier is not exposed inside 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, it is behavior-only and does not
need before/after screenshots
- [x] I have updated relevant documentation to reflect my changes, or no
documentation changes were needed for these internal fixes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-04-15 08:41:15 -05:00 committed by GitHub
parent 50cd76d8a3
commit 32a9165ddf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3014 additions and 153 deletions

View file

@ -619,11 +619,25 @@ export function agentService(db: Db) {
.from(agentApiKeys)
.where(eq(agentApiKeys.agentId, id)),
revokeKey: async (keyId: string) => {
getKeyById: async (keyId: string) =>
db
.select({
id: agentApiKeys.id,
agentId: agentApiKeys.agentId,
companyId: agentApiKeys.companyId,
name: agentApiKeys.name,
createdAt: agentApiKeys.createdAt,
revokedAt: agentApiKeys.revokedAt,
})
.from(agentApiKeys)
.where(eq(agentApiKeys.id, keyId))
.then((rows) => rows[0] ?? null),
revokeKey: async (agentId: string, keyId: string) => {
const rows = await db
.update(agentApiKeys)
.set({ revokedAt: new Date() })
.where(eq(agentApiKeys.id, keyId))
.where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId)))
.returning();
return rows[0] ?? null;
},

View file

@ -47,7 +47,9 @@ import {
readPaperclipSkillSyncPreference,
writePaperclipSkillSyncPreference,
} from "@paperclipai/adapter-utils/server-utils";
import { notFound, unprocessable } from "../errors.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
import { findServerAdapter } from "../adapters/index.js";
import { forbidden, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import type { StorageService } from "../storage/types.js";
import { accessService } from "./access.js";
@ -62,6 +64,7 @@ import { validateCron } from "./cron.js";
import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
import { routineService } from "./routines.js";
import { secretService } from "./secrets.js";
/** Build OrgNode tree from manifest agent list (slug + reportsToSlug). */
function buildOrgTreeFromManifest(agents: CompanyPortabilityManifest["agents"]): OrgNode[] {
@ -117,6 +120,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
};
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
const IMPORT_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]);
const execFileAsync = promisify(execFile);
let bundledSkillsCommitPromise: Promise<string | null> | null = null;
@ -2747,6 +2751,94 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const projects = projectService(db);
const issues = issueService(db);
const companySkills = companySkillService(db);
const secrets = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
function assertKnownImportAdapterType(type: string | null | undefined): string {
const adapterType = typeof type === "string" ? type.trim() : "";
if (!adapterType) {
throw unprocessable("Adapter type is required");
}
if (!findServerAdapter(adapterType)) {
throw unprocessable(`Unknown adapter type: ${adapterType}`);
}
return adapterType;
}
async function assertImportAdapterConfigConstraints(
companyId: string,
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
if (adapterType !== "opencode_local") return;
const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig);
const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {};
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: runtimeConfig.model,
command: runtimeConfig.command,
cwd: runtimeConfig.cwd,
env: runtimeEnv,
});
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`);
}
}
async function prepareImportedAgentAdapter(
companyId: string,
adapterType: string | null | undefined,
adapterConfig: Record<string, unknown>,
desiredSkills: string[],
mode: ImportMode,
) {
const effectiveAdapterType = assertKnownImportAdapterType(adapterType);
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
delete nextAdapterConfig.instructionsFilePath;
delete nextAdapterConfig.instructionsBundleMode;
delete nextAdapterConfig.instructionsRootPath;
delete nextAdapterConfig.instructionsEntryFile;
const normalizedAdapterConfig = await secrets.normalizeAdapterConfigForPersistence(
companyId,
nextAdapterConfig,
{ strictMode: strictSecretsMode },
);
await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig);
return {
adapterType: effectiveAdapterType,
adapterConfig: normalizedAdapterConfig,
};
}
function resolveImportedAssigneeAgentId(
assigneeSlug: string | null | undefined,
importedSlugToAgentId: Map<string, string>,
existingSlugToAgentId: Map<string, string>,
agentStatusById: Map<string, string | null | undefined>,
warnings: string[],
subjectLabel: string,
) {
if (!assigneeSlug) return null;
const assigneeAgentId =
importedSlugToAgentId.get(assigneeSlug)
?? existingSlugToAgentId.get(assigneeSlug)
?? null;
if (!assigneeAgentId) return null;
const assigneeStatus = agentStatusById.get(assigneeAgentId) ?? null;
if (assigneeStatus === "pending_approval" || assigneeStatus === "terminated") {
warnings.push(
`${subjectLabel} assignee ${assigneeSlug} is ${assigneeStatus}; imported work was left unassigned.`,
);
return null;
}
return assigneeAgentId;
}
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
if (source.type === "inline") {
@ -3856,7 +3948,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const warnings = [...plan.preview.warnings];
const include = plan.include;
let targetCompany: { id: string; name: string } | null = null;
let targetCompany: {
id: string;
name: string;
requireBoardApprovalForNewAgents?: boolean | null;
} | null = null;
let companyAction: "created" | "updated" | "unchanged" = "unchanged";
if (input.target.mode === "new_company") {
@ -3977,9 +4073,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const resultProjects: CompanyPortabilityImportResult["projects"] = [];
const importedSlugToAgentId = new Map<string, string>();
const existingSlugToAgentId = new Map<string, string>();
const agentStatusById = new Map<string, string | null | undefined>();
const existingAgents = await agents.list(targetCompany.id);
for (const existing of existingAgents) {
existingSlugToAgentId.set(normalizeAgentUrlKey(existing.name) ?? existing.id, existing.id);
agentStatusById.set(existing.id, existing.status);
}
const importedSlugToProjectId = new Map<string, string>();
const importedProjectWorkspaceIdByProjectSlug = new Map<string, Map<string, string>>();
@ -4049,22 +4147,18 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
// Apply adapter overrides from request if present
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
const baseAdapterConfig = adapterOverride?.adapterConfig
? { ...adapterOverride.adapterConfig }
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef);
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
const normalizedAdapter = await prepareImportedAgentAdapter(
targetCompany.id,
adapterOverride?.adapterType ?? manifestAgent.adapterType,
baseAdapterConfig,
desiredSkills,
mode,
);
delete adapterConfigWithSkills.promptTemplate;
delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated
delete adapterConfigWithSkills.instructionsFilePath;
delete adapterConfigWithSkills.instructionsBundleMode;
delete adapterConfigWithSkills.instructionsRootPath;
delete adapterConfigWithSkills.instructionsEntryFile;
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@ -4072,8 +4166,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
icon: manifestAgent.icon,
capabilities: manifestAgent.capabilities,
reportsTo: null,
adapterType: effectiveAdapterType,
adapterConfig: adapterConfigWithSkills,
adapterType: normalizedAdapter.adapterType,
adapterConfig: normalizedAdapter.adapterConfig,
runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig),
budgetMonthlyCents: manifestAgent.budgetMonthlyCents,
permissions: manifestAgent.permissions,
@ -4102,6 +4196,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
importedSlugToAgentId.set(planAgent.slug, updated.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
resultAgents.push({
@ -4114,7 +4209,17 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
continue;
}
let created = await agents.create(targetCompany.id, patch);
const requiresApproval =
typeof targetCompany.requireBoardApprovalForNewAgents === "boolean"
? targetCompany.requireBoardApprovalForNewAgents
: include.company
? (sourceManifest.company?.requireBoardApprovalForNewAgents ?? true)
: true;
const createdStatus = requiresApproval ? "pending_approval" : "idle";
let created = await agents.create(targetCompany.id, {
...patch,
status: createdStatus,
});
await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active");
await access.setPrincipalPermission(
targetCompany.id,
@ -4133,6 +4238,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? createdStatus);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
@ -4275,11 +4381,14 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const markdownRaw = readPortableTextFile(plan.source.files, manifestIssue.path);
const parsed = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : null;
const description = parsed?.body || manifestIssue.description || null;
const assigneeAgentId = manifestIssue.assigneeAgentSlug
? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug)
?? null
: null;
const assigneeAgentId = resolveImportedAssigneeAgentId(
manifestIssue.assigneeAgentSlug,
importedSlugToAgentId,
existingSlugToAgentId,
agentStatusById,
warnings,
`Task ${manifestIssue.slug}`,
);
const projectId = manifestIssue.projectSlug
? importedSlugToProjectId.get(manifestIssue.projectSlug)
?? existingProjectSlugToId.get(manifestIssue.projectSlug)
@ -4292,8 +4401,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Task ${manifestIssue.slug} references workspace key ${manifestIssue.projectWorkspaceKey}, but that workspace was not imported.`);
}
if (manifestIssue.recurring) {
if (!projectId || !assigneeAgentId) {
throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`);
if (!projectId) {
throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project required to create a routine.`);
}
const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule);
if (resolvedRoutine.errors.length > 0) {
@ -4373,15 +4482,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
continue;
}
let issueStatus = manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
? manifestIssue.status as typeof ISSUE_STATUSES[number]
: "backlog";
if (!assigneeAgentId && issueStatus === "in_progress") {
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
issueStatus = "todo";
}
await issues.create(targetCompany.id, {
projectId,
projectWorkspaceId,
title: manifestIssue.title,
description,
assigneeAgentId,
status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any)
? manifestIssue.status as typeof ISSUE_STATUSES[number]
: "backlog",
status: issueStatus,
priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any)
? manifestIssue.priority as typeof ISSUE_PRIORITIES[number]
: "medium",