Files
PaperClipAI/server/src/peer-version-check.ts
T
Nicky LeachandPaperclip 1de105c475 fix(observability): pin the Sentry browser SDK and gate the optional Sentry server peer on the exact version (#12270)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip uses separate server and browser packages for runtime
services and the board.
> - Sentry integrations need an exact SDK version and safe optional
loading.
> - A version range can select an SDK that the privacy tests did not
audit.
> - Missing peer metadata does not describe the optional server SDK
contract.
> - This pull request pins the browser SDK and gates the optional server
SDK on its exact version.
> - The benefit is a clear SDK contract with fail-open startup behavior.

## Linked Issues or Issue Description

**What happened?**

The browser package used the range ^10.71.0, so a lockfile refresh could
select a newer SDK. The server loaded @sentry/node dynamically but did
not declare its optional peer contract.

**Expected behavior**

The browser package must use the audited 10.71.0 version. The server
must load @sentry/node only when the installed peer matches 10.71.0. The
server must start when the optional peer is absent.

**Steps to reproduce**

1. Install the project dependencies.
2. Inspect the browser Sentry version and the server package metadata.
3. Start the server without installing @sentry/node.
4. Confirm that the server starts and that the dynamic Sentry bootstrap
does not load an unsupported peer version.

**Paperclip version or commit**

9c57c0f119

**Deployment mode**

Built from source with pnpm dev or pnpm build.

**Installation method**

Built from source.

**Agent adapter(s) involved**

Not adapter-specific (core change).

**Database mode**

Not database-related.

## What Changed

- Pin @sentry/browser to exactly 10.71.0 as a UI development dependency.
- Declare @sentry/node as an optional server peer dependency at 10.71.0.
- Gate the dynamic server bootstrap on the exact peer version.
- Add tests for the browser pin, peer metadata, version gate, and
fail-open loading.
- Document the supported server SDK version.
- Keep the lockfile unchanged because the pull request workflow
regenerates it for manifest changes.

## Verification

- Server tests pass with six expected skips when @sentry/node is absent.
- UI tests pass.
- The UI build emits the lazy Sentry browser chunk.
- git diff --check passes.
- GitHub pull request checks must pass after this pull request opens.
- Greptile must return a 5/5 score with no open findings.

## Risks

The exact version gate prevents Sentry startup when an unsupported SDK
version exists. The integration remains optional and fail-open. The
lockfile workflow must regenerate the lockfile before frozen downstream
jobs run. The label-gated Storybook visual job must not run until it can
restore the generated lockfile artifact.

## Model Used

OpenAI Codex, GPT-5, tool use and code review support, exact context
window details are managed by the execution platform.

## 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
- [x] My branch name describes the change 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>
2026-08-27 07:20:03 -07:00

123 lines
4.9 KiB
TypeScript

// Exact-version peer-dependency gate, shared by every optional-SDK bootstrap
// (OpenTelemetry in `instrumentation.ts`, Sentry in `sentry.ts`).
//
// This module has no module-init side effect — it only defines functions. A
// bootstrap module imports it and calls `checkExactPeerVersions` itself. That
// matters for `sentry.ts`: a direct import of `instrumentation.ts` would run
// the OpenTelemetry bootstrap (`instrumentationReady`) as a side effect of
// loading the Sentry gate, which this module avoids.
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
/**
* Read this package's own `peerDependencies`, so the exact-version gate
* compares an installed package against the same version this manifest
* declares — one source of truth, not a second hardcoded copy. Returns an
* empty map on any read or parse failure (fail open: an unreadable manifest
* skips the version check rather than blocking startup).
*/
function readOwnPeerDependencies(): Record<string, string> {
try {
const pkgUrl = new URL("../package.json", import.meta.url);
const raw = readFileSync(pkgUrl, "utf8");
const parsed = JSON.parse(raw) as { peerDependencies?: Record<string, string> };
return parsed.peerDependencies ?? {};
} catch {
return {};
}
}
/**
* Read an installed package's own declared `version`, without importing or
* executing the package. Resolves the package's main entry point (which
* respects its `exports` map) and then walks up the filesystem to the
* nearest `package.json` whose `name` matches — a direct
* `require.resolve(\`${packageName}/package.json\`)` throws for a package
* whose `exports` map does not expose `./package.json` as a subpath, which
* several `@opentelemetry/*` packages do not, even though the package is
* correctly installed. Returns null when the package cannot be resolved or no
* matching `package.json` is found.
*/
function readInstalledPackageVersion(packageName: string): string | null {
try {
const require = createRequire(import.meta.url);
let dir = dirname(require.resolve(packageName));
for (;;) {
const candidate = join(dir, "package.json");
if (existsSync(candidate)) {
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
name?: unknown;
version?: unknown;
};
if (parsed.name === packageName) {
return typeof parsed.version === "string" ? parsed.version : null;
}
}
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
} catch {
return null;
}
}
/**
* Verify that every package in `packageNames` is installed at the exact
* version `peerDependencies` declares. The caller passes only the packages it
* needs checked — the OpenTelemetry bootstrap passes the four common packages
* plus the one exporter `OTEL_EXPORTER_OTLP_PROTOCOL` selected, and the
* Sentry gate passes `["@sentry/node"]`. Never throws: a missing manifest, a
* missing package, or an unreadable `package.json` all resolve to a reported
* issue, not an exception.
*
* `peerDependencies` defaults to this manifest's own declared versions
* (`readOwnPeerDependencies()`), which is what each bootstrap uses. A test
* passes an explicit map instead, so it can check the comparison logic
* against a package it controls without writing into `node_modules`.
*
* The returned `diagnostic` string names the OpenTelemetry endpoint variable,
* because the OpenTelemetry bootstrap logs it directly. The Sentry gate reads
* `detail` instead and builds its own diagnostic line — see `sentry.ts`.
*/
export function checkExactPeerVersions(
packageNames: readonly string[],
peerDependencies: Record<string, string> = readOwnPeerDependencies(),
): { ok: true } | { ok: false; diagnostic: string; detail: unknown } {
const missing: string[] = [];
const mismatched: { name: string; installed: string; expected: string }[] = [];
for (const name of packageNames) {
const expected = peerDependencies[name];
const installed = readInstalledPackageVersion(name);
if (installed === null) {
missing.push(name);
} else if (expected && installed !== expected) {
mismatched.push({ name, installed, expected });
}
}
if (missing.length === 0 && mismatched.length === 0) return { ok: true };
const parts: string[] = [];
if (missing.length > 0) {
parts.push(`the @opentelemetry/* packages are not installed: ${missing.join(", ")}`);
}
if (mismatched.length > 0) {
const detail = mismatched
.map((m) => `${m.name}@${m.installed} (expected ${m.expected})`)
.join(", ");
parts.push(`a package is installed at an unsupported version: ${detail}`);
}
return {
ok: false,
diagnostic:
`[paperclip] OTEL_EXPORTER_OTLP_ENDPOINT is set but ${parts.join("; and ")}. ` +
"Continuing without tracing.",
detail: { missing, mismatched },
};
}