mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
Merge origin/master, fix a pre-bind transport-close discriminator drop
Resolve the merge conflict in execution-target-stdin-race.test.ts: keep both the probe-hardening tests from master (T20, renumbered around) and this branch's lease tests, renumbered T26-T36 so no two tests share a label. Fix a real bug the merge exposed: an exit notification that arrives before a duplex channel route binds goes into the single pre-bind exit hold, but the hold recorded only exitCode, not the transportClosed discriminator. The replay after the bind then reported a plain exit instead of a transport close. Carry transportClosed through the hold and its replay, and add a deterministic regression test that batches the exit with the open reply to force the pre-bind path. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
15 files changed
+978
-1291
No files matched your search
@@ -14,6 +14,10 @@ updates:
|
||||
open-pull-requests-limit: 20
|
||||
labels:
|
||||
- "dependencies"
|
||||
# Dependabot's npm parser reads only dependencies, devDependencies, and
|
||||
# optionalDependencies — never peerDependencies. It cannot see the
|
||||
# optional OpenTelemetry peer dependencies in server/package.json, so it
|
||||
# never bumps their declared versions.
|
||||
ignore:
|
||||
# @types/node describes the APIs available in the supported Node runtime.
|
||||
# Runtime major upgrades are deliberate compatibility changes, so keep
|
||||
|
||||
@@ -467,7 +467,7 @@ Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-p
|
||||
|
||||
## Observability
|
||||
|
||||
Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (traces only). It activates when `OTEL_EXPORTER_OTLP_ENDPOINT` is set and supports `grpc`, `http/protobuf`, and `http/json` via the standard `OTEL_EXPORTER_OTLP_PROTOCOL` env var. The `@opentelemetry/*` packages are optional peer dependencies — install them only if you want tracing. See [doc/observability.md](doc/observability.md) for install commands and the full env-var reference.
|
||||
Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (traces only). It activates when `OTEL_EXPORTER_OTLP_ENDPOINT` is set and supports `grpc`, `http/protobuf`, and `http/json` via the standard `OTEL_EXPORTER_OTLP_PROTOCOL` env var. `@opentelemetry/api` is a normal server dependency; the SDK, auto-instrumentation, and exporter packages are optional peer dependencies — install them only if you want tracing. See [doc/observability.md](doc/observability.md) for install commands and the full env-var reference.
|
||||
|
||||
Paperclip also ships with opt-in Sentry error monitoring for the server and the browser. Set `SENTRY_DSN` to activate it — the server and the browser then report to the same Sentry project. `@sentry/node` is an optional peer dependency for the server; install it only if you want error monitoring. See [doc/observability.md](doc/observability.md#sentry-error-monitoring) for the install command, the privacy settings, and the full default capture set.
|
||||
|
||||
|
||||
+40
-18
@@ -8,42 +8,62 @@ separate first-party event system.
|
||||
|
||||
Paperclip ships with **opt-in** OpenTelemetry auto-instrumentation for the
|
||||
server process. When activated it produces **traces only** — no metrics and no
|
||||
logs are exported by this integration. The OTel packages are *optional peer
|
||||
dependencies*: they are not in the default lockfile and are loaded dynamically
|
||||
only when an operator turns the feature on.
|
||||
logs are exported by this integration.
|
||||
|
||||
When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, none of the `@opentelemetry/*`
|
||||
`@opentelemetry/api` is a normal dependency of `@paperclipai/server`. Every
|
||||
install includes it. It stays a no-op interface until an SDK registers a
|
||||
provider, so it exports no telemetry by itself.
|
||||
|
||||
The SDK, the auto-instrumentation bundle, and the resources and
|
||||
semantic-conventions helpers are *optional peer dependencies*: they are not in
|
||||
the default lockfile, and the server loads them dynamically only when an
|
||||
operator turns the feature on. The three exporters below are mutually
|
||||
alternative peer dependencies — install exactly **one**, matching
|
||||
`OTEL_EXPORTER_OTLP_PROTOCOL`.
|
||||
|
||||
When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset, none of the `@opentelemetry/*` SDK
|
||||
packages are imported and there is zero runtime overhead.
|
||||
|
||||
`server/package.json` declares each optional package at the exact version the
|
||||
server tests against; install that exact version. Our Dependabot cannot bump
|
||||
these versions: its npm parser reads only `dependencies`, `devDependencies`,
|
||||
and `optionalDependencies`, never `peerDependencies`. A peer version here is a
|
||||
compatibility claim, not an installed version, so raising it is a human
|
||||
decision. `@opentelemetry/api` is the one OpenTelemetry package Paperclip
|
||||
maintains as a dependency; once you install the packages below, they become
|
||||
normal dependencies of **your own** project, and your own Dependabot updates
|
||||
them.
|
||||
|
||||
## Enabling tracing
|
||||
|
||||
### 1. Install the OTel peer dependencies
|
||||
|
||||
Install the SDK, the auto-instrumentations bundle, the resources/semconv
|
||||
helpers, and **one** exporter matching your chosen OTLP protocol.
|
||||
helpers, and **one** exporter matching your chosen OTLP protocol, at the exact
|
||||
versions below.
|
||||
|
||||
Common to every protocol:
|
||||
|
||||
```bash
|
||||
pnpm add \
|
||||
@opentelemetry/sdk-node \
|
||||
@opentelemetry/auto-instrumentations-node \
|
||||
@opentelemetry/resources \
|
||||
@opentelemetry/semantic-conventions
|
||||
@opentelemetry/sdk-node@0.221.0 \
|
||||
@opentelemetry/auto-instrumentations-node@0.79.0 \
|
||||
@opentelemetry/resources@2.10.0 \
|
||||
@opentelemetry/semantic-conventions@1.43.0
|
||||
```
|
||||
|
||||
Then add the exporter for the protocol you intend to use:
|
||||
|
||||
| `OTEL_EXPORTER_OTLP_PROTOCOL` | Exporter package |
|
||||
| ----------------------------- | --------------------------------------------- |
|
||||
| `grpc` (default if unset) | `@opentelemetry/exporter-trace-otlp-grpc` |
|
||||
| `http/protobuf` | `@opentelemetry/exporter-trace-otlp-proto` |
|
||||
| `http/json` | `@opentelemetry/exporter-trace-otlp-http` |
|
||||
| `OTEL_EXPORTER_OTLP_PROTOCOL` | Exporter package | Version |
|
||||
| ------------------------------ | ------------------------------------------- | --------- |
|
||||
| `grpc` (default if unset) | `@opentelemetry/exporter-trace-otlp-grpc` | `0.221.0` |
|
||||
| `http/protobuf` | `@opentelemetry/exporter-trace-otlp-proto` | `0.221.0` |
|
||||
| `http/json` | `@opentelemetry/exporter-trace-otlp-http` | `0.221.0` |
|
||||
|
||||
For example, for the default gRPC path:
|
||||
|
||||
```bash
|
||||
pnpm add @opentelemetry/exporter-trace-otlp-grpc
|
||||
pnpm add @opentelemetry/exporter-trace-otlp-grpc@0.221.0
|
||||
```
|
||||
|
||||
### 2. Set the environment
|
||||
@@ -94,9 +114,11 @@ can confirm the value.
|
||||
If `OTEL_EXPORTER_OTLP_PROTOCOL` is set to an unrecognized value, Paperclip
|
||||
logs a single warning and falls back to gRPC.
|
||||
|
||||
If `OTEL_EXPORTER_OTLP_ENDPOINT` is set but the OTel packages are not
|
||||
installed, the server logs a single diagnostic line on boot and continues
|
||||
without tracing — your server stays up.
|
||||
Before it imports any OTel package, the server checks the four common
|
||||
packages and the selected exporter against the exact versions
|
||||
`server/package.json` declares. If `OTEL_EXPORTER_OTLP_ENDPOINT` is set and a
|
||||
package is missing or installed at a different version, the server logs one
|
||||
diagnostic line on boot and continues without tracing — your server stays up.
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFile as execFileCallback, spawn } from "node:child_process";
|
||||
import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { symlinkSync } from "node:fs";
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -745,6 +746,109 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
return fakeBirthtimePreloadPath;
|
||||
}
|
||||
|
||||
// A test-only preload for PAP-5355: it deterministically simulates a
|
||||
// same-sandbox peer that wins the gap between the wrapper's final identity
|
||||
// check and its removal call. nextProbeFileName() is deterministic (pid +
|
||||
// call sequence), so this preload can compute the exact probe path the
|
||||
// wrapper itself will check next. It patches fs.promises.lstat inside the
|
||||
// wrapper's own process: the first time that call targets the expected
|
||||
// probe path, it replaces the path with a peer-owned entry before the real
|
||||
// lstat runs, so the wrapper observes the swapped entry's identity, not its
|
||||
// own. This is the worst case for the wrapper (the swap always lands
|
||||
// before the wrapper's very last look at the path), so a wrapper that
|
||||
// still leaves the peer's entry untouched under this preload proves the
|
||||
// fix for every less-adversarial timing too. It never runs unless a test
|
||||
// opts in, and it never touches this test file's own process.
|
||||
let probeSwapPreloadDir: string | null = null;
|
||||
afterAll(async () => {
|
||||
if (probeSwapPreloadDir) await rm(probeSwapPreloadDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
let probeSwapPreloadPath: Promise<string> | null = null;
|
||||
async function getProbeSwapPreloadPath(): Promise<string> {
|
||||
if (!probeSwapPreloadPath) {
|
||||
probeSwapPreloadPath = (async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-preload-"));
|
||||
probeSwapPreloadDir = dir;
|
||||
const preloadPath = path.join(dir, "probe-swap-preload.cjs");
|
||||
await writeFile(
|
||||
preloadPath,
|
||||
[
|
||||
`const fs = require("fs");`,
|
||||
`const path = require("path");`,
|
||||
`const mode = process.env.PAPERCLIP_TEST_PROBE_SWAP_MODE;`,
|
||||
`const seq = process.env.PAPERCLIP_TEST_PROBE_SWAP_SEQ;`,
|
||||
`const symlinkTarget = process.env.PAPERCLIP_TEST_PROBE_SWAP_SYMLINK_TARGET;`,
|
||||
`if (mode && seq) {`,
|
||||
` const expectedName = ".paperclip-birthtime-probe-" + process.pid + "-" + seq;`,
|
||||
` let swapped = false;`,
|
||||
` const originalLstat = fs.promises.lstat.bind(fs.promises);`,
|
||||
` fs.promises.lstat = async (candidatePath, opts) => {`,
|
||||
` if (!swapped && path.basename(String(candidatePath)) === expectedName) {`,
|
||||
` swapped = true;`,
|
||||
` try { fs.unlinkSync(candidatePath); } catch {}`,
|
||||
` if (mode === "file") fs.writeFileSync(candidatePath, "peer-owned-content");`,
|
||||
` else if (mode === "dir") fs.mkdirSync(candidatePath);`,
|
||||
` else if (mode === "symlink") fs.symlinkSync(symlinkTarget, candidatePath);`,
|
||||
` }`,
|
||||
` return originalLstat(candidatePath, opts);`,
|
||||
` };`,
|
||||
`}`,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return preloadPath;
|
||||
})();
|
||||
}
|
||||
return probeSwapPreloadPath;
|
||||
}
|
||||
|
||||
// A test-only preload for PAP-5374: it simulates fstat() failing on the
|
||||
// wrapper's own just-opened probe file descriptor, the one signal the real
|
||||
// filesystem in this sandbox never produces on demand. nextProbeFileName()
|
||||
// is deterministic (pid + call sequence), so this preload knows which
|
||||
// fs.promises.open() call is the wrapper's probe write and patches only the
|
||||
// FileHandle that call returns, leaving every other open() untouched. It
|
||||
// never runs unless a test opts in, and it never touches this test file's
|
||||
// own process.
|
||||
let fstatFailurePreloadDir: string | null = null;
|
||||
afterAll(async () => {
|
||||
if (fstatFailurePreloadDir) await rm(fstatFailurePreloadDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
let fstatFailurePreloadPath: Promise<string> | null = null;
|
||||
async function getFstatFailurePreloadPath(): Promise<string> {
|
||||
if (!fstatFailurePreloadPath) {
|
||||
fstatFailurePreloadPath = (async () => {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-fstat-failure-preload-"));
|
||||
fstatFailurePreloadDir = dir;
|
||||
const preloadPath = path.join(dir, "fstat-failure-preload.cjs");
|
||||
await writeFile(
|
||||
preloadPath,
|
||||
[
|
||||
`const fs = require("fs");`,
|
||||
`const path = require("path");`,
|
||||
`const seq = process.env.PAPERCLIP_TEST_FSTAT_FAILURE_SEQ;`,
|
||||
`if (seq) {`,
|
||||
` const expectedName = ".paperclip-birthtime-probe-" + process.pid + "-" + seq;`,
|
||||
` const originalOpen = fs.promises.open.bind(fs.promises);`,
|
||||
` fs.promises.open = async (targetPath, flags, mode) => {`,
|
||||
` const handle = await originalOpen(targetPath, flags, mode);`,
|
||||
` if (path.basename(String(targetPath)) === expectedName) {`,
|
||||
` handle.stat = async () => {`,
|
||||
` throw new Error("EIO: simulated fstat failure for test");`,
|
||||
` };`,
|
||||
` }`,
|
||||
` return handle;`,
|
||||
` };`,
|
||||
`}`,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
return preloadPath;
|
||||
})();
|
||||
}
|
||||
return fstatFailurePreloadPath;
|
||||
}
|
||||
|
||||
// Run the real emitted wrapper (either variant) as a node process, with no
|
||||
// sandbox and no bridge in front of it. The test owns the wrapper's node
|
||||
// ChildProcess handle directly, so it can observe the wrapper's own exit
|
||||
@@ -770,6 +874,15 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
// Used to drive the lease timing overrides directly, with no host bridge
|
||||
// in front of the wrapper.
|
||||
env?: Record<string, string>;
|
||||
// Makes the wrapper's own process observe a same-sandbox peer replacing
|
||||
// its birth-time probe file, through the preload above (PAP-5355). seq 1
|
||||
// is sessionDir's probe (the first one captureSessionIdentity() runs).
|
||||
probeSwap?: { seq: 1 | 2; mode: "file" | "dir" | "symlink"; symlinkTarget?: string };
|
||||
// Makes the wrapper's own process observe an fstat() failure on the open
|
||||
// descriptor for its own birth-time probe file, through the preload above
|
||||
// (PAP-5374). seq 1 is sessionDir's probe (the first one
|
||||
// captureSessionIdentity() runs).
|
||||
fstatFailure?: { seq: 1 | 2 };
|
||||
}) {
|
||||
const sessionDir = await mkdtemp(path.join(options?.parentDir ?? os.tmpdir(), "paperclip-wrapper-lifecycle-"));
|
||||
cleanupDirs.push(sessionDir);
|
||||
@@ -799,6 +912,16 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
env.PAPERCLIP_TEST_FAKE_BIRTHTIME_MODE = options.fakeBirthtime.mode;
|
||||
execArgv.push("--require", await getFakeBirthtimePreloadPath());
|
||||
}
|
||||
if (options?.probeSwap) {
|
||||
env.PAPERCLIP_TEST_PROBE_SWAP_SEQ = String(options.probeSwap.seq);
|
||||
env.PAPERCLIP_TEST_PROBE_SWAP_MODE = options.probeSwap.mode;
|
||||
if (options.probeSwap.symlinkTarget) env.PAPERCLIP_TEST_PROBE_SWAP_SYMLINK_TARGET = options.probeSwap.symlinkTarget;
|
||||
execArgv.push("--require", await getProbeSwapPreloadPath());
|
||||
}
|
||||
if (options?.fstatFailure) {
|
||||
env.PAPERCLIP_TEST_FSTAT_FAILURE_SEQ = String(options.fstatFailure.seq);
|
||||
execArgv.push("--require", await getFstatFailurePreloadPath());
|
||||
}
|
||||
|
||||
const child = spawn(process.execPath, [...execArgv, wrapperPath], {
|
||||
cwd: sessionDir,
|
||||
@@ -852,6 +975,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
|
||||
return {
|
||||
pid: child.pid,
|
||||
sessionDir,
|
||||
stdinDir,
|
||||
eventsDir,
|
||||
@@ -1832,6 +1956,200 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
expect(wrapper.stderrText()).toMatch(/Latching on a lost process session identity/);
|
||||
}, 15_000);
|
||||
|
||||
it("T20 refuses to write through a probe path a sandbox peer pre-created as a symbolic link, and leaves that link and its target untouched", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-symlink-race-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const pidFile = path.join(rootDir, "t20-child.pid");
|
||||
const childPath = path.join(rootDir, "t20-child.mjs");
|
||||
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
|
||||
|
||||
const sessionDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-symlink-session-"));
|
||||
cleanupDirs.push(sessionDir);
|
||||
const stdinDir = path.join(sessionDir, "stdin");
|
||||
await mkdir(stdinDir, { recursive: true });
|
||||
|
||||
const wrapperPath = path.join(sessionDir, "wrapper.mjs");
|
||||
await writeFile(wrapperPath, getProcessSessionRemoteSource({ outputToStdout: true }), "utf8");
|
||||
const config = { command: process.execPath, args: [childPath], cwd: sessionDir, env: {} };
|
||||
const commandPayload = Buffer.from(JSON.stringify(config), "utf8").toString("base64");
|
||||
|
||||
// A file this test owns, standing in for a file a sandbox peer already
|
||||
// controls. The wrapper's probe write must never reach it.
|
||||
const probeLinkTarget = path.join(rootDir, "t20-probe-target.txt");
|
||||
const knownContent = "t20-untouched-content";
|
||||
await writeFile(probeLinkTarget, knownContent, "utf8");
|
||||
|
||||
const child = spawn(process.execPath, [wrapperPath], {
|
||||
cwd: sessionDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
|
||||
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: commandPayload,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// Wins the race to the probe path against the wrapper's own probe write.
|
||||
// nextProbeFileName() is deterministic: it names
|
||||
// ".paperclip-birthtime-probe-<pid>-1" on the wrapper's first probe call,
|
||||
// which always targets sessionDir. child.pid is available synchronously
|
||||
// right after spawn() returns, well before the freshly spawned process
|
||||
// has loaded Node or parsed its own script, so this synchronous
|
||||
// symlinkSync call lands first. This is the same advantage a real
|
||||
// sandbox peer racing to pre-create the path would have, so it gives the
|
||||
// strongest proof: the real wrapper process, under the real race, must
|
||||
// still refuse to follow the link.
|
||||
const probePath = path.join(sessionDir, `.paperclip-birthtime-probe-${child.pid}-1`);
|
||||
symlinkSync(probeLinkTarget, probePath);
|
||||
|
||||
let stderrText = "";
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderrText += chunk.toString("utf8");
|
||||
});
|
||||
const exited = new Promise<void>((resolve) => child.on("close", () => resolve()));
|
||||
|
||||
await Promise.race([
|
||||
exited,
|
||||
delay(8_000).then(() => {
|
||||
throw new Error("The wrapper process did not exit.");
|
||||
}),
|
||||
]);
|
||||
|
||||
await expectNoLiveProcessByArgvSubstring(childPath);
|
||||
expect(stderrText).toMatch(/could not be created exclusively/);
|
||||
expect((await lstat(probePath)).isSymbolicLink()).toBe(true);
|
||||
expect(await readFile(probeLinkTarget, "utf8")).toBe(knownContent);
|
||||
}, 15_000);
|
||||
|
||||
// ---- PAP-5355: identity-aware cleanup after a same-sandbox peer replaces
|
||||
// the probe file this wrapper just created, in the gap between this
|
||||
// wrapper's last identity check and its removal call. The probeSwap
|
||||
// preload (see getProbeSwapPreloadPath above) simulates the worst-case
|
||||
// timing for that gap deterministically, instead of racing real wall-clock
|
||||
// time: it swaps the path the instant the wrapper itself looks at it for
|
||||
// the last time before deciding whether to remove it.
|
||||
|
||||
it("T21 still removes its own probe file normally when no peer ever replaces it", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-no-swap-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const pidFile = path.join(rootDir, "t21-child.pid");
|
||||
const childPath = path.join(rootDir, "t21-child.mjs");
|
||||
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
|
||||
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout: false,
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
});
|
||||
await waitForTrackedChildPid(pidFile);
|
||||
|
||||
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
|
||||
await waitFor(async () => !(await lstat(probePath).then(() => true).catch(() => false)), 4_000);
|
||||
await expect(lstat(probePath)).rejects.toThrow();
|
||||
}, 15_000);
|
||||
|
||||
it("T22 leaves a peer's replacement file untouched instead of deleting it", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-file-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const pidFile = path.join(rootDir, "t22-child.pid");
|
||||
const childPath = path.join(rootDir, "t22-child.mjs");
|
||||
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
|
||||
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout: false,
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
probeSwap: { seq: 1, mode: "file" },
|
||||
});
|
||||
await waitForTrackedChildPid(pidFile);
|
||||
|
||||
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
|
||||
await waitFor(async () => (await readFile(probePath, "utf8").catch(() => null)) === "peer-owned-content", 4_000);
|
||||
// The wrapper's own cleanup call already ran (the preload only swaps the
|
||||
// path the moment the wrapper itself checks it). This delay proves that
|
||||
// run settled and nothing removes the peer's file afterward.
|
||||
await delay(200);
|
||||
expect(await readFile(probePath, "utf8")).toBe("peer-owned-content");
|
||||
}, 15_000);
|
||||
|
||||
it("T23 leaves a peer's replacement directory untouched instead of deleting it", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-dir-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const pidFile = path.join(rootDir, "t23-child.pid");
|
||||
const childPath = path.join(rootDir, "t23-child.mjs");
|
||||
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
|
||||
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout: false,
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
probeSwap: { seq: 1, mode: "dir" },
|
||||
});
|
||||
await waitForTrackedChildPid(pidFile);
|
||||
|
||||
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
|
||||
await waitFor(async () => await lstat(probePath).then((stats) => stats.isDirectory()).catch(() => false), 4_000);
|
||||
await delay(200);
|
||||
expect((await lstat(probePath)).isDirectory()).toBe(true);
|
||||
}, 15_000);
|
||||
|
||||
it("T24 leaves a peer's replacement symbolic link and its target untouched instead of deleting or following it", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-swap-symlink-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const pidFile = path.join(rootDir, "t24-child.pid");
|
||||
const childPath = path.join(rootDir, "t24-child.mjs");
|
||||
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
|
||||
|
||||
const linkTarget = path.join(rootDir, "t24-probe-target.txt");
|
||||
const knownContent = "t24-untouched-content";
|
||||
await writeFile(linkTarget, knownContent, "utf8");
|
||||
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout: false,
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
probeSwap: { seq: 1, mode: "symlink", symlinkTarget: linkTarget },
|
||||
});
|
||||
await waitForTrackedChildPid(pidFile);
|
||||
|
||||
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
|
||||
await waitFor(async () => await lstat(probePath).then((stats) => stats.isSymbolicLink()).catch(() => false), 4_000);
|
||||
await delay(200);
|
||||
expect((await lstat(probePath)).isSymbolicLink()).toBe(true);
|
||||
expect(await readlink(probePath)).toBe(linkTarget);
|
||||
expect(await readFile(linkTarget, "utf8")).toBe(knownContent);
|
||||
}, 15_000);
|
||||
|
||||
it("T25 fails closed at capture when its own probe file's identity cannot be read, so no orphan wrapper or child ever starts polling", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-fstat-failure-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const pidFile = path.join(rootDir, "t25-child.pid");
|
||||
const childPath = path.join(rootDir, "t25-child.mjs");
|
||||
await writeFile(childPath, trackedChildSource(pidFile), "utf8");
|
||||
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout: false,
|
||||
command: process.execPath,
|
||||
args: [childPath],
|
||||
fstatFailure: { seq: 1 },
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
wrapper.exited,
|
||||
delay(8_000).then(() => {
|
||||
throw new Error("The wrapper process did not exit.");
|
||||
}),
|
||||
]);
|
||||
expect(wrapper.stderrText()).toMatch(/its own probe file's identity could not be read/);
|
||||
await expectNoLiveProcessByArgvSubstring(childPath);
|
||||
|
||||
// With no verified identity for the probe file, the wrapper must not
|
||||
// remove it by path alone: it leaves the file exactly as it created it,
|
||||
// rather than risking removal of a different entry a peer may have put
|
||||
// at the same path.
|
||||
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
|
||||
expect((await lstat(probePath)).isFile()).toBe(true);
|
||||
}, 15_000);
|
||||
// ---- PAP-5361: a host-refreshed lease bounds the wrapper's own lifetime,
|
||||
// so a host crash cannot orphan it. The host writes and refreshes
|
||||
// ".paperclip-session-lease" under sessionDir; the wrapper reads only its
|
||||
@@ -1840,7 +2158,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
|
||||
const LEASE_FILE_NAME = ".paperclip-session-lease";
|
||||
|
||||
it("T21 no refresh: the wrapper terminates itself and its child after the lease duration elapses", async () => {
|
||||
it("T26 no refresh: the wrapper terminates itself and its child after the lease duration elapses", async () => {
|
||||
for (const outputToStdout of [false, true]) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-no-refresh-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
@@ -1866,7 +2184,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("T22 a valid rename-based refresh resets the deadline, and the wrapper stays alive past the lease duration", async () => {
|
||||
it("T27 a valid rename-based refresh resets the deadline, and the wrapper stays alive past the lease duration", async () => {
|
||||
for (const outputToStdout of [false, true]) {
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout,
|
||||
@@ -1913,7 +2231,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("T23 a missing lease never resets the deadline, and ordinary stdin traffic does not substitute for it", async () => {
|
||||
it("T28 a missing lease never resets the deadline, and ordinary stdin traffic does not substitute for it", async () => {
|
||||
for (const outputToStdout of [false, true]) {
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout,
|
||||
@@ -1946,7 +2264,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("T24 a symbolic link at the lease path is never followed, its target stays unchanged, and the wrapper expires", async () => {
|
||||
it("T29 a symbolic link at the lease path is never followed, its target stays unchanged, and the wrapper expires", async () => {
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
for (const outputToStdout of [false, true]) {
|
||||
const wrapper = await startWrapperProcess({
|
||||
@@ -1973,7 +2291,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("T25 a directory at the lease path is never treated as a valid lease, and the wrapper expires", async () => {
|
||||
it("T30 a directory at the lease path is never treated as a valid lease, and the wrapper expires", async () => {
|
||||
for (const outputToStdout of [false, true]) {
|
||||
const wrapper = await startWrapperProcess({
|
||||
outputToStdout,
|
||||
@@ -2000,7 +2318,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
// touches the lease path defers this wrapper's expiry. This is not a
|
||||
// capability the peer lacked -- it can already spawn its own long-lived
|
||||
// work -- so a later change must not silently alter this shape.
|
||||
it("T26 a peer that keeps touching the lease file defers this wrapper's expiry", async () => {
|
||||
it("T31 a peer that keeps touching the lease file defers this wrapper's expiry", async () => {
|
||||
const { utimes } = await import("node:fs/promises");
|
||||
for (const outputToStdout of [false, true]) {
|
||||
const wrapper = await startWrapperProcess({
|
||||
@@ -2040,7 +2358,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("T27 each wrapper source has exactly one lease deadline declaration and one lease check call site, and the lease code adds no kill caller or numeric process identifier", () => {
|
||||
it("T32 each wrapper source has exactly one lease deadline declaration and one lease check call site, and the lease code adds no kill caller or numeric process identifier", () => {
|
||||
for (const outputToStdout of [true, false]) {
|
||||
const src = getProcessSessionRemoteSource({ outputToStdout });
|
||||
const code = src
|
||||
@@ -2071,7 +2389,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
});
|
||||
|
||||
it("T28 an injected slow lease refresh in flight during stop() cannot recreate sessionDir after removal", async () => {
|
||||
it("T33 an injected slow lease refresh in flight during stop() cannot recreate sessionDir after removal", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-host-race-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "quiet-child.mjs");
|
||||
@@ -2160,7 +2478,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
expect(stillThere).toBeNull();
|
||||
}, 15_000);
|
||||
|
||||
it("T29 the lease refresh command carries bypassSession and contains no mkdir", async () => {
|
||||
it("T34 the lease refresh command carries bypassSession and contains no mkdir", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-command-shape-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "quiet-child.mjs");
|
||||
@@ -2236,7 +2554,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("T30 validation: a zero, a negative, a non-numeric, and a D<=P lease override pair all fall back to the defaults on the host and in the wrapper", async () => {
|
||||
it("T35 validation: a zero, a negative, a non-numeric, and a D<=P lease override pair all fall back to the defaults on the host and in the wrapper", async () => {
|
||||
const invalidPairs: Array<[string, string]> = [
|
||||
["0", "900000"],
|
||||
["-1000", "900000"],
|
||||
@@ -2320,7 +2638,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
|
||||
]);
|
||||
}, 30_000);
|
||||
|
||||
it("T31 the lease refresh exec carries its own short timeout, never the run's adapter timeout", async () => {
|
||||
it("T36 the lease refresh exec carries its own short timeout, never the run's adapter timeout", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-lease-refresh-timeout-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const childPath = path.join(rootDir, "quiet-child.mjs");
|
||||
|
||||
@@ -2697,31 +2697,104 @@ function nextProbeFileName() {
|
||||
// change-time copy. Creating and removing a file inside a directory changes
|
||||
// that directory's OWN change time but never its true creation time, so a
|
||||
// birthtimeMs that moves across the probe is a change-time copy. Returns
|
||||
// false both on a detected copy and on a probe that cannot run at all (for
|
||||
// example a permission error): either way the caller must not trust the
|
||||
// value.
|
||||
// null when the value is proven real. Returns a stderr-ready reason string
|
||||
// on any failure (a detected copy, or a probe that cannot run at all, for
|
||||
// example a permission error or a pre-created probe path): either way the
|
||||
// caller must not trust the value.
|
||||
//
|
||||
// The open uses the "wx" flag: exclusive create, fail if the path exists.
|
||||
// A sandbox peer cannot pre-create the probe path as a symbolic link and
|
||||
// have this call follow it, because "wx" fails closed on an existing path
|
||||
// instead of following a link to it.
|
||||
//
|
||||
// Cleanup checks identity, not only ownership of the initial create. This
|
||||
// wrapper reads the probe file's identity, (dev, ino, ctimeMs), off the open
|
||||
// file descriptor itself (fstat), not off the path, so a peer that swaps the
|
||||
// path in the short gap after create cannot poison the identity this
|
||||
// wrapper trusts as its own. Right before removal, this wrapper reads the
|
||||
// path's identity again and removes it only when that identity still
|
||||
// matches. A same-sandbox peer that deletes the probe file and creates its
|
||||
// own entry at the same path in between leaves a different identity behind,
|
||||
// so this wrapper leaves that entry untouched instead of removing it. This
|
||||
// covers a peer's replacement file, a peer's replacement directory, and a
|
||||
// peer's replacement symbolic link alike, because all three change the
|
||||
// identity this wrapper reads back. The identity check includes ctimeMs,
|
||||
// not only (dev, ino): a filesystem can hand this call's freed inode number
|
||||
// straight back out to a peer's very next create at the same path, so
|
||||
// (dev, ino) alone can match a path this call no longer owns; ctimeMs resets
|
||||
// on every create, so a peer's replacement carries a different one even when
|
||||
// the inode number repeats. Node's filesystem API has no call that removes a
|
||||
// path only when its identity still matches an earlier read as one atomic
|
||||
// step, so a gap remains between this wrapper's final identity read and the
|
||||
// removal call itself. A peer that wins this gap can put any entry at the
|
||||
// probe path before the removal call runs. This can include a pre-existing
|
||||
// file the peer renames into place, not only a file the peer creates fresh.
|
||||
// The removal call then removes whatever entry sits at the probe path at
|
||||
// that moment. Two bounds still hold on that removal. The path always
|
||||
// stays under dirPath. If the entry is a symbolic link, the removal call
|
||||
// removes the link itself instead of following it to a different target.
|
||||
// A non-recursive removal call also fails if the entry is a directory.
|
||||
async function birthtimeSurvivesProbe(dirPath) {
|
||||
let before;
|
||||
try {
|
||||
before = (await fs.lstat(dirPath)).birthtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
return "its reported creation time could not be read";
|
||||
}
|
||||
const probePath = path.posix.join(dirPath, nextProbeFileName());
|
||||
let handle;
|
||||
try {
|
||||
await fs.writeFile(probePath, "");
|
||||
handle = await fs.open(probePath, "wx");
|
||||
} catch {
|
||||
return false;
|
||||
return "its probe file could not be created exclusively (the path may already exist)";
|
||||
}
|
||||
// fstat on the open handle names the exact inode this call just created.
|
||||
// A path-based lstat here instead would be racy against a peer that swaps
|
||||
// the path in the gap between the create above and the stat: fstat has no
|
||||
// such gap, because a file descriptor keeps naming the inode it opened no
|
||||
// matter what a later swap does to the path.
|
||||
let ownedIdentity = null;
|
||||
try {
|
||||
const createdStats = await handle.stat();
|
||||
// ctimeMs guards against inode reuse; see the function comment above.
|
||||
ownedIdentity = { dev: createdStats.dev, ino: createdStats.ino, ctimeMs: createdStats.ctimeMs };
|
||||
} catch {
|
||||
ownedIdentity = null;
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
if (!ownedIdentity) {
|
||||
// fstat on this call's own just-opened descriptor failed. This call then
|
||||
// has no verified identity for the probe file it created, so it must not
|
||||
// check or remove that file by path: a peer could already own the entry
|
||||
// at that path, and a path-based removal here could delete the peer's
|
||||
// entry instead of this call's own file. Fail closed right here instead
|
||||
// of falling through to the birthtime comparison below, so a failed
|
||||
// identity read can never let this probe report success.
|
||||
return "its own probe file's identity could not be read from the open file descriptor";
|
||||
}
|
||||
// The one gap the fs API cannot close: this lstat and the removal below
|
||||
// are two separate calls, not one atomic "remove if identity still
|
||||
// matches" step. A peer that wins this narrow gap can put any entry at
|
||||
// the probe path, including a pre-existing file it renames into place,
|
||||
// and the removal call below removes whatever entry is there when it
|
||||
// runs.
|
||||
const currentStats = await fs.lstat(probePath).catch(() => null);
|
||||
const stillOwned =
|
||||
currentStats !== null &&
|
||||
currentStats.dev === ownedIdentity.dev &&
|
||||
currentStats.ino === ownedIdentity.ino &&
|
||||
currentStats.ctimeMs === ownedIdentity.ctimeMs;
|
||||
if (stillOwned) {
|
||||
await fs.rm(probePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
let after;
|
||||
try {
|
||||
after = (await fs.lstat(dirPath)).birthtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
return "its reported creation time could not be read";
|
||||
}
|
||||
return before === after;
|
||||
return before === after ? null : "its reported creation time changed after a probe write";
|
||||
}
|
||||
|
||||
async function refuseUnusableCreationTime(label, dirPath, reason) {
|
||||
@@ -2748,12 +2821,14 @@ async function refuseUnusableCreationTime(label, dirPath, reason) {
|
||||
// it, run once here, before either directory's identity is captured.
|
||||
async function captureSessionIdentity() {
|
||||
try {
|
||||
if (!(await birthtimeSurvivesProbe(sessionDir))) {
|
||||
await refuseUnusableCreationTime("sessionDir", sessionDir, "its reported creation time changed after a probe write");
|
||||
const sessionProbeFailure = await birthtimeSurvivesProbe(sessionDir);
|
||||
if (sessionProbeFailure) {
|
||||
await refuseUnusableCreationTime("sessionDir", sessionDir, sessionProbeFailure);
|
||||
return;
|
||||
}
|
||||
if (!(await birthtimeSurvivesProbe(stdinDir))) {
|
||||
await refuseUnusableCreationTime("stdinDir", stdinDir, "its reported creation time changed after a probe write");
|
||||
const stdinProbeFailure = await birthtimeSurvivesProbe(stdinDir);
|
||||
if (stdinProbeFailure) {
|
||||
await refuseUnusableCreationTime("stdinDir", stdinDir, stdinProbeFailure);
|
||||
return;
|
||||
}
|
||||
const session = await statPathIdentity(sessionDir);
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.70.0",
|
||||
"@anthropic-ai/sdk": "0.121.0",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
|
||||
Generated
+97
-1239
File diff suppressed because it is too large.
Load diff
@@ -100,6 +100,38 @@
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/auto-instrumentations-node": "0.79.0",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": "0.221.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.221.0",
|
||||
"@opentelemetry/exporter-trace-otlp-proto": "0.221.0",
|
||||
"@opentelemetry/resources": "2.10.0",
|
||||
"@opentelemetry/sdk-node": "0.221.0",
|
||||
"@opentelemetry/semantic-conventions": "1.43.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/auto-instrumentations-node": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/exporter-trace-otlp-grpc": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/exporter-trace-otlp-http": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/exporter-trace-otlp-proto": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/resources": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/sdk-node": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/semantic-conventions": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
}
|
||||
|
||||
@@ -88,6 +88,157 @@ describe("instrumentationReady", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkExactPeerVersions", () => {
|
||||
it("passes when the installed version matches the declared version exactly", async () => {
|
||||
const { checkExactPeerVersions } = await importFreshInstrumentation();
|
||||
const require = createRequire(import.meta.url);
|
||||
const vitestVersion = (require("vitest/package.json") as { version: string }).version;
|
||||
|
||||
const result = checkExactPeerVersions(["vitest"], { vitest: vitestVersion });
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("reports a package as missing when it cannot be resolved", async () => {
|
||||
const { checkExactPeerVersions } = await importFreshInstrumentation();
|
||||
|
||||
const result = checkExactPeerVersions(["@paperclipai/does-not-exist-anywhere"], {
|
||||
"@paperclipai/does-not-exist-anywhere": "1.0.0",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.diagnostic).toContain("@opentelemetry/* packages are not installed");
|
||||
expect(result.diagnostic).toContain("@paperclipai/does-not-exist-anywhere");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a package as mismatched when the installed version differs from the declared version", async () => {
|
||||
const { checkExactPeerVersions } = await importFreshInstrumentation();
|
||||
const require = createRequire(import.meta.url);
|
||||
const vitestVersion = (require("vitest/package.json") as { version: string }).version;
|
||||
|
||||
const result = checkExactPeerVersions(["vitest"], {
|
||||
vitest: "0.0.0-not-the-installed-version",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.diagnostic).toContain("a package is installed at an unsupported version");
|
||||
expect(result.diagnostic).toContain(`vitest@${vitestVersion}`);
|
||||
expect(result.diagnostic).toContain("expected 0.0.0-not-the-installed-version");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapOtel exact-version gate", () => {
|
||||
it("does not mention the two exporters OTEL_EXPORTER_OTLP_PROTOCOL did not select", async () => {
|
||||
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
||||
process.env[PROTOCOL_ENV] = "http/json";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const { instrumentationReady } = await importFreshInstrumentation();
|
||||
await instrumentationReady;
|
||||
|
||||
// The OTel packages are absent in this test environment, so the gate
|
||||
// reports every checked package as missing. It must have checked the
|
||||
// selected exporter (http/json → exporter-trace-otlp-http) and skipped
|
||||
// the two the protocol did not select.
|
||||
const diagnosticCall = warn.mock.calls.find((call) =>
|
||||
String(call[0]).includes("@opentelemetry/* packages are not installed"),
|
||||
);
|
||||
expect(diagnosticCall).toBeDefined();
|
||||
const message = String(diagnosticCall![0]);
|
||||
expect(message).toContain("@opentelemetry/exporter-trace-otlp-http");
|
||||
expect(message).not.toContain("@opentelemetry/exporter-trace-otlp-grpc");
|
||||
expect(message).not.toContain("@opentelemetry/exporter-trace-otlp-proto");
|
||||
});
|
||||
|
||||
it("emits exactly one diagnostic when the endpoint is set and packages are absent", async () => {
|
||||
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const { instrumentationReady } = await importFreshInstrumentation();
|
||||
await instrumentationReady;
|
||||
|
||||
const diagnosticCalls = warn.mock.calls.filter((call) =>
|
||||
String(call[0]).includes("@opentelemetry/* packages are not installed"),
|
||||
);
|
||||
expect(diagnosticCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrapOtel post-gate load failure", () => {
|
||||
it("logs a load-failure diagnostic, not the missing-package message, when a package passes the gate but its dynamic import fails", async () => {
|
||||
process.env[ENDPOINT_ENV] = "http://collector:4318";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
// Fake the resolution steps the exact-version gate uses, so every checked
|
||||
// package reports as installed at its declared version. The dynamic
|
||||
// `import(...)` calls in bootstrapOtel are untouched, so they still fail
|
||||
// for real — the packages are not actually installed in this test run.
|
||||
// That forces the one path the catch block now must describe: a package
|
||||
// that passed the gate but failed to load.
|
||||
const FAKE_ROOT = "/fake-otel-pkg";
|
||||
const require = createRequire(import.meta.url);
|
||||
const peerDependencies = (
|
||||
require("../../package.json") as { peerDependencies?: Record<string, string> }
|
||||
).peerDependencies ?? {};
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (path: unknown) =>
|
||||
String(path).startsWith(FAKE_ROOT) ? true : actual.existsSync(path as never),
|
||||
readFileSync: (path: unknown, options?: unknown) => {
|
||||
const asString = String(path);
|
||||
if (asString.startsWith(FAKE_ROOT) && asString.endsWith("package.json")) {
|
||||
const name = decodeURIComponent(
|
||||
asString.slice(FAKE_ROOT.length + 1, -"/package.json".length),
|
||||
);
|
||||
return JSON.stringify({ name, version: peerDependencies[name] });
|
||||
}
|
||||
return actual.readFileSync(path as never, options as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.doMock("node:module", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:module")>();
|
||||
return {
|
||||
...actual,
|
||||
createRequire: (...args: Parameters<typeof actual.createRequire>) => {
|
||||
const real = actual.createRequire(...args);
|
||||
const fake = ((id: string) => real(id)) as typeof real;
|
||||
fake.resolve = ((id: string, options?: unknown) =>
|
||||
id in peerDependencies
|
||||
? `${FAKE_ROOT}/${encodeURIComponent(id)}/index.js`
|
||||
: real.resolve(id, options as never)) as typeof real.resolve;
|
||||
return fake;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const { instrumentationReady } = await import("../instrumentation.js");
|
||||
await instrumentationReady;
|
||||
} finally {
|
||||
vi.doUnmock("node:fs");
|
||||
vi.doUnmock("node:module");
|
||||
}
|
||||
|
||||
for (const call of warn.mock.calls) {
|
||||
expect(String(call[0])).not.toContain("packages are not installed");
|
||||
expect(String(call[0])).not.toContain("Install @opentelemetry/sdk-node");
|
||||
}
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("passed the version check"),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStartupTracer", () => {
|
||||
it("returns a usable tracer-shaped object when OTEL_EXPORTER_OTLP_ENDPOINT is unset", async () => {
|
||||
const { getStartupTracer } = await importFreshInstrumentation();
|
||||
|
||||
@@ -656,6 +656,33 @@ describe("plugin worker manager duplex channel route", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("carries the transport-close discriminator through a pre-bind exit hold and replay", async () => {
|
||||
// Regression test: an exit notification that batches with the open reply
|
||||
// arrives before the bind, so the host holds it in the single pre-bind exit
|
||||
// slot and replays it after the bind. The hold must keep the transport-close
|
||||
// discriminator, or the replay reports a plain exit instead of a transport
|
||||
// close.
|
||||
const handle = makeDuplexHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openDuplexChannel(
|
||||
duplexOpenInput({
|
||||
batchWithOpenReply: true,
|
||||
workerSessionId: "ws-A",
|
||||
data: [{ chunk: "one" }],
|
||||
transportClosed: true,
|
||||
}),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(new TextDecoder().decode(chunk)));
|
||||
await expect(session.wait()).resolves.toEqual({ exitCode: null, transportClosed: true });
|
||||
expect(chunks).toEqual(["one"]);
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("ends the route when a batched frame passes the per-chunk limit before the bind", async () => {
|
||||
const handle = makeDuplexHandle({
|
||||
duplexChannelLimits: { maxChunkChars: 4 },
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* The seven OpenTelemetry packages `server/src/instrumentation.ts` loads
|
||||
* through a dynamic import. Each one must be an optional peer dependency, not
|
||||
* a normal dependency, so a default install never pulls in the SDK. This test
|
||||
* guards the manifest half of that contract; `instrumentation.test.ts` guards
|
||||
* the runtime half (the bootstrap fails open when a package is absent).
|
||||
*/
|
||||
|
||||
const OPTIONAL_OTEL_PACKAGES = [
|
||||
"@opentelemetry/sdk-node",
|
||||
"@opentelemetry/auto-instrumentations-node",
|
||||
"@opentelemetry/resources",
|
||||
"@opentelemetry/semantic-conventions",
|
||||
"@opentelemetry/exporter-trace-otlp-grpc",
|
||||
"@opentelemetry/exporter-trace-otlp-proto",
|
||||
"@opentelemetry/exporter-trace-otlp-http",
|
||||
] as const;
|
||||
|
||||
const packageJsonPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
||||
|
||||
describe("server package OpenTelemetry peer metadata", () => {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
};
|
||||
|
||||
it.each(OPTIONAL_OTEL_PACKAGES)(
|
||||
"declares %s as an optional peer dependency",
|
||||
(packageName) => {
|
||||
expect(packageJson.peerDependencies?.[packageName]).toBeDefined();
|
||||
expect(packageJson.peerDependenciesMeta?.[packageName]?.optional).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(OPTIONAL_OTEL_PACKAGES)(
|
||||
"does not list %s in dependencies or devDependencies",
|
||||
(packageName) => {
|
||||
expect(packageJson.dependencies?.[packageName]).toBeUndefined();
|
||||
expect(packageJson.devDependencies?.[packageName]).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps @opentelemetry/api as a normal dependency", () => {
|
||||
expect(packageJson.dependencies?.["@opentelemetry/api"]).toBeDefined();
|
||||
expect(packageJson.peerDependencies?.["@opentelemetry/api"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,12 @@
|
||||
// - `http/json` → @opentelemetry/exporter-trace-otlp-http
|
||||
// Any other value logs a warning and falls back to grpc.
|
||||
//
|
||||
// Before it imports any package, the bootstrap checks the four common
|
||||
// packages and the selected exporter against the exact versions this
|
||||
// manifest's `peerDependencies` declare. A missing or a mismatched version
|
||||
// logs one diagnostic and leaves the server running without tracing; it
|
||||
// never throws.
|
||||
//
|
||||
// Timing guarantee: the bootstrap is async (dynamic imports), so it cannot
|
||||
// patch modules "before they are evaluated" — by the time the first await
|
||||
// yields, index.ts's static imports (http, express, pg) are already loaded.
|
||||
@@ -27,8 +33,9 @@
|
||||
// handler before `process.exit`.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
||||
|
||||
@@ -273,6 +280,20 @@ export function recordProviderPluginSpan(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The four OTel packages that every protocol needs, regardless of which
|
||||
* exporter `OTEL_EXPORTER_OTLP_PROTOCOL` selects. `bootstrapOtel` reads this
|
||||
* synchronously (before its first `await`), so it must be declared above
|
||||
* `instrumentationReady`: that export calls `bootstrapOtel` at module-init
|
||||
* time, and a `const` declared below it is not yet initialized then.
|
||||
*/
|
||||
const OTEL_COMMON_PACKAGES = [
|
||||
"@opentelemetry/sdk-node",
|
||||
"@opentelemetry/auto-instrumentations-node",
|
||||
"@opentelemetry/resources",
|
||||
"@opentelemetry/semantic-conventions",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Resolves once the OTel SDK has started (or once bootstrap has failed and
|
||||
* logged, or immediately when the feature is off). Await before constructing
|
||||
@@ -340,6 +361,112 @@ export function resolveProtocol(): {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Checks only the packages the caller
|
||||
* passes in — the bootstrap passes the four common packages plus the one
|
||||
* exporter `OTEL_EXPORTER_OTLP_PROTOCOL` selected, never the two unselected
|
||||
* exporters. 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 the 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`.
|
||||
*/
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
async function importExporter(protocol: ExporterProtocol): Promise<{
|
||||
OTLPTraceExporter: new (config?: Record<string, unknown>) => unknown;
|
||||
}> {
|
||||
@@ -425,6 +552,17 @@ export function resolveServiceVersion(
|
||||
async function bootstrapOtel(endpoint: string): Promise<void> {
|
||||
const { protocol, packageName: exporterPackage } = resolveProtocol();
|
||||
|
||||
// Gate on exact peer versions before touching a single dynamic import: a
|
||||
// package installed at the wrong version can still load and start, then
|
||||
// fail in a way the operator only sees in the collector, not the server
|
||||
// log. Checking first turns that into one precise, fail-open diagnostic.
|
||||
const versionCheck = checkExactPeerVersions([...OTEL_COMMON_PACKAGES, exporterPackage]);
|
||||
if (!versionCheck.ok) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(versionCheck.diagnostic, versionCheck.detail);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamic imports so type-resolution doesn't require the packages to
|
||||
// be installed unless the operator actually opts in.
|
||||
@@ -517,14 +655,16 @@ async function bootstrapOtel(endpoint: string): Promise<void> {
|
||||
process.once("SIGTERM", () => void shutdownInstrumentation());
|
||||
process.once("SIGINT", () => void shutdownInstrumentation());
|
||||
} catch (err) {
|
||||
// OTel packages not installed, or dynamic import failed. Fall through
|
||||
// with a single diagnostic so the opt-in path is self-documenting.
|
||||
// The exact-version gate above already confirmed every checked package is
|
||||
// installed at the declared version, so only a load failure after that
|
||||
// point reaches this block: a bad build, a broken native binding, or a
|
||||
// package that throws during its own module init. Fall through with a
|
||||
// single diagnostic so the opt-in path is self-documenting.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[paperclip] OTEL_EXPORTER_OTLP_ENDPOINT is set but the @opentelemetry/* " +
|
||||
`packages are not installed. Install @opentelemetry/sdk-node, ` +
|
||||
`@opentelemetry/auto-instrumentations-node, ${exporterPackage}, ` +
|
||||
`@opentelemetry/resources, and @opentelemetry/semantic-conventions to enable tracing.`,
|
||||
"[paperclip] OTEL_EXPORTER_OTLP_ENDPOINT is set and the @opentelemetry/* " +
|
||||
"packages passed the version check, but one of them failed to load. " +
|
||||
"Continuing without tracing.",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1804,6 +1804,9 @@ export function createPluginWorkerHandle(
|
||||
interface HeldDuplexExitEvent {
|
||||
workerSessionId: string;
|
||||
exitCode: number | null;
|
||||
// A reason-less transport close carries this discriminator. The hold must
|
||||
// keep it, or a replay reports a real exit for what was a transport close.
|
||||
transportClosed: boolean;
|
||||
token: ReservationToken | null;
|
||||
}
|
||||
|
||||
@@ -2334,6 +2337,7 @@ export function createPluginWorkerHandle(
|
||||
route.preBindExit = {
|
||||
workerSessionId,
|
||||
exitCode,
|
||||
transportClosed: params.transportClosed === true,
|
||||
token: reserved === "no-ledger" ? null : reserved,
|
||||
};
|
||||
return;
|
||||
@@ -2414,6 +2418,7 @@ export function createPluginWorkerHandle(
|
||||
hostRouteId: route.hostRouteId,
|
||||
workerSessionId: heldExit.workerSessionId,
|
||||
exitCode: heldExit.exitCode,
|
||||
...(heldExit.transportClosed ? { transportClosed: true } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+2
-1
@@ -69,7 +69,8 @@
|
||||
"react-resizable-panels": "^4.12.3",
|
||||
"react-router-dom": "^7.18.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"yjs": "13.6.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/browser": "^10.71.0",
|
||||
|
||||
Reference in new issue
Block a user