diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index f955e67792a..7a3d1f7b401 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -118,6 +118,7 @@ Paperclip’s core identity is a **control plane for autonomous AI companies**, - Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable. - Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review. - Do not build enterprise-grade RBAC first. Paperclip now has authenticated mode, company memberships, instance roles, and permission grants, but fine-grained enterprise governance should remain secondary to the core company control plane. +- Do not interpret agent-level privacy flags as a project/issue privacy feature in V1; work visibility stays company-scoped. - Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath. - Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real. diff --git a/doc/SPEC.md b/doc/SPEC.md index 6a7039ca67f..95b632d492f 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -141,6 +141,8 @@ Hierarchical reporting structure. CEO at top, reports cascade down. **Full visibility across the org.** Every agent can see the entire org chart, all tasks, all agents. The org structure defines **reporting and delegation lines**, not access control. +Visibility settings on an agent profile (where supported) do not alter company-level visibility for tasks, projects, issues, comments, costs, or activity. Those work-object privacy controls are not a V1 feature until centralized scoped authorization is in place. + Each agent publishes a short description of their responsibilities and capabilities — almost like skills ("when I'm relevant"). This lets other agents discover who can help with what. ### Cross-Team Work diff --git a/packages/adapters/codex-local/src/server/codex-home.test.ts b/packages/adapters/codex-local/src/server/codex-home.test.ts new file mode 100644 index 00000000000..864837510b3 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-home.test.ts @@ -0,0 +1,57 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { prepareManagedCodexHome } from "./codex-home.js"; + +describe("codex managed home", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("treats a concurrently-created expected auth symlink as success", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-")); + const sharedCodexHome = path.join(root, "shared-codex-home"); + const paperclipHome = path.join(root, "paperclip-home"); + const managedCodexHome = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "codex-home", + ); + const sharedAuth = path.join(sharedCodexHome, "auth.json"); + const managedAuth = path.join(managedCodexHome, "auth.json"); + + await fs.mkdir(sharedCodexHome, { recursive: true }); + await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8"); + + const originalSymlink = fs.symlink.bind(fs); + vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => { + await originalSymlink(source, target, type); + const error = new Error("file already exists") as NodeJS.ErrnoException; + error.code = "EEXIST"; + throw error; + }); + + try { + await expect( + prepareManagedCodexHome( + { + CODEX_HOME: sharedCodexHome, + PAPERCLIP_HOME: paperclipHome, + PAPERCLIP_INSTANCE_ID: "default", + }, + async () => {}, + "company-1", + ), + ).resolves.toBe(managedCodexHome); + + expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true); + expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-home.ts b/packages/adapters/codex-local/src/server/codex-home.ts index 0cb737bb28f..ee87e6cbf5e 100644 --- a/packages/adapters/codex-local/src/server/codex-home.ts +++ b/packages/adapters/codex-local/src/server/codex-home.ts @@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise { await fs.mkdir(path.dirname(target), { recursive: true }); } +async function isExpectedSymlink(target: string, source: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) return false; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return false; + + return path.resolve(path.dirname(target), linkedPath) === path.resolve(source); +} + +async function createExpectedSymlink(target: string, source: string): Promise { + try { + await fs.symlink(source, target); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" && await isExpectedSymlink(target, source)) return; + throw error; + } +} + async function ensureSymlink(target: string, source: string): Promise { const existing = await fs.lstat(target).catch(() => null); if (!existing) { await ensureParentDir(target); - await fs.symlink(source, target); + await createExpectedSymlink(target, source); return; } @@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise { return; } - const linkedPath = await fs.readlink(target).catch(() => null); - if (!linkedPath) return; - - const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); - if (resolvedLinkedPath === source) return; + if (await isExpectedSymlink(target, source)) return; await fs.unlink(target); - await fs.symlink(source, target); + await createExpectedSymlink(target, source); } async function ensureCopiedFile(target: string, source: string): Promise { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 03f6ef58ee5..290627f262b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -543,6 +543,24 @@ export function agentRoutes( async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); + const decide = (access as typeof access & { decide?: typeof access.decide }).decide; + if (typeof decide === "function") { + const decision = await decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) { + throw forbidden(decision.explanation); + } + if (req.actor.type !== "agent") return null; + const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + return actorAgent; + } + if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return null; const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); @@ -566,6 +584,17 @@ export function agentRoutes( async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { assertBoard(req); assertCompanyAccess(req, companyId); + const decide = (access as typeof access & { decide?: typeof access.decide }).decide; + if (typeof decide === "function") { + const decision = await decide({ + actor: req.actor, + action: "agents:create", + resource: { type: "company", companyId }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); if (!allowed) { @@ -592,6 +621,16 @@ export function agentRoutes( async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); + const decide = (access as typeof access & { decide?: typeof access.decide }).decide; + if (typeof decide === "function") { + const decision = await decide({ + actor: req.actor, + action: "agent_config:read", + resource: { type: "company", companyId }, + }); + return decision.allowed; + } + if (req.actor.type === "board") { if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true; return access.canUser(companyId, req.actor.userId, "agents:create"); @@ -672,6 +711,17 @@ export function agentRoutes( async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); + const decide = (access as typeof access & { decide?: typeof access.decide }).decide; + if (typeof decide === "function") { + const decision = await decide({ + actor: req.actor, + action: "agent_config:update", + resource: { type: "agent", companyId: targetAgent.companyId, agentId: targetAgent.id }, + }); + if (decision.allowed) return; + throw forbidden(decision.explanation); + } + if (req.actor.type === "board") { await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); return;