Root cause
Authentication & access control introduced
Implement agent runtime services and WebSocket realtime
How AI contributed: Direct introductionPublished Apr 16, 2026 in
paperclipai/paperclipAll 8 findings in this repository
Trust-boundary failure (CWE-284): Paperclip-managed codex_local runtime spawns the local codex CLI with host environment/cwd inherited (runChildProcess(runId, 'codex', args, {cwd, env})) and passes through --dangerously-bypass-approvals-and-sandbox, so the Codex sandbox inherits ChatGPT/OpenAI app connectors (Gmail) from the host ~/.codex; dangerous-by-default: applyCreateDefaultsByAdapterType defaults dangerouslyBypassApprovalsAndSandbox to true when unset (DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX). Advisory: inherited Gmail connector enabled mailbox access and real outbound email; repeated send capability after manual stop.
Root cause
Implement agent runtime services and WebSocket realtime
Fix
fix: isolate codex home in worktrees
Case facts
@@ -0,0 +1,159 @@+import { createReadStream, createWriteStream, promises as fs } from "node:fs";+import path from "node:path";+import { createHash } from "node:crypto";+import { notFound } from "../errors.js";++export type RunLogStoreType = "local_file";++export interface RunLogHandle {+ store: RunLogStoreType;+ logRef: string;+}++export interface RunLogReadOptions {+ offset?: number;+ limitBytes?: number;+}++export interface RunLogReadResult {+ content: string;+ nextOffset?: number;+}++export interface RunLogFinalizeSummary {+ bytes: number;+ sha256?: string;+ compressed: boolean;+}++export interface RunLogStore {+ begin(input: { companyId: string; agentId: string; runId: string }): Promise<RunLogHandle>;+ append(+ handle: RunLogHandle,+ event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string },+ ): Promise<void>;+ finalize(handle: RunLogHandle): Promise<RunLogFinalizeSummary>;+ read(handle: RunLogHandle, opts?: RunLogReadOptions): Promise<RunLogReadResult>;+}++function safeSegments(...segments: string[]) {+ return segments.map((segment) => segment.replace(/[^a-zA-Z0-9._-]/g, "_"));+}++function resolveWithin(basePath: string, relativePath: string) {+ const resolved = path.resolve(basePath, relativePath);+ const base = path.resolve(basePath) + path.sep;+ if (!resolved.startsWith(base) && resolved !== path.resolve(basePath)) {+ throw new Error("Invalid log path");+ }+ return resolved;+}++function createLocalFileRunLogStore(basePath: string): RunLogStore {+ async function ensureDir(relativeDir: string) {+ const dir = resolveWithin(basePath, relativeDir);+ await fs.mkdir(dir, { recursive: true });+ }++ async function readFileRange(filePath: string, offset: number, limitBytes: number): Promise<RunLogReadResult> {+ const stat = await fs.stat(filePath).catch(() => null);+ if (!stat) throw notFound("Run log not found");++ const start = Math.max(0, Math.min(offset, stat.size));+ const end = Math.max(start, Math.min(start + limitBytes - 1, stat.size - 1));++ if (start > end) {+ return { content: "", nextOffset: start };+ }++ const chunks: Buffer[] = [];+ await new Promise<void>((resolve, reject) => {+ const stream = createReadStream(filePath, { start, end });+ stream.on("data", (chunk) => {+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));+ });+ stream.on("error", reject);+ stream.on("end", () => resolve());+ });++ const content = Buffer.concat(chunks).toString("utf8");+ const nextOffset = end + 1 < stat.size ? end + 1 : undefined;+ return { content, nextOffset };+ }++ async function sha256File(filePath: string): Promise<string> {+ return new Promise<string>((resolve, reject) => {+ const hash = createHash("sha256");+ const stream = createReadStream(filePath);+ stream.on("data", (chunk) => hash.update(chunk));+ stream.on("error", reject);@@ -1,16 +1,67 @@ import { spawn, type ChildProcess } from "node:child_process";-import { and, eq, inArray } from "drizzle-orm";+import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclip/db";-import { agents, heartbeatRuns } from "@paperclip/db";+import {+ agents,+ agentRuntimeState,+ agentWakeupRequests,+ heartbeatRunEvents,+ heartbeatRuns,+ costEvents,+} from "@paperclip/db"; import { conflict, notFound } from "../errors.js"; import { logger } from "../middleware/logger.js";+import { publishLiveEvent } from "./live-events.js";+import { getRunLogStore, type RunLogHandle } from "./run-log-store.js"; interface RunningProcess { child: ChildProcess; graceSec: number; } +interface RunProcessResult {+ exitCode: number | null;+ signal: string | null;+ timedOut: boolean;+ stdout: string;+ stderr: string;+}++interface UsageSummary {+ inputTokens: number;+ outputTokens: number;+ cachedInputTokens?: number;+}++interface AdapterExecutionResult {+ exitCode: number | null;+ signal: string | null;+ timedOut: boolean;+ errorMessage?: string | null;+ usage?: UsageSummary;+ sessionId?: string | null;+ provider?: string | null;+ model?: string | null;+ costUsd?: number | null;+ resultJson?: Record<string, unknown> | null;+ summary?: string | null;+}++interface WakeupOptions {+ source?: "timer" | "assignment" | "on_demand" | "automation";+ triggerDetail?: "manual" | "ping" | "callback" | "system";+ reason?: string | null;+ payload?: Record<string, unknown> | null;+ idempotencyKey?: string | null;+ requestedByActorType?: "user" | "agent" | "system";+ requestedByActorId?: string | null;+ contextSnapshot?: Record<string, unknown>;+}+ const runningProcesses = new Map<string, RunningProcess>();+const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;+const MAX_EXCERPT_BYTES = 32 * 1024;+const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; function parseObject(value: unknown): Record<string, unknown> { if (typeof value !== "object" || value === null || Array.isArray(value)) {@@ -27,11 +78,181 @@ function asNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +function asBoolean(value: unknown, fallback: boolean): boolean {+ return typeof value === "boolean" ? value : fallback;+}+ function asStringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } +function parseJson(value: string): Record<string, unknown> | null {+ try {+ return JSON.parse(value) as Record<string, unknown>;+ } catch {+ return null;+ }+}+@@ -7,12 +7,14 @@ import { updateIssueSchema, } from "@paperclip/shared"; import { validate } from "../middleware/validate.js";-import { issueService, logActivity } from "../services/index.js";+import { heartbeatService, issueService, logActivity } from "../services/index.js";+import { logger } from "../middleware/logger.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; export function issueRoutes(db: Db) { const router = Router(); const svc = issueService(db);+ const heartbeat = heartbeatService(db); router.get("/companies/:companyId/issues", async (req, res) => { const companyId = req.params.companyId as string;@@ -58,6 +60,20 @@ export function issueRoutes(db: Db) { details: { title: issue.title }, }); + if (issue.assigneeAgentId) {+ void heartbeat+ .wakeup(issue.assigneeAgentId, {+ source: "assignment",+ triggerDetail: "system",+ reason: "issue_assigned",+ payload: { issueId: issue.id, mutation: "create" },+ requestedByActorType: actor.actorType,+ requestedByActorId: actor.actorId,+ contextSnapshot: { issueId: issue.id, source: "issue.create" },+ })+ .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue create"));+ }+ res.status(201).json(issue); }); @@ -88,6 +104,22 @@ export function issueRoutes(db: Db) { details: req.body, }); + const assigneeChanged =+ req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;+ if (assigneeChanged && issue.assigneeAgentId) {+ void heartbeat+ .wakeup(issue.assigneeAgentId, {+ source: "assignment",+ triggerDetail: "system",+ reason: "issue_assigned",+ payload: { issueId: issue.id, mutation: "update" },+ requestedByActorType: actor.actorType,+ requestedByActorId: actor.actorId,+ contextSnapshot: { issueId: issue.id, source: "issue.update" },+ })+ .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update"));+ }+ res.json(issue); }); @@ -148,6 +180,18 @@ export function issueRoutes(db: Db) { details: { agentId: req.body.agentId }, }); + void heartbeat+ .wakeup(req.body.agentId, {+ source: "assignment",+ triggerDetail: "system",+ reason: "issue_checked_out",+ payload: { issueId: issue.id, mutation: "checkout" },+ requestedByActorType: actor.actorType,+ requestedByActorId: actor.actorId,+ contextSnapshot: { issueId: issue.id, source: "issue.checkout" },+ })+ .catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));+ res.json(updated); }); @@ -1,5 +1,4 @@ import fs from "node:fs/promises";-import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";@@ -22,6 +21,7 @@ import { runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";+import { prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE =@@ -61,10 +61,36 @@ function resolveCodexBillingType(env: Record<string, string>): "api" | "subscrip return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; } -function codexHomeDir(): string {- const fromEnv = process.env.CODEX_HOME;- if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();- return path.join(os.homedir(), ".codex");+async function pathExists(candidate: string): Promise<boolean> {+ return fs.access(candidate).then(() => true).catch(() => false);+}++async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {+ const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([+ pathExists(path.join(candidate, "pnpm-workspace.yaml")),+ pathExists(path.join(candidate, "package.json")),+ pathExists(path.join(candidate, "server")),+ pathExists(path.join(candidate, "packages", "adapter-utils")),+ ]);++ return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;+}++async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> {+ if (path.basename(candidate) !== skillName) return false;+ const skillsRoot = path.dirname(candidate);+ if (path.basename(skillsRoot) !== "skills") return false;+ if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false;++ let cursor = path.dirname(skillsRoot);+ for (let depth = 0; depth < 6; depth += 1) {+ if (await isLikelyPaperclipRepoRoot(cursor)) return true;+ const parent = path.dirname(cursor);+ if (parent === cursor) break;+ cursor = parent;+ }++ return false; } type EnsureCodexSkillsInjectedOptions = {@@ -80,7 +106,7 @@ export async function ensureCodexSkillsInjected( const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); if (skillsEntries.length === 0) return; - const skillsHome = options.skillsHome ?? path.join(codexHomeDir(), "skills");+ const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills"); await fs.mkdir(skillsHome, { recursive: true }); const removedSkills = await removeMaintainerOnlySkillSymlinks( skillsHome,@@ -97,6 +123,31 @@ export async function ensureCodexSkillsInjected( const target = path.join(skillsHome, entry.name); try {+ const existing = await fs.lstat(target).catch(() => null);+ if (existing?.isSymbolicLink()) {+ const linkedPath = await fs.readlink(target).catch(() => null);+ const resolvedLinkedPath = linkedPath+ ? path.resolve(path.dirname(target), linkedPath)+ : null;+ if (+ resolvedLinkedPath &&+ resolvedLinkedPath !== entry.source &&+ (await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))+ ) {+ await fs.unlink(target);+ if (linkSkill) {+ await linkSkill(entry.source, target);+ } else {+ await fs.symlink(entry.source, target);+ }+ await onLog(+ "stderr",+ `[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,+ );+ continue;@@ -0,0 +1,101 @@+import fs from "node:fs/promises";+import os from "node:os";+import path from "node:path";+import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";++const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;+const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"] as const;+const SYMLINKED_SHARED_FILES = ["auth.json"] as const;++function nonEmpty(value: string | undefined): string | null {+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;+}++async function pathExists(candidate: string): Promise<boolean> {+ return fs.access(candidate).then(() => true).catch(() => false);+}++export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string {+ const fromEnv = nonEmpty(env.CODEX_HOME);+ if (fromEnv) return path.resolve(fromEnv);+ return path.join(os.homedir(), ".codex");+}++function isWorktreeMode(env: NodeJS.ProcessEnv): boolean {+ return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");+}++function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null {+ if (!isWorktreeMode(env)) return null;+ const paperclipHome = nonEmpty(env.PAPERCLIP_HOME);+ if (!paperclipHome) return null;+ const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID);+ if (instanceId) {+ return path.resolve(paperclipHome, "instances", instanceId, "codex-home");+ }+ return path.resolve(paperclipHome, "codex-home");+}++async function ensureParentDir(target: string): Promise<void> {+ await fs.mkdir(path.dirname(target), { recursive: true });+}++async function ensureSymlink(target: string, source: string): Promise<void> {+ const existing = await fs.lstat(target).catch(() => null);+ if (!existing) {+ await ensureParentDir(target);+ await fs.symlink(source, target);+ return;+ }++ if (!existing.isSymbolicLink()) {+ return;+ }++ const linkedPath = await fs.readlink(target).catch(() => null);+ if (!linkedPath) return;++ const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);+ if (resolvedLinkedPath === source) return;++ await fs.unlink(target);+ await fs.symlink(source, target);+}++async function ensureCopiedFile(target: string, source: string): Promise<void> {+ const existing = await fs.lstat(target).catch(() => null);+ if (existing) return;+ await ensureParentDir(target);+ await fs.copyFile(source, target);+}++export async function prepareWorktreeCodexHome(+ env: NodeJS.ProcessEnv,+ onLog: AdapterExecutionContext["onLog"],+): Promise<string | null> {+ const targetHome = resolveWorktreeCodexHomeDir(env);+ if (!targetHome) return null;++ const sourceHome = resolveCodexHomeDir(env);+ if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;++ await fs.mkdir(targetHome, { recursive: true });++ for (const name of SYMLINKED_SHARED_FILES) {+ const source = path.join(sourceHome, name);+ if (!(await pathExists(source))) continue;+ await ensureSymlink(path.join(targetHome, name), source);+ }+@@ -0,0 +1,91 @@+import fs from "node:fs/promises";+import os from "node:os";+import path from "node:path";+import { afterEach, describe, expect, it } from "vitest";+import { ensureCodexSkillsInjected } from "@paperclipai/adapter-codex-local/server";++async function makeTempDir(prefix: string): Promise<string> {+ return fs.mkdtemp(path.join(os.tmpdir(), prefix));+}++async function createPaperclipRepoSkill(root: string, skillName: string) {+ await fs.mkdir(path.join(root, "server"), { recursive: true });+ await fs.mkdir(path.join(root, "packages", "adapter-utils"), { recursive: true });+ await fs.mkdir(path.join(root, "skills", skillName), { recursive: true });+ await fs.writeFile(path.join(root, "pnpm-workspace.yaml"), "packages:\n - packages/*\n", "utf8");+ await fs.writeFile(path.join(root, "package.json"), '{"name":"paperclip"}\n', "utf8");+ await fs.writeFile(+ path.join(root, "skills", skillName, "SKILL.md"),+ `---\nname: ${skillName}\n---\n`,+ "utf8",+ );+}++async function createCustomSkill(root: string, skillName: string) {+ await fs.mkdir(path.join(root, "custom", skillName), { recursive: true });+ await fs.writeFile(+ path.join(root, "custom", skillName, "SKILL.md"),+ `---\nname: ${skillName}\n---\n`,+ "utf8",+ );+}++describe("codex local adapter skill injection", () => {+ const cleanupDirs = new Set<string>();++ afterEach(async () => {+ await Promise.all(Array.from(cleanupDirs).map((dir) => fs.rm(dir, { recursive: true, force: true })));+ cleanupDirs.clear();+ });++ it("repairs a Codex Paperclip skill symlink that still points at another live checkout", async () => {+ const currentRepo = await makeTempDir("paperclip-codex-current-");+ const oldRepo = await makeTempDir("paperclip-codex-old-");+ const skillsHome = await makeTempDir("paperclip-codex-home-");+ cleanupDirs.add(currentRepo);+ cleanupDirs.add(oldRepo);+ cleanupDirs.add(skillsHome);++ await createPaperclipRepoSkill(currentRepo, "paperclip");+ await createPaperclipRepoSkill(oldRepo, "paperclip");+ await fs.symlink(path.join(oldRepo, "skills", "paperclip"), path.join(skillsHome, "paperclip"));++ const logs: string[] = [];+ await ensureCodexSkillsInjected(+ async (_stream, chunk) => {+ logs.push(chunk);+ },+ {+ skillsHome,+ skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],+ },+ );++ expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(+ await fs.realpath(path.join(currentRepo, "skills", "paperclip")),+ );+ expect(logs.some((line) => line.includes('Repaired Codex skill "paperclip"'))).toBe(true);+ });++ it("preserves a custom Codex skill symlink outside Paperclip repo checkouts", async () => {+ const currentRepo = await makeTempDir("paperclip-codex-current-");+ const customRoot = await makeTempDir("paperclip-codex-custom-");+ const skillsHome = await makeTempDir("paperclip-codex-home-");+ cleanupDirs.add(currentRepo);+ cleanupDirs.add(customRoot);+ cleanupDirs.add(skillsHome);++ await createPaperclipRepoSkill(currentRepo, "paperclip");+ await createCustomSkill(customRoot, "paperclip");+ await fs.symlink(path.join(customRoot, "custom", "paperclip"), path.join(skillsHome, "paperclip"));++ await ensureCodexSkillsInjected(async () => {}, {+ skillsHome,+ skillsEntries: [{ name: "paperclip", source: path.join(currentRepo, "skills", "paperclip") }],+ });++ expect(await fs.realpath(path.join(skillsHome, "paperclip"))).toBe(+ await fs.realpath(path.join(customRoot, "custom", "paperclip")),+ );AI-assisted change 07c2d80b6bd0b5f23c1ec9271d9dab5067bbd53d66efe880fb10b7574a86ffe1 · Fix 7c1ecbeb4eea0057734700396dc1c61377245e3cc3ce0f590ebf52f8e5415dab