Files
paperclip/scripts/assert-cloud-image-sentry.mjs
Nicky LeachandPaperclip 7895f7f2b0 Install the declared Sentry server package into the hosted image (#12330)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip supports opt-in Sentry error monitoring for server and
browser errors.
> - The hosted image must include the server package when an operator
sets SENTRY_DSN.
> - The server package is an optional peer in the source tree, so the
image did not include it.
> - This pull request installs the declared server package in the hosted
image and checks the result.
> - The benefit is a hosted tenant can send server errors without a
manual package install.

## Linked Issues or Issue Description

No public issue exists for this change.

**What happened?**

The hosted image did not include the declared @sentry/node server
package. A hosted tenant could set SENTRY_DSN, but the server could not
load the package from the image.

**Expected behavior**

The hosted image must include the exact @sentry/node version from
server/package.json. The self-hosted image must remain without this
optional package.

**Steps to reproduce**

1. Build or pull the hosted image.
2. Resolve @sentry/node from the server package path.
3. Compare its version with server/package.json.
4. Confirm that the tsx loader path still resolves.

**Paperclip version or commit**

Commit b6ff556a33ebdbe764b7f495951cd59009776608.

**Deployment mode**

Docker hosted image.

## What Changed

- Add a cloud-server-deps Docker stage that installs the declared
@sentry/node version in isolation.
- Copy the isolated package into the cloud image without changing the
production image.
- Add a probe that checks the tsx loader and the resolved Sentry
version.
- Run the probe after the hosted image push in the Docker workflow.
- Add server tests and update the observability documentation.

## Verification

- Run `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/cloud-image-sentry.test.ts`.
- Confirm that the changed test passes in CI.
- Confirm that all pull request checks pass.
- Note that the Docker workflow does not run for pull requests. It runs
after a push to master, for configured tags, or after manual dispatch.

## Risks

- Low risk. The production image body stays unchanged.
- The cloud image adds the declared Sentry package and a small
dependency tree.
- The workflow probe fails if the image loses the tsx loader or resolves
a different Sentry version.

## Model Used

OpenAI GPT-5; exact model version supplied by the execution service;
tool use and code execution; context window not specified.

## 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>
2026-08-27 19:10:49 -07:00

68 lines
2.9 KiB
JavaScript

#!/usr/bin/env node
// Prove two things about the target image's server directory, in order:
//
// 1. The production `CMD` can still find its ECMAScript module loader,
// `server/node_modules/tsx/dist/loader.mjs`. That path is a symbolic
// link into the workspace pnpm store, and the `cloud` stage's Sentry
// copy writes into the same `server/node_modules` directory. A copy
// that removes or shadows the link stops the container from booting.
// 2. The installed `@sentry/node` package resolves, the same way the
// server's own peer-version gate does
// (server/src/peer-version-check.ts). Then print its version.
//
// Exit non-zero, with a clear message on standard error, when either check
// fails. Print only the version string on standard output on success.
//
// Mount this file at a path inside the target image's server directory and
// run it there with `node`, so module resolution walks the same
// `node_modules` tree the running server itself resolves from:
//
// docker run --rm \
// -v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \
// --entrypoint node <image> /app/server/.ci-sentry-probe.mjs
import { createRequire } from "node:module";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const serverDir = dirname(fileURLToPath(import.meta.url));
// The production `CMD` boots the server through this exact path, resolved
// as a plain relative file path from the container's `/app` working
// directory (`./server/node_modules/tsx/dist/loader.mjs`), so it bypasses
// package-export checks and only needs the file to exist once symbolic
// links resolve. Follow the link the same way Node's own module loader
// does, so a broken or missing link fails this probe before it fails a
// live container.
const tsxLoaderPath = join(serverDir, "node_modules", "tsx", "dist", "loader.mjs");
try {
realpathSync(tsxLoaderPath);
} catch (error) {
console.error(
`could not resolve ${tsxLoaderPath}: the production CMD boots through this path and the server cannot start without it (${error.message})`,
);
process.exit(1);
}
// Proves the ECMAScript import path resolves. `require.resolve` below
// checks the CommonJS path; the server needs both to succeed.
await import("@sentry/node");
const require = createRequire(import.meta.url);
let dir = dirname(require.resolve("@sentry/node"));
for (;;) {
const candidate = join(dir, "package.json");
if (existsSync(candidate)) {
const parsed = JSON.parse(readFileSync(candidate, "utf8"));
if (parsed.name === "@sentry/node") {
process.stdout.write(parsed.version);
process.exit(0);
}
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
console.error("could not resolve the installed @sentry/node package");
process.exit(1);