From 4cd7b402551603a8d91bdafb3987ab051ba063ff Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:45:34 -0500 Subject: [PATCH 01/43] fix: recover new messages after historical native runs stop (#13405) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native recovery must distinguish a fresh user request from replay of failed work. > - Older runs can lose their process fields before a local stop receipt exists. > - A suspended durable session can still prove that the exact runner and provider session are idle. > - The message admission path ignored that evidence and kept new user messages blocked. > - This pull request uses the existing exact-state verifier for those historical runs. > - The user can start one fresh turn while the old history and unknown outcomes remain intact. ## Linked Issues or Issue Description **What happened?** A user sent a new message after a native run exhausted recovery. Paperclip saved the message but said the previous run had no verified stop record. The old runner was suspended, with no active provider turn or pending output. Its process fields had been cleared before stop receipts were added. **Expected behavior** A new user message starts a fresh turn when the exact retained session proves it is suspended and the other execution gates pass. **Steps to reproduce** 1. Retain a failed native run with a terminal controller, cleared process fields, and no process receipt events. 2. Retain its exact suspended runner state and idle provider state. Keep its recovery hold. 3. Send a new user comment. Before this fix, admission returns no successor. **Paperclip version or commit** Reproduced on master at d351e08de. **Deployment mode** Self-hosted server. The regression uses an embedded PostgreSQL test database and real durable state files. Related work: Refs #13270, Refs #13338. This adds compatibility for older stopped runs. Refs #13332 concerns separate recovery-hold scope rules. ## What Changed - Add exact suspended-state evidence to explicit native message admission for runs that predate process receipts. - Reuse the existing failed-retry verifier for run, runner, workspace, provider identity, and pending-work checks. - Reject this fallback if any server-authored process receipt or launch event exists. - Add a red/green regression with real message admission, duplicate delivery, dry-run behavior, and blocked-state cases. - Document the new-message recovery rule. ## Verification - Red: the historical suspended-state regression failed on master because admission returned null. Eight rejection cases passed. - Green: 469 tests passed across explicit native continuation and native session execution. - Full repository `pnpm -r typecheck` and `pnpm build` passed locally. - The complete Vitest CI matrix and all browser test shards passed. The duplicate local `pnpm test:run` was stopped after these CI results; it did not complete locally. - The first runner verification worker received an infrastructure shutdown signal during compilation. The single retry passed. - Greptile reviewed commit `6836d7310` at 5/5 with no findings. - Inspected the affected server's database and durable state read-only. It has the historical missing-PID shape and an exact suspended runner with no active provider turn, pending tools, or undelivered output. ## Risks - Incorrect idle evidence could allow overlapping work. The fallback requires an exact suspended root and rejects active or pending work, mismatched identities, missing files, and newer process evidence. - Normal task, controller, lease cleanup, decision, and active-run gates remain in force. - This does not resume old provider actions or reset recovery attempts. Unknown outcomes remain unknown. - No schema change or deployment is included. ## Model Used OpenAI Codex, GPT-6. The session does not expose the exact model snapshot or context-window size. Used reasoning, repository inspection, code editing, shell execution, and test tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/execution-semantics.md | 2 + .../explicit-native-continuation.test.ts | 84 ++++++++++++++++++- .../services/explicit-native-continuation.ts | 5 +- .../src/services/native-local-process-stop.ts | 31 +++++++ 4 files changed, 119 insertions(+), 3 deletions(-) diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index a4fa177040..344bfeffd4 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -410,6 +410,8 @@ The handshake failure code is distinct from a session-identity mismatch. A timeo An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item. +A new user message can continue a terminal native run whose process fields were cleared before local stop receipts existed. Admission must verify the exact run, runner, workspace, and provider session in the retained suspended state, with no active provider turn, pending tool call, or undelivered output. Missing or mismatched state keeps the hold. A later recorded process launch also keeps the hold until its stop is verified. Normal assignment, decision, controller, environment cleanup, and active-run gates still apply. The message starts one fresh conversation turn; it does not replay the failed run, reset its recovery budget, or certify unknown action outcomes. + The task thread exposes the existing guarded Retry action for failed or timed-out legacy conversation runs. Where the server supports an explicit new attempt after a stopped legacy conversation, the thread must not hide that action solely because the old run still has a recovery-needed projection. Native and process recovery holds, pending decisions, active execution, and other retry gates remain in force. When a gate hides Retry, the thread says the message is preserved instead of promising an unavailable action. This presentation change does not rewrite historical outcomes or certify prior actions. A valid recovery action must name: diff --git a/server/src/services/explicit-native-continuation.test.ts b/server/src/services/explicit-native-continuation.test.ts index 49b4708eee..e8bad1eb3e 100644 --- a/server/src/services/explicit-native-continuation.test.ts +++ b/server/src/services/explicit-native-continuation.test.ts @@ -1,7 +1,10 @@ import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js"; import { recordNativeLocalProcessStop, hasNativeLocalProcessStop, PROCESS_START_REQUESTED } from "./native-local-process-stop.js"; import { remoteTerminationReceipt } from "./remote-execution-termination.js"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { and, eq } from "drizzle-orm"; import { beforeAll, afterAll, describe, it, expect } from "vitest"; import { @@ -369,6 +372,85 @@ const support = await getEmbeddedPostgresTestSupport(); agentId: f.agentId, status: "queued", contextSnapshot: { issueId: f.issueId, previousRunId: result.previousRunId, forceFreshSession: true } }); return result; }); + + it.each(["suspended", "ready", "wrong_run", "wrong_thread", "active_provider", "pending_tool", "pending_output", "missing_state", "new_launch"])( + "recovers a historical run without process metadata only from exact suspended state (%s)", async kind => { + const f = await seed(); + const stateBase = await mkdtemp(join(tmpdir(), "historical-native-followup-")); + const previous = process.env.PAPERCLIP_RUNNER_STATE_DIR; + process.env.PAPERCLIP_RUNNER_STATE_DIR = stateBase; + try { + const nativeSessionId = randomUUID(), runnerInstanceId = randomUUID(); + const execution = { + schema: "paperclip.native-execution-input.v1", provider: { kind: "codex", model: null }, + binding: { companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, runId: f.sourceRunId, executionWorkspaceId: "workspace" }, + task: { identifier: "TEST", title: "Continue", description: null, prompt: "Continue", workMode: "standard" }, + workspace: { cwd: stateBase, repoUrl: null, repoRef: null, branchName: null }, + session: { normalizedSessionId: nativeSessionId, driverKind: "codex_app_server", protocolVersion: 1, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }, + completionContract: { id: "contract", sha256: "sha", schemaVersion: "paperclip.completion-contract.v1", + contract: { revision: "1", objective: "Continue", criteria: [{ id: "objective", requirement: "Continue" }] } }, + interactionResponses: [], credentialBindings: [], + }; + await db.update(heartbeatRuns).set({ processPid: null, nativeSessionId, runnerInstanceId, + errorCode: "native_runner_process_exited", runnerProfileJson: { nativeExecutionInput: execution, + sessionCheckpoint: { sessionId: "exact-thread", providerSessionId: "backend-account" } }, + }).where(eq(heartbeatRuns.id, f.sourceRunId)); + const canonical = (value: unknown): string => value && typeof value === "object" && !Array.isArray(value) + ? `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(",")}}` + : JSON.stringify(value); + const root = join(stateBase, createHash("sha256").update(canonical({ + schema: "paperclip.native-session-scope.v2", companyId: f.companyId, agentId: f.agentId, + workspace: { kind: "managed", executionWorkspaceId: "workspace" }, + provider: { driverKind: "codex_app_server", identity: { kind: "codex" } }, normalizedSessionId: nativeSessionId, + })).digest("hex")); + if (kind !== "missing_state") { + await mkdir(join(root, "control-plane"), { recursive: true }); + await mkdir(join(root, "runner"), { recursive: true }); + const identity = { runId: kind === "wrong_run" ? randomUUID() : f.sourceRunId, runnerInstanceId, + normalizedSessionId: nativeSessionId, environmentLeaseId: "workspace" }; + await writeFile(join(root, "control-plane/control-plane-state.json"), JSON.stringify({ schema: "paperclip.runner.durable.control-plane-state.v1", identity })); + await writeFile(join(root, "runner/runner-state.json"), JSON.stringify({ schema: "paperclip.runner.durable.state.v1", + ...identity, lifecycle: kind === "ready" ? "ready" : "suspended", outbox: kind === "pending_output" ? [{}] : [] })); + await writeFile(join(root, "runner/codex-provider-state.json"), JSON.stringify({ + schema: "paperclip.runner.codex-provider-state.v1", lifecycle: "prepared", + threadId: kind === "wrong_thread" ? "another-thread" : "exact-thread", providerSessionId: "backend-account", + activeProviderTurnId: kind === "active_provider" ? "unfinished-turn" : null, ambiguousTurnStartPending: false, + config: { provider: "codex", driver: "codex_app_server" }, pendingEvents: [], queuedEvents: [], + toolBridge: { pending: kind === "pending_tool" ? { call: {} } : {} }, activeProviderResultFingerprint: null, + })); + } + if (kind === "new_launch") await appendHeartbeatRunEvent(db, { companyId: f.companyId, runId: f.sourceRunId, + agentId: f.agentId, eventType: PROCESS_START_REQUESTED }); + if (kind !== "suspended") { + expect(await admit(f, true)).toBeNull(); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull(); + return; + } + expect(await admit(f, true)).toMatchObject({ previousRunId: f.sourceRunId }); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull(); + // Exercise the real message admission path while keeping the provider slot occupied. + await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" }); + const heartbeat = heartbeatService(db); + for (let n = 0; n < 2; n++) await heartbeat.wakeup(f.agentId, { source: "automation", triggerDetail: "system", + reason: "issue_commented", requestedByActorType: "user", requestedByActorId: "board", + payload: { issueId: f.issueId, commentId: f.commentId }, + contextSnapshot: { issueId: f.issueId, wakeCommentId: f.commentId } }); + const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued"))); + expect(successors).toHaveLength(1); + expect(successors[0].contextSnapshot).toMatchObject({ previousRunId: f.sourceRunId, forceFreshSession: true, wakeCommentId: f.commentId }); + expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull(); + const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId)); + expect(coordinator).toMatchObject({ phase: "terminal_failure", attempt: 3 }); + const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)); + expect(action.evidence.automaticRecovery).toMatchObject({ actionOutcome: "unknown", replay: "explicit_user_continuation" }); + expect(await hasNativeLocalProcessStop(db, f.companyId, f.sourceRunId)).toBe(false); + } finally { + if (previous === undefined) delete process.env.PAPERCLIP_RUNNER_STATE_DIR; + else process.env.PAPERCLIP_RUNNER_STATE_DIR = previous; + await rm(stateBase, { recursive: true, force: true }); + } + }, + ); async function seedCancelledStartup() { const f = await seed(); await db.update(heartbeatRuns).set({ status: "cancelled", processPid: null, diff --git a/server/src/services/explicit-native-continuation.ts b/server/src/services/explicit-native-continuation.ts index 4be1f26581..fecb224ca0 100644 --- a/server/src/services/explicit-native-continuation.ts +++ b/server/src/services/explicit-native-continuation.ts @@ -1,5 +1,5 @@ import { isCancelledNativeStartup } from "./cancelled-native-startup.js"; -import { hasNativeLocalProcessStop } from "./native-local-process-stop.js"; +import { hasNativeLocalProcessStop, hasHistoricalSuspendedNativeSession } from "./native-local-process-stop.js"; import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js"; import { hasRemoteTerminationReceipt, remoteLeaseCleanupScope } from "./remote-execution-termination.js"; import { z } from "zod"; @@ -212,7 +212,8 @@ export async function admitExplicitNativeContinuation(input: { if (!unusedAdmission && !cancelledStartup) { // A missing process identity is not evidence that a provider exited. if (!run.processPid && !run.processGroupId && - !await hasNativeLocalProcessStop(db, companyId, run.id)) return blocked("process_identity_missing", "The previous run has no verified stop record. Paperclip cannot start this message yet."); + !await hasNativeLocalProcessStop(db, companyId, run.id) && + !await hasHistoricalSuspendedNativeSession(db, run)) return blocked("process_identity_missing", "The previous run has no verified stop record. Paperclip cannot start this message yet."); if (run.processPid && !processStopped(run.processPid)) return blocked("process_running", "Waiting for the previous process to stop. Your message will start automatically."); if (run.processGroupId && !processStopped(-run.processGroupId)) return blocked("process_running", "Waiting for the previous process to stop. Your message will start automatically."); } diff --git a/server/src/services/native-local-process-stop.ts b/server/src/services/native-local-process-stop.ts index 3c82cddeb3..29cbafdf0b 100644 --- a/server/src/services/native-local-process-stop.ts +++ b/server/src/services/native-local-process-stop.ts @@ -59,6 +59,37 @@ export async function hasNativeLocalProcessStop(db: Db, companyId: string, runId return event?.eventType === LOCAL_PROCESS_STOPPED; } +/** Pre-receipt native runs can retain an exact suspended session after their + * mutable process fields were cleared. This is admission evidence for a new + * user turn only, never permission to replay the old run or infer its outcomes. + * The caller must hold the run/controller locks and verify local lease cleanup. + */ +export async function hasHistoricalSuspendedNativeSession(db: Db, run: typeof heartbeatRuns.$inferSelect) { + if (run.runtimeMode !== "native" || run.processPid || run.processGroupId || + !run.nativeSessionId || !run.runnerInstanceId || !run.nativeIssueId) return false; + const [modernProcessEvidence] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and( + eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id), + isNull(heartbeatRunEvents.sourceEventId), + inArray(heartbeatRunEvents.eventType, [PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED, LOCAL_PROCESS_STOPPED]), + )).limit(1); + // A newer launch invalidates an old stop receipt. Never bypass that fence + // with a suspended file that could belong to the earlier process generation. + if (modernProcessEvidence) return false; + const checkpoint = run.runnerProfileJson?.sessionCheckpoint as Record | undefined; + if (checkpoint?.providerSessionId != null && (typeof checkpoint.providerSessionId !== "string" || + !checkpoint.providerSessionId.trim())) return false; + const { nativeFailedRunRetryStateIsSafe } = await import("./native-runtime/native-session-executor.js"); + return nativeFailedRunRetryStateIsSafe({ + execution: run.runnerProfileJson?.nativeExecutionInput, + companyId: run.companyId, issueId: run.nativeIssueId, agentId: run.agentId, runId: run.id, + nativeSessionId: run.nativeSessionId, runnerInstanceId: run.runnerInstanceId, + processPid: null, processGroupId: null, + providerSessionId: typeof checkpoint?.sessionId === "string" ? checkpoint.sessionId : null, + providerBackendSessionId: typeof checkpoint?.providerSessionId === "string" ? checkpoint.providerSessionId : null, + recoveryMode: "exact_checkpoint_resume", allowVerifiedBackup: false, + }); +} + /** Recover the exact stopped identity after the mutable run fields were cleared. */ export async function readNativeLocalProcessStop(db: Db, companyId: string, runId: string) { const [event] = await db.select({ eventType: heartbeatRunEvents.eventType, payload: heartbeatRunEvents.payload }) From ef84f363b717f5780c3df54bd9b1dba296b7d356 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:47:10 -0500 Subject: [PATCH 02/43] fix(ui): remove execution workspace access card (#13406) ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Execution workspace pages show the tools and state for one isolated workspace. > - The workspace access card repeats runtime state that the page already shows. > - The card also adds open and repair actions that are not needed on this page. > - This pull request removes the complete workspace access card from execution workspace detail pages. > - The benefit is a smaller page that keeps attention on workspace controls and work results. ## Linked Issues or Issue Description **What existing behavior does this improve?** The execution workspace detail page shows a workspace access status card. **Current behavior** The page can show Ready, preparation, degraded, failed, or Workspace is not running states. The card can also show Open workspace and repair actions. **Proposed behavior** The page does not show the workspace access status card or its page-only actions. **Reason and benefit** The card adds status and controls that are not needed on this page. Its removal makes the isolated workspace page simpler. **Breaking changes** The workspace access card and its actions are no longer available from the execution workspace detail page. Workspace runtime controls remain available. ## What Changed - Removed the workspace access status card from execution workspace detail pages. - Removed the page-only login handoff and repair mutations that served the card. - Added a regression test for the removed card and text. ## Verification - `pnpm exec vitest run ui/src/pages/ExecutionWorkspaceDetail.test.tsx` passes with 8 tests. - `pnpm typecheck` passes. - `pnpm check:token-gates` passes. - `pnpm build` passes. - `pnpm test:run` found unrelated failures in `server/src/__tests__/workspace-runtime.test.ts` during the local broad run. The focused changed-area tests pass. - The complete GitHub Actions matrix passes on the latest run, including build, typecheck, server tests, runner verification, and e2e tests. ## Risks - Low risk. The change removes one UI card and the page-only code that supported it. - Users must use the remaining workspace runtime controls instead of this card. > This change is focused UI cleanup. It does not add a roadmap feature. ## Model Used - OpenAI Codex based on GPT-5, with reasoning, tool use, and code execution. The exact context window is not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../pages/ExecutionWorkspaceDetail.test.tsx | 8 ++ ui/src/pages/ExecutionWorkspaceDetail.tsx | 110 ------------------ 2 files changed, 8 insertions(+), 110 deletions(-) diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx index d4356c0d21..acc7564938 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -318,6 +318,14 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { expect(container.querySelector('[data-testid="summary-slot-card"]')).not.toBeNull(); }); + it("does not show a workspace access status card", async () => { + await render(); + + expect(container.querySelector('[data-testid="workspace-access-card"]')).toBeNull(); + expect(container.textContent).not.toContain("Workspace is not running"); + expect(container.textContent).not.toContain("Open workspace"); + }); + it("does not mount plugin slots scoped to other entity types", async () => { await render(); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 17506e841f..62df8bf8a6 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -13,7 +13,6 @@ import { CopyText } from "../components/CopyText"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder"; import { agentsApi } from "../api/agents"; -import { ApiError } from "../api/client"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { heartbeatsApi } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; @@ -36,7 +35,6 @@ import { type WorkspaceRuntimeControlRequest, } from "../components/WorkspaceRuntimeControls"; import { WorkspaceServiceControlBar } from "../components/WorkspaceServiceControlBar"; -import { WorkspaceAccessCard } from "../components/WorkspaceAccessCard"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly"; @@ -44,10 +42,6 @@ import { useToastActions } from "../context/ToastContext"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { queryKeys } from "../lib/queryKeys"; import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; -import { - resolveWorkspaceAccessState, - type WorkspaceLoginHandoffFailureInfo, -} from "../lib/workspace-access-state"; import { getWorkspaceSpecificRoutineVariableNames, routineHasWorkspaceSpecificVariables, @@ -423,27 +417,6 @@ export function resolveRuntimeProvisionStatus(input: { return configured ? { kind: "deferred" } : { kind: "eager" }; } -/** - * Read the structured refusal the login-handoff endpoint returns. - * - * The server keeps a machine `reason` (and, where it probed, the workspace's own - * readiness) on the error body so the UI can name the cause instead of showing a - * bare HTTP failure. Anything else is a genuine transport error. - */ -export function readWorkspaceHandoffFailure(error: unknown): WorkspaceLoginHandoffFailureInfo | null { - if (!(error instanceof ApiError)) return null; - const body = error.body as - | { reason?: unknown; detail?: unknown; readiness?: unknown } - | null - | undefined; - if (!body || typeof body.reason !== "string") return null; - return { - reason: body.reason, - detail: typeof body.detail === "string" ? body.detail : null, - readiness: (body.readiness as WorkspaceLoginHandoffFailureInfo["readiness"]) ?? null, - }; -} - function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -812,8 +785,6 @@ export function ExecutionWorkspaceDetail() { const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); - const [handoffFailure, setHandoffFailure] = useState(null); - const [handoffErrorMessage, setHandoffErrorMessage] = useState(null); const [pendingRuntimeActions, setPendingRuntimeActions] = useState([]); const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null; const pluginTabFromSearch = useMemo(() => { @@ -1003,67 +974,6 @@ export function ExecutionWorkspaceDetail() { }, }); - /** - * Password-independent workspace entry (PAP-17572). - * - * The server answers with a ticket-bearing URL, and the workspace answers *that* - * with a redirect — which is what keeps the ticket out of session history. - * - * The target tab is opened synchronously on click and only pointed at the URL - * once the ticket arrives. Opening it after the request resolves would be a - * popup the browser did not attribute to the click, and Safari and Firefox - * block exactly that. If the tab could not be opened anyway, fall back to - * navigating this one rather than silently doing nothing. - */ - const openWorkspace = useMutation({ - mutationFn: async () => { - const target = window.open("about:blank", "_blank", "noopener,noreferrer"); - try { - return { ticket: await executionWorkspacesApi.requestLoginHandoff(workspace!.id), target }; - } catch (error) { - target?.close(); - throw error; - } - }, - onSuccess: ({ ticket, target }) => { - setHandoffFailure(null); - setHandoffErrorMessage(null); - if (target && !target.closed) target.location.replace(ticket.url); - else window.location.assign(ticket.url); - }, - onError: async (error) => { - // A structured refusal is rendered as workspace state by the access card, - // so only an unrecognized transport error needs its own message line. - const failure = readWorkspaceHandoffFailure(error); - setHandoffFailure(failure); - setHandoffErrorMessage( - failure ? null : error instanceof Error ? error.message : "Failed to open the workspace.", - ); - // The refusal reason often comes from an operation that has since advanced, - // so refresh the log the access card derives its state from. - await queryClient.invalidateQueries({ - queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspace!.id), - }); - }, - }); - const repairWorkspace = useMutation({ - mutationFn: () => executionWorkspacesApi.repair(workspace!.id), - onSuccess: (result) => { - queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace); - queryClient.invalidateQueries({ - queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id), - }); - setHandoffFailure(null); - setHandoffErrorMessage(null); - setRuntimeActionErrorMessage(null); - setRuntimeActionMessage("Workspace database repaired."); - }, - onError: (error) => { - setRuntimeActionMessage(null); - setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to repair the workspace."); - }, - }); - if (workspaceQuery.isLoading) return

Loading workspace…

; if (workspaceQuery.error) { return ( @@ -1088,12 +998,6 @@ export function ExecutionWorkspaceDetail() { runtimeServices: workspace.runtimeServices ?? [], pendingRequests: pendingRuntimeActions, }); - const workspaceAccess = resolveWorkspaceAccessState({ - runtimeServices: workspace.runtimeServices ?? [], - operations: workspaceOperationsQuery.data, - handoffFailure, - }); - const pluginSlotContext = { companyId: workspace.companyId, projectId: workspace.projectId, @@ -1163,20 +1067,6 @@ export function ExecutionWorkspaceDetail() { {runtimeActionErrorMessage ?

{runtimeActionErrorMessage}

: null} {!runtimeActionErrorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} - openWorkspace.mutate()} - onStart={() => { - runRuntimeControlRequests( - resolveWorkspaceServiceControlRequests(runtimeControlSections, "start", null), - ); - }} - onRepair={() => repairWorkspace.mutate()} - onViewLogs={() => handleTabChange("runtime_logs")} - errorMessage={handoffErrorMessage} - /> - Date: Mon, 14 Sep 2026 09:58:44 -0500 Subject: [PATCH 03/43] fix(ui): hide task chat attribution badge (#13251) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task chat shows messages from agents and users. > - An agent message can contain an internal on-behalf-of user value. > - The new task view showed this value as a badge next to the agent name. > - This badge added unwanted identity text to the task chat. > - This pull request removes the badge from the new task view. > - The benefit is a simpler agent identity row in the task chat. ## Linked Issues or Issue Description **What happened?** The new task view shows a `for ` badge next to an agent name when a message has an on-behalf-of user value. **Expected behavior** The task chat shows the agent name without the on-behalf-of badge. **Steps to reproduce** 1. Open the new task view. 2. Show an agent message that has an on-behalf-of user value. 3. See the attribution badge next to the agent name. **Paperclip version or commit** The issue occurs on `master` at commit `d0b7ba419`. **Deployment mode** Local dev and all other modes that use the web UI. ## What Changed - Removed the attribution chip from the task chat agent identity. - Added a regression test that supplies an on-behalf-of value and confirms that the badge is absent. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat/TaskChatBubble.test.tsx` passed with 21 tests. - `pnpm check:token-gates` passed. - `pnpm --filter @paperclipai/ui typecheck` passed. ## Risks - Low risk. The change only removes one badge from the new task chat view. - The message data remains unchanged. > This is a small UI fix. It does not add roadmap scope. ## Model Used - OpenAI Codex, GPT-5.4, with reasoning and tool use. The provider did not expose the context window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/no-internal-issue-references`, `fix/sandbox-secret-resolution`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../task-chat/TaskChatBubble.test.tsx | 23 +++++++++++++++++++ .../components/task-chat/TaskChatBubble.tsx | 10 -------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/ui/src/components/task-chat/TaskChatBubble.test.tsx b/ui/src/components/task-chat/TaskChatBubble.test.tsx index 00d8746155..70fe754ae9 100644 --- a/ui/src/components/task-chat/TaskChatBubble.test.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.test.tsx @@ -285,6 +285,29 @@ describe("TaskChatBubble accent-bubble text color", () => { }); describe("TaskChatBubble agent page-surface treatment", () => { + it("does not show an on-behalf-of badge in the new task view", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + flushSync(() => + root.render( + + + , + ), + ); + + expect(container.textContent).toContain("Fable"); + expect(container.textContent).not.toContain("for Dotta"); + expect(container.querySelector('[data-testid="comment-attribution-chip"]')).toBeNull(); + + flushSync(() => root.unmount()); + container.remove(); + }); + it("renders agent prose without a card background or constrained width", () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 3caae25934..bc51a51133 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -11,7 +11,6 @@ import { } from "@/components/ImageGalleryModal"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { AgentIcon } from "@/components/AgentIconPicker"; -import { CommentAttributionChip } from "@/components/CommentAttributionChip"; import { Attachment, AttachmentContent, @@ -75,11 +74,9 @@ function initialsForName(name: string) { export function TaskChatAgentIdentity({ agentName, agentIcon, - onBehalfOfUserName, }: { agentName: string; agentIcon?: string | null; - onBehalfOfUserName?: string; }) { return ( {agentName} - {onBehalfOfUserName ? ( - - ) : null} ); } @@ -241,7 +232,6 @@ function TaskChatBubbleContent({ ) : null} {bodyText.length > 0 ? ( From 2d47a8058d7d8b7876090cacb0b871fa689e617c Mon Sep 17 00:00:00 2001 From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:05:27 -0700 Subject: [PATCH 04/43] fix(apps): update connector artwork and theme fallback (#13361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Awaiting author review. Do not merge until the author explicitly approves. ## Thinking Path > - Paperclip helps people manage AI agents for work. > - Connector screens need recognizable app artwork. > - Several bundled marks have inconsistent artwork or dark-theme behavior. > - The shared logo component should retain the existing frame. > - This change replaces selected artwork and fixes local theme fallback. > - Users see consistent connector icons across shared component callers. ## Linked Issues or Issue Description **Current behavior** Some connector marks use outdated artwork or unsuitable theme variants. A remote dark logo can override a canonical local mark that works in both themes. **Proposed behavior** Use the selected bundled artwork with the existing gray rounded frame. Use local light artwork in both themes unless a distinct local dark variant exists. **Subsystem affected** Connector artwork, the shared AppLogo resolver, and its validation. **Breaking changes** No connector capability, permission, credential, or catalog activation changes. No duplicate PR was found in the earlier search. ## What Changed - Update 46 artwork files and only the app definitions whose logo paths need to change. - Keep a compact public manifest of identities, paths, visibility, and aliases. - Prefer local artwork in both themes and preserve the existing frame and padding. - Add locally runnable artwork safety checks and a light/dark Storybook gallery; leave PR workflows unchanged. - Document artwork conventions. Source research is kept outside the public manifest. ## Verification - Artwork check: 69 identities pass. - Node artwork validation tests: 13 pass (rechecked September 14). - Focused resolver, component, and catalog tests: 40 pass (rechecked September 14). - Maintainer decision: dedicated icon-validation CI is not required; the workflow remains unchanged. Greptile acknowledged 5/5 with no remaining code concerns on September 14. - UI typecheck, token gates, and Storybook build pass. - Earlier full build and repository typecheck passed. The broad local test run was stopped after workspace-runtime dependency fixture failures outside this change. Current GitHub CI remains the full-suite gate. - Visual approval remains outstanding. Review the canonical icon registry in both themes at 24–48px. ## Risks The SVG checker rejects common active features; it is not a general sanitizer for arbitrary uploads. Optical balance still requires human review. Remote fallback for unknown brands retains existing behavior. This change does not add an asset importer or new connector capabilities. ## Model Used OpenAI Codex, assisted by GPT-5 and GPT-6 with code execution and browser tooling. Exact hosted model IDs and context window sizes were not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Artwork comparison Before and after for every affected identity. Images follow your GitHub light/dark theme and are pinned to the base and PR commits. This compares artwork; the existing gray rounded product frame and padding are unchanged. | Connector | Before | After | |---|:---:|:---:| | AgentMail | AgentMail | AgentMail | | Airtable | Airtable | Airtable | | Asana | Asana | Asana | | ClickHouse | ClickHouse | ClickHouse | | Cloudflare | Cloudflare | Cloudflare | | Cloudinary | Cloudinary | Cloudinary | | Discord | Discord | Discord | | GitHub | GitHub | GitHub | | Gmail | Gmail | Gmail | | Google Calendar | Google Calendar | Google Calendar | | Google Chat | Google Chat | Google Chat | | Google Docs | Google Docs | Google Docs | | Google Drive | Google Drive | Google Drive | | Google People | Google People | Google People | | Google Sheets | Google Sheets | Google Sheets | | Google Slides | Google Slides | Google Slides | | Google Workspace Search | Google Workspace Search | Google Workspace Search | | Hugging Face | Hugging Face | Hugging Face | | Jam.dev (library only) | New library entry | Jam.dev | | Jira | Jira | Jira | | Linear | Linear | Linear | | Manufact | Manufact | Manufact | | Microsoft Teams | Microsoft Teams | Microsoft Teams | | Miro | Miro | Miro | | Mixpanel | Mixpanel | Mixpanel | | Netlify | Netlify | Netlify | | Notion | Notion | Notion | | PagerDuty | PagerDuty | PagerDuty | | PostHog | PostHog | PostHog | | Postman | Postman | Postman | | Shopify | Shopify | Shopify | | Slack | Slack | Slack | | Stripe | Stripe | Stripe | | Supabase | Supabase | Supabase | | Telegram | Telegram | Telegram | | Todoist | Todoist | Todoist | | Wix | Wix | Wix | | Zapier | Zapier | Zapier | --- doc/connections/CONNECTOR-ICONS.md | 29 ++ doc/connections/CONNECTOR-PLAYBOOK.md | 14 +- packages/shared/src/app-definitions.test.ts | 23 +- .../shared/src/app-definitions/agentmail.json | 3 +- .../src/app-definitions/clickhouse.json | 3 +- .../shared/src/app-definitions/discord.json | 3 +- packages/shared/src/app-definitions/jira.json | 3 +- .../shared/src/app-definitions/linear.json | 3 +- .../shared/src/app-definitions/manufact.json | 3 +- packages/shared/src/app-definitions/miro.json | 3 +- .../shared/src/app-definitions/mixpanel.json | 3 +- .../shared/src/app-definitions/netlify.json | 3 +- .../shared/src/app-definitions/notion.json | 3 +- .../shared/src/app-definitions/slack.json | 2 +- packages/shared/src/app-definitions/wix.json | 3 +- scripts/app-brand-validation.mjs | 47 ++ scripts/app-brand-validation.test.mjs | 34 ++ scripts/check-app-brand-assets.mjs | 8 + ui/public/brands/apps/agentmail.svg | 31 +- ui/public/brands/apps/airtable.svg | 2 +- ui/public/brands/apps/asana.svg | 13 +- ui/public/brands/apps/clickhouse-dark.svg | 1 + ui/public/brands/apps/clickhouse.svg | 2 +- ui/public/brands/apps/cloudflare.svg | 2 +- ui/public/brands/apps/cloudinary.svg | 4 +- ui/public/brands/apps/discord-dark.svg | 1 + ui/public/brands/apps/discord.svg | 5 +- ui/public/brands/apps/github-dark.svg | 11 +- ui/public/brands/apps/github.svg | 11 +- ui/public/brands/apps/gmail.svg | 2 +- ui/public/brands/apps/google-calendar.svg | 2 +- ui/public/brands/apps/google-chat.svg | 2 +- ui/public/brands/apps/google-docs.svg | 2 +- ui/public/brands/apps/google-drive.svg | 2 +- ui/public/brands/apps/google-people.svg | 2 +- ui/public/brands/apps/google-sheets.svg | 2 +- ui/public/brands/apps/google-slides.svg | 2 +- .../brands/apps/google-workspace-search.svg | 2 +- ui/public/brands/apps/hugging-face.svg | 9 +- ui/public/brands/apps/jam-dev-dark.svg | 1 + ui/public/brands/apps/jam-dev.svg | 1 + ui/public/brands/apps/jira.svg | 5 +- ui/public/brands/apps/linear-dark.svg | 5 + ui/public/brands/apps/linear.svg | 6 +- ui/public/brands/apps/manifest.json | 425 ++++-------------- ui/public/brands/apps/manufact-dark.svg | 6 + ui/public/brands/apps/manufact.svg | 25 +- ui/public/brands/apps/microsoft-teams.svg | 88 +++- ui/public/brands/apps/miro.svg | 5 +- ui/public/brands/apps/mixpanel-dark.svg | 1 + ui/public/brands/apps/netlify-dark.svg | 1 + ui/public/brands/apps/netlify.svg | 17 +- ui/public/brands/apps/notion.svg | 2 +- ui/public/brands/apps/pagerduty.svg | 5 +- ui/public/brands/apps/posthog.svg | 2 +- ui/public/brands/apps/postman.svg | 2 +- ui/public/brands/apps/shopify.svg | 27 +- ui/public/brands/apps/slack.svg | 17 + ui/public/brands/apps/stripe.svg | 12 +- ui/public/brands/apps/supabase.svg | 16 +- ui/public/brands/apps/telegram.svg | 14 +- ui/public/brands/apps/todoist.svg | 2 +- ui/public/brands/apps/wix-dark.svg | 12 + ui/public/brands/apps/wix.svg | 13 +- ui/public/brands/apps/zapier.svg | 2 +- ui/src/lib/app-brand-assets.test.ts | 31 +- ui/src/lib/app-brand-assets.ts | 20 +- .../pages/apps/AppLogo.brand-assets.test.tsx | 24 + ui/src/pages/apps/AppLogo.tsx | 4 +- .../stories/AppLogoRegistry.stories.tsx | 38 ++ 70 files changed, 642 insertions(+), 487 deletions(-) create mode 100644 doc/connections/CONNECTOR-ICONS.md create mode 100644 scripts/app-brand-validation.mjs create mode 100644 scripts/app-brand-validation.test.mjs create mode 100644 scripts/check-app-brand-assets.mjs create mode 100644 ui/public/brands/apps/clickhouse-dark.svg create mode 100644 ui/public/brands/apps/discord-dark.svg create mode 100644 ui/public/brands/apps/jam-dev-dark.svg create mode 100644 ui/public/brands/apps/jam-dev.svg create mode 100644 ui/public/brands/apps/linear-dark.svg create mode 100644 ui/public/brands/apps/manufact-dark.svg create mode 100644 ui/public/brands/apps/mixpanel-dark.svg create mode 100644 ui/public/brands/apps/netlify-dark.svg create mode 100644 ui/public/brands/apps/slack.svg create mode 100644 ui/public/brands/apps/wix-dark.svg create mode 100644 ui/storybook/stories/AppLogoRegistry.stories.tsx diff --git a/doc/connections/CONNECTOR-ICONS.md b/doc/connections/CONNECTOR-ICONS.md new file mode 100644 index 0000000000..3648ebbed7 --- /dev/null +++ b/doc/connections/CONNECTOR-ICONS.md @@ -0,0 +1,29 @@ +# Connector icons + +Use the shared `AppLogo` component and bundled artwork in `ui/public/brands/apps`. +Keep the existing gray rounded frame, caller size and border, and contained image +padding. Preserve vendor shapes and colors; use a separate dark asset only when +the light artwork is unsuitable on the dark frame. Do not invert or stretch marks. + +The public manifest contains only identity, catalog visibility, artwork paths, +and optional aliases. Omit `darkAsset` when both themes use the same file. Keep +matching logo paths in the app definition. Brand-library membership does not +enable a connector. Google People and Workspace Search share the Google mark. + +Use reviewed vendor or supplied source files. Keep source research and review +records outside the browser-served manifest. Never add credentials or private +review links to public assets. Render SVGs as images, not inline HTML. The +structural safety check rejects common active SVG features; it is not a general +sanitizer for untrusted uploads. + +Before submitting artwork, run: + +```sh +node scripts/check-app-brand-assets.mjs +node --test scripts/app-brand-validation.test.mjs +pnpm exec vitest run ui/src/lib/app-brand-assets.test.ts ui/src/pages/apps/AppLogo.brand-assets.test.tsx packages/shared/src/app-definitions.test.ts +``` + +Run the structural artwork check locally. Review the Storybook canonical icon registry +in light and dark themes at 24–48px, then inspect affected product surfaces. Check +contrast, optical size, native details, and the existing image-error fallback. diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index ca77a2600b..6bd8a9de77 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -5,6 +5,8 @@ and shipping Paperclip app connections. Status: canonical end-to-end authoring guide for Apps v2 catalog connections. +For connector artwork, follow [Connector icons](./CONNECTOR-ICONS.md): fixed gray Paperclip frames, authentic vendor artwork, explicit theme variants, optical fit and exact provenance. Brand-library additions do not activate connectors. Use the shared registry/resolver and branding generator; do not introduce per-screen logos or outer-surface overrides. + This runbook is the repeatable, agent-executable procedure for adding a vendor to the Apps catalog as data, not as a plugin. It follows the accepted connections framework in [PAP-13211](/PAP/issues/PAP-13211), the first-30 @@ -87,7 +89,7 @@ scripts/ingest-app-definitions.mjs # human-authored definition so packages/shared/src/app-definitions/.json # generated definition packages/shared/src/app-definitions.generated.ts # generated registry ui/public/brands/apps/.svg # official, sanitized mark -ui/public/brands/apps/manifest.json # branding provenance +ui/public/brands/apps/manifest.json # runtime branding paths packages/shared/src/app-definitions.test.ts # manifest/provider assertions ``` @@ -571,15 +573,15 @@ only a runtime image-failure fallback. external executable content, or unsafe references. 5. Save assets under `ui/public/brands/apps/`. Add a `-dark` variant only when the normal mark loses contrast in dark mode. -6. Add the provider to `ui/public/brands/apps/manifest.json` with slug, local - asset, optional dark asset, official source URL, exact upstream asset URL, - asset type, visibility, and dark-variant requirement. +6. Add the provider to `ui/public/brands/apps/manifest.json` with slug, name, + local asset, optional dark asset, visibility, and optional aliases. Keep source + URLs and verification notes in the review record, outside the public manifest. 7. Let the ingestion script derive `branding.logoUrl` and `darkLogoUrl` from the - provenance manifest. + runtime manifest. The manifest test decodes PNG headers, requires at least 128 by 128 pixels, sanity-checks SVG markup, verifies files exist, and requires store-visible -definitions and visible provenance entries to match exactly. +definitions and visible manifest entries to match exactly. ### Phase 5: Author the definition at the durable source diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 37cbc57045..2f70bd73e5 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -698,7 +698,7 @@ describe("AppDefinition catalog", () => { expect(storeSlugs.has(slug), slug).toBe(false); } }); - it("ships complete local branding provenance for all 46 store-visible providers", () => { + it("ships matching local artwork for every store-visible provider", () => { const uiPublic = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../../ui/public", @@ -711,21 +711,13 @@ describe("AppDefinition catalog", () => { catalogVisible: boolean; localAsset: string; darkAsset?: string; - officialSourceUrl: string; - upstreamAssetUrl: string; - assetType: "svg" | "png"; - darkVariantRequired: boolean; }>; }; const visible = manifest.providers.filter((entry) => entry.catalogVisible); - expect(visible).toHaveLength(46); + expect(visible).toHaveLength(APP_STORE_DEFINITIONS.length); expect(new Set(visible.map((entry) => entry.slug))).toHaveProperty( "size", - 46, - ); - expect(new Set(visible.map((entry) => entry.localAsset))).toHaveProperty( - "size", - 46, + visible.length, ); expect(new Set(APP_STORE_DEFINITIONS.map((entry) => entry.slug))).toEqual( new Set(visible.map((entry) => entry.slug)), @@ -735,21 +727,16 @@ describe("AppDefinition catalog", () => { expect(provenance).toBeTruthy(); expect(provenance.localAsset).toBe(app.branding.logoUrl); expect(provenance.darkAsset).toBe(app.branding.darkLogoUrl); - expect(provenance.darkVariantRequired).toBe( - Boolean(provenance.darkAsset), - ); - expect(new URL(provenance.officialSourceUrl).protocol).toBe("https:"); - expect(new URL(provenance.upstreamAssetUrl).protocol).toBe("https:"); expect(provenance.localAsset).toMatch(/^\/brands\/apps\/.+\.(svg|png)$/); expect(provenance.localAsset).not.toContain("google.com/s2/favicons"); const asset = fs.readFileSync(path.join(uiPublic, provenance.localAsset)); - if (provenance.assetType === "png") { + if (provenance.localAsset.endsWith(".png")) { expect(asset.subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a"); expect(asset.readUInt32BE(16)).toBeGreaterThanOrEqual(128); expect(asset.readUInt32BE(20)).toBeGreaterThanOrEqual(128); } else { const svg = asset.toString("utf8"); - expect(svg).toMatch(/^\s*)? value.trim().toLowerCase().replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + +// Conservative structural rejection, not a general-purpose SVG sanitizer. +// Original accepted artwork is copied byte-for-byte, never rewritten here. +export function validateArtwork(bytes, filename) { + if (filename.endsWith(".png")) { + if (bytes.length < 45 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + || bytes.toString("ascii", 12, 16) !== "IHDR" || bytes.readUInt32BE(16) === 0 || bytes.readUInt32BE(20) === 0 + || bytes.toString("ascii", bytes.length - 8, bytes.length - 4) !== "IEND") { + throw new Error(`${filename}: invalid PNG signature`); + } + return; + } + const svg = bytes.toString("utf8"); + if (!filename.endsWith(".svg") || (!/]*\bviewBox\s*=\s*["'][^"']+["']/i.test(svg) && !(/]*\bwidth\s*=\s*["'][0-9.]+(?:px)?["']/i.test(svg) && /]*\bheight\s*=\s*["'][0-9.]+(?:px)?["']/i.test(svg)))) { + throw new Error(`${filename}: SVG requires a viewBox or intrinsic width and height`); + } + if (/ typeof alias !== "string" || !alias.trim())) throw new Error(`${row.slug}: invalid aliases`); + for (const name of [row.slug, row.provider, ...aliases]) { + const key = normalizeBrandKey(name); + if (keys.has(key) && keys.get(key) !== row.slug) throw new Error(`${row.slug}: ambiguous brand alias ${name}`); + keys.set(key, row.slug); + } + for (const asset of new Set([row.localAsset, ...(row.darkAsset === undefined ? [] : [row.darkAsset])])) { + if (typeof asset !== "string" || !assetPathPattern.test(asset)) throw new Error(`${row.slug}: invalid asset path`); + validateArtwork(readAsset(asset), asset); + } + } + return manifest.providers.length; +} diff --git a/scripts/app-brand-validation.test.mjs b/scripts/app-brand-validation.test.mjs new file mode 100644 index 0000000000..df7a427776 --- /dev/null +++ b/scripts/app-brand-validation.test.mjs @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateArtwork, validateManifest } from "./app-brand-validation.mjs"; + +const svg = Buffer.from(''); +const record = () => ({ + slug: "example", provider: "Example", aliases: [], localAsset: "/brands/apps/example.svg", darkAsset: "/brands/apps/example.svg", +}); +const check = (row, read = () => svg) => validateManifest({ schemaVersion: 1, providers: [row] }, read); + +test("accepts authentic vector artwork, native gradients and actual PNG bytes", () => { + assert.equal(check(record()), 1); + validateArtwork(Buffer.from(''), "gradient.svg"); + validateArtwork(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"), "native.png"); + assert.throws(() => validateArtwork(Buffer.from("89504e470d0a1a0a", "hex"), "truncated.png"), /PNG/); + assert.throws(() => validateArtwork(svg, "fake.png"), /PNG/); +}); +for (const payload of ['" }).title).toContain("", "
Click
", + "Navigate", "
", + "", "", + "", "", "", + "

Other namespace

", + "Go", + "", + "", "", + ])("rejects active or resource-loading markup: %s", (html) => { + expect(() => validateAnnouncementAnimation(Buffer.from(html))).toThrow(); + }); + it("rejects empty, oversized and invalid UTF-8 files", () => { + for (const bytes of [Buffer.alloc(0), Buffer.alloc(128 * 1024 + 1), Buffer.from([0xff])]) { + expect(() => validateAnnouncementAnimation(bytes)).toThrow(); + } + }); +}); diff --git a/server/src/__tests__/announcement-feed.test.ts b/server/src/__tests__/announcement-feed.test.ts new file mode 100644 index 0000000000..e3f3eb760b --- /dev/null +++ b/server/src/__tests__/announcement-feed.test.ts @@ -0,0 +1,149 @@ +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { announcementFeedService, ANNOUNCEMENT_CACHE_MS, ANNOUNCEMENT_FAILURE_MS } from "../services/announcement-feed.js"; +import { logger } from "../middleware/logger.js"; + +const item = { id: "new-projects", eyebrow: "New", title: "Projects", description: "Organize your work.", primaryAction: { kind: "route", label: "Open", path: "/projects" } }; +const json = (announcement: unknown = item, etag = '"v1"') => new Response(JSON.stringify({ schemaVersion: 1, announcement }), { headers: { "Content-Type": "application/json", ETag: etag } }); +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); +describe("announcement feed", () => { + it("treats a 404 as quiet empty content, drops stale ETags, and recovers after cooldown", async () => { + let now = 0; + const warn = vi.spyOn(logger, "warn"); + const fetch = vi.fn().mockImplementationOnce(async () => json()) + .mockImplementationOnce(async () => new Response("Not found", { status: 404 })) + .mockImplementationOnce(async () => json({ ...item, id: "restored" })); + const service = announcementFeedService({ version: "1.0.0", now: () => now, fetch }); + expect(await service.current()).toEqual(item); + now += ANNOUNCEMENT_CACHE_MS; + expect(await service.current()).toBeNull(); + expect(await service.image(item.id)).toBeNull(); + now += ANNOUNCEMENT_FAILURE_MS - 1; + expect(await service.current()).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + expect(warn).not.toHaveBeenCalled(); + now++; + expect((await service.current())?.id).toBe("restored"); + expect(fetch.mock.calls[2][1].headers).not.toHaveProperty("If-None-Match"); + }); + + it("deduplicates requests, caches for an hour, then revalidates with ETag", async () => { + let now = 0; + const fetch = vi.fn().mockImplementationOnce(async () => json()).mockResolvedValueOnce(new Response(null, { status: 304 })); + const service = announcementFeedService({ version: "2026.913.0", now: () => now, fetch }); + expect(await Promise.all([service.current(), service.current()])).toEqual([item, item]); + expect(fetch).toHaveBeenCalledTimes(1); + now = ANNOUNCEMENT_CACHE_MS - 1; + await service.current(); + expect(fetch).toHaveBeenCalledTimes(1); + now++; + expect(await service.current()).toEqual(item); + expect(fetch.mock.calls[1][1]).toMatchObject({ headers: { "If-None-Match": '"v1"' }, credentials: "omit" }); + expect(Object.keys(fetch.mock.calls[0][1].headers)).toEqual(["Accept"]); + }); + it("withdraws, updates same-ID copy and discovers new IDs after refresh", async () => { + let now = 0; + const fetch = vi.fn().mockImplementationOnce(async () => json()).mockImplementationOnce(async () => json({ ...item, title: "Corrected" }, '"v2"')).mockImplementationOnce(async () => json(null)).mockImplementationOnce(async () => json({ ...item, id: "next" })); + const service = announcementFeedService({ version: "2026.913.0", now: () => now, fetch }); + expect((await service.current())?.id).toBe(item.id); + now += ANNOUNCEMENT_CACHE_MS; + expect((await service.current())?.title).toBe("Corrected"); + now += ANNOUNCEMENT_CACHE_MS; + expect(await service.current()).toBeNull(); + now += ANNOUNCEMENT_CACHE_MS; + expect((await service.current())?.id).toBe("next"); + }); + it.each([ + () => new Response("bad json", { headers: { "Content-Type": "application/json" } }), + () => new Response(JSON.stringify({ schemaVersion: 9, announcement: item }), { headers: { "Content-Type": "application/json" } }), + () => new Response("x".repeat(65537), { headers: { "Content-Type": "application/json" } }), + () => new Response("", { status: 302, headers: { Location: "http://127.0.0.1" } }), + () => new Response("error", { status: 503 }), + ])("suppresses invalid/unavailable feeds and observes failure cooldown", async (response) => { + let now = 0; + const fetch = vi.fn().mockImplementationOnce(async () => response()).mockImplementationOnce(async () => json()); + const service = announcementFeedService({ version: "1.0.0", now: () => now, fetch }); + expect(await service.current()).toBeNull(); + now = ANNOUNCEMENT_FAILURE_MS - 1; + expect(await service.current()).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(1); + now++; + expect(await service.current()).toEqual(item); + }); + it("bounds a hung fetch to three seconds", async () => { + vi.useFakeTimers(); + const service = announcementFeedService({ version: "1.0.0", fetch: () => new Promise(() => {}) }); + const result = service.current(); + await vi.advanceTimersByTimeAsync(3000); + expect(await result).toBeNull(); + }); + it("does not fetch when disabled or an operator URL is invalid", async () => { + const fetch = vi.fn(); + for (const options of [{ enabled: false }, { feedUrl: "http://localhost/feed" }, { feedUrl: "https://user:secret@example.com/feed" }]) { + const service = announcementFeedService({ version: "1.0.0", fetch, ...options }); + expect(await service.current()).toBeNull(); + expect(await service.image(item.id)).toBeNull(); + expect(await service.animation(item.id)).toBeNull(); + } + expect(fetch).not.toHaveBeenCalled(); + }); + it("expires a cached announcement and filters version-incompatible content", async () => { + let now = 0; + const service = announcementFeedService({ version: "1.0.0", now: () => now, fetch: async () => json({ ...item, expiresAt: "1970-01-01T00:00:01Z" }) }); + expect(await service.current()).not.toBeNull(); + now = 1000; + expect(await service.current()).toBeNull(); + expect(await announcementFeedService({ version: "1.0.0", fetch: async () => json({ ...item, minimumPaperclipVersion: "2.0.0" }) }).current()).toBeNull(); + }); + it("proxies only the current content-addressed image and caches bytes", async () => { + const bytes = Buffer.from("test-image"); + const path = `assets/${createHash("sha256").update(bytes).digest("hex")}.png`; + const fetch = vi.fn().mockImplementationOnce(async () => json({ ...item, image: { path, alt: "" } })).mockImplementationOnce(async () => new Response(bytes, { headers: { "Content-Type": "image/png" } })); + const service = announcementFeedService({ version: "1.0.0", fetch }); + expect(await service.image("other")).toBeNull(); + const images = await Promise.all([service.image(item.id), service.image(item.id)]); + expect(images[0]?.bytes).toEqual(bytes); + expect(images[1]).toEqual(images[0]); + await service.image(item.id); + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(fetch.mock.calls[1][0])).toBe(`https://pages.paperclip.ing/announcements/v1/${path}`); + }); + it("rejects image digest mismatches and cools down image retries", async () => { + const fetch = vi.fn().mockImplementationOnce(async () => json({ ...item, image: { path: `assets/${"0".repeat(64)}.png`, alt: "" } })).mockImplementation(async () => new Response("wrong", { headers: { "Content-Type": "image/png" } })); + const service = announcementFeedService({ version: "1.0.0", fetch }); + expect(await service.image(item.id)).toBeNull(); + expect(await service.image(item.id)).toBeNull(); + expect(fetch).toHaveBeenCalledTimes(2); + }); + it("deduplicates and caches validated animations on the configured host", async () => { + const bytes = Buffer.from("
Team
"); + const path = `assets/${createHash("sha256").update(bytes).digest("hex")}.html`; + const fetch = vi.fn().mockImplementationOnce(async () => json({ ...item, image: { path: `assets/${"0".repeat(64)}.png`, alt: "Poster" }, animation: { path, alt: "Team" } })) + .mockImplementationOnce(async () => new Response(bytes, { headers: { "Content-Type": "text/html; charset=utf-8" } })); + const service = announcementFeedService({ version: "1.0.0", feedUrl: "https://mirror.example/preview/current.json", fetch }); + expect(await service.animation("wrong-id")).toBeNull(); + const result = await Promise.all([service.animation(item.id), service.animation(item.id)]); + expect(result[0]?.bytes.toString()).toContain("Team"); + expect(result[1]).toEqual(result[0]); + await service.animation(item.id); + expect(fetch).toHaveBeenCalledTimes(2); + expect(String(fetch.mock.calls[1][0])).toBe(`https://mirror.example/preview/${path}`); + expect(fetch.mock.calls[1][1]).toMatchObject({ credentials: "omit", redirect: "error", headers: { Accept: "text/html" } }); + }); + it.each([ + ["", "text/html", 200], + ["
ok
", "text/plain", 200], + ["not found", "text/html", 404], + ["x".repeat(128 * 1024 + 1), "text/html", 200], + ])("falls back on rejected animation assets and cools down retries", async (html, contentType, status) => { + const path = `assets/${createHash("sha256").update(html).digest("hex")}.html`; + const fetch = vi.fn().mockImplementationOnce(async () => json({ ...item, image: { path: `assets/${"0".repeat(64)}.png`, alt: "Poster" }, animation: { path, alt: "Team" } })) + .mockImplementation(async () => new Response(html, { status, headers: { "Content-Type": contentType } })); + const service = announcementFeedService({ version: "1.0.0", fetch }); + expect(await service.animation(item.id)).toBeNull(); + expect(await service.animation(item.id)).toBeNull(); + expect((await service.current())?.id).toBe(item.id); + expect(fetch).toHaveBeenCalledTimes(2); + }); + +}); diff --git a/server/src/__tests__/announcement-publisher.test.ts b/server/src/__tests__/announcement-publisher.test.ts new file mode 100644 index 0000000000..6568d45257 --- /dev/null +++ b/server/src/__tests__/announcement-publisher.test.ts @@ -0,0 +1,74 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, mkdir, writeFile, symlink, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareAnnouncementPublish, announcementUploadArgs, parseAnnouncementPublishArgs, announcementPublishPrefix } from "../../../scripts/publish-announcements.js"; + +const dirs: string[] = []; +async function fixture() { const dir = await mkdtemp(path.join(os.tmpdir(), "announcement-publish-")); dirs.push(dir); return dir; } +afterEach(async () => { for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true }); }); +describe("announcement publishing", () => { + it("keeps named staging feeds separate from production and defaults to dry-run", async () => { + const dir = await fixture(); + await writeFile(path.join(dir, "current.json"), JSON.stringify({ schemaVersion: 1, announcement: null })); + const result = await prepareAnnouncementPublish(dir, "preview-projects"); + expect(result.files.map((file) => file.key)).toEqual(["announcements/staging/preview-projects/v1/current.json"]); + expect(parseAnnouncementPublishArgs(["--staging", "preview-projects"])).toEqual({ + sourceDirectory: "announcements/examples/staging", staging: "preview-projects", publish: false, + }); + expect(parseAnnouncementPublishArgs([dir, "--staging", "preview-projects", "--publish"]).publish).toBe(true); + expect(announcementPublishPrefix()).toBe("announcements/v1"); + expect((await prepareAnnouncementPublish(dir, "preview-projects", "storybook/branches/codex-announcements")).files[0].key) + .toBe("storybook/branches/codex-announcements/announcements/staging/preview-projects/v1/current.json"); + for (const prefix of ["../outside", "/leading", "trailing/", "bad//path", "", "https://example.com"]) { + expect(() => announcementPublishPrefix("preview-projects", prefix)).toThrow(); + } + for (const name of ["../v1", "", "/production", "preview/nested"]) { + expect(() => announcementPublishPrefix(name)).toThrow(); + } + for (const args of [["--staging"], ["--publish", "--dry-run"], ["one", "two"], ["--unknown"]]) { + expect(() => parseAnnouncementPublishArgs(args)).toThrow(); + } + }); + + it("uploads content-addressed assets before the five-minute manifest", async () => { + const dir = await fixture(); + const bytes = Buffer.from("test-image"); + const imagePath = `assets/${createHash("sha256").update(bytes).digest("hex")}.png`; + await mkdir(path.join(dir, "assets")); + await writeFile(path.join(dir, imagePath), bytes); + await writeFile(path.join(dir, "current.json"), JSON.stringify({ schemaVersion: 1, announcement: { id: "test", title: "Test", eyebrow: "New", description: "Example", image: { path: imagePath, alt: "" }, primaryAction: { kind: "route", label: "Open", path: "/projects" } } })); + const { files } = await prepareAnnouncementPublish(dir); + expect(files.map((file) => file.key)).toEqual([`announcements/v1/${imagePath}`, "announcements/v1/current.json"]); + expect(files[0].cacheControl).toContain("immutable"); + expect(announcementUploadArgs("bucket", files[1])).toContain("public,max-age=300"); + await writeFile(path.join(dir, imagePath), "changed"); + await expect(prepareAnnouncementPublish(dir)).rejects.toThrow("SHA-256"); + }); + it("validates HTML animation fixtures and uploads both assets before the manifest", async () => { + const result = await prepareAnnouncementPublish(path.resolve(import.meta.dirname, "../../../announcements/examples/animated"), "animated-preview"); + expect(result.files.map((file) => file.contentType)).toEqual(["image/png", "text/html", "application/json"]); + const dir = await fixture(); + await mkdir(path.join(dir, "assets")); + const html = ""; + const asset = `assets/${createHash("sha256").update(html).digest("hex")}.html`; + const image = result.files[0]; + const imagePath = result.manifest.announcement!.image!.path; + await writeFile(path.join(dir, imagePath), await import("node:fs/promises").then((fs) => fs.readFile(image.file))); + await writeFile(path.join(dir, asset), html); + await writeFile(path.join(dir, "current.json"), JSON.stringify({ ...result.manifest, announcement: { ...result.manifest.announcement, animation: { path: asset, alt: "Unsafe" } } })); + await expect(prepareAnnouncementPublish(dir)).rejects.toThrow("only visual HTML/CSS"); + }); + it("supports withdrawal and rejects symlinks and unsupported schemas", async () => { + const dir = await fixture(); + await writeFile(path.join(dir, "current.json"), JSON.stringify({ schemaVersion: 1, announcement: null })); + expect((await prepareAnnouncementPublish(dir)).files).toHaveLength(1); + const link = path.join(dir, "link"); await symlink(dir, link); + await expect(prepareAnnouncementPublish(link)).rejects.toThrow("real directory"); + await writeFile(path.join(dir, "current.json"), JSON.stringify({ schemaVersion: 2, announcement: null })); + await expect(prepareAnnouncementPublish(dir)).rejects.toThrow(); + await writeFile(path.join(dir, "current.json"), JSON.stringify({ schemaVersion: 1, announcement: null, announcements: [] })); + await expect(prepareAnnouncementPublish(dir)).rejects.toThrow(); + }); +}); diff --git a/server/src/__tests__/announcements-routes.test.ts b/server/src/__tests__/announcements-routes.test.ts new file mode 100644 index 0000000000..be2ca76cea --- /dev/null +++ b/server/src/__tests__/announcements-routes.test.ts @@ -0,0 +1,119 @@ +import express from "express"; +import { createHash } from "node:crypto"; +import request from "supertest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { activityLog, announcementDismissals, announcementPublications, companies, createDb, startEmbeddedPostgresTestDatabase, type EmbeddedPostgresTestDatabase } from "@paperclipai/db"; +import { announcementRoutes } from "../routes/announcements.js"; +import { announcementService } from "../services/announcements.js"; +import { errorHandler } from "../middleware/error-handler.js"; + +const companyId = "11111111-1111-4111-8111-111111111111"; +const otherCompanyId = "22222222-2222-4222-8222-222222222222"; +const item = { id: "new-projects", eyebrow: "New", title: "Projects", description: "Organize your work.", primaryAction: { kind: "route", label: "Open", path: "/projects" } }; +describe("announcement routes and durable dismissals", () => { + let database: EmbeddedPostgresTestDatabase; + let db: ReturnType; + function app(userId = "alice", actorOverride?: Record, announcement: unknown = item, feedStatus = 200, animationHtml?: string) { + const server = express(); + server.use(express.json()); + server.use((req, _res, next) => { + req.actor = (actorOverride ?? { type: "board", userId, source: "session", companyIds: [companyId, otherCompanyId], memberships: [{ companyId, membershipRole: "viewer", status: "active" }] }) as never; + next(); + }); + server.use("/api", announcementRoutes(db, { version: "2026.913.0", fetch: async (url) => animationHtml && url.pathname.endsWith(".html") ? new Response(animationHtml, { headers: { "Content-Type": "text/html" } }) : new Response(JSON.stringify({ schemaVersion: 1, announcement }), { status: feedStatus, headers: { "Content-Type": "application/json" } }) })); + server.use(errorHandler); + return server; + } + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-announcements-"); + db = createDb(database.connectionString); + await db.insert(companies).values([{ id: companyId, name: "Test", issuePrefix: "ANN" }, { id: otherCompanyId, name: "Other", issuePrefix: "ANB" }]); + }, 90_000); + beforeEach(async () => { await db.delete(announcementDismissals); await db.delete(announcementPublications); await db.delete(activityLog); }); + afterAll(async () => { await database?.cleanup(); }, 30_000); + it.each([200, 404])("returns a successful empty response for no remote announcement (HTTP %s)", async (status) => { + const response = await request(app("alice", undefined, null, status)).get("/api/announcements/current"); + expect(response.status).toBe(200); + expect(response.body).toBeNull(); + expect(response.headers["cache-control"]).toBe("private, no-store"); + }); + it("serves animation documents with a sandbox and network-denying CSP", async () => { + const html = "
Team
"; + const path = `assets/${createHash("sha256").update(html).digest("hex")}.html`; + const server = app("alice", undefined, { ...item, image: { path: `assets/${"0".repeat(64)}.png`, alt: "Poster" }, animation: { path, alt: "Team" } }, 200, html); + const response = await request(server).get(`/api/announcements/${item.id}/animation`); + expect(response.status).toBe(200); + expect(response.headers["cache-control"]).toBe("private, no-store"); + expect(response.headers["content-security-policy"]).toContain("sandbox; default-src 'none'"); + expect(response.headers["referrer-policy"]).toBe("no-referrer"); + expect(response.headers["x-content-type-options"]).toBe("nosniff"); + expect(response.type).toBe("text/html"); + expect((await request(server).get("/api/announcements/wrong-id/animation")).status).toBe(404); + }); + it("persists across app/service restarts, browsers and companies, isolated by user", async () => { + const first = app(); + const response = await request(first).get("/api/announcements/current"); + expect(response.body.id).toBe(item.id); + expect(response.headers["cache-control"]).toBe("private, no-store"); + expect((await request(first).post(`/api/announcements/${item.id}/dismiss`).send({ companyId })).status).toBe(204); + expect((await request(app()).get("/api/announcements/current")).body).toBeNull(); + expect((await request(app("bob")).get("/api/announcements/current")).body.id).toBe(item.id); + await request(app()).post(`/api/announcements/${item.id}/dismiss`).send({ companyId: otherCompanyId }); + expect(await db.select().from(activityLog)).toHaveLength(1); + }); + it("accepts viewers and concurrent duplicate dismissals with one audit", async () => { + const server = app(); + await request(server).get("/api/announcements/current"); + const responses = await Promise.all(Array.from({ length: 5 }, () => request(server).post(`/api/announcements/${item.id}/dismiss`).send({ companyId }))); + expect(responses.map((res) => res.status)).toEqual([204, 204, 204, 204, 204]); + expect(await db.select().from(announcementDismissals)).toHaveLength(1); + expect(await db.select().from(activityLog).where(eq(activityLog.action, "announcement.dismissed"))).toHaveLength(1); + }); + it("rolls back the dismissal if its audit cannot commit", async () => { + await announcementService(db).registerPublication(item.id); + await expect(announcementService(db).dismiss("alice", item.id, "33333333-3333-4333-8333-333333333333")).rejects.toThrow(); + expect(await announcementService(db).isDismissed("alice", item.id)).toBe(false); + }); + it("never resurrects a dismissed ID after copy changes or rollback; a new ID appears", async () => { + await announcementService(db).registerPublication(item.id); + await announcementService(db).dismiss("alice", item.id, companyId); + expect((await request(app("alice", undefined, { ...item, title: "Fixed copy" })).get("/api/announcements/current")).body).toBeNull(); + expect((await request(app("alice", undefined, { ...item, id: "next" })).get("/api/announcements/current")).body.id).toBe("next"); + expect((await request(app()).get("/api/announcements/current")).body).toBeNull(); + }); + it("uses the local-board identity without an auth user row", async () => { + const server = app("local-board", { type: "board", userId: "local-board", source: "local_implicit" }); + await request(server).get("/api/announcements/current"); + expect((await request(server).post(`/api/announcements/${item.id}/dismiss`).send({ companyId })).status).toBe(204); + expect(await announcementService(db).isDismissed("local-board", item.id)).toBe(true); + }); + it("rejects invented IDs without storing dismissals or audit entries", async () => { + const server = app(); + await request(server).get("/api/announcements/current"); + for (const id of ["invented-one", "invented-two", "invented-three"]) { + expect((await request(server).post(`/api/announcements/${id}/dismiss`).send({ companyId })).status).toBe(404); + } + expect(await db.select().from(announcementDismissals)).toHaveLength(0); + expect(await db.select().from(activityLog)).toHaveLength(0); + expect(await db.select().from(announcementPublications)).toEqual([{ announcementId: item.id }]); + }); + it("accepts offline retries for validated IDs after withdrawal and restart", async () => { + await request(app()).get("/api/announcements/current"); + const restarted = app("alice", undefined, null, 404); + expect((await request(restarted).get("/api/announcements/current")).body).toBeNull(); + expect((await request(restarted).post(`/api/announcements/${item.id}/dismiss`).send({ companyId })).status).toBe(204); + expect(await announcementService(db).isDismissed("alice", item.id)).toBe(true); + expect(await db.select().from(activityLog)).toHaveLength(1); + }); + it("rejects anonymous/agent callers and inaccessible audit companies", async () => { + for (const actor of [{ type: "none" }, { type: "agent", companyId, userId: "alice" }]) { + const server = app("alice", actor); + const expected = actor.type === "none" ? 401 : 403; + for (const url of ["current", `${item.id}/image`, `${item.id}/animation`]) expect((await request(server).get(`/api/announcements/${url}`)).status).toBe(expected); + expect((await request(server).post(`/api/announcements/${item.id}/dismiss`).send({ companyId })).status).toBe(expected); + } + expect((await request(app()).post(`/api/announcements/${item.id}/dismiss`).send({ companyId: "33333333-3333-4333-8333-333333333333" })).status).toBe(404); + expect((await request(app()).post(`/api/announcements/${item.id}/dismiss`).send({ companyId, userId: "bob" })).status).toBe(400); + }); +}); diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 287823be4e..9e29753867 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -19,6 +19,7 @@ const apiPrefixes: Record = { "activity.ts": "/api", "adapters.ts": "/api", "agents.ts": "/api", + "announcements.ts": "/api", "ai-connections.ts": "/api", "attention.ts": "/api", "approvals.ts": "/api", @@ -223,6 +224,26 @@ function loadSpecRoutes() { } describe("openapi routes", () => { + it("documents personal board-only announcements and private responses", () => { + const { spec } = loadSpecRoutes(); + const current = spec.paths["/api/announcements/current"].get; + const image = spec.paths["/api/announcements/{id}/image"].get; + const animation = spec.paths["/api/announcements/{id}/animation"].get; + const dismiss = spec.paths["/api/announcements/{id}/dismiss"].post; + for (const operation of [current, image, animation, dismiss]) { + expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]); + expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" }); + const success = operation.responses["200"] ?? operation.responses["204"]; + expect(success.headers["Cache-Control"].schema.enum).toEqual(["private, no-store"]); + } + expect(current.responses["200"].content["application/json"].schema.nullable).toBe(true); + expect(Object.keys(image.responses["200"].content)).toEqual(["image/png", "image/jpeg", "image/webp"]); + expect(dismiss.requestBody.content["application/json"].schema).toMatchObject({ + required: ["companyId"], additionalProperties: false, + }); + expect(dismiss.description).toContain("viewers may dismiss their own"); + }); + it("documents exact failed-run selection and durable accepted retry responses", async () => { const res = await request(createApp()).get("/api/openapi.json"); const wake = res.body.paths["/api/agents/{id}/wakeup"].post; diff --git a/server/src/app.ts b/server/src/app.ts index 003183d8d7..8898f0a3d0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -91,6 +91,8 @@ import type { DecisionServiceOptions } from "./services/decisions.js"; import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; +import { announcementRoutes } from "./routes/announcements.js"; +import { serverVersion } from "./version.js"; import { resourceMembershipRoutes } from "./routes/resource-memberships.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; @@ -474,6 +476,7 @@ export async function createApp( chatWebhookPublicBaseUrl?: string; authReady: boolean; companyDeletionEnabled: boolean; + announcements?: { enabled: boolean; feedUrl: string }; instanceId?: string; hostVersion?: string; localPluginDir?: string; @@ -776,6 +779,7 @@ export async function createApp( api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); + api.use(announcementRoutes(db, { ...opts.announcements, version: opts.hostVersion ?? serverVersion })); api.use(resourceMembershipRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); diff --git a/server/src/config.ts b/server/src/config.ts index 97c45402e2..fcb10c6834 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -95,6 +95,8 @@ export interface Config { heartbeatSchedulerIntervalMs: number; companyDeletionEnabled: boolean; telemetryEnabled: boolean; + announcementsEnabled: boolean; + announcementsFeedUrl: string; } function detectTailnetBindHost(): string | undefined { @@ -364,5 +366,7 @@ export function loadConfig(): Config { heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), companyDeletionEnabled, telemetryEnabled: fileConfig?.telemetry?.enabled ?? true, + announcementsEnabled: process.env.PAPERCLIP_ANNOUNCEMENTS_ENABLED !== "false", + announcementsFeedUrl: process.env.PAPERCLIP_ANNOUNCEMENTS_FEED_URL?.trim() || "https://pages.paperclip.ing/announcements/v1/current.json", }; } diff --git a/server/src/index.ts b/server/src/index.ts index da59802de0..959cf299ef 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -911,6 +911,7 @@ async function startServerWithDatabaseTeardown( chatWebhookPublicBaseUrl: config.chatWebhookPublicBaseUrl, authReady, companyDeletionEnabled: config.companyDeletionEnabled, + announcements: { enabled: config.announcementsEnabled, feedUrl: config.announcementsFeedUrl }, pluginMigrationDb: pluginMigrationDb as any, betterAuthHandler, resolveSession, diff --git a/server/src/routes/announcements.ts b/server/src/routes/announcements.ts new file mode 100644 index 0000000000..2f4124c9b6 --- /dev/null +++ b/server/src/routes/announcements.ts @@ -0,0 +1,61 @@ +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import { companies, type Db } from "@paperclipai/db"; +import { ANNOUNCEMENT_ANIMATION_CSP, announcementIdSchema, dismissAnnouncementSchema } from "@paperclipai/shared"; +import { badRequest, forbidden, notFound } from "../errors.js"; +import { validate } from "../middleware/validate.js"; +import { assertAuthenticated, assertBoard, hasCompanyAccess } from "./authz.js"; +import { announcementService } from "../services/announcements.js"; +import { announcementFeedService, type AnnouncementFeedOptions } from "../services/announcement-feed.js"; + +export function announcementRoutes(db: Db, options: AnnouncementFeedOptions) { + const router = Router(); + const feed = announcementFeedService(options); + const service = announcementService(db); + router.use("/announcements", (req, res, next) => { + assertAuthenticated(req); + assertBoard(req); + if (!req.actor.userId) throw forbidden("Board user context required"); + res.setHeader("Cache-Control", "private, no-store"); + next(); + }); + router.get("/announcements/current", async (req, res) => { + const announcement = await feed.current(); + if (announcement) await service.registerPublication(announcement.id); + res.json(announcement && !await service.isDismissed(req.actor.userId!, announcement.id) ? announcement : null); + }); + router.get("/announcements/:id/image", async (req, res) => { + const id = announcementIdSchema.safeParse(req.params.id); + if (!id.success) throw badRequest("Invalid announcement ID"); + const image = await feed.image(id.data); + if (!image) throw notFound("Announcement image unavailable"); + res.setHeader("Content-Type", image.contentType); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.send(image.bytes); + }); + router.get("/announcements/:id/animation", async (req, res) => { + const id = announcementIdSchema.safeParse(req.params.id); + if (!id.success) throw badRequest("Invalid announcement ID"); + const animation = await feed.animation(id.data); + if (!animation) throw notFound("Announcement animation unavailable"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("Content-Security-Policy", `sandbox; ${ANNOUNCEMENT_ANIMATION_CSP}`); + res.setHeader("Referrer-Policy", "no-referrer"); + res.send(animation.bytes); + }); + router.post("/announcements/:id/dismiss", validate(dismissAnnouncementSchema), async (req, res) => { + const id = announcementIdSchema.safeParse(req.params.id); + if (!id.success) throw badRequest("Invalid announcement ID"); + const { companyId } = req.body; + // This writes the caller's personal preference. Viewers may dismiss it; + // company read membership supplies audit context, not write authority. + if (!hasCompanyAccess(req, companyId)) throw notFound("Company not found"); + if (!await db.query.companies.findFirst({ where: eq(companies.id, companyId), columns: { id: true } })) { + throw notFound("Company not found"); + } + if (!await service.dismiss(req.actor.userId!, id.data, companyId)) throw notFound("Announcement not found"); + res.status(204).end(); + }); + return router; +} diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 2981e2dcf4..3ba131044f 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -112,6 +112,10 @@ import { resolveBudgetIncidentSchema, // Sidebar upsertSidebarOrderPreferenceSchema, + // Announcements + announcementIdSchema, + announcementSchema, + dismissAnnouncementSchema, // Execution workspaces reconcileExecutionWorkspaceBranchSchema, updateExecutionWorkspaceSchema, @@ -1279,6 +1283,7 @@ const PUBLIC_OPERATIONS = new Set([ ]); const BOARD_ONLY_PREFIXES = [ + "/api/announcements/", "/api/auth/", "/api/admin/", "/api/plugins", @@ -5782,6 +5787,87 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +// ─── Announcements ─────────────────────────────────────────────────────────── + +const announcementResponseHeaders = { + "Cache-Control": { schema: { type: "string", enum: ["private, no-store"] } }, +}; + +registry.registerPath({ + method: "get", + path: "/api/announcements/current", + tags: ["announcements"], + summary: "Get the current user's eligible announcement", + description: "Returns null for dismissed, disabled, unavailable, expired or incompatible content. Dismissals follow the board user across companies within this instance; no-login installations use local-board.", + responses: { + 200: { ...r.ok(announcementSchema.nullable()), headers: announcementResponseHeaders }, + 401: r.unauthorized, + 403: r.forbidden, + 500: r.serverError, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/announcements/{id}/image", + tags: ["announcements"], + summary: "Get the current announcement's validated image", + description: "Proxies only the content-addressed raster asset in the eligible manifest. Arbitrary URLs and asset paths are not accepted.", + request: { params: z.object({ id: announcementIdSchema }) }, + responses: { + 200: { + description: "Validated announcement image", + headers: announcementResponseHeaders, + content: { + "image/png": { schema: { type: "string", format: "binary" } }, + "image/jpeg": { schema: { type: "string", format: "binary" } }, + "image/webp": { schema: { type: "string", format: "binary" } }, + }, + }, + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/announcements/{id}/animation", + tags: ["announcements"], + summary: "Get the current announcement's isolated HTML/CSS animation", + description: "Board-only, validated content-addressed HTML. Scripts, links, forms and embedded resources are rejected; CSP sandbox and resource restrictions also apply to direct visits. Missing or invalid assets return 404 and the card uses its static image.", + request: { params: z.object({ id: announcementIdSchema }) }, + responses: { + 200: { + description: "Validated visual HTML/CSS document", + headers: { ...announcementResponseHeaders, "Content-Security-Policy": { schema: { type: "string" } } }, + content: { "text/html": { schema: { type: "string" } } }, + }, + 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/announcements/{id}/dismiss", + tags: ["announcements"], + summary: "Dismiss an announcement for the current user", + description: "Idempotently saves a personal preference. The supplied company is validated audit context; viewers may dismiss their own announcement. The first dismissal and its audit entry commit together. IDs from a previously validated feed remain valid for offline retries; unknown IDs return 404 without creating records.", + request: { + params: z.object({ id: announcementIdSchema }), + body: jsonBody(dismissAnnouncementSchema), + }, + responses: { + 204: { ...r.noContent, headers: announcementResponseHeaders }, + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 500: r.serverError, + }, +}); + // ─── Inbox dismissals ──────────────────────────────────────────────────────── registry.registerPath({ diff --git a/server/src/services/announcement-animation.ts b/server/src/services/announcement-animation.ts new file mode 100644 index 0000000000..6e5e1d96e8 --- /dev/null +++ b/server/src/services/announcement-animation.ts @@ -0,0 +1,33 @@ +import createDOMPurify from "dompurify"; +import { JSDOM } from "jsdom"; +import { ANNOUNCEMENT_ANIMATION_MAX_BYTES } from "@paperclipai/shared"; + +// A visual HTML/CSS document, never an application. JSDOM does not execute +// scripts or load resources. DOMPurify handles HTML parsing/normalization; +// CSP on delivery also blocks all network requests, including CSS URLs. +export function validateAnnouncementAnimation(bytes: Uint8Array): string { + if (!bytes.length || bytes.byteLength > ANNOUNCEMENT_ANIMATION_MAX_BYTES) { + throw new Error("Invalid or oversized announcement animation"); + } + const source = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const dom = new JSDOM(""); + try { + const purifier = createDOMPurify(dom.window as unknown as Parameters[0]); + const html = purifier.sanitize(source, { + WHOLE_DOCUMENT: true, + ALLOWED_TAGS: ["html", "head", "body", "style", "div", "span", "p", "br", "strong", "em", "b", "i", + "svg", "g", "path", "circle", "ellipse", "rect", "line", "polyline", "polygon", "text", "tspan", "title", "desc"], + ALLOWED_ATTR: ["class", "id", "style", "viewBox", "xmlns", "width", "height", "x", "y", "x1", "x2", "y1", "y2", + "cx", "cy", "r", "rx", "ry", "d", "points", "fill", "stroke", "stroke-width", "stroke-linecap", + "stroke-linejoin", "stroke-dasharray", "stroke-dashoffset", "opacity", "transform", "text-anchor"], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: false, + }); + if (purifier.removed.length) { + throw new Error("Animation must contain only visual HTML/CSS or inline SVG; scripts, navigation, resources and interactive elements are not supported"); + } + return html; + } finally { + dom.window.close(); + } +} diff --git a/server/src/services/announcement-feed.ts b/server/src/services/announcement-feed.ts new file mode 100644 index 0000000000..c251c5612d --- /dev/null +++ b/server/src/services/announcement-feed.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { + ANNOUNCEMENT_IMAGE_MAX_BYTES, ANNOUNCEMENT_MANIFEST_MAX_BYTES, ANNOUNCEMENT_ANIMATION_MAX_BYTES, + DEFAULT_ANNOUNCEMENT_FEED_URL, announcementManifestSchema, isAnnouncementEligible, + type AnnouncementManifest, +} from "@paperclipai/shared"; +import { guardedRemoteHttpFetch } from "./remote-http-fetch.js"; +import { validateAnnouncementAnimation } from "./announcement-animation.js"; +import { logger } from "../middleware/logger.js"; + +export const ANNOUNCEMENT_CACHE_MS = 60 * 60 * 1000; +export const ANNOUNCEMENT_FAILURE_MS = 15 * 60 * 1000; +const TIMEOUT_MS = 3000; +type AnnouncementAsset = { path: string; bytes: Buffer; contentType: string }; +function assetSlot() { + return { + cache: null as AnnouncementAsset | null, + pending: null as { path: string; promise: Promise } | null, + failure: null as { path: string; retryAt: number } | null, + }; +} +export interface AnnouncementFeedOptions { + enabled?: boolean; + feedUrl?: string; + version: string; + now?: () => number; + fetch?: (url: URL, init: RequestInit) => Promise; +} + +export async function readAnnouncementBytes(response: Response, maximum: number): Promise { + if (Number(response.headers.get("content-length")) > maximum) { + await response.body?.cancel(); + throw new Error("Announcement response is too large"); + } + const reader = response.body?.getReader(); + if (!reader) return Buffer.alloc(0); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > maximum) throw new Error("Announcement response is too large"); + chunks.push(value); + } + return Buffer.concat(chunks, length); + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +export function announcementFeedService(options: AnnouncementFeedOptions) { + const now = options.now ?? Date.now; + const fetchRemote = options.fetch ?? ((url, init) => guardedRemoteHttpFetch(url, init, { + error: (message) => new Error(message), dnsTimeoutMs: TIMEOUT_MS, + connectTimeoutMs: TIMEOUT_MS, responseTimeoutMs: TIMEOUT_MS, + })); + let manifest: AnnouncementManifest | null = null; + let etag: string | null = null; + let nextCheck = 0; + let available = false; + let pending: Promise | null = null; + // One bounded cache slot per media kind, shared across board users. + const assets = { + image: assetSlot(), + animation: assetSlot(), + }; + + function endpoint() { + const url = new URL(options.feedUrl ?? DEFAULT_ANNOUNCEMENT_FEED_URL); + if (url.protocol !== "https:" || url.username || url.password || url.hash || url.search) { + throw new Error("Announcement feed must be an HTTPS URL without credentials, query, or fragment"); + } + return url; + } + + async function request(url: URL, headers: Record, consume: (response: Response) => Promise): Promise { + const controller = new AbortController(); + let timer: ReturnType; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + controller.abort(); + reject(new Error("Announcement request timed out")); + }, TIMEOUT_MS); + }); + try { + return await Promise.race([ + fetchRemote(url, { method: "GET", headers, signal: controller.signal, redirect: "error", credentials: "omit" }) + .then(consume), + deadline, + ]); + } finally { + clearTimeout(timer!); + controller.abort(); + } + } + + async function refresh() { + try { + const result = await request(endpoint(), { + Accept: "application/json", ...(etag ? { "If-None-Match": etag } : {}), + }, async (response) => { + if (response.status === 304 && manifest) return { manifest, etag, ttl: ANNOUNCEMENT_CACHE_MS }; + if (response.status === 404) { + await response.body?.cancel(); + // An unpublished/removed feed is an expected empty state. Forget the + // previous ETag so recovery cannot resurrect a stale cached card. + return { manifest: { schemaVersion: 1, announcement: null } as AnnouncementManifest, etag: null, ttl: ANNOUNCEMENT_FAILURE_MS }; + } + if (!response.ok || response.status >= 300) { + await response.body?.cancel(); + throw new Error("Announcement feed unavailable"); + } + if (response.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") { + await response.body?.cancel(); + throw new Error("Announcement feed is not JSON"); + } + const bytes = await readAnnouncementBytes(response, ANNOUNCEMENT_MANIFEST_MAX_BYTES); + return { manifest: announcementManifestSchema.parse(JSON.parse(bytes.toString("utf8"))), etag: response.headers.get("etag"), ttl: ANNOUNCEMENT_CACHE_MS }; + }); + manifest = result.manifest; + etag = result.etag; + available = true; + nextCheck = now() + result.ttl; + } catch { + available = false; + nextCheck = now() + ANNOUNCEMENT_FAILURE_MS; + // Do not log remote content or operator URLs (which can carry secrets). + logger.warn("Announcement feed unavailable; retrying on demand after cooldown"); + } + } + + async function current() { + if (options.enabled === false) return null; + if (pending) await pending; + else if (now() >= nextCheck) { + pending = refresh().finally(() => { pending = null; }); + await pending; + } + const announcement = available ? manifest?.announcement : null; + return announcement && isAnnouncementEligible(announcement, options.version, now()) ? announcement : null; + } + + async function asset(id: string, kind: "image" | "animation") { + const announcement = await current(); + const media = announcement?.[kind]; + if (announcement?.id !== id || !media) return null; + const { path } = media; + const slot = assets[kind]; + if (slot.cache?.path === path) return slot.cache; + if (slot.pending?.path === path) return slot.pending.promise; + if (slot.failure?.path === path && now() < slot.failure.retryAt) return null; + const expected = kind === "animation" ? "text/html" : path.endsWith(".png") ? "image/png" : path.endsWith(".jpg") ? "image/jpeg" : "image/webp"; + const promise = (async () => { + try { + const result = await request(new URL(path, endpoint()), { Accept: expected }, async (response) => { + const contentType = response.headers.get("content-type")?.split(";")[0]?.trim(); + if (!response.ok || contentType !== expected) { + await response.body?.cancel(); + throw new Error("Invalid announcement asset response"); + } + const bytes = await readAnnouncementBytes(response, kind === "animation" ? ANNOUNCEMENT_ANIMATION_MAX_BYTES : ANNOUNCEMENT_IMAGE_MAX_BYTES); + const hash = createHash("sha256").update(bytes).digest("hex"); + if (!path.startsWith(`assets/${hash}.`)) throw new Error("Announcement asset digest mismatch"); + return { path, bytes: kind === "animation" ? Buffer.from(validateAnnouncementAnimation(bytes)) : bytes, contentType }; + }); + slot.cache = result; + slot.failure = null; + return result; + } catch { + slot.failure = { path, retryAt: now() + ANNOUNCEMENT_FAILURE_MS }; + return null; + } + })(); + const entry = { path, promise }; + slot.pending = entry; + try { return await promise; } + finally { if (slot.pending === entry) slot.pending = null; } + } + + return { current, image: (id: string) => asset(id, "image"), animation: (id: string) => asset(id, "animation") }; +} diff --git a/server/src/services/announcements.ts b/server/src/services/announcements.ts new file mode 100644 index 0000000000..f77f3de4a9 --- /dev/null +++ b/server/src/services/announcements.ts @@ -0,0 +1,46 @@ +import { and, eq } from "drizzle-orm"; +import { announcementDismissals, announcementPublications, type Db } from "@paperclipai/db"; +import { persistActivity, publishActivity } from "./activity-log.js"; +import { logger } from "../middleware/logger.js"; + +export function announcementService(db: Db) { + return { + async registerPublication(announcementId: string) { + await db.insert(announcementPublications).values({ announcementId }).onConflictDoNothing(); + }, + async isDismissed(userId: string, announcementId: string) { + const row = await db.query.announcementDismissals.findFirst({ + where: and(eq(announcementDismissals.userId, userId), eq(announcementDismissals.announcementId, announcementId)), + }); + return Boolean(row); + }, + async dismiss(userId: string, announcementId: string, companyId: string) { + const result = await db.transaction(async (tx) => { + const known = await tx.query.announcementPublications.findFirst({ + where: eq(announcementPublications.announcementId, announcementId), + }); + if (!known) { + // Preserve idempotency for dismissals written before the registry was + // added, but never create a new row or audit for an unknown ID. + const existing = await tx.query.announcementDismissals.findFirst({ + where: and(eq(announcementDismissals.userId, userId), eq(announcementDismissals.announcementId, announcementId)), + }); + return { known: Boolean(existing), publication: null }; + } + const [inserted] = await tx.insert(announcementDismissals).values({ userId, announcementId }) + .onConflictDoNothing().returning(); + if (!inserted) return { known: true, publication: null }; + const activity = await persistActivity(tx as unknown as Db, { + companyId, actorType: "user", actorId: userId, + action: "announcement.dismissed", entityType: "announcement", entityId: announcementId, + }); + return { known: true, publication: activity.publication }; + }); + if (result.publication) { + try { publishActivity(result.publication); } + catch { logger.warn("Could not publish committed announcement dismissal activity"); } + } + return result.known; + }, + }; +} diff --git a/ui/public/announcement-preview.svg b/ui/public/announcement-preview.svg new file mode 100644 index 0000000000..2921a87ffc --- /dev/null +++ b/ui/public/announcement-preview.svg @@ -0,0 +1,11 @@ + + + + + + + + + paperclip + Ideas become work. + diff --git a/ui/src/api/announcements.ts b/ui/src/api/announcements.ts new file mode 100644 index 0000000000..bd60003417 --- /dev/null +++ b/ui/src/api/announcements.ts @@ -0,0 +1,24 @@ +import { announcementSchema, type Announcement } from "@paperclipai/shared"; +import { ApiError } from "./client"; + +async function request(path: string, init: RequestInit) { + const response = await fetch(`/api/announcements/${path}`, { + credentials: "same-origin", cache: "no-store", ...init, + }); + if (!response.ok) throw new ApiError("Announcement request failed", response.status, null); + return response; +} + +export const announcementsApi = { + // Deliberately not coalesced by URL across account changes. + async current(signal: AbortSignal): Promise { + const response = await request("current", { signal }); + const payload = await response.json(); + return payload === null ? null : announcementSchema.parse(payload); + }, + async dismiss(id: string, companyId: string, signal: AbortSignal) { + await request(`${encodeURIComponent(id)}/dismiss`, { + method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ companyId }), + }); + }, +}; diff --git a/ui/src/components/AnnouncementCard.test.tsx b/ui/src/components/AnnouncementCard.test.tsx new file mode 100644 index 0000000000..7f305fe513 --- /dev/null +++ b/ui/src/components/AnnouncementCard.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AnnouncementCard } from "./AnnouncementCard"; +import { announcementPreview, announcementAnimationPreview } from "@/lib/announcement-preview"; + +vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => {children} })); +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +describe("AnnouncementCard", () => { + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + async function animatedCard() { + const div = document.createElement("div"); document.body.append(div); + const root = createRoot(div); + const dismiss = vi.fn(); + await act(async () => root.render()); + return { div, root, dismiss, cleanup: async () => { await act(async () => root.unmount()); div.remove(); } }; + } + it("renders isolated animated media with only the announcement controls", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("
Animated hero
", { headers: { "Content-Type": "text/html" } }))); + const { div, dismiss, cleanup } = await animatedCard(); + const frame = div.querySelector("iframe")!; + expect(frame.getAttribute("sandbox")).toBe(""); + expect(frame.getAttribute("tabindex")).toBe("-1"); + expect(frame.getAttribute("aria-hidden")).toBe("true"); + expect(frame.srcdoc).toContain("default-src 'none'"); + expect(frame.srcdoc).toContain("Animated hero"); + expect(div.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe(announcementAnimationPreview.animation!.alt); + expect(Array.from(div.querySelectorAll("button"), (button) => button.getAttribute("aria-label"))) + .toEqual(["Dismiss announcement"]); + expect(div.querySelectorAll("a")).toHaveLength(2); + expect(dismiss).not.toHaveBeenCalled(); + await cleanup(); + }); + it("does not load animation when reduced motion is requested", async () => { + vi.stubGlobal("matchMedia", () => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })); + const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); + const { div, cleanup } = await animatedCard(); + expect(fetch).not.toHaveBeenCalled(); + expect(div.querySelector("iframe")).toBeNull(); + expect(div.querySelector("img")).not.toBeNull(); + await cleanup(); + }); + it.each([404, 503])("keeps the poster and actions usable for animation HTTP %s", async (status) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("unavailable", { status }))); + const { div, dismiss, cleanup } = await animatedCard(); + expect(div.querySelector("iframe")).toBeNull(); + expect(div.querySelector("img")).not.toBeNull(); + await act(async () => div.querySelector('[aria-label="Dismiss announcement"]')!.click()); + expect(dismiss).toHaveBeenCalledOnce(); + await cleanup(); + }); + it("aborts a pending animation fetch when dismissed", async () => { + let signal: AbortSignal | undefined; + vi.stubGlobal("fetch", vi.fn((_url, init) => { signal = init.signal; return new Promise(() => {}); })); + const { div, cleanup } = await animatedCard(); + expect(div.querySelector("img")).not.toBeNull(); + await cleanup(); + expect(signal?.aborted).toBe(true); + }); + it("renders accessible plain text, navigational actions, image fallback and dismissal", async () => { + const div = document.createElement("div"); document.body.append(div); + const root = createRoot(div); + const dismiss = vi.fn(); + await act(async () => root.render(hello" }} onDismiss={dismiss} />)); + expect(div.querySelector("script")).toBeNull(); + expect(div.querySelector("h2")?.textContent).toBe(""); + expect(div.querySelector('[role="region"]')?.getAttribute("aria-labelledby")).toBe(div.querySelector("h2")?.id); + expect(div.querySelector('a[href="https://paperclip.ing"]')?.getAttribute("rel")).toContain("noreferrer"); + expect(div.querySelector('a[href="/projects"]')).not.toBeNull(); + await act(async () => div.querySelector("img")!.dispatchEvent(new Event("error"))); + expect(div.querySelector("img")).toBeNull(); + await act(async () => div.querySelector("button")!.click()); + expect(dismiss).toHaveBeenCalledTimes(1); + await act(async () => div.querySelector('[role="region"]')!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))); + expect(dismiss).toHaveBeenCalledTimes(2); + for (const link of div.querySelectorAll("a")) { + await act(async () => link.dispatchEvent(new MouseEvent("auxclick", { button: 1, bubbles: true }))); + } + expect(dismiss).toHaveBeenCalledTimes(4); + await act(async () => div.querySelector("a")!.dispatchEvent(new MouseEvent("auxclick", { button: 2, bubbles: true }))); + expect(dismiss).toHaveBeenCalledTimes(4); // Opening a context menu is not navigation. + await act(async () => root.unmount()); div.remove(); + }); +}); diff --git a/ui/src/components/AnnouncementCard.tsx b/ui/src/components/AnnouncementCard.tsx new file mode 100644 index 0000000000..e50cab1e5e --- /dev/null +++ b/ui/src/components/AnnouncementCard.tsx @@ -0,0 +1,66 @@ +import { useId, useState, type MouseEvent } from "react"; +import { ArrowUpRight, X } from "lucide-react"; +import type { Announcement, AnnouncementAction } from "@paperclipai/shared"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { useAnnouncementAnimation } from "@/hooks/useAnnouncementAnimation"; + +export interface AnnouncementCardProps { + announcement: Announcement; + onDismiss: () => void; + imageSrc?: string; + animationSrc?: string; + className?: string; +} + +function Action({ action, primary, onClick }: { action: AnnouncementAction; primary?: boolean; onClick: () => void }) { + const content = <>{action.label}{!primary && action.kind === "external" &&