mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Cloud serves each tenant through a browser session and an HttpOnly cookie. > - A parked tenant tab can outlive that tenant session. > - The active SPA then receives a tenant-session 401 from its API calls and shows the internal error code. > - A page reload already enters the secure Cloud document and OIDC handoff and keeps the requested tenant route. > - This pull request detects only the two Cloud tenant-session 401 codes and starts that existing handoff once. > - The benefit is that an expired tenant session recovers without exposing tokens or showing temporary API errors. ## Linked Issues or Issue Description **What happened?** A Paperclip Cloud tenant tab can stay open after its HttpOnly tenant session expires. The next API request returns `401 tenant_session_required` or `401 tenant_session_invalid`. The SPA shows the internal error code in the full page or in sidebar data consumers. A manual page refresh clears the error. **Expected behavior** The tenant tab must enter the existing Cloud session handoff when an API request reports an expired tenant session. The handoff must keep the current route and query. The UI must not show the internal tenant-session error code. **Steps to reproduce** 1. Open a Paperclip Cloud tenant route. 2. Keep the SPA open until the tenant session expires. 3. Let the page make an API request. 4. Observe the tenant-session 401 in the page or sidebar. 5. Refresh the page and observe that the existing Cloud handoff restores the session. **Paperclip version or commit** The problem reproduces on `master` at commit `b5f862376`. **Deployment mode** Paperclip Cloud tenant deployment. ## What Changed - Added one tenant-session recovery coordinator for exact top-level Cloud error codes. - Reloaded the top-level document once and shared one pending promise across concurrent failures. - Applied recovery before normal error handling in the shared API client, auth API, and health API. - Applied the same recovery to direct audit CSV exports and provider trace downloads. - Preserved ordinary self-hosted 401 behavior and avoided automatic mutation replay. - Added tests for exact detection, concurrent failures, auth-session behavior, health bootstrap, and direct-fetch behavior. ## Verification - `pnpm exec vitest run --config vitest.config.ts src/lib/tenant-session-recovery.test.ts src/api/client.test.ts src/api/auth.test.ts src/api/health.test.ts src/api/heartbeats.test.ts src/api/audit.test.ts` from `ui/` — 30 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — passed. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — the changed UI tests passed, but the full local macOS run also hit existing failures in untouched server worktree and temporary-path tests. - GitHub verification — 30 checks passed, no checks failed, and the Storybook job was intentionally skipped because this PR has no visual changes. - Greptile — 5/5 on `fbba29a2f`, with no open findings. ## Risks - Low risk. Detection requires HTTP 401 and one exact top-level Cloud error code. - The recovery promise intentionally stays pending because document navigation replaces the active SPA. - If the Paperclip ID session has also expired, the existing Cloud sign-in flow remains authoritative. - This change does not modify APIs, cookies, token lifetimes, database state, or Cloud server code. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex. The agent runtime identifies the model as GPT-5. The context-window size is not exposed. Reasoning, repository editing, shell execution, test execution, and GitHub CLI tool use were enabled. ## 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 <noreply@paperclip.ing>
122 lines
4.5 KiB
TypeScript
122 lines
4.5 KiB
TypeScript
import { tenantSessionRecovery } from "@/lib/tenant-session-recovery";
|
|
import { api } from "./client";
|
|
|
|
/**
|
|
* Agent audit API client.
|
|
*
|
|
* Consumes the unified read API shipped by Phase 2c
|
|
* (`server/src/routes/activity.ts` → `services/agent-action-audit.ts`):
|
|
* GET /companies/:companyId/audit/agent-actions
|
|
* The default agent scope is gated server-side by the
|
|
* `audit:view_agent_actions` board permission. The explicit all-actors scope
|
|
* also supports a company-read basic tier with attribution stripped. CSV
|
|
* export streams from the permission-gated sibling `.csv` endpoint, which
|
|
* logs the export action itself.
|
|
*/
|
|
|
|
/** Render-ready entity snippets attached to each row at read time (no N+1). */
|
|
export interface AuditEntitySnippet {
|
|
issue: { id: string; identifier: string | null; title: string | null } | null;
|
|
comment: { id: string; excerpt: string } | null;
|
|
document: { id: string; key: string } | null;
|
|
}
|
|
|
|
/** One enriched `activity_log` row from the agent-action audit feed. */
|
|
export interface AuditActionRecord {
|
|
id: string;
|
|
companyId: string;
|
|
actorType: "agent" | "user" | "system" | "plugin" | null;
|
|
actorId: string | null;
|
|
action: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
responsibleUserId: string | null;
|
|
details: Record<string, unknown> | null;
|
|
createdAt: string;
|
|
entity: AuditEntitySnippet;
|
|
}
|
|
|
|
export interface AuditActionsResponse {
|
|
items: AuditActionRecord[];
|
|
nextCursor: string | null;
|
|
/** Controls whether attribution filters and CSV export are available. */
|
|
accessTier: "basic" | "full";
|
|
}
|
|
|
|
/** Server-side filters for the audit feed. All optional. */
|
|
export interface AuditActionFilters {
|
|
/** Defaults to `agents`; `all` opts into the unified all-actors feed. */
|
|
actorScope?: "agents" | "all";
|
|
agentId?: string | null;
|
|
responsibleUserId?: string | null;
|
|
runId?: string | null;
|
|
entityType?: string | null;
|
|
entityId?: string | null;
|
|
/** Action-domain prefix, e.g. `issue.` or `issue.comment_added`. */
|
|
action?: string | null;
|
|
/** ISO-8601 with offset. */
|
|
from?: string | null;
|
|
to?: string | null;
|
|
actorType?: "agent" | "user" | "system" | "plugin" | null;
|
|
cursor?: string | null;
|
|
limit?: number;
|
|
}
|
|
|
|
function buildAuditQuery(filters: AuditActionFilters): URLSearchParams {
|
|
const search = new URLSearchParams();
|
|
if (filters.actorScope) search.set("actorScope", filters.actorScope);
|
|
if (filters.agentId) search.set("agentId", filters.agentId);
|
|
if (filters.responsibleUserId) search.set("responsibleUserId", filters.responsibleUserId);
|
|
if (filters.runId) search.set("runId", filters.runId);
|
|
if (filters.entityType) search.set("entityType", filters.entityType);
|
|
if (filters.entityId) search.set("entityId", filters.entityId);
|
|
if (filters.action) search.set("action", filters.action);
|
|
if (filters.from) search.set("from", filters.from);
|
|
if (filters.to) search.set("to", filters.to);
|
|
if (filters.actorType) search.set("actorType", filters.actorType);
|
|
if (filters.cursor) search.set("cursor", filters.cursor);
|
|
if (filters.limit != null) search.set("limit", String(filters.limit));
|
|
return search;
|
|
}
|
|
|
|
export const auditApi = {
|
|
/**
|
|
* Cursor-paginated activity/audit feed. The default agent scope throws a
|
|
* 403 without `audit:view_agent_actions`; `actorScope: "all"` returns a
|
|
* redacted basic tier to ordinary company readers.
|
|
*/
|
|
listAgentActions: (companyId: string, filters: AuditActionFilters = {}) => {
|
|
const search = buildAuditQuery(filters);
|
|
const qs = search.toString();
|
|
return api.get<AuditActionsResponse>(
|
|
`/companies/${companyId}/audit/agent-actions${qs ? `?${qs}` : ""}`,
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Fetch the filtered feed as a CSV blob. The server logs an `audit.exported`
|
|
* activity row for the export itself (training-data export precedent).
|
|
*/
|
|
exportAgentActionsCsv: async (
|
|
companyId: string,
|
|
filters: Omit<AuditActionFilters, "cursor" | "limit"> = {},
|
|
): Promise<Blob> => {
|
|
const search = buildAuditQuery(filters);
|
|
const qs = search.toString();
|
|
const res = await fetch(
|
|
`/api/companies/${companyId}/audit/agent-actions.csv${qs ? `?${qs}` : ""}`,
|
|
{ credentials: "include", headers: { Accept: "text/csv" } },
|
|
);
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => null);
|
|
const recovery = tenantSessionRecovery.recoverIfNeeded(res.status, body);
|
|
if (recovery) return recovery;
|
|
const message = (body as { error?: string } | null)?.error ?? `Export failed: ${res.status}`;
|
|
throw new Error(message);
|
|
}
|
|
return res.blob();
|
|
},
|
|
};
|