mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
## Thinking Path > - Paperclip manages work for AI agents and their companies. > - Cloud instances proxy a trusted user's portfolio through the control plane. > - A failed request returns a generic error to the client. > - That replacement error loses the failure phase and network code in Sentry. > - Operators need bounded evidence without upstream messages or credentials. > - This PR adds safe diagnostics while preserving the existing request behavior. ## Linked Issues or Issue Description Refs #10850, which added the portfolio proxy. No open PR for this diagnostic gap was found. **What happened?** A rejected portfolio fetch becomes a generic 502 in Sentry. The event cannot distinguish a connection reset, deadline, HTTP response failure, or body failure. The route's previous warning also included the original error and a stack identifier. **Steps to reproduce** Make the portfolio proxy's fetch reject with a TypeError whose cause has `code: ECONNRESET`. The client correctly receives the generic 502, but the captured replacement error loses that code. **Expected behavior** Keep the existing client response. Attach only bounded server-side diagnostic fields to the failure event. Do not retry the request or expose the original error. ## What Changed - Add a typed portfolio error with a private frozen diagnostic record: phase, upstream HTTP status, elapsed milliseconds, and an allowlisted network code. - Read at most four error/cause objects through own data properties. Unknown codes, messages, getters, and out-of-range values do not enter the record. - Replace the route's raw-error warnings with safe fields. Send a plain error plus event-local context through the existing optional Sentry gate. Keep the route callsite and default fingerprint policy. - Preserve authentication, trusted headers, cookies, exact HTTP error bodies, cache behavior, the ten-second deadline, and one fetch per request. Public responses receive no diagnostic fields. - Document the fields and test HTTP behavior, privacy, and event isolation with the real Sentry SDK. ## Verification - `PAPERCLIP_REQUIRE_SENTRY_TEST_SDK=1 pnpm exec vitest run server/src/__tests__/cloud-portfolio-error.test.ts server/src/__tests__/cloud-routes.test.ts server/src/__tests__/sentry.test.ts server/src/__tests__/run-failure-sentry-real-sdk.test.ts`: 65 passed, with the audited optional SDK installed. - Full local `pnpm -r typecheck` and `pnpm build` passed on Node 24.21.0 and pnpm 9.15.4. - Independent review found no blockers and independently passed all 65 tests, including the real SDK checks, on this exact commit. - Full Linux CI passed on this exact commit and provides aggregate suite coverage (54 successful checks, 2 intentional skips). A duplicate full local aggregate was not run. - The first SDK contract job failed before tests when npm could not resolve an OpenTelemetry transitive package. A subsequent empty-cache install first encountered a missing tarball, then succeeded after the registry artifact became available. All 6 real-SDK tests passed against that fresh install; the single unchanged-head CI retry passed. The SDK pin, workflow, and dependency files are unchanged. - The first browser shard 8 run timed out waiting for the inbox retry reply after 45 seconds. The unchanged isolated case passed (1/1), and the test, UI handler, fixture, and recovery files match the base commit. The failed log contains no wakeup POST before the test's immediate navigation; a navigation/request timing race is suspected but unproven without a trace. The single unchanged-head shard retry passed (20 passed, 1 skipped); no timeout or source change was made. - Greptile reviewed this exact commit at 5/5 with no unresolved review threads. - No live portfolio request was replayed. Route tests use controlled local upstream responses. - The added diff passed the secret and PII scan and `git diff --check`. ## Risks This is a diagnostic change. It does not identify or repair the origin of a connection reset. Unknown transport failures remain `unknown`. Elapsed values outside 0–60,000 ms become null. Default Sentry fingerprinting remains enabled; exact historical group membership is not guaranteed. No retry, migration, deployment, or configuration change is included. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository editing, code execution, and independent agent review. The exact deployment model ID and context window are not exposed in this session. ## 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>
137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
import { Router } from "express";
|
|
import { forbidden, HttpError, notFound } from "../errors.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { CloudPortfolioError, type CloudPortfolioPhase } from "../services/cloud-portfolio-error.js";
|
|
import {
|
|
getCloudStackContext,
|
|
type CloudInstanceEnv,
|
|
} from "../services/cloud-instance.js";
|
|
|
|
const DEFAULT_PORTFOLIO_CACHE_TTL_MS = 30_000;
|
|
const CLOUD_PORTFOLIO_PATH = "/v1/tenant/portfolio";
|
|
|
|
type PortfolioCacheEntry = {
|
|
expiresAt: number;
|
|
payload: unknown;
|
|
};
|
|
|
|
export function cloudRoutes(opts: {
|
|
runtimeEnv?: CloudInstanceEnv;
|
|
fetchImpl?: typeof fetch;
|
|
now?: () => number;
|
|
cacheTtlMs?: number;
|
|
} = {}) {
|
|
const router = Router();
|
|
const runtimeEnv = opts.runtimeEnv ?? process.env;
|
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
const now = opts.now ?? Date.now;
|
|
const cacheTtlMs = opts.cacheTtlMs ?? DEFAULT_PORTFOLIO_CACHE_TTL_MS;
|
|
const portfolioCache = new Map<string, PortfolioCacheEntry>();
|
|
|
|
router.get("/stacks", async (req, res) => {
|
|
const context = getCloudStackContext(runtimeEnv);
|
|
if (!context) {
|
|
throw notFound("Cloud stack portfolio is unavailable");
|
|
}
|
|
if (req.actor.source !== "cloud_tenant" || !req.actor.userId?.trim()) {
|
|
throw forbidden("Trusted Cloud tenant access required", {
|
|
code: "cloud_tenant_required",
|
|
});
|
|
}
|
|
|
|
const tenantToken = runtimeEnv.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim();
|
|
if (!context.cloudOrigin || !context.stackId || !tenantToken) {
|
|
throw new HttpError(503, "Paperclip Cloud portfolio is not configured", {
|
|
code: "cloud_portfolio_not_configured",
|
|
});
|
|
}
|
|
|
|
const actorUserId = req.actor.userId.trim();
|
|
const cacheKey = `${context.cloudOrigin}\n${context.stackId}\n${actorUserId}`;
|
|
const currentTime = now();
|
|
for (const [key, entry] of portfolioCache) {
|
|
if (entry.expiresAt <= currentTime) portfolioCache.delete(key);
|
|
}
|
|
const cached = portfolioCache.get(cacheKey);
|
|
if (cached) {
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.json(cached.payload);
|
|
return;
|
|
}
|
|
|
|
let portfolioUrl: URL;
|
|
try {
|
|
portfolioUrl = new URL(CLOUD_PORTFOLIO_PATH, context.cloudOrigin);
|
|
} catch {
|
|
throw new HttpError(503, "Paperclip Cloud portfolio is not configured", {
|
|
code: "cloud_portfolio_not_configured",
|
|
});
|
|
}
|
|
|
|
const startedAt = performance.now();
|
|
let phase: CloudPortfolioPhase = "fetch";
|
|
let upstreamStatus: number | null = null;
|
|
let upstreamSignal: AbortSignal | undefined;
|
|
const portfolioError = (kind: "upstream" | "invalid_response", error?: unknown) => {
|
|
const reportableError = new CloudPortfolioError(kind, {
|
|
phase,
|
|
elapsedMs: performance.now() - startedAt,
|
|
upstreamStatus,
|
|
deadlineExceeded: upstreamSignal?.aborted === true,
|
|
}, error);
|
|
// Preserve the route callsite instead of grouping by this helper frame.
|
|
Error.captureStackTrace?.(reportableError, portfolioError);
|
|
return reportableError;
|
|
};
|
|
|
|
try {
|
|
upstreamSignal = AbortSignal.timeout(10_000);
|
|
const upstream = await fetchImpl(portfolioUrl, {
|
|
method: "GET",
|
|
headers: {
|
|
accept: "application/json",
|
|
authorization: `Bearer ${tenantToken}`,
|
|
"x-paperclip-cloud-user-id": actorUserId,
|
|
"x-paperclip-cloud-stack-id": context.stackId,
|
|
},
|
|
signal: upstreamSignal,
|
|
});
|
|
phase = "http_response";
|
|
upstreamStatus = upstream.status;
|
|
if (!upstream.ok) {
|
|
const error = portfolioError("upstream");
|
|
logger.warn(
|
|
{ cloudPortfolio: error.diagnostics },
|
|
"Paperclip Cloud portfolio request failed",
|
|
);
|
|
throw error;
|
|
}
|
|
|
|
phase = "response_body";
|
|
let payload: unknown;
|
|
try {
|
|
payload = await upstream.json();
|
|
} catch (error) {
|
|
throw portfolioError("invalid_response", error);
|
|
}
|
|
phase = "response_write";
|
|
portfolioCache.set(cacheKey, {
|
|
expiresAt: currentTime + cacheTtlMs,
|
|
payload,
|
|
});
|
|
res.setHeader("Cache-Control", "no-store");
|
|
res.json(payload);
|
|
} catch (error) {
|
|
if (error instanceof HttpError) throw error;
|
|
const reportableError = portfolioError("upstream", error);
|
|
logger.warn(
|
|
{ cloudPortfolio: reportableError.diagnostics },
|
|
"Paperclip Cloud portfolio request failed",
|
|
);
|
|
throw reportableError;
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|