mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
## 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>
123 lines
4.9 KiB
TypeScript
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 },
|
|
};
|
|
}
|