From efdbf735b2cdb54f84f0cb8c2b706e0a801f405a Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 15 Apr 2026 16:02:13 -0500 Subject: [PATCH] wip: preserve local master delta on top of origin/master --- docs/adapters/creating-an-adapter.md | 37 -- docs/deploy/environment-variables.md | 3 +- packages/adapter-utils/src/types.ts | 30 -- .../adapters/claude-local/src/server/quota.ts | 8 +- packages/shared/src/types/routine.ts | 2 +- packages/shared/src/validators/approval.ts | 2 + releases/v2026.415.0.md | 27 ++ server/src/__tests__/activity-routes.test.ts | 27 +- server/src/__tests__/adapter-registry.test.ts | 45 -- server/src/__tests__/adapter-routes.test.ts | 69 --- .../agent-cross-tenant-authz-routes.test.ts | 267 ----------- .../agent-permissions-routes.test.ts | 190 -------- .../app-private-hostname-gate.test.ts | 32 -- .../approval-routes-idempotency.test.ts | 84 ---- server/src/__tests__/cli-auth-routes.test.ts | 48 +- .../src/__tests__/company-portability.test.ts | 200 +------- .../__tests__/company-skills-service.test.ts | 92 ++++ server/src/__tests__/costs-service.test.ts | 160 +++++++ server/src/__tests__/health.test.ts | 118 +---- .../__tests__/invite-onboarding-text.test.ts | 1 - .../issue-comment-reopen-routes.test.ts | 30 +- .../issue-workspace-command-authz.test.ts | 165 ------- .../issues-goal-context-routes.test.ts | 4 + server/src/__tests__/issues-service.test.ts | 115 +++++ .../src/__tests__/plugin-routes-authz.test.ts | 168 ------- server/src/__tests__/quota-windows.test.ts | 79 +--- server/src/__tests__/routines-e2e.test.ts | 8 - .../server-startup-feedback-export.test.ts | 24 - .../workspace-runtime-routes-authz.test.ts | 437 ------------------ .../workspace-runtime-service-authz.test.ts | 279 ----------- server/src/adapters/registry.ts | 23 - server/src/app.ts | 59 +-- server/src/index.ts | 6 +- server/src/log-redaction.ts | 2 + server/src/middleware/board-mutation-guard.ts | 6 - server/src/routes/access.ts | 59 +-- server/src/routes/activity.ts | 3 +- server/src/routes/adapters.ts | 18 - server/src/routes/agents.ts | 229 ++------- server/src/routes/approvals.ts | 21 +- server/src/routes/authz.ts | 14 +- server/src/routes/execution-workspaces.ts | 18 - server/src/routes/health.ts | 34 +- server/src/routes/issues.ts | 8 +- server/src/routes/plugins.ts | 15 +- server/src/routes/projects.ts | 30 -- server/src/routes/workspace-command-authz.ts | 115 ----- .../routes/workspace-runtime-service-authz.ts | 138 ------ server/src/services/agents.ts | 20 +- server/src/services/budgets.ts | 2 +- server/src/services/companies.ts | 8 +- server/src/services/company-portability.ts | 160 +------ server/src/services/company-skills.ts | 104 ++++- server/src/services/costs.ts | 82 ++-- server/src/services/dashboard.ts | 2 +- server/src/services/finance.ts | 14 +- server/src/services/issues.ts | 78 +++- server/src/services/routines.ts | 2 - ui/index.html | 2 +- ui/src/adapters/use-adapter-capabilities.ts | 54 --- ui/src/api/adapters.ts | 8 - ui/src/components/AgentConfigForm.tsx | 6 +- ui/src/components/InlineEditor.test.tsx | 56 --- ui/src/components/InlineEditor.tsx | 11 +- ui/src/components/IssueFiltersPopover.tsx | 100 +++- ui/src/components/IssueLinkQuicklook.tsx | 8 +- ui/src/components/IssueProperties.test.tsx | 3 - ui/src/components/IssueProperties.tsx | 17 +- ui/src/components/IssuesList.test.tsx | 41 -- ui/src/components/IssuesList.tsx | 83 +++- ui/src/components/MarkdownBody.test.tsx | 8 - ui/src/components/MarkdownBody.tsx | 12 +- ui/src/components/MarkdownEditor.test.tsx | 21 +- ui/src/components/OnboardingWizard.tsx | 6 +- .../transcript/RunTranscriptView.test.tsx | 8 +- ui/src/lib/inbox.test.ts | 9 + ui/src/lib/inbox.ts | 22 +- ui/src/lib/issue-filters.test.ts | 69 +++ ui/src/lib/issue-filters.ts | 32 ++ ui/src/lib/issue-reference.test.ts | 6 + ui/src/lib/issue-reference.ts | 4 +- ui/src/lib/issueDetailCache.ts | 14 - ui/src/lib/issueDetailQuery.test.tsx | 129 ------ ui/src/pages/AdapterManager.tsx | 6 - ui/src/pages/AgentDetail.tsx | 10 +- ui/src/pages/Inbox.tsx | 68 +++ ui/src/pages/IssueDetail.tsx | 36 +- ui/vite.config.ts | 14 +- 88 files changed, 1205 insertions(+), 3679 deletions(-) create mode 100644 releases/v2026.415.0.md delete mode 100644 server/src/__tests__/agent-cross-tenant-authz-routes.test.ts delete mode 100644 server/src/__tests__/app-private-hostname-gate.test.ts create mode 100644 server/src/__tests__/company-skills-service.test.ts delete mode 100644 server/src/__tests__/issue-workspace-command-authz.test.ts delete mode 100644 server/src/__tests__/plugin-routes-authz.test.ts delete mode 100644 server/src/__tests__/workspace-runtime-routes-authz.test.ts delete mode 100644 server/src/__tests__/workspace-runtime-service-authz.test.ts delete mode 100644 server/src/routes/workspace-command-authz.ts delete mode 100644 server/src/routes/workspace-runtime-service-authz.ts delete mode 100644 ui/src/adapters/use-adapter-capabilities.ts create mode 100644 ui/src/lib/issue-filters.test.ts delete mode 100644 ui/src/lib/issueDetailQuery.test.tsx diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index c81274f7559..ae5e4ccbe53 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -203,43 +203,6 @@ export const sessionCodec: AdapterSessionCodec = { }; ``` -## Capability Flags - -Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.). - -| Flag | Type | Default | What it controls | -|------|------|---------|------------------| -| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent | -| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor | -| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path | -| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution | - -These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined). - -### Example - -```ts -export function createServerAdapter(): ServerAdapterModule { - return { - type: "my_k8s_adapter", - execute: myExecute, - testEnvironment: myTestEnvironment, - listSkills: myListSkills, - syncSkills: mySyncSkills, - - // Capability flags - supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: true, - }; -} -``` - -With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required. - -If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities. - ## Skills Injection Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory: diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index b5b6486dfb7..5ebef728266 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -18,7 +18,6 @@ All environment variables that Paperclip uses for server configuration. | `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) | | `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override | | `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` | -| `PAPERCLIP_API_URL` | (auto-derived) | Paperclip API base URL. When set externally (e.g., via Kubernetes ConfigMap, load balancer, or reverse proxy), the server preserves the value instead of deriving it from the listen host and port. Useful for deployments where the public-facing URL differs from the local bind address. | ## Secrets @@ -36,7 +35,7 @@ These are set automatically by the server when invoking agents: |----------|-------------| | `PAPERCLIP_AGENT_ID` | Agent's unique ID | | `PAPERCLIP_COMPANY_ID` | Company ID | -| `PAPERCLIP_API_URL` | Paperclip API base URL (inherits the server-level value; see Server Configuration above) | +| `PAPERCLIP_API_URL` | Paperclip API base URL | | `PAPERCLIP_API_KEY` | Short-lived JWT for API auth | | `PAPERCLIP_RUN_ID` | Current heartbeat run ID | | `PAPERCLIP_TASK_ID` | Issue that triggered this wake | diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index e02b9efe8de..534a5a59754 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -328,36 +328,6 @@ export interface ServerAdapterModule { * resolved inside this method — the caller receives a fully hydrated schema. */ getConfigSchema?: () => Promise | AdapterConfigSchema; - - // --------------------------------------------------------------------------- - // Adapter capability flags - // - // These allow adapter plugins to declare what "local" capabilities they - // support, replacing hardcoded type lists in the server and UI. - // All flags are optional — when undefined, the server falls back to - // legacy hardcoded lists for built-in adapters. - // --------------------------------------------------------------------------- - - /** - * Adapter supports managed instructions bundle (AGENTS.md files). - * When true, the server uses instructionsPathKey (default "instructionsFilePath") - * to resolve the instructions config key, and the UI shows the bundle editor. - * Built-in local adapters default to true; external plugins must opt in. - */ - supportsInstructionsBundle?: boolean; - - /** - * The adapterConfig key that holds the instructions file path. - * Defaults to "instructionsFilePath" when supportsInstructionsBundle is true. - */ - instructionsPathKey?: string; - - /** - * Adapter needs runtime skill entries materialized (written to disk) - * before being passed via config. Used by adapters that scan a directory - * rather than reading config.paperclipRuntimeSkills. - */ - requiresMaterializedRuntimeSkills?: boolean; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/src/server/quota.ts b/packages/adapters/claude-local/src/server/quota.ts index eac10e44eb0..859a59c28e2 100644 --- a/packages/adapters/claude-local/src/server/quota.ts +++ b/packages/adapters/claude-local/src/server/quota.ts @@ -187,15 +187,13 @@ function formatExtraUsageLabel(extraUsage: AnthropicExtraUsage): string | null { ) { return null; } - // API returns values in cents — convert to dollars for display - return `${formatCurrencyAmount(usedCredits / 100, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit / 100, extraUsage.currency)}`; + return `${formatCurrencyAmount(usedCredits, extraUsage.currency)} / ${formatCurrencyAmount(monthlyLimit, extraUsage.currency)}`; } -/** Convert a utilization value to a 0-100 integer percent. Returns null for null/undefined input. - * Handles both 0-1 fractions (legacy) and 0-100 percentages (current API). */ +/** Convert a 0-1 utilization fraction to a 0-100 integer percent. Returns null for null/undefined input. */ export function toPercent(utilization: number | null | undefined): number | null { if (utilization == null) return null; - return Math.min(100, Math.round(utilization < 1 ? utilization * 100 : utilization)); + return Math.min(100, Math.round(utilization * 100)); } /** fetch with an abort-based timeout so a hanging provider api doesn't block the response indefinitely */ diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index c25ecb358e9..4efdc748c64 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -129,7 +129,7 @@ export interface RoutineExecutionIssueOrigin { } export interface RoutineListItem extends Routine { - triggers: Pick[]; + triggers: Pick[]; lastRun: RoutineRunSummary | null; activeIssue: RoutineIssueSummary | null; } diff --git a/packages/shared/src/validators/approval.ts b/packages/shared/src/validators/approval.ts index ae6b2eb3b30..d6a6cf0d575 100644 --- a/packages/shared/src/validators/approval.ts +++ b/packages/shared/src/validators/approval.ts @@ -12,12 +12,14 @@ export type CreateApproval = z.infer; export const resolveApprovalSchema = z.object({ decisionNote: z.string().optional().nullable(), + decidedByUserId: z.string().optional().default("board"), }); export type ResolveApproval = z.infer; export const requestApprovalRevisionSchema = z.object({ decisionNote: z.string().optional().nullable(), + decidedByUserId: z.string().optional().default("board"), }); export type RequestApprovalRevision = z.infer; diff --git a/releases/v2026.415.0.md b/releases/v2026.415.0.md new file mode 100644 index 00000000000..2286554a731 --- /dev/null +++ b/releases/v2026.415.0.md @@ -0,0 +1,27 @@ +# v2026.415.0 + +> Released: 2026-04-15 + +## Highlights + +- **Faster issues page first paint** — The issues list now renders the initial view significantly faster by deferring non-critical work and optimizing workspace lookups. +- **Issue detail performance** — Reduced unnecessary rerenders on the issue detail page and kept queued issue chat mounted to avoid layout thrash during agent runs. +- **Inbox search expansion** — Added an "Other results" section to inbox search, surfacing matches beyond the current filter scope. + +## Improvements + +- **Properties pane polish** — Workspace link, copy-on-click for identifiers, and an inline parent navigation arrow in the issue properties sidebar. +- **Routine UX** — Routine name now appears above the "Run routine" title in the run dialog; routine execution issues are shown in issue lists by default. +- **Filter label clarity** — Renamed the routine-run filter to "Hide routine runs" so the default state shows no active filter badge. +- **Stranded issue diagnostics** — Stranded issue comments now include the latest run failure message for faster triage. +- **Issues search responsiveness** — Debounce and rendering improvements make the issues page search feel snappier. +- **Live run refresh** — Visible issue runs now refresh automatically on status updates without a manual reload. +- **Heartbeat payload hygiene** — Raw `result_json` writes are preserved exactly as received; payloads are bounded to prevent oversized records. +- **Heartbeat log scoping** — Narrowed heartbeat log endpoint lookups to reduce query overhead. +- **Vite dev watch** — UI test files are now excluded from the Vite dev watcher, reducing unnecessary rebuilds during development. + +## Fixes + +- **Self-comment code block scrolling** — Fixed horizontal scrolling for code blocks inside an agent's own comment thread. +- **Markdown long-string wrapping** — Long unbroken strings in markdown comments now wrap correctly instead of overflowing the container. +- **Dev asset serving order** — Fixed dev mode to serve public assets before the HTML shell, preventing 404s on static files during local development. diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 974fcb2a093..e8b50dba1ed 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -28,15 +28,7 @@ vi.mock("../services/index.js", () => ({ heartbeatService: () => mockHeartbeatService, })); -async function createApp( - actor: Record = { - type: "board", - userId: "user-1", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }, -) { +async function createApp() { const [{ errorHandler }, { activityRoutes }] = await Promise.all([ import("../middleware/index.js"), import("../routes/activity.js"), @@ -44,7 +36,13 @@ async function createApp( const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }; next(); }); app.use("/api", activityRoutes({} as any)); @@ -107,13 +105,4 @@ describe("activity routes", () => { expect(res.status).toBe(403); expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); }); - - it("rejects anonymous heartbeat run issue lookups before run existence checks", async () => { - const app = await createApp({ type: "none", source: "none" }); - const res = await request(app).get("/api/heartbeat-runs/missing-run/issues"); - - expect(res.status).toBe(401); - expect(mockHeartbeatService.getRun).not.toHaveBeenCalled(); - expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); - }); }); diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 4e473df5b17..6f7b0973991 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -95,51 +95,6 @@ describe("server adapter registry", () => { ]); }); - it("exposes capability flags from registered adapters", () => { - const adapterWithCaps: ServerAdapterModule = { - type: "external_test", - execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), - testEnvironment: async () => ({ - adapterType: "external_test", - status: "pass" as const, - checks: [], - testedAt: new Date(0).toISOString(), - }), - supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "customPathKey", - requiresMaterializedRuntimeSkills: true, - }; - - registerServerAdapter(adapterWithCaps); - - const resolved = findActiveServerAdapter("external_test"); - expect(resolved).not.toBeNull(); - expect(resolved!.supportsInstructionsBundle).toBe(true); - expect(resolved!.instructionsPathKey).toBe("customPathKey"); - expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true); - expect(resolved!.supportsLocalAgentJwt).toBe(true); - }); - - it("returns undefined for capability flags on adapters that do not set them", () => { - registerServerAdapter(externalAdapter); - - const resolved = findActiveServerAdapter("external_test"); - expect(resolved).not.toBeNull(); - expect(resolved!.supportsInstructionsBundle).toBeUndefined(); - expect(resolved!.instructionsPathKey).toBeUndefined(); - expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined(); - }); - - it("built-in claude_local adapter declares capability flags", () => { - const adapter = findActiveServerAdapter("claude_local"); - expect(adapter).not.toBeNull(); - expect(adapter!.supportsInstructionsBundle).toBe(true); - expect(adapter!.instructionsPathKey).toBe("instructionsFilePath"); - expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false); - expect(adapter!.supportsLocalAgentJwt).toBe(true); - }); - it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index 31168aecd3b..32bf991dd10 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -77,75 +77,6 @@ describe("adapter routes", () => { unregisterServerAdapter("claude_local"); }); - it("GET /api/adapters includes capabilities object for each adapter", async () => { - const app = createApp(); - - const res = await request(app).get("/api/adapters"); - expect(res.status).toBe(200); - expect(Array.isArray(res.body)).toBe(true); - expect(res.body.length).toBeGreaterThan(0); - - // Every adapter should have a capabilities object - for (const adapter of res.body) { - expect(adapter.capabilities).toBeDefined(); - expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean"); - expect(typeof adapter.capabilities.supportsSkills).toBe("boolean"); - expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean"); - expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean"); - } - }); - - it("GET /api/adapters returns correct capabilities for built-in adapters", async () => { - const app = createApp(); - - const res = await request(app).get("/api/adapters"); - expect(res.status).toBe(200); - - // codex_local has instructions bundle + skills + jwt, no materialized skills - // (claude_local is overridden by beforeEach, so check codex_local instead) - const codexLocal = res.body.find((a: any) => a.type === "codex_local"); - expect(codexLocal).toBeDefined(); - expect(codexLocal.capabilities).toMatchObject({ - supportsInstructionsBundle: true, - supportsSkills: true, - supportsLocalAgentJwt: true, - requiresMaterializedRuntimeSkills: false, - }); - - // process adapter should have no local capabilities - const processAdapter = res.body.find((a: any) => a.type === "process"); - expect(processAdapter).toBeDefined(); - expect(processAdapter.capabilities).toMatchObject({ - supportsInstructionsBundle: false, - supportsSkills: false, - supportsLocalAgentJwt: false, - requiresMaterializedRuntimeSkills: false, - }); - - // cursor adapter should require materialized runtime skills - const cursorAdapter = res.body.find((a: any) => a.type === "cursor"); - expect(cursorAdapter).toBeDefined(); - expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); - expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); - }); - - it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => { - const app = createApp(); - - const res = await request(app).get("/api/adapters"); - expect(res.status).toBe(200); - - // http adapter has no listSkills/syncSkills - const httpAdapter = res.body.find((a: any) => a.type === "http"); - expect(httpAdapter).toBeDefined(); - expect(httpAdapter.capabilities.supportsSkills).toBe(false); - - // codex_local has listSkills/syncSkills - const codexLocal = res.body.find((a: any) => a.type === "codex_local"); - expect(codexLocal).toBeDefined(); - expect(codexLocal.capabilities.supportsSkills).toBe(true); - }); - it("uses the active adapter when resolving config schema for a paused builtin override", async () => { const app = createApp(); diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts deleted file mode 100644 index 34b7dec4f4d..00000000000 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { agentRoutes } from "../routes/agents.js"; - -const agentId = "11111111-1111-4111-8111-111111111111"; -const companyId = "22222222-2222-4222-8222-222222222222"; -const keyId = "33333333-3333-4333-8333-333333333333"; - -const baseAgent = { - id: agentId, - companyId, - name: "Builder", - urlKey: "builder", - role: "engineer", - title: "Builder", - icon: null, - status: "idle", - reportsTo: null, - capabilities: null, - adapterType: "process", - adapterConfig: {}, - runtimeConfig: {}, - budgetMonthlyCents: 0, - spentMonthlyCents: 0, - pauseReason: null, - pausedAt: null, - permissions: { canCreateAgents: false }, - lastHeartbeatAt: null, - metadata: null, - createdAt: new Date("2026-04-11T00:00:00.000Z"), - updatedAt: new Date("2026-04-11T00:00:00.000Z"), -}; - -const baseKey = { - id: keyId, - agentId, - companyId, - name: "exploit", - createdAt: new Date("2026-04-11T00:00:00.000Z"), - revokedAt: null, -}; - -const mockAgentService = vi.hoisted(() => ({ - getById: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - terminate: vi.fn(), - remove: vi.fn(), - listKeys: vi.fn(), - createApiKey: vi.fn(), - getKeyById: vi.fn(), - revokeKey: vi.fn(), -})); - -const mockAccessService = vi.hoisted(() => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - getMembership: vi.fn(), - ensureMembership: vi.fn(), - listPrincipalGrants: vi.fn(), - setPrincipalPermission: vi.fn(), -})); - -const mockApprovalService = vi.hoisted(() => ({ - create: vi.fn(), - getById: vi.fn(), -})); - -const mockBudgetService = vi.hoisted(() => ({ - upsertPolicy: vi.fn(), -})); - -const mockHeartbeatService = vi.hoisted(() => ({ - cancelActiveForAgent: vi.fn(), -})); - -const mockIssueApprovalService = vi.hoisted(() => ({ - linkManyForApproval: vi.fn(), -})); - -const mockIssueService = vi.hoisted(() => ({ - list: vi.fn(), -})); - -const mockSecretService = vi.hoisted(() => ({ - normalizeAdapterConfigForPersistence: vi.fn(), - resolveAdapterConfigForRuntime: vi.fn(), -})); - -const mockAgentInstructionsService = vi.hoisted(() => ({ - materializeManagedBundle: vi.fn(), -})); - -const mockCompanySkillService = vi.hoisted(() => ({ - listRuntimeSkillEntries: vi.fn(), - resolveRequestedSkillKeys: vi.fn(), -})); - -const mockWorkspaceOperationService = vi.hoisted(() => ({})); -const mockLogActivity = vi.hoisted(() => vi.fn()); -const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); - -vi.mock("@paperclipai/shared/telemetry", () => ({ - trackAgentCreated: vi.fn(), - trackErrorHandlerCrash: vi.fn(), -})); - -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); - -vi.mock("../services/index.js", () => ({ - agentService: () => mockAgentService, - agentInstructionsService: () => mockAgentInstructionsService, - accessService: () => mockAccessService, - approvalService: () => mockApprovalService, - companySkillService: () => mockCompanySkillService, - budgetService: () => mockBudgetService, - heartbeatService: () => mockHeartbeatService, - issueApprovalService: () => mockIssueApprovalService, - issueService: () => mockIssueService, - logActivity: mockLogActivity, - secretService: () => mockSecretService, - syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), - workspaceOperationService: () => mockWorkspaceOperationService, -})); - -vi.mock("../services/instance-settings.js", () => ({ - instanceSettingsService: () => ({ - getGeneral: vi.fn(async () => ({ censorUsernameInLogs: false })), - }), -})); - -function createApp(actor: Record) { - const app = express(); - app.use(express.json()); - app.use((req, _res, next) => { - (req as any).actor = actor; - next(); - }); - app.use("/api", agentRoutes({} as any)); - app.use(errorHandler); - return app; -} - -describe("agent cross-tenant route authorization", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); - mockAgentService.getById.mockResolvedValue(baseAgent); - mockAgentService.pause.mockResolvedValue(baseAgent); - mockAgentService.resume.mockResolvedValue(baseAgent); - mockAgentService.terminate.mockResolvedValue(baseAgent); - mockAgentService.remove.mockResolvedValue(baseAgent); - mockAgentService.listKeys.mockResolvedValue([]); - mockAgentService.createApiKey.mockResolvedValue({ - id: keyId, - name: baseKey.name, - token: "pcp_test_token", - createdAt: baseKey.createdAt, - }); - mockAgentService.getKeyById.mockResolvedValue(baseKey); - mockAgentService.revokeKey.mockResolvedValue({ - ...baseKey, - revokedAt: new Date("2026-04-11T00:05:00.000Z"), - }); - mockHeartbeatService.cancelActiveForAgent.mockResolvedValue(undefined); - mockLogActivity.mockResolvedValue(undefined); - }); - - it("rejects cross-tenant board pause before mutating the agent", async () => { - const app = createApp({ - type: "board", - userId: "mallory", - companyIds: [], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app).post(`/api/agents/${agentId}/pause`).send({}); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.pause).not.toHaveBeenCalled(); - expect(mockHeartbeatService.cancelActiveForAgent).not.toHaveBeenCalled(); - }); - - it("rejects cross-tenant board key listing before reading any keys", async () => { - const app = createApp({ - type: "board", - userId: "mallory", - companyIds: [], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app).get(`/api/agents/${agentId}/keys`); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.listKeys).not.toHaveBeenCalled(); - }); - - it("rejects cross-tenant board key creation before minting a token", async () => { - const app = createApp({ - type: "board", - userId: "mallory", - companyIds: [], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app) - .post(`/api/agents/${agentId}/keys`) - .send({ name: "exploit" }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.createApiKey).not.toHaveBeenCalled(); - }); - - it("rejects cross-tenant board key revocation before touching the key", async () => { - const app = createApp({ - type: "board", - userId: "mallory", - companyIds: [], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("User does not have access to this company"); - expect(mockAgentService.getById).toHaveBeenCalledWith(agentId); - expect(mockAgentService.getKeyById).not.toHaveBeenCalled(); - expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); - }); - - it("requires the key to belong to the route agent before revocation", async () => { - mockAgentService.getKeyById.mockResolvedValue({ - ...baseKey, - agentId: "44444444-4444-4444-8444-444444444444", - }); - mockAccessService.canUser.mockResolvedValue(true); - - const app = createApp({ - type: "board", - userId: "board-user", - companyIds: [companyId], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app).delete(`/api/agents/${agentId}/keys/${keyId}`); - - expect(res.status).toBe(404); - expect(res.body.error).toContain("Key not found"); - expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId); - expect(mockAgentService.revokeKey).not.toHaveBeenCalled(); - }); -}); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index bbb0c2a9da2..af9f7658df9 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -205,196 +205,6 @@ describe("agent permission routes", () => { mockLogActivity.mockResolvedValue(undefined); }); - it("redacts agent detail for authenticated company members without agent admin permission", async () => { - mockAccessService.canUser.mockResolvedValue(false); - - const app = await createApp({ - type: "board", - userId: "member-user", - source: "session", - isInstanceAdmin: false, - companyIds: [companyId], - }); - - const res = await request(app).get(`/api/agents/${agentId}`); - - expect(res.status).toBe(200); - expect(res.body.adapterConfig).toEqual({}); - expect(res.body.runtimeConfig).toEqual({}); - }); - - it("redacts company agent list for authenticated company members without agent admin permission", async () => { - mockAccessService.canUser.mockResolvedValue(false); - - const app = await createApp({ - type: "board", - userId: "member-user", - source: "session", - isInstanceAdmin: false, - companyIds: [companyId], - }); - - const res = await request(app).get(`/api/companies/${companyId}/agents`); - - expect(res.status).toBe(200); - expect(res.body).toEqual([ - expect.objectContaining({ - id: agentId, - adapterConfig: {}, - runtimeConfig: {}, - }), - ]); - }); - - it("blocks agent updates for authenticated company members without agent admin permission", async () => { - mockAccessService.canUser.mockResolvedValue(false); - - const app = await createApp({ - type: "board", - userId: "member-user", - source: "session", - isInstanceAdmin: false, - companyIds: [companyId], - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ title: "Compromised" }); - - expect(res.status).toBe(403); - }); - - it("blocks api key creation for authenticated company members without agent admin permission", async () => { - mockAccessService.canUser.mockResolvedValue(false); - - const app = await createApp({ - type: "board", - userId: "member-user", - source: "session", - isInstanceAdmin: false, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/agents/${agentId}/keys`) - .send({ name: "backdoor" }); - - expect(res.status).toBe(403); - }); - - it("blocks wakeups for authenticated company members without agent admin permission", async () => { - mockAccessService.canUser.mockResolvedValue(false); - - const app = await createApp({ - type: "board", - userId: "member-user", - source: "session", - isInstanceAdmin: false, - companyIds: [companyId], - }); - - const res = await request(app) - .post(`/api/agents/${agentId}/wakeup`) - .send({}); - - expect(res.status).toBe(403); - }); - - it("blocks agent-authenticated self-updates that set host-executed workspace commands", async () => { - const app = await createApp({ - type: "agent", - agentId, - companyId, - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - adapterConfig: { - workspaceStrategy: { - type: "git_worktree", - provisionCommand: "touch /tmp/paperclip-rce", - }, - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("host-executed workspace commands"); - expect(mockLogActivity).not.toHaveBeenCalled(); - }); - - it("blocks agent-authenticated self-updates that set instructions bundle roots", async () => { - const app = await createApp({ - type: "agent", - agentId, - companyId, - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}`) - .send({ - adapterConfig: { - instructionsRootPath: "/etc", - instructionsEntryFile: "passwd", - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("instructions path or bundle configuration"); - expect(mockLogActivity).not.toHaveBeenCalled(); - }); - - it("blocks agent-authenticated instructions-path updates", async () => { - const app = await createApp({ - type: "agent", - agentId, - companyId, - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .patch(`/api/agents/${agentId}/instructions-path`) - .send({ path: "/etc/passwd" }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("instructions path or bundle configuration"); - expect(mockLogActivity).not.toHaveBeenCalled(); - }); - - it("blocks agent-authenticated hires that set instructions bundle config", async () => { - mockAccessService.hasPermission.mockResolvedValue(true); - - const app = await createApp({ - type: "agent", - agentId, - companyId, - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .post(`/api/companies/${companyId}/agent-hires`) - .send({ - name: "Injected", - role: "engineer", - adapterType: "codex_local", - adapterConfig: { - instructionsRootPath: "/etc", - instructionsEntryFile: "passwd", - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("instructions path or bundle configuration"); - expect(mockAgentService.create).not.toHaveBeenCalled(); - expect(mockLogActivity).not.toHaveBeenCalled(); - }); - it("grants tasks:assign by default when board creates a new agent", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/app-private-hostname-gate.test.ts b/server/src/__tests__/app-private-hostname-gate.test.ts deleted file mode 100644 index cf60a1b542c..00000000000 --- a/server/src/__tests__/app-private-hostname-gate.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldEnablePrivateHostnameGuard } from "../app.ts"; - -describe("shouldEnablePrivateHostnameGuard", () => { - it("enables the hostname guard for local_trusted private deployments", () => { - expect(shouldEnablePrivateHostnameGuard({ - deploymentMode: "local_trusted", - deploymentExposure: "private", - })).toBe(true); - }); - - it("does not enable the hostname guard for local_trusted public deployments", () => { - expect(shouldEnablePrivateHostnameGuard({ - deploymentMode: "local_trusted", - deploymentExposure: "public", - })).toBe(false); - }); - - it("enables the hostname guard for authenticated private deployments", () => { - expect(shouldEnablePrivateHostnameGuard({ - deploymentMode: "authenticated", - deploymentExposure: "private", - })).toBe(true); - }); - - it("does not enable the hostname guard for authenticated public deployments", () => { - expect(shouldEnablePrivateHostnameGuard({ - deploymentMode: "authenticated", - deploymentExposure: "public", - })).toBe(false); - }); -}); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index d87561a9a0f..338bfe32797 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -196,90 +196,6 @@ describe("approval routes idempotent retries", () => { expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); }); - it("derives approval attribution from the authenticated actor on approve", async () => { - mockApprovalService.getById.mockResolvedValue({ - id: "approval-4", - companyId: "company-1", - type: "hire_agent", - status: "pending", - payload: {}, - requestedByAgentId: null, - }); - mockApprovalService.approve.mockResolvedValue({ - approval: { - id: "approval-4", - companyId: "company-1", - type: "hire_agent", - status: "approved", - payload: {}, - requestedByAgentId: null, - }, - applied: true, - }); - - const res = await request(await createApp()) - .post("/api/approvals/approval-4/approve") - .send({ decidedByUserId: "forged-user", decisionNote: "ship it" }); - - expect(res.status).toBe(200); - expect(mockApprovalService.approve).toHaveBeenCalledWith("approval-4", "user-1", "ship it"); - }); - - it("derives approval attribution from the authenticated actor on reject", async () => { - mockApprovalService.getById.mockResolvedValue({ - id: "approval-5", - companyId: "company-1", - type: "hire_agent", - status: "pending", - payload: {}, - }); - mockApprovalService.reject.mockResolvedValue({ - approval: { - id: "approval-5", - companyId: "company-1", - type: "hire_agent", - status: "rejected", - payload: {}, - }, - applied: true, - }); - - const res = await request(await createApp()) - .post("/api/approvals/approval-5/reject") - .send({ decidedByUserId: "forged-user", decisionNote: "not now" }); - - expect(res.status).toBe(200); - expect(mockApprovalService.reject).toHaveBeenCalledWith("approval-5", "user-1", "not now"); - }); - - it("derives approval attribution from the authenticated actor on request revision", async () => { - mockApprovalService.getById.mockResolvedValue({ - id: "approval-6", - companyId: "company-1", - type: "hire_agent", - status: "pending", - payload: {}, - }); - mockApprovalService.requestRevision.mockResolvedValue({ - id: "approval-6", - companyId: "company-1", - type: "hire_agent", - status: "revision_requested", - payload: {}, - }); - - const res = await request(await createApp()) - .post("/api/approvals/approval-6/request-revision") - .send({ decidedByUserId: "forged-user", decisionNote: "Need changes" }); - - expect(res.status).toBe(200); - expect(mockApprovalService.requestRevision).toHaveBeenCalledWith( - "approval-6", - "user-1", - "Need changes", - ); - }); - it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 121d959d70a..006321d2797 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -45,7 +45,7 @@ function registerModuleMocks() { })); } -async function createApp(actor: any, db: any = {} as any) { +async function createApp(actor: any) { const [{ accessRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/access.js"), vi.importActual("../middleware/index.js"), @@ -58,7 +58,7 @@ async function createApp(actor: any, db: any = {} as any) { }); app.use( "/api", - accessRoutes(db, { + accessRoutes({} as any, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", @@ -101,56 +101,14 @@ describe("cli auth routes", () => { expect(res.body).toMatchObject({ id: "challenge-1", token: "pcp_cli_auth_secret", + boardApiToken: "pcp_board_token", approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret", pollPath: "/cli-auth/challenges/challenge-1", expiresAt: "2026-03-23T13:00:00.000Z", }); - expect(res.body.boardApiToken).toBe("pcp_board_token"); expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret"); }); - it("rejects anonymous access to generic skill documents", async () => { - const app = await createApp({ type: "none", source: "none" }); - const [indexRes, skillRes] = await Promise.all([ - request(app).get("/api/skills/index"), - request(app).get("/api/skills/paperclip"), - ]); - - expect(indexRes.status).toBe(401); - expect(skillRes.status).toBe(401); - }); - - it("serves the invite-scoped paperclip skill anonymously for active invites", async () => { - const invite = { - id: "invite-1", - companyId: "company-1", - inviteType: "company_join", - allowedJoinTypes: "agent", - tokenHash: "hash", - defaultsPayload: null, - expiresAt: new Date(Date.now() + 60_000), - invitedByUserId: null, - revokedAt: null, - acceptedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }; - const db = { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([invite]), - })), - })), - }; - - const app = await createApp({ type: "none", source: "none" }, db); - const res = await request(app).get("/api/invites/token-123/skills/paperclip"); - - expect(res.status).toBe(200); - expect(res.headers["content-type"]).toContain("text/markdown"); - expect(res.text).toContain("# Paperclip Skill"); - }); - it("marks challenge status as requiring sign-in for anonymous viewers", async () => { mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "challenge-1", diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index f7a6e88be16..b43f7b44829 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -59,11 +59,6 @@ const assetSvc = { create: vi.fn(), }; -const secretSvc = { - normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), - resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config, secretKeys: new Set() })), -}; - const agentInstructionsSvc = { exportFiles: vi.fn(), materializeManagedBundle: vi.fn(), @@ -101,10 +96,6 @@ vi.mock("../services/assets.js", () => ({ assetService: () => assetSvc, })); -vi.mock("../services/secrets.js", () => ({ - secretService: () => secretSvc, -})); - vi.mock("../services/agent-instructions.js", () => ({ agentInstructionsService: () => agentInstructionsSvc, })); @@ -126,11 +117,6 @@ describe("company portability", () => { beforeEach(() => { vi.clearAllMocks(); - secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config); - secretSvc.resolveAdapterConfigForRuntime.mockImplementation(async (_companyId, config) => ({ - config, - secretKeys: new Set(), - })); companySvc.getById.mockResolvedValue({ id: "company-1", name: "Paperclip", @@ -141,11 +127,6 @@ describe("company portability", () => { logoUrl: null, requireBoardApprovalForNewAgents: true, }); - companySvc.create.mockResolvedValue({ - id: "company-imported", - name: "Imported Paperclip", - requireBoardApprovalForNewAgents: true, - }); agentSvc.list.mockResolvedValue([ { id: "agent-1", @@ -1528,7 +1509,7 @@ describe("company portability", () => { }), ]); - const result = await portability.importBundle({ + await portability.importBundle({ source: { type: "inline", rootPath: "paperclip-demo", files }, include: { company: true, agents: true, projects: true, issues: true, skills: false }, target: { mode: "new_company", newCompanyName: "Imported Paperclip" }, @@ -1539,15 +1520,12 @@ describe("company portability", () => { expect(routineSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ projectId: "project-created", title: "Monday Review", - assigneeAgentId: null, + assigneeAgentId: "agent-created", priority: "high", status: "paused", concurrencyPolicy: "always_enqueue", catchUpPolicy: "enqueue_missed_with_cap", }), expect.any(Object)); - expect(result.warnings).toContain( - "Task monday-review assignee claudecoder is pending_approval; imported work was left unassigned.", - ); expect(routineSvc.createTrigger).toHaveBeenCalledTimes(2); expect(routineSvc.createTrigger).toHaveBeenCalledWith("routine-created", expect.objectContaining({ kind: "schedule", @@ -2440,178 +2418,4 @@ describe("company portability", () => { expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toMatch(/^---\n/); expect(nestedMaterializedFiles?.["AGENTS.md"]).not.toContain('name: "ClaudeCoder"'); }); - - it("rejects dangerous adapter types on agent-safe imports", async () => { - const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); - - agentSvc.list.mockResolvedValue([]); - - await expect(portability.importBundle({ - source: { - type: "inline", - rootPath: exported.rootPath, - files: exported.files, - }, - include: { - company: false, - agents: true, - projects: false, - issues: false, - }, - target: { - mode: "existing_company", - companyId: "company-1", - }, - agents: ["claudecoder"], - collisionStrategy: "rename", - adapterOverrides: { - claudecoder: { - adapterType: "process", - adapterConfig: { - command: "/bin/sh", - args: ["-c", "id"], - }, - }, - }, - }, "user-1", { - mode: "agent_safe", - sourceCompanyId: "company-1", - })).rejects.toThrow('Adapter type "process" is not allowed in safe imports'); - - expect(agentSvc.create).not.toHaveBeenCalled(); - }); - - it("imports new agents through approval and adapter-config normalization", async () => { - const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); - - agentSvc.list.mockResolvedValue([]); - secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ - normalized: true, - env: { - OPENAI_API_KEY: { - type: "secret_ref", - secretId: "secret-1", - version: "latest", - }, - }, - }); - agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ - id: "agent-created", - name: String(input.name), - adapterType: input.adapterType, - adapterConfig: input.adapterConfig, - status: input.status, - })); - - await portability.importBundle({ - source: { - type: "inline", - rootPath: exported.rootPath, - files: exported.files, - }, - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - target: { - mode: "new_company", - newCompanyName: "Imported Paperclip", - }, - agents: ["claudecoder"], - collisionStrategy: "rename", - }, "user-1"); - - expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( - "company-imported", - expect.any(Object), - { strictMode: false }, - ); - expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ - adapterType: "claude_local", - adapterConfig: expect.objectContaining({ - normalized: true, - }), - status: "pending_approval", - })); - }); - - it("normalizes adapter config on replace imports before updating existing agents", async () => { - const portability = companyPortabilityService({} as any); - const exported = await portability.exportBundle("company-1", { - include: { - company: true, - agents: true, - projects: false, - issues: false, - }, - }); - - secretSvc.normalizeAdapterConfigForPersistence.mockResolvedValueOnce({ - normalized: "updated", - }); - agentSvc.update.mockImplementation(async (id: string, patch: Record) => ({ - id, - name: "ClaudeCoder", - adapterType: patch.adapterType, - adapterConfig: patch.adapterConfig, - })); - - await portability.importBundle({ - source: { - type: "inline", - rootPath: exported.rootPath, - files: exported.files, - }, - include: { - company: false, - agents: true, - projects: false, - issues: false, - }, - target: { - mode: "existing_company", - companyId: "company-1", - }, - agents: ["claudecoder"], - collisionStrategy: "replace", - adapterOverrides: { - claudecoder: { - adapterType: "codex_local", - adapterConfig: { - model: "gpt-5.4", - }, - }, - }, - }, "user-1"); - - expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith( - "company-1", - expect.any(Object), - { strictMode: false }, - ); - expect(agentSvc.update).toHaveBeenCalledWith("agent-1", expect.objectContaining({ - adapterType: "codex_local", - adapterConfig: { - normalized: "updated", - }, - })); - }); }); diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts new file mode 100644 index 00000000000..b2f54f92862 --- /dev/null +++ b/server/src/__tests__/company-skills-service.test.ts @@ -0,0 +1,92 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { companies, companySkills, createDb } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService.list", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("lists skills without exposing markdown content", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-heavy-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + description: "Large skill used for list projection regression coverage.", + markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`, + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }); + + const listed = await svc.list(companyId); + const skill = listed.find((entry) => entry.id === skillId); + + expect(skill).toBeDefined(); + expect(skill).not.toHaveProperty("markdown"); + expect(skill).toMatchObject({ + id: skillId, + key: `company/${companyId}/heavy-skill`, + slug: "heavy-skill", + name: "Heavy Skill", + sourceType: "local_path", + sourceLocator: skillDir, + attachedAgentCount: 0, + sourceBadge: "local", + editable: true, + }); + }); +}); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index 5ee13e4c06e..83e4e87b703 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -1,6 +1,15 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { randomUUID } from "node:crypto"; +import { createDb, companies, agents, costEvents, financeEvents, projects } from "@paperclipai/db"; +import { costService } from "../services/costs.ts"; +import { financeService } from "../services/finance.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; function makeDb(overrides: Record = {}) { const selectChain = { @@ -230,3 +239,154 @@ describe("cost routes", () => { expect(mockAgentService.update).not.toHaveBeenCalled(); }); }); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { + let db!: ReturnType; + let costs!: ReturnType; + let finance!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-costs-service-"); + db = createDb(tempDb.connectionString); + costs = costService(db); + finance = financeService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(financeEvents); + await db.delete(costEvents); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("aggregates cost event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Cost Agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Overflow Project", + status: "active", + }); + + await db.insert(costEvents).values([ + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 0, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + agentId, + projectId, + provider: "openai", + biller: "openai", + billingType: "metered_api", + model: "gpt-5", + inputTokens: 2_000_000_000, + cachedInputTokens: 10, + outputTokens: 200_000_000, + costCents: 2_000_000_000, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const [byAgentRow] = await costs.byAgent(companyId, range); + const [byProjectRow] = await costs.byProject(companyId, range); + const [byAgentModelRow] = await costs.byAgentModel(companyId, range); + + expect(byAgentRow?.costCents).toBe(4_000_000_000); + expect(byAgentRow?.inputTokens).toBe(4_000_000_000); + expect(byProjectRow?.costCents).toBe(4_000_000_000); + expect(byAgentModelRow?.costCents).toBe(4_000_000_000); + }); + + it("aggregates finance event sums above int32 without raising Postgres integer overflow", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(financeEvents).values([ + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: false, + occurredAt: new Date("2026-04-10T00:00:00.000Z"), + }, + { + companyId, + biller: "openai", + eventKind: "invoice", + amountCents: 2_000_000_000, + currency: "USD", + direction: "debit", + estimated: true, + occurredAt: new Date("2026-04-11T00:00:00.000Z"), + }, + ]); + + const range = { + from: new Date("2026-04-01T00:00:00.000Z"), + to: new Date("2026-04-15T23:59:59.999Z"), + }; + + const summary = await finance.summary(companyId, range); + const [byKindRow] = await finance.byKind(companyId, range); + + expect(summary.debitCents).toBe(4_000_000_000); + expect(summary.estimatedDebitCents).toBe(2_000_000_000); + expect(byKindRow?.debitCents).toBe(4_000_000_000); + expect(byKindRow?.netCents).toBe(4_000_000_000); + }); +}); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index e941776ee84..ce3c002a0ea 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -34,7 +34,7 @@ describe("GET /health", () => { const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); - }, 15_000); + }); it("returns 200 when the database probe succeeds", async () => { const db = { @@ -63,120 +63,4 @@ describe("GET /health", () => { error: "database_unreachable", }); }); - - it("redacts detailed metadata for anonymous requests in authenticated mode", async () => { - const devServerStatus = await import("../dev-server-status.js"); - vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); - const { healthRoutes } = await import("../routes/health.js"); - const db = { - execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([{ count: 1 }]), - })), - })), - } as unknown as Db; - const app = express(); - app.use((req, _res, next) => { - (req as any).actor = { type: "none", source: "none" }; - next(); - }); - app.use( - "/health", - healthRoutes(db, { - deploymentMode: "authenticated", - deploymentExposure: "public", - authReady: true, - companyDeletionEnabled: false, - }), - ); - - const res = await request(app).get("/health"); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ - status: "ok", - deploymentMode: "authenticated", - bootstrapStatus: "ready", - bootstrapInviteActive: false, - }); - }); - - it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => { - const devServerStatus = await import("../dev-server-status.js"); - vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); - const { healthRoutes } = await import("../routes/health.js"); - const db = { - execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([{ count: 1 }]), - })), - })), - } as unknown as Db; - const app = express(); - app.use( - "/health", - healthRoutes(db, { - deploymentMode: "authenticated", - deploymentExposure: "public", - authReady: true, - companyDeletionEnabled: false, - }), - ); - - const res = await request(app).get("/health"); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ - status: "ok", - deploymentMode: "authenticated", - bootstrapStatus: "ready", - bootstrapInviteActive: false, - }); - }); - - it("keeps detailed metadata for authenticated requests in authenticated mode", async () => { - const devServerStatus = await import("../dev-server-status.js"); - vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); - const { healthRoutes } = await import("../routes/health.js"); - const db = { - execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([{ count: 1 }]), - })), - })), - } as unknown as Db; - const app = express(); - app.use((req, _res, next) => { - (req as any).actor = { type: "board", userId: "user-1", source: "session" }; - next(); - }); - app.use( - "/health", - healthRoutes(db, { - deploymentMode: "authenticated", - deploymentExposure: "public", - authReady: true, - companyDeletionEnabled: false, - }), - ); - - const res = await request(app).get("/health"); - - expect(res.status).toBe(200); - expect(res.body).toMatchObject({ - status: "ok", - version: serverVersion, - deploymentMode: "authenticated", - deploymentExposure: "public", - authReady: true, - bootstrapStatus: "ready", - bootstrapInviteActive: false, - features: { - companyDeletionEnabled: false, - }, - }); - }); }); diff --git a/server/src/__tests__/invite-onboarding-text.test.ts b/server/src/__tests__/invite-onboarding-text.test.ts index fae84a63718..8ba30115bca 100644 --- a/server/src/__tests__/invite-onboarding-text.test.ts +++ b/server/src/__tests__/invite-onboarding-text.test.ts @@ -41,7 +41,6 @@ describe("buildInviteOnboardingTextDocument", () => { expect(text).toContain("/api/invites/token-123/accept"); expect(text).toContain("/api/join-requests/{requestId}/claim-api-key"); expect(text).toContain("/api/invites/token-123/onboarding.txt"); - expect(text).toContain("/api/invites/token-123/skills/paperclip"); expect(text).toContain("Suggested Paperclip base URLs to try"); expect(text).toContain("http://localhost:3100"); expect(text).toContain("host.docker.internal"); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 2ef33886348..1c887138f55 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -148,7 +148,7 @@ async function normalizePolicy(input: { return normalizeIssueExecutionPolicy(input); } -function makeIssue(status: "todo" | "done" | "blocked") { +function makeIssue(status: "todo" | "done") { return { id: "11111111-1111-4111-8111-111111111111", companyId: "company-1", @@ -430,34 +430,6 @@ describe("issue comment reopen routes", () => { expect(res.status).toBe(201); expect(mockIssueService.update).not.toHaveBeenCalled(); }); - - it("wakes the assignee when an assigned blocked issue moves back to todo", async () => { - const issue = makeIssue("blocked"); - mockIssueService.getById.mockResolvedValue(issue); - mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ - ...issue, - ...patch, - updatedAt: new Date(), - })); - - const res = await request(await installActor(createApp())) - .patch("/api/issues/11111111-1111-4111-8111-111111111111") - .send({ status: "todo" }); - - expect(res.status).toBe(200); - expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( - "22222222-2222-4222-8222-222222222222", - expect.objectContaining({ - source: "automation", - triggerDetail: "system", - reason: "issue_status_changed", - payload: expect.objectContaining({ - issueId: "11111111-1111-4111-8111-111111111111", - mutation: "update", - }), - }), - ); - }); it("interrupts an active run before a combined comment update", async () => { const issue = { ...makeIssue("todo"), diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts deleted file mode 100644 index cf9a7847b01..00000000000 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; -import { issueRoutes } from "../routes/issues.js"; - -const mockIssueService = vi.hoisted(() => ({ - addComment: vi.fn(), - assertCheckoutOwner: vi.fn(), - create: vi.fn(), - findMentionedAgents: vi.fn(), - getByIdentifier: vi.fn(), - getById: vi.fn(), - getRelationSummaries: vi.fn(), - getWakeableParentAfterChildCompletion: vi.fn(), - listWakeableBlockedDependents: vi.fn(), - update: vi.fn(), -})); - -vi.mock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(async () => true), - hasPermission: vi.fn(async () => true), - }), - agentService: () => ({ - getById: vi.fn(async () => null), - }), - documentService: () => ({}), - executionWorkspaceService: () => ({ - getById: vi.fn(async () => null), - }), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - getRun: vi.fn(async () => null), - getActiveRunForAgent: vi.fn(async () => null), - cancelRun: vi.fn(async () => null), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), -})); - -function createApp(actor: Record) { - const app = express(); - app.use(express.json()); - app.use((req, _res, next) => { - (req as any).actor = actor; - next(); - }); - app.use("/api", issueRoutes({} as any, {} as any)); - app.use(errorHandler); - return app; -} - -function makeIssue(overrides: Record = {}) { - return { - id: "issue-1", - companyId: "company-1", - status: "todo", - priority: "medium", - projectId: null, - goalId: null, - parentId: null, - assigneeAgentId: null, - assigneeUserId: null, - createdByUserId: "board-user", - identifier: "PAP-1000", - title: "Workspace authz", - executionPolicy: null, - executionState: null, - executionWorkspaceId: null, - hiddenAt: null, - ...overrides, - }; -} - -describe("issue workspace command authorization", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockIssueService.addComment.mockResolvedValue(null); - mockIssueService.create.mockResolvedValue(makeIssue()); - mockIssueService.findMentionedAgents.mockResolvedValue([]); - mockIssueService.getByIdentifier.mockResolvedValue(null); - mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); - mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); - mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); - mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); - mockIssueService.update.mockResolvedValue(makeIssue()); - }); - - it("rejects agent callers that create issue workspace provision commands", async () => { - const app = createApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .post("/api/companies/company-1/issues") - .send({ - title: "Exploit", - executionWorkspaceSettings: { - workspaceStrategy: { - type: "git_worktree", - provisionCommand: "touch /tmp/paperclip-rce", - }, - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("host-executed workspace commands"); - expect(mockIssueService.create).not.toHaveBeenCalled(); - }); - - it("rejects agent callers that patch assignee adapter workspace teardown commands", async () => { - mockIssueService.getById.mockResolvedValue(makeIssue()); - const app = createApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .patch("/api/issues/issue-1") - .send({ - assigneeAdapterOverrides: { - adapterConfig: { - workspaceStrategy: { - type: "git_worktree", - teardownCommand: "rm -rf /tmp/paperclip-rce", - }, - }, - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("host-executed workspace commands"); - expect(mockIssueService.update).not.toHaveBeenCalled(); - }); -}); diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index f7382e8b9a3..c20040840d0 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -190,6 +190,10 @@ describe("issue goal context routes", () => { title: projectGoal.title, }), ); + expect(mockIssueService.findMentionedProjectIds).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + { includeCommentBodies: false }, + ); expect(mockGoalService.getDefaultCompanyGoal).not.toHaveBeenCalled(); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 0629686a154..75aefd6a364 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -22,6 +22,7 @@ import { } from "./helpers/embedded-postgres.js"; import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; +import { buildProjectMentionHref } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -697,6 +698,39 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { "2026-03-26T10:00:00.000Z", ); }); + + it("trims list payload fields that can grow large on issue index routes", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const longDescription = "x".repeat(5_000); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Large issue", + description: longDescription, + status: "todo", + priority: "medium", + executionPolicy: { stages: Array.from({ length: 20 }, (_, index) => ({ index, kind: "review", notes: "y".repeat(400) })) }, + executionState: { history: Array.from({ length: 20 }, (_, index) => ({ index, body: "z".repeat(400) })) }, + executionWorkspaceSettings: { notes: "w".repeat(2_000) }, + }); + + const [result] = await svc.list(companyId); + + expect(result).toBeTruthy(); + expect(result?.description).toHaveLength(1200); + expect(result?.executionPolicy).toBeNull(); + expect(result?.executionState).toBeNull(); + expect(result?.executionWorkspaceSettings).toBeNull(); + }); }); describeEmbeddedPostgres("issueService.create workspace inheritance", () => { @@ -1182,3 +1216,84 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", }); }); }); + +describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-mentioned-projects-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + await ensureIssueRelationsTable(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueRelations); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("can skip comment-body scans for bounded issue detail reads", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + const titleProjectId = randomUUID(); + const commentProjectId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values([ + { + id: titleProjectId, + companyId, + name: "Title project", + status: "in_progress", + }, + { + id: commentProjectId, + companyId, + name: "Comment project", + status: "in_progress", + }, + ]); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: `Link [Title](${buildProjectMentionHref(titleProjectId)})`, + description: null, + status: "todo", + priority: "medium", + }); + + await db.insert(issueComments).values({ + companyId, + issueId, + body: `Comment link [Comment](${buildProjectMentionHref(commentProjectId)})`, + }); + + expect(await svc.findMentionedProjectIds(issueId, { includeCommentBodies: false })).toEqual([titleProjectId]); + expect(await svc.findMentionedProjectIds(issueId)).toEqual([ + titleProjectId, + commentProjectId, + ]); + }); +}); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts deleted file mode 100644 index 3150a182257..00000000000 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ /dev/null @@ -1,168 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockRegistry = vi.hoisted(() => ({ - getById: vi.fn(), - getByKey: vi.fn(), -})); - -const mockLifecycle = vi.hoisted(() => ({ - load: vi.fn(), - upgrade: vi.fn(), -})); - -vi.mock("../services/plugin-registry.js", () => ({ - pluginRegistryService: () => mockRegistry, -})); - -vi.mock("../services/plugin-lifecycle.js", () => ({ - pluginLifecycleManager: () => mockLifecycle, -})); - -vi.mock("../services/activity-log.js", () => ({ - logActivity: vi.fn(), -})); - -vi.mock("../services/live-events.js", () => ({ - publishGlobalLiveEvent: vi.fn(), -})); - -async function createApp(actor: Record, loaderOverrides: Record = {}) { - const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/plugins.js"), - import("../middleware/index.js"), - ]); - - const loader = { - installPlugin: vi.fn(), - ...loaderOverrides, - }; - - const app = express(); - app.use(express.json()); - app.use((req, _res, next) => { - req.actor = actor as typeof req.actor; - next(); - }); - app.use("/api", pluginRoutes({} as never, loader as never)); - app.use(errorHandler); - - return { app, loader }; -} - -describe("plugin install and upgrade authz", () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it("rejects plugin installation for non-admin board users", async () => { - const { app, loader } = await createApp({ - type: "board", - userId: "user-1", - source: "session", - isInstanceAdmin: false, - companyIds: ["company-1"], - }); - - const res = await request(app) - .post("/api/plugins/install") - .send({ packageName: "paperclip-plugin-example" }); - - expect(res.status).toBe(403); - expect(loader.installPlugin).not.toHaveBeenCalled(); - }, 20_000); - - it("allows instance admins to install plugins", async () => { - const pluginId = "11111111-1111-4111-8111-111111111111"; - const pluginKey = "paperclip.example"; - const discovered = { - manifest: { - id: pluginKey, - }, - }; - - mockRegistry.getByKey.mockResolvedValue({ - id: pluginId, - pluginKey, - packageName: "paperclip-plugin-example", - version: "1.0.0", - }); - mockRegistry.getById.mockResolvedValue({ - id: pluginId, - pluginKey, - packageName: "paperclip-plugin-example", - version: "1.0.0", - }); - mockLifecycle.load.mockResolvedValue(undefined); - - const { app, loader } = await createApp( - { - type: "board", - userId: "admin-1", - source: "session", - isInstanceAdmin: true, - companyIds: [], - }, - { installPlugin: vi.fn().mockResolvedValue(discovered) }, - ); - - const res = await request(app) - .post("/api/plugins/install") - .send({ packageName: "paperclip-plugin-example" }); - - expect(res.status).toBe(200); - expect(loader.installPlugin).toHaveBeenCalledWith({ - packageName: "paperclip-plugin-example", - version: undefined, - }); - expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId); - }, 20_000); - - it("rejects plugin upgrades for non-admin board users", async () => { - const pluginId = "11111111-1111-4111-8111-111111111111"; - const { app } = await createApp({ - type: "board", - userId: "user-1", - source: "session", - isInstanceAdmin: false, - companyIds: ["company-1"], - }); - - const res = await request(app) - .post(`/api/plugins/${pluginId}/upgrade`) - .send({}); - - expect(res.status).toBe(403); - expect(mockRegistry.getById).not.toHaveBeenCalled(); - expect(mockLifecycle.upgrade).not.toHaveBeenCalled(); - }, 20_000); - - it("allows instance admins to upgrade plugins", async () => { - const pluginId = "11111111-1111-4111-8111-111111111111"; - mockRegistry.getById.mockResolvedValue({ - id: pluginId, - pluginKey: "paperclip.example", - version: "1.0.0", - }); - mockLifecycle.upgrade.mockResolvedValue({ - id: pluginId, - version: "1.1.0", - }); - - const { app } = await createApp({ - type: "board", - userId: "admin-1", - source: "session", - isInstanceAdmin: true, - companyIds: [], - }); - - const res = await request(app) - .post(`/api/plugins/${pluginId}/upgrade`) - .send({ version: "1.1.0" }); - - expect(res.status).toBe(200); - expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0"); - }, 20_000); -}); diff --git a/server/src/__tests__/quota-windows.test.ts b/server/src/__tests__/quota-windows.test.ts index e9eb26a9811..c9d0fd92e27 100644 --- a/server/src/__tests__/quota-windows.test.ts +++ b/server/src/__tests__/quota-windows.test.ts @@ -39,34 +39,24 @@ describe("toPercent", () => { expect(toPercent(0)).toBe(0); }); - it("treats values < 1 as fraction and multiplies by 100 (0.5 → 50%)", () => { + it("converts 0.5 to 50", () => { expect(toPercent(0.5)).toBe(50); }); - it("treats values >= 1 as already-percentage (34 → 34%)", () => { - expect(toPercent(34.0)).toBe(34); - expect(toPercent(91.0)).toBe(91); - }); - - it("treats value exactly 1.0 as 1% (not 100%) — the < 1 heuristic boundary", () => { - // 1.0 is NOT < 1, so it is treated as already-percentage → 1% - expect(toPercent(1.0)).toBe(1); + it("converts 1.0 to 100", () => { + expect(toPercent(1.0)).toBe(100); }); it("clamps overshoot to 100", () => { - expect(toPercent(105)).toBe(100); - expect(toPercent(101)).toBe(100); + // floating-point utilization can slightly exceed 1.0 + expect(toPercent(1.001)).toBe(100); + expect(toPercent(1.01)).toBe(100); }); - it("rounds to nearest integer for fractions", () => { + it("rounds to nearest integer", () => { expect(toPercent(0.333)).toBe(33); expect(toPercent(0.666)).toBe(67); }); - - it("rounds to nearest integer for percentages", () => { - expect(toPercent(48.52)).toBe(49); - expect(toPercent(23.4)).toBe(23); - }); }); // --------------------------------------------------------------------------- @@ -526,48 +516,37 @@ describe("fetchClaudeQuota", () => { expect(windows).toEqual([]); }); - it("parses five_hour window with percentage-range utilization", async () => { - mockFetch({ five_hour: { utilization: 34.0, resets_at: "2026-01-01T00:00:00Z" } }); + it("parses five_hour window", async () => { + mockFetch({ five_hour: { utilization: 0.4, resets_at: "2026-01-01T00:00:00Z" } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current session", - usedPercent: 34, + usedPercent: 40, resetsAt: "2026-01-01T00:00:00Z", }); }); - it("parses seven_day window with percentage-range utilization", async () => { - mockFetch({ seven_day: { utilization: 91.0, resets_at: null } }); + it("parses seven_day window", async () => { + mockFetch({ seven_day: { utilization: 0.75, resets_at: null } }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(1); expect(windows[0]).toMatchObject({ label: "Current week (all models)", - usedPercent: 91, + usedPercent: 75, resetsAt: null, }); }); - it("still handles legacy 0-1 fraction utilization", async () => { - mockFetch({ five_hour: { utilization: 0.4, resets_at: null } }); - const windows = await fetchClaudeQuota("token"); - expect(windows[0]).toMatchObject({ - label: "Current session", - usedPercent: 40, - }); - }); - it("parses seven_day_sonnet and seven_day_opus windows", async () => { mockFetch({ - seven_day_sonnet: { utilization: 23.0, resets_at: null }, - seven_day_opus: { utilization: 85.0, resets_at: null }, + seven_day_sonnet: { utilization: 0.2, resets_at: null }, + seven_day_opus: { utilization: 0.9, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(2); expect(windows[0]!.label).toBe("Current week (Sonnet only)"); - expect(windows[0]!.usedPercent).toBe(23); expect(windows[1]!.label).toBe("Current week (Opus only)"); - expect(windows[1]!.usedPercent).toBe(85); }); it("sets usedPercent to null when utilization is absent", async () => { @@ -578,10 +557,10 @@ describe("fetchClaudeQuota", () => { it("includes all four windows when all are present", async () => { mockFetch({ - five_hour: { utilization: 10.0, resets_at: null }, - seven_day: { utilization: 20.0, resets_at: null }, - seven_day_sonnet: { utilization: 30.0, resets_at: null }, - seven_day_opus: { utilization: 40.0, resets_at: null }, + five_hour: { utilization: 0.1, resets_at: null }, + seven_day: { utilization: 0.2, resets_at: null }, + seven_day_sonnet: { utilization: 0.3, resets_at: null }, + seven_day_opus: { utilization: 0.4, resets_at: null }, }); const windows = await fetchClaudeQuota("token"); expect(windows).toHaveLength(4); @@ -592,7 +571,6 @@ describe("fetchClaudeQuota", () => { "Current week (Sonnet only)", "Current week (Opus only)", ]); - expect(windows.map((w: QuotaWindow) => w.usedPercent)).toEqual([10, 20, 30, 40]); }); it("parses extra usage when the OAuth response includes it", async () => { @@ -613,25 +591,6 @@ describe("fetchClaudeQuota", () => { }, ]); }); - - it("formats extra usage credits from cents to dollars", async () => { - mockFetch({ - extra_usage: { - is_enabled: true, - monthly_limit: 14000, - used_credits: 6793, - utilization: 48.52, - }, - }); - const windows = await fetchClaudeQuota("token"); - expect(windows).toHaveLength(1); - expect(windows[0]).toMatchObject({ - label: "Extra usage", - usedPercent: 49, - valueLabel: "$67.93 / $140.00", - detail: "Monthly extra usage pool", - }); - }); }); // --------------------------------------------------------------------------- diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 5af5c4dfc1f..dbc9801cd82 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -267,14 +267,6 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { expect(runRes.body.source).toBe("manual"); expect(runRes.body.linkedIssueId).toBeTruthy(); - const listRes = await request(app).get(`/api/companies/${companyId}/routines`); - expect(listRes.status).toBe(200); - const listed = listRes.body.find((r: { id: string }) => r.id === routineId); - expect(listed).toBeDefined(); - expect(listed.triggers).toHaveLength(1); - expect(listed.triggers[0].cronExpression).toBe("0 10 * * 1-5"); - expect(listed.triggers[0].timezone).toBe("UTC"); - const detailRes = await request(app).get(`/api/routines/${routineId}`); expect(detailRes.status).toBe(200); expect(detailRes.body.triggers).toHaveLength(1); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index c950aa6ee6e..f8589544e53 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -180,27 +180,3 @@ describe("startServer feedback export wiring", () => { }); }); }); - -describe("startServer PAPERCLIP_API_URL handling", () => { - beforeEach(() => { - vi.clearAllMocks(); - process.env.BETTER_AUTH_SECRET = "test-secret"; - delete process.env.PAPERCLIP_API_URL; - }); - - it("uses the externally set PAPERCLIP_API_URL when provided", async () => { - process.env.PAPERCLIP_API_URL = "http://custom-api:3100"; - - const started = await startServer(); - - expect(started.apiUrl).toBe("http://custom-api:3100"); - expect(process.env.PAPERCLIP_API_URL).toBe("http://custom-api:3100"); - }); - - it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => { - const started = await startServer(); - - expect(started.apiUrl).toBe("http://127.0.0.1:3210"); - expect(process.env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:3210"); - }); -}); diff --git a/server/src/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts deleted file mode 100644 index de5b490c912..00000000000 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mockProjectService = vi.hoisted(() => ({ - create: vi.fn(), - createWorkspace: vi.fn(), - getById: vi.fn(), - listWorkspaces: vi.fn(), - resolveByReference: vi.fn(), - update: vi.fn(), - updateWorkspace: vi.fn(), -})); - -const mockExecutionWorkspaceService = vi.hoisted(() => ({ - getById: vi.fn(), - update: vi.fn(), -})); - -const mockSecretService = vi.hoisted(() => ({ - normalizeEnvBindingsForPersistence: vi.fn(), -})); - -const mockWorkspaceOperationService = vi.hoisted(() => ({})); -const mockLogActivity = vi.hoisted(() => vi.fn()); -const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); -const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn()); - -function registerModuleMocks() { - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); - - vi.doMock("../services/index.js", () => ({ - executionWorkspaceService: () => mockExecutionWorkspaceService, - logActivity: mockLogActivity, - projectService: () => mockProjectService, - secretService: () => mockSecretService, - workspaceOperationService: () => mockWorkspaceOperationService, - })); - - vi.doMock("../services/workspace-runtime.js", () => ({ - cleanupExecutionWorkspaceArtifacts: vi.fn(), - startRuntimeServicesForWorkspaceControl: vi.fn(), - stopRuntimeServicesForExecutionWorkspace: vi.fn(), - stopRuntimeServicesForProjectWorkspace: vi.fn(), - })); - - vi.doMock("../routes/workspace-runtime-service-authz.js", () => ({ - assertCanManageProjectWorkspaceRuntimeServices: mockAssertCanManageProjectWorkspaceRuntimeServices, - assertCanManageExecutionWorkspaceRuntimeServices: mockAssertCanManageExecutionWorkspaceRuntimeServices, - })); -} - -async function createProjectApp(actor: Record) { - const { projectRoutes } = await import("../routes/projects.js"); - const { errorHandler } = await import("../middleware/index.js"); - const app = express(); - app.use(express.json()); - app.use((req, _res, next) => { - (req as any).actor = actor; - next(); - }); - app.use("/api", projectRoutes({} as any)); - app.use(errorHandler); - return app; -} - -async function createExecutionWorkspaceApp(actor: Record) { - const { executionWorkspaceRoutes } = await import("../routes/execution-workspaces.js"); - const { errorHandler } = await import("../middleware/index.js"); - const app = express(); - app.use(express.json()); - app.use((req, _res, next) => { - (req as any).actor = actor; - next(); - }); - app.use("/api", executionWorkspaceRoutes({} as any)); - app.use(errorHandler); - return app; -} - -function buildProject(overrides: Record = {}) { - return { - id: "project-1", - companyId: "company-1", - urlKey: "project-1", - goalId: null, - goalIds: [], - goals: [], - name: "Project", - description: null, - status: "backlog", - leadAgentId: null, - targetDate: null, - color: null, - env: null, - pauseReason: null, - pausedAt: null, - executionWorkspacePolicy: null, - codebase: null, - workspaces: [], - primaryWorkspace: null, - archivedAt: null, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -function buildExecutionWorkspace(overrides: Record = {}) { - return { - id: "workspace-1", - companyId: "company-1", - projectId: "project-1", - projectWorkspaceId: null, - sourceIssueId: null, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "Workspace", - status: "active", - cwd: "/tmp/workspace", - repoUrl: null, - baseRef: "main", - branchName: "feature/test", - providerType: "git_worktree", - providerRef: null, - derivedFromExecutionWorkspaceId: null, - lastUsedAt: new Date(), - openedAt: new Date(), - closedAt: null, - cleanupEligibleAt: null, - cleanupReason: null, - config: null, - metadata: null, - runtimeServices: [], - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }; -} - -describe("workspace runtime service route authorization", () => { - const projectId = "11111111-1111-4111-8111-111111111111"; - const workspaceId = "22222222-2222-4222-8222-222222222222"; - const executionWorkspaceId = "33333333-3333-4333-8333-333333333333"; - - beforeEach(() => { - vi.resetModules(); - registerModuleMocks(); - vi.clearAllMocks(); - mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env); - mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); - mockProjectService.create.mockResolvedValue(buildProject()); - mockProjectService.update.mockResolvedValue(buildProject()); - mockProjectService.createWorkspace.mockResolvedValue({ - id: workspaceId, - companyId: "company-1", - projectId, - name: "Workspace", - sourceType: "local_path", - cwd: "/tmp/project", - repoUrl: null, - repoRef: null, - defaultRef: null, - visibility: "default", - setupCommand: null, - cleanupCommand: null, - remoteProvider: null, - remoteWorkspaceRef: null, - sharedWorkspaceKey: null, - metadata: null, - runtimeConfig: null, - isPrimary: false, - runtimeServices: [], - createdAt: new Date(), - updatedAt: new Date(), - }); - mockProjectService.listWorkspaces.mockResolvedValue([{ - id: workspaceId, - companyId: "company-1", - projectId, - name: "Workspace", - sourceType: "local_path", - cwd: "/tmp/project", - repoUrl: null, - repoRef: null, - defaultRef: null, - visibility: "default", - setupCommand: null, - cleanupCommand: null, - remoteProvider: null, - remoteWorkspaceRef: null, - sharedWorkspaceKey: null, - metadata: null, - runtimeConfig: null, - isPrimary: false, - runtimeServices: [], - createdAt: new Date(), - updatedAt: new Date(), - }]); - mockProjectService.updateWorkspace.mockResolvedValue({ - id: workspaceId, - companyId: "company-1", - projectId, - name: "Workspace", - sourceType: "local_path", - cwd: "/tmp/project", - repoUrl: null, - repoRef: null, - defaultRef: null, - visibility: "default", - setupCommand: null, - cleanupCommand: null, - remoteProvider: null, - remoteWorkspaceRef: null, - sharedWorkspaceKey: null, - metadata: null, - runtimeConfig: null, - isPrimary: false, - runtimeServices: [], - createdAt: new Date(), - updatedAt: new Date(), - }); - mockExecutionWorkspaceService.update.mockResolvedValue(buildExecutionWorkspace()); - mockAssertCanManageProjectWorkspaceRuntimeServices.mockResolvedValue(undefined); - mockAssertCanManageExecutionWorkspaceRuntimeServices.mockResolvedValue(undefined); - }); - - it("rejects agent callers for project workspace runtime service mutations when workspace auth denies access", async () => { - const { forbidden } = await import("../errors.js"); - mockProjectService.getById.mockResolvedValue(buildProject({ - id: projectId, - workspaces: [{ - id: workspaceId, - companyId: "company-1", - projectId, - name: "Workspace", - sourceType: "local_path", - cwd: "/tmp/project", - repoUrl: null, - repoRef: null, - defaultRef: null, - visibility: "default", - setupCommand: null, - cleanupCommand: null, - remoteProvider: null, - remoteWorkspaceRef: null, - sharedWorkspaceKey: null, - metadata: null, - runtimeConfig: null, - isPrimary: false, - runtimeServices: [], - createdAt: new Date(), - updatedAt: new Date(), - }], - })); - mockAssertCanManageProjectWorkspaceRuntimeServices.mockRejectedValue( - forbidden("Missing permission to manage workspace runtime services"), - ); - const app = await createProjectApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) - .send({}); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("Missing permission"); - expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); - expect(mockAssertCanManageProjectWorkspaceRuntimeServices).toHaveBeenCalled(); - }, 15000); - - it("rejects agent callers that create project execution workspace commands", async () => { - const app = await createProjectApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .post("/api/companies/company-1/projects") - .send({ - name: "Exploit", - executionWorkspacePolicy: { - enabled: true, - workspaceStrategy: { - type: "git_worktree", - provisionCommand: "touch /tmp/paperclip-rce", - }, - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("host-executed workspace commands"); - expect(mockProjectService.create).not.toHaveBeenCalled(); - }); - - it("rejects agent callers that update project workspace cleanup commands", async () => { - mockProjectService.getById.mockResolvedValue(buildProject()); - const app = await createProjectApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .patch(`/api/projects/${projectId}/workspaces/${workspaceId}`) - .send({ - cleanupCommand: "rm -rf /tmp/paperclip-rce", - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("host-executed workspace commands"); - expect(mockProjectService.updateWorkspace).not.toHaveBeenCalled(); - }); - - it("allows board callers through the project workspace runtime auth gate", async () => { - mockProjectService.getById.mockResolvedValue(null); - const app = await createProjectApp({ - type: "board", - userId: "board-1", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app) - .post(`/api/projects/${projectId}/workspaces/${workspaceId}/runtime-services/start`) - .send({}); - - expect(res.status).toBe(404); - expect(res.body.error).toContain("Project not found"); - expect(mockProjectService.getById).toHaveBeenCalledWith(projectId); - }); - - it("rejects agent callers for execution workspace runtime service mutations when workspace auth denies access", async () => { - const { forbidden } = await import("../errors.js"); - mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); - mockAssertCanManageExecutionWorkspaceRuntimeServices.mockRejectedValue( - forbidden("Missing permission to manage workspace runtime services"), - ); - const app = await createExecutionWorkspaceApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) - .send({}); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("Missing permission"); - expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); - expect(mockAssertCanManageExecutionWorkspaceRuntimeServices).toHaveBeenCalled(); - }, 15000); - - it("rejects agent callers that patch execution workspace command config", async () => { - mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); - const app = await createExecutionWorkspaceApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .patch(`/api/execution-workspaces/${executionWorkspaceId}`) - .send({ - config: { - cleanupCommand: "rm -rf /tmp/paperclip-rce", - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("host-executed workspace commands"); - expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); - }); - - it("rejects agent callers that smuggle execution workspace commands through metadata.config", async () => { - mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ id: executionWorkspaceId })); - const app = await createExecutionWorkspaceApp({ - type: "agent", - agentId: "agent-1", - companyId: "company-1", - source: "agent_key", - runId: "run-1", - }); - - const res = await request(app) - .patch(`/api/execution-workspaces/${executionWorkspaceId}`) - .send({ - metadata: { - config: { - provisionCommand: "touch /tmp/paperclip-rce", - }, - }, - }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("host-executed workspace commands"); - expect(mockExecutionWorkspaceService.update).not.toHaveBeenCalled(); - }); - - it("allows board callers through the execution workspace runtime auth gate", async () => { - mockExecutionWorkspaceService.getById.mockResolvedValue(null); - const app = await createExecutionWorkspaceApp({ - type: "board", - userId: "board-1", - companyIds: ["company-1"], - source: "session", - isInstanceAdmin: false, - }); - - const res = await request(app) - .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-services/restart`) - .send({}); - - expect(res.status).toBe(404); - expect(res.body.error).toContain("Execution workspace not found"); - expect(mockExecutionWorkspaceService.getById).toHaveBeenCalledWith(executionWorkspaceId); - }); -}); diff --git a/server/src/__tests__/workspace-runtime-service-authz.test.ts b/server/src/__tests__/workspace-runtime-service-authz.test.ts deleted file mode 100644 index 87460487df6..00000000000 --- a/server/src/__tests__/workspace-runtime-service-authz.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import { - agents, - companies, - createDb, - executionWorkspaces, - issues, - projectWorkspaces, - projects, -} from "@paperclipai/db"; -import { - getEmbeddedPostgresTestSupport, - startEmbeddedPostgresTestDatabase, -} from "./helpers/embedded-postgres.js"; -import { - assertCanManageExecutionWorkspaceRuntimeServices, - assertCanManageProjectWorkspaceRuntimeServices, -} from "../routes/workspace-runtime-service-authz.js"; - -const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); -const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; - -if (!embeddedPostgresSupport.supported) { - console.warn( - `Skipping embedded Postgres workspace runtime auth tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, - ); -} - -describeEmbeddedPostgres("workspace runtime service authz helper", () => { - let db!: ReturnType; - let tempDb: Awaited> | null = null; - - beforeAll(async () => { - tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-authz-"); - db = createDb(tempDb.connectionString); - }, 20_000); - - afterEach(async () => { - await db.delete(issues); - await db.delete(executionWorkspaces); - await db.delete(projectWorkspaces); - await db.delete(projects); - await db.delete(agents); - await db.delete(companies); - }); - - afterAll(async () => { - await tempDb?.cleanup(); - }); - - async function seedCompany() { - const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `PAP-${companyId.slice(0, 8)}`, - requireBoardApprovalForNewAgents: false, - }); - return companyId; - } - - async function seedProjectWorkspace(companyId: string) { - const projectId = randomUUID(); - const projectWorkspaceId = randomUUID(); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace authz", - status: "in_progress", - }); - await db.insert(projectWorkspaces).values({ - id: projectWorkspaceId, - companyId, - projectId, - name: "Primary", - sourceType: "local_path", - cwd: "/tmp/paperclip-authz-project", - isPrimary: true, - }); - return { projectId, projectWorkspaceId }; - } - - async function seedExecutionWorkspace(companyId: string, projectId: string, projectWorkspaceId: string) { - const executionWorkspaceId = randomUUID(); - await db.insert(executionWorkspaces).values({ - id: executionWorkspaceId, - companyId, - projectId, - projectWorkspaceId, - mode: "isolated_workspace", - strategyType: "git_worktree", - name: "Execution workspace", - status: "active", - providerType: "local_fs", - cwd: "/tmp/paperclip-authz-execution", - }); - return executionWorkspaceId; - } - - async function seedAgent( - companyId: string, - input: { role?: string; reportsTo?: string | null; name?: string } = {}, - ) { - const agentId = randomUUID(); - await db.insert(agents).values({ - id: agentId, - companyId, - name: input.name ?? "Agent", - role: input.role ?? "engineer", - reportsTo: input.reportsTo ?? null, - }); - return agentId; - } - - it("allows board actors to manage project workspace runtime services", async () => { - const companyId = await seedCompany(); - const { projectWorkspaceId } = await seedProjectWorkspace(companyId); - - await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { - actor: { - type: "board", - userId: "board-1", - companyIds: [companyId], - source: "session", - isInstanceAdmin: false, - }, - } as any, { - companyId, - projectWorkspaceId, - })).resolves.toBeUndefined(); - }); - - it("allows CEO agents to manage any project workspace runtime services in their company", async () => { - const companyId = await seedCompany(); - const { projectWorkspaceId } = await seedProjectWorkspace(companyId); - const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" }); - - await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { - actor: { - type: "agent", - agentId: ceoAgentId, - companyId, - source: "agent_key", - }, - } as any, { - companyId, - projectWorkspaceId, - })).resolves.toBeUndefined(); - }); - - it("allows agents with a non-terminal assigned issue in the target project workspace", async () => { - const companyId = await seedCompany(); - const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); - const agentId = await seedAgent(companyId, { name: "Engineer" }); - - await db.insert(issues).values({ - id: randomUUID(), - companyId, - projectId, - projectWorkspaceId, - title: "Use this workspace", - status: "todo", - priority: "medium", - assigneeAgentId: agentId, - }); - - await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { - actor: { - type: "agent", - agentId, - companyId, - source: "agent_key", - }, - } as any, { - companyId, - projectWorkspaceId, - })).resolves.toBeUndefined(); - }); - - it("allows managers to manage execution workspace runtime services for their reporting subtree", async () => { - const companyId = await seedCompany(); - const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); - const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); - const managerId = await seedAgent(companyId, { role: "cto", name: "Manager" }); - const reportId = await seedAgent(companyId, { reportsTo: managerId, name: "Report" }); - - await db.insert(issues).values({ - id: randomUUID(), - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId, - title: "Use execution workspace", - status: "in_progress", - priority: "medium", - assigneeAgentId: reportId, - }); - - await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { - actor: { - type: "agent", - agentId: managerId, - companyId, - source: "agent_key", - }, - } as any, { - companyId, - executionWorkspaceId, - })).resolves.toBeUndefined(); - }); - - it("rejects unrelated same-company agents without matching workspace assignments", async () => { - const companyId = await seedCompany(); - const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); - const executionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId); - const assignedAgentId = await seedAgent(companyId, { name: "Assigned" }); - const unrelatedAgentId = await seedAgent(companyId, { name: "Unrelated" }); - - await db.insert(issues).values({ - id: randomUUID(), - companyId, - projectId, - projectWorkspaceId, - executionWorkspaceId, - title: "Assigned issue", - status: "todo", - priority: "medium", - assigneeAgentId: assignedAgentId, - }); - - await expect(assertCanManageExecutionWorkspaceRuntimeServices(db, { - actor: { - type: "agent", - agentId: unrelatedAgentId, - companyId, - source: "agent_key", - }, - } as any, { - companyId, - executionWorkspaceId, - })).rejects.toMatchObject({ - status: 403, - message: "Missing permission to manage workspace runtime services", - }); - }); - - it("rejects completed workspace assignments so stale issues do not keep access alive", async () => { - const companyId = await seedCompany(); - const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId); - const agentId = await seedAgent(companyId, { name: "Engineer" }); - - await db.insert(issues).values({ - id: randomUUID(), - companyId, - projectId, - projectWorkspaceId, - title: "Completed issue", - status: "done", - priority: "medium", - assigneeAgentId: agentId, - }); - - await expect(assertCanManageProjectWorkspaceRuntimeServices(db, { - actor: { - type: "agent", - agentId, - companyId, - source: "agent_key", - }, - } as any, { - companyId, - projectWorkspaceId, - })).rejects.toMatchObject({ - status: 403, - message: "Missing permission to manage workspace runtime services", - }); - }); -}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index f6a1afca1e1..f674622721b 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -97,9 +97,6 @@ const claudeLocalAdapter: ServerAdapterModule = { models: claudeModels, listModels: listClaudeModels, supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: claudeAgentConfigurationDoc, getQuotaWindows: claudeGetQuotaWindows, }; @@ -115,9 +112,6 @@ const codexLocalAdapter: ServerAdapterModule = { models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: codexAgentConfigurationDoc, getQuotaWindows: codexGetQuotaWindows, }; @@ -133,9 +127,6 @@ const cursorLocalAdapter: ServerAdapterModule = { models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: cursorAgentConfigurationDoc, }; @@ -149,9 +140,6 @@ const geminiLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: geminiAgentConfigurationDoc, }; @@ -161,8 +149,6 @@ const openclawGatewayAdapter: ServerAdapterModule = { testEnvironment: openclawGatewayTestEnvironment, models: openclawGatewayModels, supportsLocalAgentJwt: false, - supportsInstructionsBundle: false, - requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, }; @@ -177,9 +163,6 @@ const openCodeLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, listModels: listOpenCodeModels, supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: openCodeAgentConfigurationDoc, }; @@ -194,9 +177,6 @@ const piLocalAdapter: ServerAdapterModule = { models: [], listModels: listPiModels, supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: piAgentConfigurationDoc, }; @@ -209,9 +189,6 @@ const hermesLocalAdapter: ServerAdapterModule = { syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, - supportsInstructionsBundle: true, - instructionsPathKey: "instructionsFilePath", - requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: hermesAgentConfigurationDoc, detectModel: () => detectModelFromHermes(), }; diff --git a/server/src/app.ts b/server/src/app.ts index c60de555269..a9efe214ba0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -87,16 +87,6 @@ export function shouldServeViteDevHtml(req: ExpressRequest): boolean { return req.accepts(["html"]) === "html"; } -export function shouldEnablePrivateHostnameGuard(opts: { - deploymentMode: DeploymentMode; - deploymentExposure: DeploymentExposure; -}): boolean { - return ( - opts.deploymentExposure === "private" && - (opts.deploymentMode === "local_trusted" || opts.deploymentMode === "authenticated") - ); -} - export async function createApp( db: Db, opts: { @@ -134,10 +124,8 @@ export async function createApp( }, })); app.use(httpLogger); - const privateHostnameGateEnabled = shouldEnablePrivateHostnameGuard({ - deploymentMode: opts.deploymentMode, - deploymentExposure: opts.deploymentExposure, - }); + const privateHostnameGateEnabled = + opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private"; const privateHostnameAllowSet = resolvePrivateHostnameAllowSet({ allowedHostnames: opts.allowedHostnames, bindHost: opts.bindHost, @@ -300,46 +288,9 @@ export async function createApp( const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { const indexHtml = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); - // Hashed asset files (Vite emits them under /assets/..) - // never change once built, so they can be cached aggressively. - app.use( - "/assets", - express.static(path.join(uiDist, "assets"), { - maxAge: "1y", - immutable: true, - }), - ); - // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): - // short cache so operators who swap them out see the new version - // reasonably fast. Override for `index.html` specifically — it is - // served by this middleware for `/` and `/index.html`, and it must - // never outlive the asset hashes it points at. - app.use( - express.static(uiDist, { - maxAge: "1h", - setHeaders(res, filePath) { - if (path.basename(filePath) === "index.html") { - res.set("Cache-Control", "no-cache"); - } - }, - }), - ); - // SPA fallback. Only for non-asset routes — if the browser asks for - // /assets/something.js that doesn't exist, we must NOT serve the HTML - // shell: the browser would try to load it as a JavaScript module, fail - // with a MIME-type error, and cache that broken response. Return 404 - // instead. The index.html response itself is no-cache so a subsequent - // deploy's updated asset hashes are picked up on next load. - app.get(/.*/, (req, res) => { - if (req.path.startsWith("/assets/")) { - res.status(404).end(); - return; - } - res - .status(200) - .set("Content-Type", "text/html") - .set("Cache-Control", "no-cache") - .end(indexHtml); + app.use(express.static(uiDist)); + app.get(/.*/, (_req, res) => { + res.status(200).set("Content-Type", "text/html").end(indexHtml); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); diff --git a/server/src/index.ts b/server/src/index.ts index eb6ad4e477f..74199e36cc4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -554,9 +554,7 @@ export async function startServer(): Promise { : runtimeListenHost; process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); - if (!process.env.PAPERCLIP_API_URL) { - process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; - } + process.env.PAPERCLIP_API_URL = `http://${runtimeApiHost}:${listenPort}`; setupLiveEventsWebSocketServer(server, db as any, { deploymentMode: config.deploymentMode, @@ -794,7 +792,7 @@ export async function startServer(): Promise { server, host: config.host, listenPort, - apiUrl: process.env.PAPERCLIP_API_URL!, + apiUrl: process.env.PAPERCLIP_API_URL ?? `http://${runtimeApiHost}:${listenPort}`, databaseUrl: activeDatabaseConnectionString, }; } diff --git a/server/src/log-redaction.ts b/server/src/log-redaction.ts index ab59b3e414a..952d1553ad1 100644 --- a/server/src/log-redaction.ts +++ b/server/src/log-redaction.ts @@ -112,6 +112,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction let result = input; for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) { + if (!result.includes(homeDir)) continue; const lastSegment = splitPathSegments(homeDir).pop() ?? ""; const replacementDir = lastSegment ? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement)) @@ -120,6 +121,7 @@ export function redactCurrentUserText(input: string, opts?: CurrentUserRedaction } for (const userName of [...userNames].sort((a, b) => b.length - a.length)) { + if (!result.includes(userName)) continue; const pattern = new RegExp(`(? { - assertAuthenticated(req); + router.get("/skills/available", (_req, res) => { res.json({ skills: listAvailableSkills() }); }); - router.get("/skills/index", (req, res) => { - assertAuthenticated(req); + router.get("/skills/index", (_req, res) => { res.json({ skills: [ { name: "paperclip", path: "/api/skills/paperclip" }, @@ -1930,7 +1927,6 @@ export function accessRoutes( }); router.get("/skills/:skillName", (req, res) => { - assertAuthenticated(req); const skillName = (req.params.skillName as string).trim().toLowerCase(); const markdown = readSkillMarkdown(skillName); if (!markdown) throw notFound("Skill not found"); @@ -2104,47 +2100,6 @@ export function accessRoutes( ); }); - router.get("/invites/:token/skills/index", async (req, res) => { - const token = (req.params.token as string).trim(); - if (!token) throw notFound("Invite not found"); - const invite = await db - .select() - .from(invites) - .where(eq(invites.tokenHash, hashToken(token))) - .then((rows) => rows[0] ?? null); - if (!invite || invite.revokedAt || inviteExpired(invite)) { - throw notFound("Invite not found"); - } - - res.json({ - skills: [ - { - name: "paperclip", - path: `/api/invites/${token}/skills/paperclip`, - }, - ], - }); - }); - - router.get("/invites/:token/skills/:skillName", async (req, res) => { - const token = (req.params.token as string).trim(); - if (!token) throw notFound("Invite not found"); - const invite = await db - .select() - .from(invites) - .where(eq(invites.tokenHash, hashToken(token))) - .then((rows) => rows[0] ?? null); - if (!invite || invite.revokedAt || inviteExpired(invite)) { - throw notFound("Invite not found"); - } - - const skillName = (req.params.skillName as string).trim().toLowerCase(); - if (skillName !== "paperclip") throw notFound("Skill not found"); - const markdown = readSkillMarkdown(skillName); - if (!markdown) throw notFound("Skill not found"); - res.type("text/markdown").send(markdown); - }); - router.get("/invites/:token/test-resolution", async (req, res) => { const token = (req.params.token as string).trim(); if (!token) throw notFound("Invite not found"); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 53083b1b608..20f2ed534ed 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; -import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; @@ -81,7 +81,6 @@ export function activityRoutes(db: Db) { }); router.get("/heartbeat-runs/:runId/issues", async (req, res) => { - assertAuthenticated(req); const runId = req.params.runId as string; const run = await heartbeat.getRun(runId); if (!run) { diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 811060adabb..7a92f7835af 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -59,13 +59,6 @@ interface AdapterInstallRequest { version?: string; } -interface AdapterCapabilities { - supportsInstructionsBundle: boolean; - supportsSkills: boolean; - supportsLocalAgentJwt: boolean; - requiresMaterializedRuntimeSkills: boolean; -} - interface AdapterInfo { type: string; label: string; @@ -73,7 +66,6 @@ interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; - capabilities: AdapterCapabilities; /** True when an external plugin has replaced a built-in adapter of the same type. */ overriddenBuiltin?: boolean; /** True when the external override for a builtin type is currently paused. */ @@ -111,15 +103,6 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string } } -function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { - return { - supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, - supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), - supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, - requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, - }; -} - function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { @@ -129,7 +112,6 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP modelsCount: (adapter.models ?? []).length, loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), - capabilities: buildAdapterCapabilities(adapter), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f5e06dd62aa..cfe175b6f30 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,4 @@ -import { Router, type Request, type Response } from "express"; +import { Router, type Request } from "express"; import { generateKeyPairSync, randomUUID } from "node:crypto"; import path from "node:path"; import type { Db } from "@paperclipai/db"; @@ -46,10 +46,6 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; -import { - assertNoAgentHostWorkspaceCommandMutation, - collectAgentAdapterWorkspaceCommandPaths, -} from "./workspace-command-authz.js"; import { detectAdapterModel, findActiveServerAdapter, @@ -76,8 +72,6 @@ import { import { getTelemetryClient } from "../telemetry.js"; export function agentRoutes(db: Db) { - // Legacy hardcoded maps — used as fallback when adapter module does not - // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", @@ -89,22 +83,6 @@ export function agentRoutes(db: Db) { pi_local: "instructionsFilePath", }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); - - /** Check if an adapter supports the managed instructions bundle. */ - function adapterSupportsInstructionsBundle(adapterType: string): boolean { - const adapter = findActiveServerAdapter(adapterType); - if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle; - return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType); - } - - /** Resolve the adapter config key for the instructions file path. */ - function resolveInstructionsPathKey(adapterType: string): string | null { - const adapter = findActiveServerAdapter(adapterType); - if (adapter?.instructionsPathKey) return adapter.instructionsPathKey; - if (adapter?.supportsInstructionsBundle === true) return "instructionsFilePath"; - if (adapter?.supportsInstructionsBundle === false) return null; - return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null; - } const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ "instructionsBundleMode", @@ -235,33 +213,10 @@ export function agentRoutes(db: Db) { return actorAgent; } - async function assertBoardCanManageAgentsForCompany(req: Request, companyId: string) { - assertBoard(req); - assertCompanyAccess(req, companyId); - if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; - const allowed = await access.canUser(companyId, req.actor.userId, "agents:create"); - if (!allowed) { - throw forbidden("Missing permission: agents:create"); - } - } - async function assertCanReadConfigurations(req: Request, companyId: string) { return assertCanCreateAgentsForCompany(req, companyId); } - async function getAccessibleAgent(req: Request, res: Response, id: string) { - const agent = await svc.getById(id); - if (!agent) { - res.status(404).json({ error: "Agent not found" }); - return null; - } - assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "board") { - await assertBoardCanManageAgentsForCompany(req, agent.companyId); - } - return agent; - } - async function actorCanReadConfigurationsForCompany(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") { @@ -344,10 +299,7 @@ export function agentRoutes(db: Db) { async function assertCanUpdateAgent(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") { - await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); - return; - } + if (req.actor.type === "board") return; if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -369,10 +321,7 @@ export function agentRoutes(db: Db) { async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type === "board") { - await assertCanReadConfigurations(req, targetAgent.companyId); - return; - } + if (req.actor.type === "board") return; if (!req.actor.agentId) throw forbidden("Agent authentication required"); const actorAgent = await svc.getById(req.actor.agentId); @@ -608,7 +557,7 @@ export function agentRoutes(db: Db) { adapterType: string; adapterConfig: unknown; }>(agent: T): Promise { - if (!adapterSupportsInstructionsBundle(agent.adapterType)) { + if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) { return agent; } @@ -643,24 +592,19 @@ export function agentRoutes(db: Db) { async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) { assertCompanyAccess(req, targetAgent.companyId); - if (req.actor.type !== "board") { - throw forbidden( - "Only board-authenticated callers can manage instructions path or bundle configuration", - ); - } - await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId); - } + if (req.actor.type === "board") return; + if (!req.actor.agentId) throw forbidden("Agent authentication required"); - function assertNoAgentInstructionsConfigMutation( - req: Request, - adapterConfig: Record | null | undefined, - ) { - if (req.actor.type !== "agent" || !adapterConfig) return; - const changedSensitiveKeys = KNOWN_INSTRUCTIONS_BUNDLE_KEYS.filter((key) => adapterConfig[key] !== undefined); - if (changedSensitiveKeys.length === 0) return; - throw forbidden( - `Agent-authenticated callers cannot modify instructions path or bundle configuration (${changedSensitiveKeys.join(", ")})`, - ); + const actorAgent = await svc.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { + throw forbidden("Agent key cannot access another company"); + } + if (actorAgent.id === targetAgent.id) return; + + const chainOfCommand = await svc.getChainOfCommand(targetAgent.id); + if (chainOfCommand.some((manager) => manager.id === actorAgent.id)) return; + + throw forbidden("Only the target agent or an ancestor manager can update instructions path"); } function summarizeAgentUpdateDetails(patch: Record) { @@ -694,9 +638,7 @@ export function agentRoutes(db: Db) { }; } - // Legacy hardcoded set — used as fallback when adapter module does not - // declare requiresMaterializedRuntimeSkills explicitly. - const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ + const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([ "cursor", "gemini_local", "opencode_local", @@ -704,11 +646,7 @@ export function agentRoutes(db: Db) { ]); function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { - const adapter = findActiveServerAdapter(adapterType); - if (adapter?.requiresMaterializedRuntimeSkills !== undefined) { - return adapter.requiresMaterializedRuntimeSkills; - } - return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType); + return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType); } async function buildRuntimeSkillConfig( @@ -1035,7 +973,7 @@ export function agentRoutes(db: Db) { } const result = await svc.list(companyId); const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId); - if (canReadConfigs) { + if (canReadConfigs || req.actor.type === "board") { res.json(result); return; } @@ -1167,7 +1105,6 @@ export function agentRoutes(db: Db) { const rows = await issuesSvc.list(req.actor.companyId, { assigneeAgentId: req.actor.agentId, status: "todo,in_progress,blocked", - includeRoutineExecutions: true, }); res.json( @@ -1211,13 +1148,12 @@ export function agentRoutes(db: Db) { return; } assertCompanyAccess(req, agent.companyId); - const isSelf = req.actor.type === "agent" && req.actor.agentId === id; - const canReadSensitiveDetail = isSelf - ? true - : await actorCanReadConfigurationsForCompany(req, agent.companyId); - if (!canReadSensitiveDetail) { - res.json(await buildAgentDetail(agent, { restricted: true })); - return; + if (req.actor.type === "agent" && req.actor.agentId !== id) { + const canRead = await actorCanReadConfigurationsForCompany(req, agent.companyId); + if (!canRead) { + res.json(await buildAgentDetail(agent, { restricted: true })); + return; + } } res.json(await buildAgentDetail(agent)); }); @@ -1305,7 +1241,6 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } - await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const state = await heartbeat.getRuntimeState(id); @@ -1320,7 +1255,6 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } - await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const sessions = await heartbeat.listTaskSessions(id); @@ -1340,7 +1274,6 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } - await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); const taskKey = @@ -1373,14 +1306,6 @@ export function agentRoutes(db: Db) { ...hireInput } = req.body; hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig), - ); - assertNoAgentInstructionsConfigMutation( - req, - (hireInput.adapterConfig ?? {}) as Record, - ); const requestedAdapterConfig = applyCreateDefaultsByAdapterType( hireInput.adapterType, ((hireInput.adapterConfig ?? {}) as Record), @@ -1640,8 +1565,6 @@ export function agentRoutes(db: Db) { res.status(403).json({ error: "Only CEO can manage permissions" }); return; } - } else { - await assertBoardCanManageAgentsForCompany(req, existing.companyId); } const agent = await svc.updatePermissions(id, req.body); @@ -1693,7 +1616,7 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); - const defaultKey = resolveInstructionsPathKey(existing.adapterType); + const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; const adapterConfigKey = explicitKey ?? defaultKey; if (!adapterConfigKey) { res.status(422).json({ @@ -1942,15 +1865,10 @@ export function agentRoutes(db: Db) { res.status(422).json({ error: "adapterConfig must be an object" }); return; } - assertNoAgentInstructionsConfigMutation(req, adapterConfig); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectAgentAdapterWorkspaceCommandPaths(adapterConfig), + const changingInstructionsPath = Object.keys(adapterConfig).some((key) => + KNOWN_INSTRUCTIONS_PATH_KEYS.has(key), ); - const changingInstructionsConfig = Object.keys(adapterConfig).some((key) => - KNOWN_INSTRUCTIONS_BUNDLE_KEYS.includes(key as (typeof KNOWN_INSTRUCTIONS_BUNDLE_KEYS)[number]), - ); - if (changingInstructionsConfig) { + if (changingInstructionsPath) { await assertCanManageInstructionsPath(req, existing); } patchData.adapterConfig = adapterConfig; @@ -2051,9 +1969,6 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/pause", async (req, res) => { assertBoard(req); const id = req.params.id as string; - if (!(await getAccessibleAgent(req, res, id))) { - return; - } const agent = await svc.pause(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2077,9 +1992,6 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/resume", async (req, res) => { assertBoard(req); const id = req.params.id as string; - if (!(await getAccessibleAgent(req, res, id))) { - return; - } const agent = await svc.resume(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2101,9 +2013,6 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/terminate", async (req, res) => { assertBoard(req); const id = req.params.id as string; - if (!(await getAccessibleAgent(req, res, id))) { - return; - } const agent = await svc.terminate(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2127,9 +2036,6 @@ export function agentRoutes(db: Db) { router.delete("/agents/:id", async (req, res) => { assertBoard(req); const id = req.params.id as string; - if (!(await getAccessibleAgent(req, res, id))) { - return; - } const agent = await svc.remove(id); if (!agent) { res.status(404).json({ error: "Agent not found" }); @@ -2151,10 +2057,6 @@ export function agentRoutes(db: Db) { router.get("/agents/:id/keys", async (req, res) => { assertBoard(req); const id = req.params.id as string; - const agent = await getAccessibleAgent(req, res, id); - if (!agent) { - return; - } const keys = await svc.listKeys(id); res.json(keys); }); @@ -2162,56 +2064,32 @@ export function agentRoutes(db: Db) { router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; - const agent = await getAccessibleAgent(req, res, id); - if (!agent) { - return; - } const key = await svc.createApiKey(id, req.body.name); - await logActivity(db, { - companyId: agent.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "agent.key_created", - entityType: "agent", - entityId: agent.id, - details: { keyId: key.id, name: key.name }, - }); + const agent = await svc.getById(id); + if (agent) { + await logActivity(db, { + companyId: agent.companyId, + actorType: "user", + actorId: req.actor.userId ?? "board", + action: "agent.key_created", + entityType: "agent", + entityId: agent.id, + details: { keyId: key.id, name: key.name }, + }); + } res.status(201).json(key); }); router.delete("/agents/:id/keys/:keyId", async (req, res) => { assertBoard(req); - const id = req.params.id as string; const keyId = req.params.keyId as string; - const agent = await getAccessibleAgent(req, res, id); - if (!agent) { - return; - } - - const key = await svc.getKeyById(keyId); - if (!key || key.agentId !== agent.id) { - res.status(404).json({ error: "Key not found" }); - return; - } - - const revoked = await svc.revokeKey(agent.id, keyId); + const revoked = await svc.revokeKey(keyId); if (!revoked) { res.status(404).json({ error: "Key not found" }); return; } - - await logActivity(db, { - companyId: agent.companyId, - actorType: "user", - actorId: req.actor.userId ?? "board", - action: "agent.key_revoked", - entityType: "agent", - entityId: agent.id, - details: { keyId: key.id, name: key.name }, - }); - res.json({ ok: true }); }); @@ -2224,13 +2102,9 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent") { - if (req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; - } - } else { - await assertBoardCanManageAgentsForCompany(req, agent.companyId); + if (req.actor.type === "agent" && req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; } const run = await heartbeat.wakeup(id, { @@ -2278,13 +2152,9 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, agent.companyId); - if (req.actor.type === "agent") { - if (req.actor.agentId !== id) { - res.status(403).json({ error: "Agent can only invoke itself" }); - return; - } - } else { - await assertBoardCanManageAgentsForCompany(req, agent.companyId); + if (req.actor.type === "agent" && req.actor.agentId !== id) { + res.status(403).json({ error: "Agent can only invoke itself" }); + return; } const run = await heartbeat.invoke( @@ -2330,7 +2200,6 @@ export function agentRoutes(db: Db) { res.status(404).json({ error: "Agent not found" }); return; } - await assertBoardCanManageAgentsForCompany(req, agent.companyId); assertCompanyAccess(req, agent.companyId); if (agent.adapterType !== "claude_local") { res.status(400).json({ error: "Login is only supported for claude_local agents" }); @@ -2493,7 +2362,6 @@ export function agentRoutes(db: Db) { limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, }); - res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); @@ -2528,7 +2396,6 @@ export function agentRoutes(db: Db) { limitBytes: Number.isFinite(limitBytes) ? limitBytes : 256000, }); - res.set("Cache-Control", "no-cache, no-store"); res.json(result); }); diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index d1743cc050c..3ed20374700 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -134,8 +134,11 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const decidedByUserId = req.actor.userId ?? "board"; - const { approval, applied } = await svc.approve(id, decidedByUserId, req.body.decisionNote); + const { approval, applied } = await svc.approve( + id, + req.body.decidedByUserId ?? "board", + req.body.decisionNote, + ); if (applied) { const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id); @@ -230,8 +233,11 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const decidedByUserId = req.actor.userId ?? "board"; - const { approval, applied } = await svc.reject(id, decidedByUserId, req.body.decisionNote); + const { approval, applied } = await svc.reject( + id, + req.body.decidedByUserId ?? "board", + req.body.decisionNote, + ); if (applied) { await logActivity(db, { @@ -258,8 +264,11 @@ export function approvalRoutes(db: Db) { res.status(404).json({ error: "Approval not found" }); return; } - const decidedByUserId = req.actor.userId ?? "board"; - const approval = await svc.requestRevision(id, decidedByUserId, req.body.decisionNote); + const approval = await svc.requestRevision( + id, + req.body.decidedByUserId ?? "board", + req.body.decisionNote, + ); await logActivity(db, { companyId: approval.companyId, diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index e9017c93a3b..a881d4ff052 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -1,12 +1,6 @@ import type { Request } from "express"; import { forbidden, unauthorized } from "../errors.js"; -export function assertAuthenticated(req: Request) { - if (req.actor.type === "none") { - throw unauthorized(); - } -} - export function assertBoard(req: Request) { if (req.actor.type !== "board") { throw forbidden("Board access required"); @@ -22,7 +16,9 @@ export function assertInstanceAdmin(req: Request) { } export function assertCompanyAccess(req: Request, companyId: string) { - assertAuthenticated(req); + if (req.actor.type === "none") { + throw unauthorized(); + } if (req.actor.type === "agent" && req.actor.companyId !== companyId) { throw forbidden("Agent key cannot access another company"); } @@ -35,7 +31,9 @@ export function assertCompanyAccess(req: Request, companyId: string) { } export function getActorInfo(req: Request) { - assertAuthenticated(req); + if (req.actor.type === "none") { + throw unauthorized(); + } if (req.actor.type === "agent") { return { actorType: "agent" as const, diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 20e80c53e43..cfe36d52be5 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -23,11 +23,6 @@ import { stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; -import { - assertNoAgentHostWorkspaceCommandMutation, - collectExecutionWorkspaceCommandPaths, -} from "./workspace-command-authz.js"; -import { assertCanManageExecutionWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; export function executionWorkspaceRoutes(db: Db) { const router = Router(); @@ -104,12 +99,6 @@ export function executionWorkspaceRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); - await assertCanManageExecutionWorkspaceRuntimeServices(db, req, { - companyId: existing.companyId, - executionWorkspaceId: existing.id, - sourceIssueId: existing.sourceIssueId, - }); - const workspaceCwd = existing.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" }); @@ -442,13 +431,6 @@ export function executionWorkspaceRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectExecutionWorkspaceCommandPaths({ - config: req.body.config, - metadata: req.body.metadata, - }), - ); const patch: Record = { ...(req.body.name === undefined ? {} : { name: req.body.name }), ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index d5216e72d89..795eb9a5cb4 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -7,14 +7,6 @@ import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-se import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; -function shouldExposeFullHealthDetails( - actorType: "none" | "board" | "agent" | null | undefined, - deploymentMode: DeploymentMode, -) { - if (deploymentMode !== "authenticated") return true; - return actorType === "board" || actorType === "agent"; -} - export function healthRoutes( db?: Db, opts: { @@ -31,19 +23,9 @@ export function healthRoutes( ) { const router = Router(); - router.get("/", async (req, res) => { - const actorType = "actor" in req ? req.actor?.type : null; - const exposeFullDetails = shouldExposeFullHealthDetails( - actorType, - opts.deploymentMode, - ); - + router.get("/", async (_req, res) => { if (!db) { - res.json( - exposeFullDetails - ? { status: "ok", version: serverVersion } - : { status: "ok", deploymentMode: opts.deploymentMode }, - ); + res.json({ status: "ok", version: serverVersion }); return; } @@ -88,7 +70,7 @@ export function healthRoutes( const persistedDevServerStatus = readPersistedDevServerStatus(); let devServer: ReturnType | undefined; - if (persistedDevServerStatus && typeof (db as { select?: unknown }).select === "function") { + if (persistedDevServerStatus) { const instanceSettings = instanceSettingsService(db); const experimentalSettings = await instanceSettings.getExperimental(); const activeRunCount = await db @@ -103,16 +85,6 @@ export function healthRoutes( }); } - if (!exposeFullDetails) { - res.json({ - status: "ok", - deploymentMode: opts.deploymentMode, - bootstrapStatus, - bootstrapInviteActive, - }); - return; - } - res.json({ status: "ok", version: serverVersion, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 1531441ed1b..7a6c941718f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -48,10 +48,6 @@ import { import { logger } from "../middleware/logger.js"; import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; -import { - assertNoAgentHostWorkspaceCommandMutation, - collectIssueWorkspaceCommandPaths, -} from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { isInlineAttachmentContentType, @@ -729,7 +725,7 @@ export function issueRoutes( const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), - svc.findMentionedProjectIds(issue.id), + svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }), documentsSvc.getIssueDocumentPayload(issue), svc.getRelationSummaries(issue.id), ]); @@ -1329,7 +1325,6 @@ export function issueRoutes( router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (req.body.assigneeAgentId || req.body.assigneeUserId) { await assertCanAssignTasks(req, companyId); } @@ -1380,7 +1375,6 @@ export function issueRoutes( return; } assertCompanyAccess(req, existing.companyId); - assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); if (!(await assertAgentRunCheckoutOwnership(req, res, existing))) return; const actor = getActorInfo(req); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index a6368be9c65..95215d899af 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -11,8 +11,7 @@ * - Retrieving UI slot contributions for frontend rendering * - Discovering and executing plugin-contributed agent tools * - * All routes require board-level authentication, and sensitive instance-wide - * mutations such as install/upgrade require instance-admin privileges. + * All routes require board-level authentication (assertBoard middleware). * * @module server/routes/plugins * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification @@ -48,7 +47,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; -import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; /** UI slot declaration extracted from plugin manifest */ @@ -584,9 +583,6 @@ export function pluginRoutes( * * Install a plugin from npm or a local filesystem path. * - * Instance-wide plugin installation is restricted to instance admins because - * the install flow fetches and inspects package contents on the host. - * * Request body: * - packageName: npm package name or local path (required) * - version: Target version for npm packages (optional) @@ -605,7 +601,7 @@ export function pluginRoutes( * - `500` — installation succeeded but manifest is missing (indicates a loader bug) */ router.post("/plugins/install", async (req, res) => { - assertInstanceAdmin(req); + assertBoard(req); const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; // Input validation @@ -1454,9 +1450,6 @@ export function pluginRoutes( * * Upgrade a plugin to a newer version. * - * Upgrades are restricted to instance admins because they fetch and inspect - * new package contents on the host before activation. - * * Request body (optional): * - version: Target version (defaults to latest) * @@ -1468,7 +1461,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/upgrade", async (req, res) => { - assertInstanceAdmin(req); + assertBoard(req); const { pluginId } = req.params; const body = req.body as { version?: string } | undefined; const version = body?.version; diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index f4d3e84863b..71cd792b0a6 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -22,12 +22,6 @@ import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace, } from "../services/workspace-runtime.js"; -import { - assertNoAgentHostWorkspaceCommandMutation, - collectProjectExecutionWorkspaceCommandPaths, - collectProjectWorkspaceCommandPaths, -} from "./workspace-command-authz.js"; -import { assertCanManageProjectWorkspaceRuntimeServices } from "./workspace-runtime-service-authz.js"; import { getTelemetryClient } from "../telemetry.js"; export function projectRoutes(db: Db) { @@ -99,13 +93,6 @@ export function projectRoutes(db: Db) { }; const { workspace, ...projectData } = req.body as CreateProjectPayload; - assertNoAgentHostWorkspaceCommandMutation( - req, - [ - ...collectProjectExecutionWorkspaceCommandPaths(projectData.executionWorkspacePolicy), - ...collectProjectWorkspaceCommandPaths(workspace, "workspace"), - ], - ); if (projectData.env !== undefined) { projectData.env = await secretsSvc.normalizeEnvBindingsForPersistence( companyId, @@ -157,10 +144,6 @@ export function projectRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); const body = { ...req.body }; - assertNoAgentHostWorkspaceCommandMutation( - req, - collectProjectExecutionWorkspaceCommandPaths(body.executionWorkspacePolicy), - ); if (typeof body.archivedAt === "string") { body.archivedAt = new Date(body.archivedAt); } @@ -217,10 +200,6 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectProjectWorkspaceCommandPaths(req.body), - ); const workspace = await svc.createWorkspace(id, req.body); if (!workspace) { res.status(422).json({ error: "Invalid project workspace payload" }); @@ -259,10 +238,6 @@ export function projectRoutes(db: Db) { return; } assertCompanyAccess(req, existing.companyId); - assertNoAgentHostWorkspaceCommandMutation( - req, - collectProjectWorkspaceCommandPaths(req.body), - ); const workspaceExists = (await svc.listWorkspaces(id)).some((workspace) => workspace.id === workspaceId); if (!workspaceExists) { res.status(404).json({ error: "Project workspace not found" }); @@ -315,11 +290,6 @@ export function projectRoutes(db: Db) { return; } - await assertCanManageProjectWorkspaceRuntimeServices(db, req, { - companyId: project.companyId, - projectWorkspaceId: workspace.id, - }); - const workspaceCwd = workspace.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Project workspace needs a local path before Paperclip can run workspace commands" }); diff --git a/server/src/routes/workspace-command-authz.ts b/server/src/routes/workspace-command-authz.ts deleted file mode 100644 index e7999d11d5a..00000000000 --- a/server/src/routes/workspace-command-authz.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Request } from "express"; -import { forbidden } from "../errors.js"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function hasOwn(value: Record, key: string) { - return Object.prototype.hasOwnProperty.call(value, key); -} - -function prefixPath(prefix: string, key: string) { - return prefix.length > 0 ? `${prefix}.${key}` : key; -} - -function collectWorkspaceStrategyCommandPaths(raw: unknown, prefix: string): string[] { - if (!isRecord(raw)) return []; - const paths: string[] = []; - if (hasOwn(raw, "provisionCommand")) { - paths.push(prefixPath(prefix, "provisionCommand")); - } - if (hasOwn(raw, "teardownCommand")) { - paths.push(prefixPath(prefix, "teardownCommand")); - } - return paths; -} - -function collectExecutionWorkspaceConfigCommandPaths(raw: unknown, prefix: string): string[] { - if (!isRecord(raw)) return []; - const paths: string[] = []; - if (hasOwn(raw, "provisionCommand")) { - paths.push(prefixPath(prefix, "provisionCommand")); - } - if (hasOwn(raw, "teardownCommand")) { - paths.push(prefixPath(prefix, "teardownCommand")); - } - if (hasOwn(raw, "cleanupCommand")) { - paths.push(prefixPath(prefix, "cleanupCommand")); - } - return paths; -} - -export function assertNoAgentHostWorkspaceCommandMutation(req: Request, paths: string[]) { - if (req.actor.type !== "agent" || paths.length === 0) return; - throw forbidden( - `Agent keys cannot modify host-executed workspace commands (${paths.join(", ")}).`, - ); -} - -export function collectAgentAdapterWorkspaceCommandPaths(adapterConfig: unknown): string[] { - if (!isRecord(adapterConfig)) return []; - return collectWorkspaceStrategyCommandPaths( - adapterConfig.workspaceStrategy, - "adapterConfig.workspaceStrategy", - ); -} - -export function collectProjectExecutionWorkspaceCommandPaths(policy: unknown): string[] { - if (!isRecord(policy)) return []; - return collectWorkspaceStrategyCommandPaths( - policy.workspaceStrategy, - "executionWorkspacePolicy.workspaceStrategy", - ); -} - -export function collectProjectWorkspaceCommandPaths( - workspacePatch: unknown, - prefix = "", -): string[] { - if (!isRecord(workspacePatch)) return []; - return hasOwn(workspacePatch, "cleanupCommand") - ? [prefixPath(prefix, "cleanupCommand")] - : []; -} - -export function collectIssueWorkspaceCommandPaths(input: { - executionWorkspaceSettings?: unknown; - assigneeAdapterOverrides?: unknown; -}): string[] { - const paths: string[] = []; - if (isRecord(input.executionWorkspaceSettings)) { - paths.push( - ...collectWorkspaceStrategyCommandPaths( - input.executionWorkspaceSettings.workspaceStrategy, - "executionWorkspaceSettings.workspaceStrategy", - ), - ); - } - if (isRecord(input.assigneeAdapterOverrides)) { - const adapterConfig = input.assigneeAdapterOverrides.adapterConfig; - if (isRecord(adapterConfig)) { - paths.push( - ...collectWorkspaceStrategyCommandPaths( - adapterConfig.workspaceStrategy, - "assigneeAdapterOverrides.adapterConfig.workspaceStrategy", - ), - ); - } - } - return paths; -} - -export function collectExecutionWorkspaceCommandPaths(input: { - config?: unknown; - metadata?: unknown; -}): string[] { - const paths: string[] = []; - if (input.config !== undefined) { - paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.config, "config")); - } - if (isRecord(input.metadata) && hasOwn(input.metadata, "config")) { - paths.push(...collectExecutionWorkspaceConfigCommandPaths(input.metadata.config, "metadata.config")); - } - return paths; -} diff --git a/server/src/routes/workspace-runtime-service-authz.ts b/server/src/routes/workspace-runtime-service-authz.ts deleted file mode 100644 index 16cff277be5..00000000000 --- a/server/src/routes/workspace-runtime-service-authz.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { and, eq, inArray, isNull, ne, or } from "drizzle-orm"; -import type { Db } from "@paperclipai/db"; -import { agents, issues } from "@paperclipai/db"; -import type { Request } from "express"; -import { forbidden } from "../errors.js"; -import { assertCompanyAccess } from "./authz.js"; - -const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [ - "backlog", - "todo", - "in_progress", - "in_review", - "blocked", -]; - -async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) { - const companyAgents = await db - .select({ - id: agents.id, - reportsTo: agents.reportsTo, - }) - .from(agents) - .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); - - const reportsByManager = new Map(); - for (const agent of companyAgents) { - if (!agent.reportsTo) continue; - const reports = reportsByManager.get(agent.reportsTo) ?? []; - reports.push(agent.id); - reportsByManager.set(agent.reportsTo, reports); - } - - const visited = new Set([actorAgentId]); - const queue = [actorAgentId]; - while (queue.length > 0) { - const current = queue.shift(); - if (!current) continue; - const reports = reportsByManager.get(current) ?? []; - for (const reportId of reports) { - if (visited.has(reportId)) continue; - visited.add(reportId); - queue.push(reportId); - } - } - - return [...visited]; -} - -async function assertAgentCanManageRuntimeServicesForWorkspace( - db: Db, - req: Request, - input: { - companyId: string; - projectWorkspaceId?: string | null; - executionWorkspaceId?: string | null; - sourceIssueId?: string | null; - }, -) { - if (req.actor.type !== "agent" || !req.actor.agentId) { - throw forbidden("Agent authentication required"); - } - - const actorAgent = await db - .select({ - id: agents.id, - companyId: agents.companyId, - role: agents.role, - }) - .from(agents) - .where(eq(agents.id, req.actor.agentId)) - .then((rows) => rows[0] ?? null); - - if (!actorAgent || actorAgent.companyId !== input.companyId) { - throw forbidden("Agent key cannot access another company"); - } - - if (actorAgent.role === "ceo") { - return; - } - - const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id); - const workspaceScopeConditions = [ - input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null, - input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null, - input.sourceIssueId ? eq(issues.id, input.sourceIssueId) : null, - ].filter((condition): condition is NonNullable => condition !== null); - - if (workspaceScopeConditions.length === 0) { - throw forbidden("Missing permission to manage workspace runtime services"); - } - - const linkedIssue = await db - .select({ id: issues.id }) - .from(issues) - .where(and( - eq(issues.companyId, input.companyId), - isNull(issues.hiddenAt), - inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES), - inArray(issues.assigneeAgentId, eligibleAgentIds), - workspaceScopeConditions.length === 1 - ? workspaceScopeConditions[0]! - : or(...workspaceScopeConditions), - )) - .then((rows) => rows[0] ?? null); - - if (linkedIssue) { - return; - } - - throw forbidden("Missing permission to manage workspace runtime services"); -} - -export async function assertCanManageProjectWorkspaceRuntimeServices( - db: Db, - req: Request, - input: { - companyId: string; - projectWorkspaceId: string; - }, -) { - assertCompanyAccess(req, input.companyId); - if (req.actor.type === "board") return; - await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); -} - -export async function assertCanManageExecutionWorkspaceRuntimeServices( - db: Db, - req: Request, - input: { - companyId: string; - executionWorkspaceId: string; - sourceIssueId?: string | null; - }, -) { - assertCompanyAccess(req, input.companyId); - if (req.actor.type === "board") return; - await assertAgentCanManageRuntimeServicesForWorkspace(db, req, input); -} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 6cacb441f50..342207e4498 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -216,7 +216,7 @@ export function agentService(db: Db) { const rows = await db .select({ agentId: costEvents.agentId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( @@ -619,25 +619,11 @@ export function agentService(db: Db) { .from(agentApiKeys) .where(eq(agentApiKeys.agentId, id)), - 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) => { + revokeKey: async (keyId: string) => { const rows = await db .update(agentApiKeys) .set({ revokedAt: new Date() }) - .where(and(eq(agentApiKeys.id, keyId), eq(agentApiKeys.agentId, agentId))) + .where(eq(agentApiKeys.id, keyId)) .returning(); return rows[0] ?? null; }, diff --git a/server/src/services/budgets.ts b/server/src/services/budgets.ts index 3e7d8b4e6cb..48575438386 100644 --- a/server/src/services/budgets.ts +++ b/server/src/services/budgets.ts @@ -156,7 +156,7 @@ async function computeObservedAmount( const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)); diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index dcdeb27dd3c..89678572ca8 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -76,10 +76,10 @@ export function companyService(db: Db) { if (companyIds.length === 0) return new Map(); const { start, end } = currentUtcMonthWindow(); const rows = await database - .select({ - companyId: costEvents.companyId, - spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - }) + .select({ + companyId: costEvents.companyId, + spentMonthlyCents: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, + }) .from(costEvents) .where( and( diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 305f6a761b5..ff29a180b2d 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -47,9 +47,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; -import { findServerAdapter } from "../adapters/index.js"; -import { forbidden, notFound, unprocessable } from "../errors.js"; +import { 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"; @@ -64,7 +62,6 @@ 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[] { @@ -120,7 +117,6 @@ 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 | null = null; @@ -2751,94 +2747,6 @@ 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, - ) { - 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, - 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, - existingSlugToAgentId: Map, - agentStatusById: Map, - 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 { if (source.type === "inline") { @@ -3948,11 +3856,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const warnings = [...plan.preview.warnings]; const include = plan.include; - let targetCompany: { - id: string; - name: string; - requireBoardApprovalForNewAgents?: boolean | null; - } | null = null; + let targetCompany: { id: string; name: string } | null = null; let companyAction: "created" | "updated" | "unchanged" = "unchanged"; if (input.target.mode === "new_company") { @@ -4073,11 +3977,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { const resultProjects: CompanyPortabilityImportResult["projects"] = []; const importedSlugToAgentId = new Map(); const existingSlugToAgentId = new Map(); - const agentStatusById = new Map(); 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(); const importedProjectWorkspaceIdByProjectSlug = new Map>(); @@ -4147,18 +4049,22 @@ 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; const desiredSkills = (manifestAgent.skills ?? []).map((skillRef) => desiredSkillRefMap.get(skillRef) ?? skillRef); - const normalizedAdapter = await prepareImportedAgentAdapter( - targetCompany.id, - adapterOverride?.adapterType ?? manifestAgent.adapterType, + const adapterConfigWithSkills = writePaperclipSkillSyncPreference( 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, @@ -4166,8 +4072,8 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { icon: manifestAgent.icon, capabilities: manifestAgent.capabilities, reportsTo: null, - adapterType: normalizedAdapter.adapterType, - adapterConfig: normalizedAdapter.adapterConfig, + adapterType: effectiveAdapterType, + adapterConfig: adapterConfigWithSkills, runtimeConfig: disableImportedTimerHeartbeat(manifestAgent.runtimeConfig), budgetMonthlyCents: manifestAgent.budgetMonthlyCents, permissions: manifestAgent.permissions, @@ -4196,7 +4102,6 @@ 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({ @@ -4209,17 +4114,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { continue; } - 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, - }); + let created = await agents.create(targetCompany.id, patch); await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, @@ -4238,7 +4133,6 @@ 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({ @@ -4381,14 +4275,11 @@ 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 = resolveImportedAssigneeAgentId( - manifestIssue.assigneeAgentSlug, - importedSlugToAgentId, - existingSlugToAgentId, - agentStatusById, - warnings, - `Task ${manifestIssue.slug}`, - ); + const assigneeAgentId = manifestIssue.assigneeAgentSlug + ? importedSlugToAgentId.get(manifestIssue.assigneeAgentSlug) + ?? existingSlugToAgentId.get(manifestIssue.assigneeAgentSlug) + ?? null + : null; const projectId = manifestIssue.projectSlug ? importedSlugToProjectId.get(manifestIssue.projectSlug) ?? existingProjectSlugToId.get(manifestIssue.projectSlug) @@ -4401,8 +4292,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) { - throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project required to create a routine.`); + if (!projectId || !assigneeAgentId) { + throw unprocessable(`Recurring task ${manifestIssue.slug} is missing the project or assignee required to create a routine.`); } const resolvedRoutine = resolvePortableRoutineDefinition(manifestIssue, parsed?.frontmatter.schedule); if (resolvedRoutine.errors.length > 0) { @@ -4482,20 +4373,15 @@ 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: issueStatus, + status: manifestIssue.status && ISSUE_STATUSES.includes(manifestIssue.status as any) + ? manifestIssue.status as typeof ISSUE_STATUSES[number] + : "backlog", priority: manifestIssue.priority && ISSUE_PRIORITIES.includes(manifestIssue.priority as any) ? manifestIssue.priority as typeof ISSUE_PRIORITIES[number] : "medium", diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 60fc06b40f5..0985c2b6226 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -36,6 +36,50 @@ import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; type CompanySkillRow = typeof companySkills.$inferSelect; +type CompanySkillListDbRow = Pick< + CompanySkillRow, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type CompanySkillListRow = Pick< + CompanySkill, + | "id" + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "fileInventory" + | "metadata" + | "createdAt" + | "updatedAt" +>; +type SkillReferenceTarget = Pick; +type SkillSourceInfoTarget = Pick< + CompanySkill, + | "companyId" + | "sourceType" + | "sourceLocator" + | "metadata" +>; type ImportedSkill = { key: string; @@ -1150,6 +1194,28 @@ function toCompanySkill(row: CompanySkillRow): CompanySkill { }; } +function toCompanySkillListRow(row: CompanySkillListDbRow): CompanySkillListRow { + return { + ...row, + description: row.description ?? null, + sourceType: row.sourceType as CompanySkillSourceType, + sourceLocator: row.sourceLocator ?? null, + sourceRef: row.sourceRef ?? null, + trustLevel: row.trustLevel as CompanySkillTrustLevel, + compatibility: row.compatibility as CompanySkillCompatibility, + fileInventory: Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!isPlainRecord(entry)) return []; + return [{ + path: String(entry.path ?? ""), + kind: (String(entry.kind ?? "other") as CompanySkillFileInventoryEntry["kind"]), + }]; + }) + : [], + metadata: isPlainRecord(row.metadata) ? row.metadata : null, + }; +} + function serializeFileInventory( fileInventory: CompanySkillFileInventoryEntry[], ): Array> { @@ -1159,14 +1225,14 @@ function serializeFileInventory( })); } -function getSkillMeta(skill: CompanySkill): SkillSourceMeta { +function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } function resolveSkillReference( - skills: CompanySkill[], + skills: SkillReferenceTarget[], reference: string, -): { skill: CompanySkill | null; ambiguous: boolean } { +): { skill: SkillReferenceTarget | null; ambiguous: boolean } { const trimmed = reference.trim(); if (!trimmed) { return { skill: null, ambiguous: false }; @@ -1242,7 +1308,7 @@ function resolveRequestedSkillKeysOrThrow( } function resolveDesiredSkillKeys( - skills: CompanySkill[], + skills: SkillReferenceTarget[], config: Record, ) { const preference = readPaperclipSkillSyncPreference(config); @@ -1253,7 +1319,7 @@ function resolveDesiredSkillKeys( )); } -function normalizeSkillDirectory(skill: CompanySkill) { +function normalizeSkillDirectory(skill: SkillSourceInfoTarget) { if ((skill.sourceType !== "local_path" && skill.sourceType !== "catalog") || !skill.sourceLocator) return null; const resolved = path.resolve(skill.sourceLocator); if (path.basename(resolved).toLowerCase() === "skill.md") { @@ -1329,7 +1395,7 @@ function isMarkdownPath(filePath: string) { return fileName === "skill.md" || fileName.endsWith(".md"); } -function deriveSkillSourceInfo(skill: CompanySkill): { +function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): { editable: boolean; editableReason: string | null; sourceLabel: string | null; @@ -1428,7 +1494,7 @@ function enrichSkill(skill: CompanySkill, attachedAgentCount: number, usedByAgen }; } -function toCompanySkillListItem(skill: CompanySkill, attachedAgentCount: number): CompanySkillListItem { +function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: number): CompanySkillListItem { const source = deriveSkillSourceInfo(skill); return { id: skill.id, @@ -1526,7 +1592,29 @@ export function companySkillService(db: Db) { } async function list(companyId: string): Promise { - const rows = await listFull(companyId); + await ensureSkillInventoryCurrent(companyId); + const rows = await db + .select({ + id: companySkills.id, + companyId: companySkills.companyId, + key: companySkills.key, + slug: companySkills.slug, + name: companySkills.name, + description: companySkills.description, + sourceType: companySkills.sourceType, + sourceLocator: companySkills.sourceLocator, + sourceRef: companySkills.sourceRef, + trustLevel: companySkills.trustLevel, + compatibility: companySkills.compatibility, + fileInventory: companySkills.fileInventory, + metadata: companySkills.metadata, + createdAt: companySkills.createdAt, + updatedAt: companySkills.updatedAt, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .orderBy(asc(companySkills.name), asc(companySkills.key)) + .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); return rows.map((skill) => { const attachedAgentCount = agentRows.filter((agent) => { diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 76a90f2dad6..88825b498bd 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -12,6 +12,10 @@ export interface CostDateRange { const METERED_BILLING_TYPE = "metered_api"; const SUBSCRIPTION_BILLING_TYPES = ["subscription_included", "subscription_overage"] as const; +function sumAsNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) { + return sql`coalesce(sum(${column}), 0)::double precision`; +} + function currentUtcMonthWindow(now = new Date()) { const year = now.getUTCFullYear(); const month = now.getUTCMonth(); @@ -36,7 +40,7 @@ async function getMonthlySpendTotal( } const [row] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -111,7 +115,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { const [{ total }] = await db .select({ - total: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + total: sumAsNumber(costEvents.costCents), }) .from(costEvents) .where(and(...conditions)); @@ -140,26 +144,26 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { agentId: costEvents.agentId, agentName: agents.name, agentStatus: agents.status, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) .where(and(...conditions)) .groupBy(costEvents.agentId, agents.name, agents.status) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byProvider: async (companyId: string, range?: CostDateRange) => { @@ -173,25 +177,25 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, byBiller: async (companyId: string, range?: CostDateRange) => { @@ -202,27 +206,27 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { return db .select({ biller: costEvents.biller, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), apiRunCount: sql`count(distinct case when ${costEvents.billingType} = ${METERED_BILLING_TYPE} then ${costEvents.heartbeatRunId} end)::int`, subscriptionRunCount: sql`count(distinct case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.heartbeatRunId} end)::int`, subscriptionCachedInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.cachedInputTokens} else 0 end), 0)::double precision`, subscriptionInputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.inputTokens} else 0 end), 0)::double precision`, subscriptionOutputTokens: - sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::int`, + sql`coalesce(sum(case when ${costEvents.billingType} in (${sql.join(SUBSCRIPTION_BILLING_TYPES.map((value) => sql`${value}`), sql`, `)}) then ${costEvents.outputTokens} else 0 end), 0)::double precision`, providerCount: sql`count(distinct ${costEvents.provider})::int`, modelCount: sql`count(distinct ${costEvents.model})::int`, }) .from(costEvents) .where(and(...conditions)) .groupBy(costEvents.biller) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); }, /** @@ -244,10 +248,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .select({ provider: costEvents.provider, biller: sql`case when count(distinct ${costEvents.biller}) = 1 then min(${costEvents.biller}) else 'mixed' end`, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .where( @@ -257,7 +261,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { ), ) .groupBy(costEvents.provider) - .orderBy(desc(sql`coalesce(sum(${costEvents.costCents}), 0)::int`)); + .orderBy(desc(sumAsNumber(costEvents.costCents))); return rows.map((row) => ({ provider: row.provider, @@ -292,10 +296,10 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { biller: costEvents.biller, billingType: costEvents.billingType, model: costEvents.model, - costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + costCents: sumAsNumber(costEvents.costCents), + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(agents, eq(costEvents.agentId, agents.id)) @@ -342,16 +346,16 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from)); if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to)); - const costCentsExpr = sql`coalesce(sum(${costEvents.costCents}), 0)::int`; + const costCentsExpr = sumAsNumber(costEvents.costCents); return db .select({ projectId: effectiveProjectId, projectName: projects.name, costCents: costCentsExpr, - inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, - cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, - outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + inputTokens: sumAsNumber(costEvents.inputTokens), + cachedInputTokens: sumAsNumber(costEvents.cachedInputTokens), + outputTokens: sumAsNumber(costEvents.outputTokens), }) .from(costEvents) .leftJoin(runProjectLinks, eq(costEvents.heartbeatRunId, runProjectLinks.runId)) diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index e495208d9cd..c1169aa98ca 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -65,7 +65,7 @@ export function dashboardService(db: Db) { const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const [{ monthSpend }] = await db .select({ - monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + monthSpend: sql`coalesce(sum(${costEvents.costCents}), 0)::double precision`, }) .from(costEvents) .where( diff --git a/server/src/services/finance.ts b/server/src/services/finance.ts index 29dcec494ab..766bea7b1f3 100644 --- a/server/src/services/finance.ts +++ b/server/src/services/finance.ts @@ -35,9 +35,9 @@ function rangeConditions(companyId: string, range?: FinanceDateRange) { } export function financeService(db: Db) { - const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::int`; - const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::int`; + const debitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const creditExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'credit' then ${financeEvents.amountCents} else 0 end), 0)::double precision`; + const estimatedDebitExpr = sql`coalesce(sum(case when ${financeEvents.direction} = 'debit' and ${financeEvents.estimated} = true then ${financeEvents.amountCents} else 0 end), 0)::double precision`; return { createEvent: async (companyId: string, data: Omit) => { @@ -95,12 +95,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, kindCount: sql`count(distinct ${financeEvents.eventKind})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.biller) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.biller); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.biller); }, byKind: async (companyId: string, range?: FinanceDateRange) => { @@ -113,12 +113,12 @@ export function financeService(db: Db) { estimatedDebitCents: estimatedDebitExpr, eventCount: sql`count(*)::int`, billerCount: sql`count(distinct ${financeEvents.biller})::int`, - netCents: sql`(${debitExpr} - ${creditExpr})::int`, + netCents: sql`(${debitExpr} - ${creditExpr})::double precision`, }) .from(financeEvents) .where(and(...conditions)) .groupBy(financeEvents.eventKind) - .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::int`), financeEvents.eventKind); + .orderBy(desc(sql`(${debitExpr} - ${creditExpr})::double precision`), financeEvents.eventKind); }, list: async (companyId: string, range?: FinanceDateRange, limit: number = 100) => { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e18cd7c359a..5d045e47ae0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -132,6 +132,7 @@ function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { } const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set(["succeeded", "failed", "cancelled", "timed_out"]); +const ISSUE_LIST_DESCRIPTION_MAX_CHARS = 1200; function escapeLikePattern(value: string): string { return value.replace(/[\\%_]/g, "\\$&"); @@ -527,6 +528,51 @@ async function activeRunMapForIssues( return map; } +const issueListSelect = { + id: issues.id, + companyId: issues.companyId, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + goalId: issues.goalId, + parentId: issues.parentId, + title: issues.title, + description: sql` + CASE + WHEN ${issues.description} IS NULL THEN NULL + ELSE substring(${issues.description} FROM 1 FOR ${ISSUE_LIST_DESCRIPTION_MAX_CHARS}) + END + `, + status: issues.status, + priority: issues.priority, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + checkoutRunId: issues.checkoutRunId, + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + executionLockedAt: issues.executionLockedAt, + createdByAgentId: issues.createdByAgentId, + createdByUserId: issues.createdByUserId, + issueNumber: issues.issueNumber, + identifier: issues.identifier, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, + requestDepth: issues.requestDepth, + billingCode: issues.billingCode, + assigneeAdapterOverrides: issues.assigneeAdapterOverrides, + executionPolicy: sql`null`, + executionState: sql`null`, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspacePreference: issues.executionWorkspacePreference, + executionWorkspaceSettings: sql`null`, + startedAt: issues.startedAt, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + hiddenAt: issues.hiddenAt, + createdAt: issues.createdAt, + updatedAt: issues.updatedAt, +}; + function withActiveRuns( issueRows: IssueWithLabels[], runMap: Map, @@ -1005,7 +1051,7 @@ export function issueService(db: Db) { `; const canonicalLastActivityAt = issueCanonicalLastActivityAtExpr(companyId); const baseQuery = db - .select() + .select(issueListSelect) .from(issues) .where(and(...conditions)) .orderBy( @@ -2350,7 +2396,10 @@ export function issueService(db: Db) { return [...resolved]; }, - findMentionedProjectIds: async (issueId: string) => { + findMentionedProjectIds: async ( + issueId: string, + opts?: { includeCommentBodies?: boolean }, + ) => { const issue = await db .select({ companyId: issues.companyId, @@ -2362,21 +2411,26 @@ export function issueService(db: Db) { .then((rows) => rows[0] ?? null); if (!issue) return []; - const comments = await db - .select({ body: issueComments.body }) - .from(issueComments) - .where(eq(issueComments.issueId, issueId)); - const mentionedIds = new Set(); - for (const source of [ - issue.title, - issue.description ?? "", - ...comments.map((comment) => comment.body), - ]) { + for (const source of [issue.title, issue.description ?? ""]) { for (const projectId of extractProjectMentionIds(source)) { mentionedIds.add(projectId); } } + + if (opts?.includeCommentBodies !== false) { + const comments = await db + .select({ body: issueComments.body }) + .from(issueComments) + .where(eq(issueComments.issueId, issueId)); + + for (const comment of comments) { + for (const projectId of extractProjectMentionIds(comment.body)) { + mentionedIds.add(projectId); + } + } + } + if (mentionedIds.size === 0) return []; const rows = await db diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 80dfd33a8fe..db117653b07 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -921,8 +921,6 @@ export function routineService(db: Db, deps: { heartbeat?: IssueAssignmentWakeup kind: trigger.kind as RoutineListItem["triggers"][number]["kind"], label: trigger.label, enabled: trigger.enabled, - cronExpression: trigger.cronExpression, - timezone: trigger.timezone, nextRunAt: trigger.nextRunAt, lastFiredAt: trigger.lastFiredAt, lastResult: trigger.lastResult, diff --git a/ui/index.html b/ui/index.html index c41e22f2a7e..70a8550ead5 100644 --- a/ui/index.html +++ b/ui/index.html @@ -2,7 +2,7 @@ - + diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts deleted file mode 100644 index 281d3adcc06..00000000000 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { adaptersApi, type AdapterCapabilities } from "@/api/adapters"; -import { queryKeys } from "@/lib/queryKeys"; - -const ALL_FALSE: AdapterCapabilities = { - supportsInstructionsBundle: false, - supportsSkills: false, - supportsLocalAgentJwt: false, - requiresMaterializedRuntimeSkills: false, -}; - -/** - * Synchronous fallback for known built-in adapter types so capability checks - * return correct values on first render before the /api/adapters call resolves. - */ -const KNOWN_DEFAULTS: Record = { - claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, - codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, - cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - hermes_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, - openclaw_gateway: ALL_FALSE, -}; - -/** - * Returns a lookup function that resolves adapter capabilities by type. - * - * Capabilities are fetched from the server adapter listing API and cached - * via react-query. Before the data loads, known built-in adapter types - * return correct synchronous defaults to avoid cold-load regressions. - */ -export function useAdapterCapabilities(): (type: string) => AdapterCapabilities { - const { data: adapters } = useQuery({ - queryKey: queryKeys.adapters.all, - queryFn: () => adaptersApi.list(), - staleTime: 5 * 60 * 1000, - }); - - const capMap = useMemo(() => { - const map = new Map(); - if (adapters) { - for (const a of adapters) { - map.set(a.type, a.capabilities); - } - } - return map; - }, [adapters]); - - return (type: string): AdapterCapabilities => - capMap.get(type) ?? KNOWN_DEFAULTS[type] ?? ALL_FALSE; -} diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 18b154efedc..86705bd4e75 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -4,13 +4,6 @@ import { api } from "./client"; -export interface AdapterCapabilities { - supportsInstructionsBundle: boolean; - supportsSkills: boolean; - supportsLocalAgentJwt: boolean; - requiresMaterializedRuntimeSkills: boolean; -} - export interface AdapterInfo { type: string; label: string; @@ -18,7 +11,6 @@ export interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; - capabilities: AdapterCapabilities; /** Installed version (for external npm adapters) */ version?: string; /** Package name (for external adapters) */ diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index dcf9bf1148e..065405f9a2d 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -50,7 +50,6 @@ import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadat import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; -import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; /* ---- Create mode values ---- */ @@ -270,9 +269,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const getCapabilities = useAdapterCapabilities(); - const adapterCaps = getCapabilities(adapterType); - const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; + const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); + const isLocal = !NONLOCAL_TYPES.has(adapterType); const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); diff --git a/ui/src/components/InlineEditor.test.tsx b/ui/src/components/InlineEditor.test.tsx index 830d2cedc6d..1f474f7b1fd 100644 --- a/ui/src/components/InlineEditor.test.tsx +++ b/ui/src/components/InlineEditor.test.tsx @@ -164,62 +164,6 @@ describe("InlineEditor", () => { }); outside.remove(); }); - - it("syncs a new multiline value while focused when the user has not edited locally", () => { - const onSave = vi.fn().mockResolvedValue(undefined); - const root = createRoot(container); - - act(() => { - root.render(); - }); - - const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); - expect(textarea).not.toBeNull(); - expect(textarea?.value).toBe(""); - - act(() => { - textarea!.focus(); - }); - - act(() => { - root.render(); - }); - - expect(textarea?.value).toBe("Loaded description"); - - act(() => { - root.unmount(); - }); - }); - - it("preserves focused multiline local edits when the prop value changes underneath them", () => { - const onSave = vi.fn().mockResolvedValue(undefined); - const root = createRoot(container); - - act(() => { - root.render(); - }); - - const textarea = container.querySelector('[data-testid="multiline-md-mock"]'); - expect(textarea).not.toBeNull(); - - act(() => { - textarea!.focus(); - }); - act(() => { - setNativeTextareaValue(textarea!, "Local draft"); - }); - - act(() => { - root.render(); - }); - - expect(textarea?.value).toBe("Local draft"); - - act(() => { - root.unmount(); - }); - }); }); describe("queueContainedBlurCommit", () => { diff --git a/ui/src/components/InlineEditor.tsx b/ui/src/components/InlineEditor.tsx index 2c878b3fa22..f509fe7aa62 100644 --- a/ui/src/components/InlineEditor.tsx +++ b/ui/src/components/InlineEditor.tsx @@ -54,7 +54,6 @@ export function InlineEditor({ const [editing, setEditing] = useState(false); const [multilineFocused, setMultilineFocused] = useState(false); const [draft, setDraft] = useState(value); - const lastPropValueRef = useRef(value); const inputRef = useRef(null); const markdownRef = useRef(null); const autosaveDebounceRef = useRef | null>(null); @@ -67,14 +66,8 @@ export function InlineEditor({ } = useAutosaveIndicator(); useEffect(() => { - const previousValue = lastPropValueRef.current; - lastPropValueRef.current = value; - setDraft((currentDraft) => { - if (multiline && multilineFocused && currentDraft !== previousValue) { - return currentDraft; - } - return value; - }); + if (multiline && multilineFocused) return; + setDraft(value); }, [value, multiline, multilineFocused]); useEffect(() => { diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 321d9b1d227..2702ea0c7e5 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -1,7 +1,10 @@ +import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Filter, X, User, HardDrive } from "lucide-react"; +import { Bot, Filter, HardDrive, Search, User, X } from "lucide-react"; import { PriorityIcon } from "./PriorityIcon"; import { StatusIcon } from "./StatusIcon"; import { @@ -14,6 +17,7 @@ import { toggleIssueFilterValue, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; type AgentOption = { id: string; @@ -36,6 +40,13 @@ type WorkspaceOption = { name: string; }; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + export function IssueFiltersPopover({ state, onChange, @@ -48,6 +59,7 @@ export function IssueFiltersPopover({ buttonVariant = "ghost", iconOnly = false, workspaces, + creators, }: { state: IssueFilterState; onChange: (patch: Partial) => void; @@ -60,7 +72,39 @@ export function IssueFiltersPopover({ buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; workspaces?: WorkspaceOption[]; + creators?: CreatorOption[]; }) { + const [creatorSearch, setCreatorSearch] = useState(""); + const creatorOptions = creators ?? []; + const creatorOptionById = useMemo( + () => new Map(creatorOptions.map((option) => [option.id, option])), + [creatorOptions], + ); + const normalizedCreatorSearch = creatorSearch.trim().toLowerCase(); + const visibleCreatorOptions = useMemo(() => { + if (!normalizedCreatorSearch) return creatorOptions; + return creatorOptions.filter((option) => + `${option.label} ${option.searchText ?? ""}`.toLowerCase().includes(normalizedCreatorSearch), + ); + }, [creatorOptions, normalizedCreatorSearch]); + const selectedCreatorOptions = useMemo( + () => state.creators.map((creatorId) => { + const knownOption = creatorOptionById.get(creatorId); + if (knownOption) return knownOption; + if (creatorId.startsWith("agent:")) { + const agentId = creatorId.slice("agent:".length); + return { id: creatorId, label: agentId.slice(0, 8), kind: "agent" as const }; + } + const userId = creatorId.startsWith("user:") ? creatorId.slice("user:".length) : creatorId; + return { + id: creatorId, + label: formatAssigneeUserLabel(userId, currentUserId) ?? userId.slice(0, 5), + kind: "user" as const, + }; + }), + [creatorOptionById, currentUserId, state.creators], + ); + return ( @@ -191,6 +235,60 @@ export function IssueFiltersPopover({ + {creatorOptions.length > 0 ? ( +
+ Creator + {selectedCreatorOptions.length > 0 ? ( +
+ {selectedCreatorOptions.map((creator) => ( + + {creator.kind === "agent" ? : } + {creator.label} + + + ))} +
+ ) : null} +
+ + setCreatorSearch(event.target.value)} + placeholder="Search creators..." + className="h-8 pl-7 text-xs" + /> +
+
+ {visibleCreatorOptions.length > 0 ? visibleCreatorOptions.map((creator) => { + const selected = state.creators.includes(creator.id); + return ( + + ); + }) : ( +
No creators match.
+ )} +
+
+ ) : null} + {projects && projects.length > 0 ? (
Project diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 1d66b2d0f79..b6311eff1af 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -6,7 +6,8 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { timeAgo } from "@/lib/timeAgo"; import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; import { - getIssueDetailQueryOptions, + fetchIssueDetail, + getCachedIssueDetail, ISSUE_DETAIL_STALE_TIME_MS, prefetchIssueDetail, } from "@/lib/issueDetailCache"; @@ -97,9 +98,12 @@ export const IssueLinkQuicklook = React.forwardRef< const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const prefetchedState = issuePrefetch ? withIssueDetailHeaderSeed(state, issuePrefetch) : state; + const cachedIssue = getCachedIssueDetail(queryClient, issuePathId, issuePrefetch ?? undefined); const { data, isLoading } = useQuery({ - ...getIssueDetailQueryOptions(queryClient, issuePathId, { placeholderIssue: issuePrefetch ?? undefined }), + queryKey: queryKeys.issues.detail(issuePathId), + queryFn: () => fetchIssueDetail(queryClient, issuePathId), enabled: open, + initialData: () => cachedIssue, staleTime: ISSUE_DETAIL_STALE_TIME_MS, }); diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 2069d76b1bf..3c92683a7b0 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -323,9 +323,6 @@ describe("IssueProperties", () => { const selectedParentTrigger = Array.from(container.querySelectorAll("button")) .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); expect(selectedParentTrigger).not.toBeUndefined(); - const parentLink = container.querySelector('a[href="/issues/PAP-2"]'); - expect(parentLink).not.toBeNull(); - expect(selectedParentTrigger!.contains(parentLink)).toBe(false); await act(async () => { selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 0deebe6376d..32592a885fd 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -708,19 +708,17 @@ export function IssueProperties({ {parentIdentifier ? `${parentIdentifier} ` : ""} {parentTitle} + e.stopPropagation()} + > + + ) : ( No parent ); - const parentLink = issue.parentId ? ( - e.stopPropagation()} - > - - - ) : undefined; const parentOptions = (allIssues ?? []) .filter((candidate) => candidate.id !== issue.id) .filter((candidate) => !descendantIssueIds.has(candidate.id)) @@ -946,7 +944,6 @@ export function IssueProperties({ triggerContent={parentTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-72" - extra={parentLink} > {parentContent} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 4c263f96ed5..9dee6d6b91b 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -421,47 +421,6 @@ describe("IssuesList", () => { }); }); - it("skips deferred row sizing for expanded parent rows with visible children", async () => { - const parentIssue = createIssue({ - id: "issue-parent", - identifier: "PAP-1", - title: "Parent issue", - }); - const childIssue = createIssue({ - id: "issue-child", - identifier: "PAP-2", - title: "Child issue", - parentId: "issue-parent", - }); - - const { root } = renderWithQueryClient( - undefined} - />, - container, - ); - - await waitForAssertion(() => { - const rows = Array.from(container.querySelectorAll('[data-testid="issue-row"]')); - const parentRow = rows.find((row) => row.textContent?.includes("Parent issue")); - const childRow = rows.find((row) => row.textContent?.includes("Child issue")); - expect(parentRow).not.toBeUndefined(); - expect(childRow).not.toBeUndefined(); - expect((parentRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe(""); - expect((parentRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe(""); - expect((childRow?.parentElement as HTMLDivElement | null)?.style.contentVisibility).toBe("auto"); - expect((childRow?.parentElement as HTMLDivElement | null)?.style.containIntrinsicSize).toBe("44px"); - }); - - act(() => { - root.unmount(); - }); - }); - it("uses context-scoped persisted column visibility", async () => { localStorage.setItem("paperclip:test-issues:company-1:issue-columns", JSON.stringify(["id", "assignee"])); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 1f8ebcdcba7..f3c962fe9a9 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -19,6 +19,7 @@ import { defaultIssueFilterState, issueFilterLabel, issuePriorityOrder, + normalizeIssueFilterState, resolveIssueFilterWorkspaceId, issueStatusOrder, type IssueFilterState, @@ -86,7 +87,10 @@ const defaultViewState: IssueViewState = { function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); - if (raw) return { ...defaultViewState, ...JSON.parse(raw) }; + if (raw) { + const parsed = JSON.parse(raw); + return { ...defaultViewState, ...parsed, ...normalizeIssueFilterState(parsed) }; + } } catch { /* ignore */ } return { ...defaultViewState }; } @@ -161,6 +165,13 @@ interface Agent { name: string; } +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; + type ProjectOption = Pick & Partial>; type IssueListRequestFilters = NonNullable[1]>; @@ -432,6 +443,66 @@ export function IssuesList({ .map(([id, name]) => ({ id, name })); }, [workspaceNameMap]); + const creatorOptions = useMemo(() => { + const options = new Map(); + const knownAgentIds = new Set(); + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of issues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of issues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, issues]); + const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), @@ -671,6 +742,7 @@ export function IssuesList({ onChange={updateView} activeFilterCount={activeFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} @@ -835,7 +907,6 @@ export function IssuesList({ const hasChildren = children.length > 0; const totalDescendants = hasChildren ? countDescendants(issue.id, childMap) : 0; const isExpanded = !viewState.collapsedParents.includes(issue.id); - const useDeferredRowRendering = !(hasChildren && isExpanded); const issueProject = issue.projectId ? projectById.get(issue.projectId) ?? null : null; const parentIssue = issue.parentId ? issueById.get(issue.parentId) ?? null : null; const toggleCollapse = (e: { preventDefault: () => void; stopPropagation: () => void }) => { @@ -853,12 +924,8 @@ export function IssuesList({ key={issue.id} style={{ ...(depth > 0 ? { paddingLeft: `${depth * 16}px` } : {}), - ...(useDeferredRowRendering - ? { - contentVisibility: "auto", - containIntrinsicSize: "44px", - } - : {}), + contentVisibility: "auto", + containIntrinsicSize: "44px", }} > { expect(html).toContain('data-mention-kind="skill"'); }); - it("sanitizes unsafe javascript markdown links", () => { - const html = renderMarkdown("[click me](javascript:alert(document.cookie))"); - - expect(html).toContain('click me"); - expect(html).not.toContain("javascript:"); - }); - it("uses soft-break styling by default", () => { const html = renderMarkdown("First line\nSecond line"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index e168997b301..129448c3afc 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,6 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; +import Markdown, { type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; @@ -95,10 +95,6 @@ function extractMermaidSource(children: ReactNode): string | null { return flattenText(childProps.children).replace(/\n$/, ""); } -function safeMarkdownUrlTransform(url: string): string { - return parseMentionChipHref(url) ? url : defaultUrlTransform(url); -} - function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -273,11 +269,7 @@ export function MarkdownBody({ )} style={mergeWrapStyle(style)} > - + url}> {children}
diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 976ad51c561..106a394b996 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -19,7 +19,6 @@ const mdxEditorMockState = vi.hoisted(() => ({ emitMountParseError: false, emitMountSilentEmptyState: false, markdownValues: [] as string[], - suppressHtmlProcessingValues: [] as boolean[], })); vi.mock("@mdxeditor/editor", async () => { @@ -42,19 +41,16 @@ vi.mock("@mdxeditor/editor", async () => { onChange, onError, className, - suppressHtmlProcessing, }: { markdown: string; placeholder?: string; onChange?: (value: string) => void; onError?: (error: unknown) => void; - suppressHtmlProcessing?: boolean; className?: string; }, forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>, ) { mdxEditorMockState.markdownValues.push(markdown); - mdxEditorMockState.suppressHtmlProcessingValues.push(Boolean(suppressHtmlProcessing)); const [content, setContent] = React.useState(markdown); const editableRef = React.useRef(null); const handle = React.useMemo(() => ({ @@ -63,16 +59,8 @@ vi.mock("@mdxeditor/editor", async () => { }), []); React.useEffect(() => { - if (!suppressHtmlProcessing && markdown.includes(" { setForwardedRef(forwardedRef, null); @@ -177,7 +165,6 @@ describe("MarkdownEditor", () => { mdxEditorMockState.emitMountParseError = false; mdxEditorMockState.emitMountSilentEmptyState = false; mdxEditorMockState.markdownValues = []; - mdxEditorMockState.suppressHtmlProcessingValues = []; }); it("applies async external value updates once the editor ref becomes ready", async () => { @@ -251,7 +238,6 @@ describe("MarkdownEditor", () => { await flush(); expect(mdxEditorMockState.markdownValues.at(-1)).toContain("![image](https://example.com/test.png)"); expect(mdxEditorMockState.markdownValues.at(-1)).not.toContain(" { }); await flush(); + await vi.waitFor(() => { expect(container.querySelector("textarea")).not.toBeNull(); }); + const textarea = container.querySelector("textarea"); expect(textarea).not.toBeNull(); expect(textarea?.value).toBe("Affected versions: <= v0.3.1"); @@ -306,9 +294,11 @@ describe("MarkdownEditor", () => { }); await flush(); + await vi.waitFor(() => { expect(container.querySelector("textarea")).not.toBeNull(); }); + const textarea = container.querySelector("textarea"); expect(textarea).not.toBeNull(); expect(textarea?.value).toBe("Affected versions: <= v0.3.1"); @@ -319,6 +309,7 @@ describe("MarkdownEditor", () => { root.unmount(); }); }); + it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => { expect( computeMentionMenuPosition( diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 413c98e21d3..37509fee194 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -25,7 +25,6 @@ import { import { getUIAdapter } from "../adapters"; import { listUIAdapters } from "../adapters"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; -import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { getAdapterDisplay } from "../adapters/adapter-display-registry"; import { defaultCreateValues } from "./agent-config-defaults"; import { parseOnboardingGoalInput } from "../lib/onboarding-goal"; @@ -199,9 +198,8 @@ export function OnboardingWizard() { queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2 }); - const getCapabilities = useAdapterCapabilities(); - const adapterCaps = getCapabilities(adapterType); - const isLocalAdapter = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; + const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); + const isLocalAdapter = !NONLOCAL_TYPES.has(adapterType); // Build adapter grids dynamically from the UI registry + display metadata. // External/plugin adapters automatically appear with generic defaults. diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx index 41f635d2b3f..230ab5a42db 100644 --- a/ui/src/components/transcript/RunTranscriptView.test.tsx +++ b/ui/src/components/transcript/RunTranscriptView.test.tsx @@ -54,8 +54,8 @@ describe("RunTranscriptView", () => { ); expect(html).toContain("world"); - expect(html).toMatch(/]*>first<\/li>/); - expect(html).toMatch(/]*>second<\/li>/); + expect(html).toContain("
  • first
  • "); + expect(html).toContain("
  • second
  • "); }); it("hides saved-session resume skip stderr from nice mode normalization", () => { @@ -106,8 +106,8 @@ describe("RunTranscriptView", () => { ); expect(html).toContain("

    Summary

    "); - expect(html).toMatch(/]*>fixed deploy config<\/li>/); - expect(html).toMatch(/]*>posted issue update<\/li>/); + expect(html).toContain("
  • fixed deploy config
  • "); + expect(html).toContain("
  • posted issue update
  • "); expect(html).not.toContain("result"); }); }); diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index 3c1008b3b7d..834c262c890 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -716,6 +716,7 @@ describe("inbox helpers", () => { statuses: ["in_progress"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -734,6 +735,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -752,6 +754,7 @@ describe("inbox helpers", () => { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -816,6 +819,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -829,6 +833,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -843,6 +848,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: ["high"], assignees: ["agent-1"], + creators: ["user:user-1"], labels: ["label-1"], projects: ["project-1"], workspaces: ["workspace-1"], @@ -856,6 +862,7 @@ describe("inbox helpers", () => { statuses: ["done"], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -872,6 +879,7 @@ describe("inbox helpers", () => { statuses: ["todo", 123], priorities: "high", assignees: ["agent-1"], + creators: ["user:user-1", 42], labels: null, projects: ["project-1"], workspaces: ["workspace-1", false], @@ -886,6 +894,7 @@ describe("inbox helpers", () => { statuses: ["todo"], priorities: [], assignees: ["agent-1"], + creators: ["user:user-1"], labels: [], projects: ["project-1"], workspaces: ["workspace-1"], diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index 43f116451de..6b66f66f2d2 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -9,6 +9,7 @@ import type { import { applyIssueFilters, defaultIssueFilterState, + normalizeIssueFilterState, type IssueFilterState, } from "./issue-filters"; @@ -137,25 +138,6 @@ const defaultInboxFilterPreferences: InboxFilterPreferences = { issueFilters: defaultIssueFilterState, }; -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.filter((entry): entry is string => typeof entry === "string"); -} - -function normalizeIssueFilterState(value: unknown): IssueFilterState { - if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; - const candidate = value as Partial>; - return { - statuses: normalizeStringArray(candidate.statuses), - priorities: normalizeStringArray(candidate.priorities), - assignees: normalizeStringArray(candidate.assignees), - labels: normalizeStringArray(candidate.labels), - projects: normalizeStringArray(candidate.projects), - workspaces: normalizeStringArray(candidate.workspaces), - hideRoutineExecutions: candidate.hideRoutineExecutions === true, - }; -} - function normalizeInboxCategoryFilter(value: unknown): InboxCategoryFilter { return value === "issues_i_touched" || value === "join_requests" @@ -244,7 +226,7 @@ export function loadCollapsedInboxGroupKeys( const raw = localStorage.getItem(storageKey); if (!raw) return new Set(); const parsed = JSON.parse(raw); - return new Set(normalizeStringArray(parsed)); + return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []); } catch { return new Set(); } diff --git a/ui/src/lib/issue-filters.test.ts b/ui/src/lib/issue-filters.test.ts new file mode 100644 index 00000000000..523a2222d6e --- /dev/null +++ b/ui/src/lib/issue-filters.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters"; + +function makeIssue(overrides: Partial = {}): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Issue", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + identifier: "PAP-1", + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + labels: [], + labelIds: [], + createdAt: new Date("2026-04-15T00:00:00.000Z"), + updatedAt: new Date("2026-04-15T00:00:00.000Z"), + ...overrides, + }; +} + +describe("issue filters", () => { + it("filters issues by creator across agents and users", () => { + const issues = [ + makeIssue({ id: "agent-match", createdByAgentId: "agent-1" }), + makeIssue({ id: "user-match", createdByUserId: "user-1" }), + makeIssue({ id: "excluded", createdByAgentId: "agent-2", createdByUserId: "user-2" }), + ]; + + const filtered = applyIssueFilters(issues, { + ...defaultIssueFilterState, + creators: ["agent:agent-1", "user:user-1"], + }); + + expect(filtered.map((issue) => issue.id)).toEqual(["agent-match", "user-match"]); + }); + + it("counts creator filters as an active filter group", () => { + expect(countActiveIssueFilters({ + ...defaultIssueFilterState, + creators: ["user:user-1"], + })).toBe(1); + }); +}); diff --git a/ui/src/lib/issue-filters.ts b/ui/src/lib/issue-filters.ts index c9ec4a5bade..4a64b839f3f 100644 --- a/ui/src/lib/issue-filters.ts +++ b/ui/src/lib/issue-filters.ts @@ -4,6 +4,7 @@ export type IssueFilterState = { statuses: string[]; priorities: string[]; assignees: string[]; + creators: string[]; labels: string[]; projects: string[]; workspaces: string[]; @@ -14,6 +15,7 @@ export const defaultIssueFilterState: IssueFilterState = { statuses: [], priorities: [], assignees: [], + creators: [], labels: [], projects: [], workspaces: [], @@ -41,6 +43,26 @@ export function issueFilterArraysEqual(a: string[], b: string[]): boolean { return sortedA.every((value, index) => value === sortedB[index]); } +function normalizeIssueFilterValueArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((entry): entry is string => typeof entry === "string"); +} + +export function normalizeIssueFilterState(value: unknown): IssueFilterState { + if (!value || typeof value !== "object") return { ...defaultIssueFilterState }; + const candidate = value as Partial>; + return { + statuses: normalizeIssueFilterValueArray(candidate.statuses), + priorities: normalizeIssueFilterValueArray(candidate.priorities), + assignees: normalizeIssueFilterValueArray(candidate.assignees), + creators: normalizeIssueFilterValueArray(candidate.creators), + labels: normalizeIssueFilterValueArray(candidate.labels), + projects: normalizeIssueFilterValueArray(candidate.projects), + workspaces: normalizeIssueFilterValueArray(candidate.workspaces), + hideRoutineExecutions: candidate.hideRoutineExecutions === true, + }; +} + export function toggleIssueFilterValue(values: string[], value: string): string[] { return values.includes(value) ? values.filter((existing) => existing !== value) : [...values, value]; } @@ -73,6 +95,15 @@ export function applyIssueFilters( return false; }); } + if (state.creators.length > 0) { + result = result.filter((issue) => { + for (const creator of state.creators) { + if (creator.startsWith("agent:") && issue.createdByAgentId === creator.slice("agent:".length)) return true; + if (creator.startsWith("user:") && issue.createdByUserId === creator.slice("user:".length)) return true; + } + return false; + }); + } if (state.labels.length > 0) { result = result.filter((issue) => (issue.labelIds ?? []).some((id) => state.labels.includes(id))); } @@ -96,6 +127,7 @@ export function countActiveIssueFilters( if (state.statuses.length > 0) count += 1; if (state.priorities.length > 0) count += 1; if (state.assignees.length > 0) count += 1; + if (state.creators.length > 0) count += 1; if (state.labels.length > 0) count += 1; if (state.projects.length > 0) count += 1; if (state.workspaces.length > 0) count += 1; diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index a7c7d4c768f..27d4e9c42d0 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -5,6 +5,7 @@ describe("issue-reference", () => { it("extracts issue ids from company-scoped issue paths", () => { expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271"); expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179"); + expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); }); it("extracts issue ids from full issue URLs", () => { @@ -36,4 +37,9 @@ describe("issue-reference", () => { href: "/issues/PAP-1271", }); }); + + it("ignores literal route placeholder paths", () => { + expect(parseIssueReferenceFromHref("/issues/:id")).toBeNull(); + expect(parseIssueReferenceFromHref("http://localhost:3100/api/issues/:id")).toBeNull(); + }); }); diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index 18de5a32440..25b4a4a27fd 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -25,7 +25,9 @@ export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): const segments = pathname.split("/").filter(Boolean); const issueIndex = segments.findIndex((segment) => segment === "issues"); if (issueIndex === -1 || issueIndex === segments.length - 1) return null; - return decodeURIComponent(segments[issueIndex + 1] ?? ""); + const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? ""); + if (!issuePathId || issuePathId.startsWith(":")) return null; + return issuePathId; } export function parseIssueReferenceFromHref(href: string | null | undefined) { diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index a2770b3118d..8e02e46eb81 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -84,20 +84,6 @@ export async function fetchIssueDetail( return seedIssueDetailCache(queryClient, issue, { issueRef }); } -export function getIssueDetailQueryOptions( - queryClient: QueryClient, - issueRef: string, - options?: { - placeholderIssue?: Pick | null; - }, -) { - return { - queryKey: queryKeys.issues.detail(issueRef), - queryFn: () => fetchIssueDetail(queryClient, issueRef), - placeholderData: getCachedIssueDetail(queryClient, issueRef, options?.placeholderIssue ?? undefined), - }; -} - export function prefetchIssueDetail( queryClient: QueryClient, issueRef: string, diff --git a/ui/src/lib/issueDetailQuery.test.tsx b/ui/src/lib/issueDetailQuery.test.tsx deleted file mode 100644 index 83be05ddb11..00000000000 --- a/ui/src/lib/issueDetailQuery.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// @vitest-environment jsdom - -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Issue } from "@paperclipai/shared"; -import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { issuesApi } from "@/api/issues"; -import { queryKeys } from "@/lib/queryKeys"; -import { getIssueDetailQueryOptions } from "./issueDetailCache"; - -vi.mock("@/api/issues", () => ({ - issuesApi: { - get: vi.fn(), - }, -})); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - -function makeIssue(overrides: Partial = {}): Issue { - const now = new Date("2026-04-13T20:00:00.000Z"); - return { - id: "issue-1", - companyId: "company-1", - projectId: null, - projectWorkspaceId: null, - goalId: null, - parentId: null, - title: "Issue title", - description: null, - status: "todo", - priority: "medium", - assigneeAgentId: null, - assigneeUserId: null, - checkoutRunId: null, - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - createdByAgentId: null, - createdByUserId: null, - issueNumber: 1442, - identifier: "PAP-1442", - requestDepth: 0, - billingCode: null, - assigneeAdapterOverrides: null, - executionWorkspaceId: null, - executionWorkspacePreference: null, - executionWorkspaceSettings: null, - startedAt: null, - completedAt: null, - cancelledAt: null, - hiddenAt: null, - createdAt: now, - updatedAt: now, - ...overrides, - }; -} - -function IssueDetailQueryHarness({ - issueRef, - placeholderIssue, -}: { - issueRef: string; - placeholderIssue?: Pick | null; -}) { - const queryClient = useQueryClient(); - const query = useQuery({ - ...getIssueDetailQueryOptions(queryClient, issueRef, { placeholderIssue }), - }); - - return
    {query.data?.description ?? "EMPTY"}
    ; -} - -async function flush() { - // Multiple act cycles to allow React Query to process the async queryFn - for (let i = 0; i < 5; i++) { - await act(async () => { - await new Promise((r) => setTimeout(r, 0)); - }); - } -} - -describe("getIssueDetailQueryOptions", () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it("treats cached issue data as placeholder and still fetches full detail", async () => { - const container = document.createElement("div"); - document.body.appendChild(container); - const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - const partialIssue = makeIssue({ description: null }); - const fullIssue = makeIssue({ description: "GitHub Security Advisory body" }); - - queryClient.setQueryData(queryKeys.issues.detail("issue-1"), partialIssue); - queryClient.setQueryData(queryKeys.issues.detail("PAP-1442"), partialIssue); - vi.mocked(issuesApi.get).mockResolvedValue(fullIssue); - - await act(async () => { - root.render( - - - , - ); - }); - - await flush(); - - expect(issuesApi.get).toHaveBeenCalledWith("PAP-1442"); - expect(container.textContent).toContain("GitHub Security Advisory body"); - - await act(async () => { - root.unmount(); - }); - queryClient.clear(); - container.remove(); - }); -}); diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index acda68778f9..0f303bcc2e2 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -610,12 +610,6 @@ export function AdapterManager() { modelsCount: 0, loaded: true, disabled: virtual.menuDisabled, - capabilities: { - supportsInstructionsBundle: false, - supportsSkills: false, - supportsLocalAgentJwt: false, - requiresMaterializedRuntimeSkills: false, - }, }} canRemove={false} onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 2777cb07626..354e4320744 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -26,7 +26,6 @@ import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; -import { useAdapterCapabilities } from "@/adapters/use-adapter-capabilities"; import { MarkdownEditor } from "../components/MarkdownEditor"; import { assetsApi } from "../api/assets"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters"; @@ -1720,8 +1719,13 @@ function PromptsTab({ externalBundleRef.current = null; }, [agent.id]); - const getCapabilities = useAdapterCapabilities(); - const isLocal = getCapabilities(agent.adapterType).supportsInstructionsBundle; + const isLocal = + agent.adapterType === "claude_local" || + agent.adapterType === "codex_local" || + agent.adapterType === "opencode_local" || + agent.adapterType === "pi_local" || + agent.adapterType === "hermes_local" || + agent.adapterType === "cursor"; const { data: bundle, isLoading: bundleLoading } = useQuery({ queryKey: queryKeys.agents.instructionsBundle(agent.id), diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 6cf23a8b7da..5134bd9b1a2 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -23,6 +23,7 @@ import { countActiveIssueFilters, type IssueFilterState, } from "../lib/issue-filters"; +import { formatAssigneeUserLabel } from "../lib/assignees"; import { armIssueDetailInboxQuickArchive, createIssueDetailLocationState, @@ -149,6 +150,12 @@ type SectionKey = /** A flat navigation entry for keyboard j/k traversal that includes expanded children. */ type NavEntry = InboxKeyboardNavEntry; +type CreatorOption = { + id: string; + label: string; + kind: "agent" | "user"; + searchText?: string; +}; function firstNonEmptyLine(value: string | null | undefined): string | null { if (!value) return null; @@ -796,6 +803,66 @@ export function Inbox() { () => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe), [visibleTouchedIssues], ); + const creatorOptions = useMemo(() => { + const options = new Map(); + const sourceIssues = [...mineIssues, ...touchedIssues]; + + if (currentUserId) { + options.set(`user:${currentUserId}`, { + id: `user:${currentUserId}`, + label: currentUserId === "local-board" ? "Board" : "Me", + kind: "user", + searchText: currentUserId === "local-board" ? "board me human local-board" : `me board human ${currentUserId}`, + }); + } + + for (const issue of sourceIssues) { + if (issue.createdByUserId) { + const id = `user:${issue.createdByUserId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: formatAssigneeUserLabel(issue.createdByUserId, currentUserId) ?? issue.createdByUserId.slice(0, 5), + kind: "user", + searchText: `${issue.createdByUserId} board user human`, + }); + } + } + } + + const knownAgentIds = new Set(); + for (const agent of agents ?? []) { + knownAgentIds.add(agent.id); + const id = `agent:${agent.id}`; + if (!options.has(id)) { + options.set(id, { + id, + label: agent.name, + kind: "agent", + searchText: `${agent.name} ${agent.id} agent`, + }); + } + } + + for (const issue of sourceIssues) { + if (issue.createdByAgentId && !knownAgentIds.has(issue.createdByAgentId)) { + const id = `agent:${issue.createdByAgentId}`; + if (!options.has(id)) { + options.set(id, { + id, + label: issue.createdByAgentId.slice(0, 8), + kind: "agent", + searchText: `${issue.createdByAgentId} agent`, + }); + } + } + } + + return [...options.values()].sort((a, b) => { + if (a.kind !== b.kind) return a.kind === "user" ? -1 : 1; + return a.label.localeCompare(b.label); + }); + }, [agents, currentUserId, mineIssues, touchedIssues]); const issuesToRender = useMemo( () => { if (tab === "mine") return visibleMineIssues; @@ -1814,6 +1881,7 @@ export function Inbox() { onChange={updateIssueFilters} activeFilterCount={activeIssueFilterCount} agents={agents} + creators={creatorOptions} projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 4a6f2269062..08f34394298 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -28,8 +28,8 @@ import { readIssueDetailHeaderSeed, rememberIssueDetailLocationState, } from "../lib/issueDetailBreadcrumb"; +import { fetchIssueDetail, getCachedIssueDetail } from "../lib/issueDetailCache"; import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun"; -import { getIssueDetailQueryOptions } from "../lib/issueDetailCache"; import { hasBlockingShortcutDialog, resolveIssueDetailGoKeyAction, @@ -573,7 +573,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ refetchInterval: liveRunCount > 0 ? false : 3000, placeholderData: keepPreviousDataForSameQueryTail(issueId), }); - const hasLiveRuns = liveRunCount > 0 || !!activeRun; + const resolvedActiveRun = useMemo( + () => resolveIssueActiveRun({ status: issueStatus, executionRunId }, activeRun), + [activeRun, executionRunId, issueStatus], + ); + const hasLiveRuns = liveRunCount > 0 || !!resolvedActiveRun; const { data: linkedRuns } = useQuery({ queryKey: queryKeys.issues.runs(issueId), queryFn: () => activityApi.runsForIssue(issueId), @@ -584,8 +588,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const resolvedLinkedRuns = linkedRuns ?? []; const runningIssueRun = useMemo( - () => resolveRunningIssueRun(activeRun, resolvedLiveRuns), - [activeRun, resolvedLiveRuns], + () => resolveRunningIssueRun(resolvedActiveRun, resolvedLiveRuns), + [resolvedActiveRun, resolvedLiveRuns], ); const timelineRuns = useMemo(() => { const liveIds = new Set(); @@ -672,7 +676,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ linkedRuns={timelineRuns} timelineEvents={timelineEvents} liveRuns={resolvedLiveRuns} - activeRun={activeRun} + activeRun={resolvedActiveRun} companyId={companyId} projectId={projectId} issueStatus={issueStatus} @@ -883,15 +887,22 @@ export function IssueDetail() { () => readIssueDetailHeaderSeed(location.state) ?? readIssueDetailHeaderSeed(resolvedIssueDetailState), [location.state, resolvedIssueDetailState], ); + const cachedIssue = useMemo( + () => + issueId + ? getCachedIssueDetail(queryClient, issueId, issueHeaderSeed ? { + id: issueHeaderSeed.id, + identifier: issueHeaderSeed.identifier, + } : null) + : undefined, + [issueHeaderSeed, issueId, queryClient], + ); const { data: issue, isLoading, error } = useQuery({ - ...getIssueDetailQueryOptions(queryClient, issueId!, { - placeholderIssue: issueHeaderSeed ? { - id: issueHeaderSeed.id, - identifier: issueHeaderSeed.identifier, - } : null, - }), + queryKey: queryKeys.issues.detail(issueId!), + queryFn: () => fetchIssueDetail(queryClient, issueId!), enabled: !!issueId, + initialData: () => cachedIssue, }); const resolvedCompanyId = issue?.companyId ?? selectedCompanyId; const commentComposerDisabledReason = useMemo(() => { @@ -950,7 +961,8 @@ export function IssueDetail() { select: (run) => !!run, placeholderData: keepPreviousDataForSameQueryTail(issueId ?? "pending"), }); - const hasLiveRuns = liveRunCount > 0 || hasActiveRun; + const resolvedHasActiveRun = issue ? shouldTrackIssueActiveRun(issue) && hasActiveRun : hasActiveRun; + const hasLiveRuns = liveRunCount > 0 || resolvedHasActiveRun; const sourceBreadcrumb = useMemo( () => readIssueDetailBreadcrumb(issueId, location.state, location.search) ?? { label: "Issues", href: "/issues" }, [issueId, location.state, location.search], diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 27f481a4019..468c34e91d5 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -4,18 +4,8 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import { createUiDevWatchOptions } from "./src/lib/vite-watch"; -export default defineConfig(({ mode }) => ({ +export default defineConfig({ plugins: [react(), tailwindcss()], - build: { - minify: "esbuild", - }, - esbuild: - mode === "production" - ? { - drop: ["console", "debugger"], - legalComments: "none", - } - : undefined, resolve: { alias: { "@": path.resolve(__dirname, "./src"), @@ -32,4 +22,4 @@ export default defineConfig(({ mode }) => ({ }, }, }, -})); +});