feat(observability): add provider OTel spans - cache-hit flag, plugin tracer, Daytona pack/transfer spans (#10764)

## Thinking Path

> - Paperclip uses pull requests to ship control-plane changes with
review and audit.
> - This change extends sandbox provider telemetry.
> - The first PR added startup and exec spans.
> - This PR adds provider spans for cache hits, plugin tracer flow, and
Daytona file sync steps.
> - The host must keep the trust boundary and reject unsafe span data.
> - The result is fuller OTel coverage with bounded labels and safe
parentage.

## Linked Issues or Issue Description

**Problem or motivation**

Sandbox provider telemetry lacks an explicit cache-hit signal, plugin
span context, and file-sync pack and transfer spans.
The current proxy for a cache hit is fragile.
The plugin SDK also needs a safe tracer surface and a per-call parent
context.

**Proposed solution**

Add an explicit `cacheHit` flag from the Daytona sandbox handle lookup.
Expose `ctx.tracer` on the plugin context and pass a `traceparent`
string with each call.
Record provider spans on the host with allowlist clamping and capability
checks.
Wrap Daytona file sync pack and transfer steps in spans.

**Alternatives considered**

Keep the old `providerGetMs == 0` proxy.
Reject that path because the cache-hit decision belongs at the handle
lookup, not in a timing proxy.

The host stays the trust boundary for worker span data.
It clamps labels, rejects bad parent data, and gates the worker-to-host
span RPC by capability.
A security review completed before this PR opened.

## What Changed

- Added an explicit `cacheHit` flag from the Daytona sandbox handle
lookup.
- Added `ctx.tracer` on the plugin context and a `traceparent` field in
the per-call context.
- Added host-side span recording with attribute clamping and capability
checks.
- Wrapped Daytona file sync pack and transfer steps in spans.
- Added tests for the host trust boundary, the plugin tracer no-op path,
and the Daytona span paths.

## Verification

- `pnpm --filter @paperclipai/plugin-daytona exec vitest run`
- `pnpm --filter @paperclipai/plugin-sdk exec vitest run`
- `pnpm --filter @paperclipai/server exec vitest run
plugin-host-services environment-execution-target instrumentation
plugin-worker-manager`
- `tsc --noEmit` for server, plugin-sdk, and adapter-utils

## Risks

- Span data now crosses the worker boundary, so the host allowlist and
capability gate must stay strict.
- The `traceparent` path must stay valid and host-owned.
- Daytona file sync spans must not add new round trips.

## Model Used

OpenAI Codex, GPT-5, tool use.

## 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 linked existing issues with `Fixes:` / `Closes:` /
`Refs:` or described the issue in the PR body
- [x] I have not referenced internal or 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>
This commit is contained in:
Nicky Leach 2026-08-03 13:09:01 -07:00 committed by GitHub
parent ba396c608c
commit af6b32d82a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1130 additions and 12 deletions

View File

@ -86,6 +86,8 @@ export const SANDBOX_STARTUP_SPAN_ATTRS = {
execNetworkMs: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}exec.network_ms`,
/** Whether one execution sits on the startup critical path. */
execCriticalPath: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}exec.critical_path`,
/** Whether the provider served the sandbox handle from its warm cache. */
execCacheHit: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}exec.cache_hit`,
/** The root-span wall time of the whole bring-up. */
rootWallMs: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}root.wall_ms`,
/** The sum of the step wall times of the whole bring-up. */
@ -108,6 +110,12 @@ export const SANDBOX_STARTUP_SPAN_ATTRS = {
handshakeEnsureSessionWallMs: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}handshake.ensure_session.wall_ms`,
/** A shared low-cardinality tag that marks two steps as one parallel batch. */
batch: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}batch`,
/** The host-local wall time of the pack step (build the tarball). */
packWallMs: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}pack.wall_ms`,
/** The wall time of the transfer step (upload the files to the sandbox). */
transferWallMs: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.wall_ms`,
/** The number of serial guard round trips before one transfer. */
transferGuardCount: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.guard.count`,
} as const;
/** The closed value set for the `outcome` attribute. */

View File

@ -11,9 +11,53 @@ import type {
PluginSyncFileMapping,
PluginSyncOperation,
} from "@paperclipai/plugin-sdk";
import { getPluginTracer } from "./plugin.js";
const execFileAsync = promisify(execFile);
// The span-attribute names. They mirror the host span-attribute contract by
// value. The plugin ships bundled, so it stays free of the host packages and
// repeats these strings; the host re-clamps a provider span by these exact keys.
const SPAN_ATTR_PREFIX = "paperclip.sandbox.startup.";
const SPAN_ATTR = {
provider: `${SPAN_ATTR_PREFIX}provider`,
packWallMs: `${SPAN_ATTR_PREFIX}pack.wall_ms`,
transferWallMs: `${SPAN_ATTR_PREFIX}transfer.wall_ms`,
transferGuardCount: `${SPAN_ATTR_PREFIX}transfer.guard.count`,
} as const;
/** The value of `SpanStatusCode.ERROR` in `@opentelemetry/api`. The plugin stays
* OpenTelemetry-free, so it uses the numeric value directly. */
const SPAN_STATUS_CODE_ERROR = 2;
/**
* Run one span-wrapped step through the plugin tracer. The pack step and the
* transfer step share this helper. It seeds the provider family, runs the step,
* sets the wall time, marks a thrown step failed, and always ends the span. The
* tracer is a no-op until the host injects a live tracer, so the span never
* changes the sync control flow.
*/
async function withProviderSpan<T>(input: {
name: string;
wallMsAttr: string;
attributes?: Record<string, string | number | boolean>;
run: () => Promise<T>;
}): Promise<T> {
const span = getPluginTracer().startSpan(input.name, {
attributes: { [SPAN_ATTR.provider]: "daytona", ...(input.attributes ?? {}) },
});
const startedAtMs = Date.now();
try {
return await input.run();
} catch (error) {
span.setStatus({ code: SPAN_STATUS_CODE_ERROR });
throw error;
} finally {
span.setAttribute(input.wallMsAttr, Date.now() - startedAtMs);
span.end();
}
}
/** Convert a millisecond timeout to the whole-seconds value the Daytona SDK expects. */
function toTimeoutSeconds(timeoutMs: number): number {
return Math.max(1, Math.ceil(timeoutMs / 1000));
@ -404,9 +448,14 @@ async function syncInFileMappings(input: {
bytesTransferred += (await fs.stat(mapping.sourcePath)).size;
}
// Count the serial guard round trips before the transfer, so the transfer span
// records how much of the wall time is guard cost.
let guardRoundTrips = 0;
// Ensure every target directory exists before the bulk upload writes its temp.
const mkdirCommand = [...parentDirs].map((dir) => `mkdir -p ${shellQuote(dir)}`).join(" && ");
await assertSandboxCommandOk(sandbox, mkdirCommand, timeoutSeconds, "syncIn mkdir");
guardRoundTrips += 1;
// Defense-in-depth beyond the lexical `assertConfinedSandboxPath`: a sandbox
// can replace a target parent with a symlink to `/etc` so the string check
@ -419,13 +468,19 @@ async function syncInFileMappings(input: {
timeoutSeconds,
label: "inbound symlink-escape guard",
});
guardRoundTrips += 1;
// A failed upload or a mid-batch `mv -f` failure leaves reserved temps (some
// targets promoted, others not) — sweep every staged temp on any error so a
// retry never accumulates stale `.paperclip-upload-*` scratch.
try {
// One batched bulk upload (single /files/bulk-upload) for all file mappings.
await sandbox.fs.uploadFiles(uploads, timeoutSeconds);
await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: { [SPAN_ATTR.transferGuardCount]: guardRoundTrips },
run: () => sandbox.fs.uploadFiles(uploads, timeoutSeconds),
});
// Apply the requested mode on the temp file BEFORE the rename so the target
// never appears at a widened window — a secret lands `0600` at targetPath from
@ -490,16 +545,25 @@ async function syncInDirectoryMapping(input: {
assertConfinedSandboxPath(remoteDir, mapping.targetPath, "target");
return withHostTempDir(async (tmp) => {
const archivePath = path.join(tmp, "sync-in.tar");
await createHostTarball({
localDir: mapping.sourcePath,
archivePath,
exclude: mapping.exclude,
followSymlinks: mapping.followSymlinks,
// The pack step is host-local: it builds the tarball and makes no sandbox
// round trip. The `pack` span records its wall time.
await withProviderSpan({
name: "pack",
wallMsAttr: SPAN_ATTR.packWallMs,
run: () => createHostTarball({
localDir: mapping.sourcePath,
archivePath,
exclude: mapping.exclude,
followSymlinks: mapping.followSymlinks,
}),
});
const bytesTransferred = (await fs.stat(archivePath)).size;
// The tar bytes ride the native bulk channel (string source ⇒ streamed);
// only the extract/cleanup control commands use exec.
const remoteTar = path.posix.join(remoteDir, scratchName(".tar"));
// Count the serial guard round trips before the transfer, so the transfer
// span records how much of the wall time is guard cost.
let guardRoundTrips = 0;
// Materialize the target dir first so the realpath guard resolves real
// components, then confirm it (and any existing parent) canonicalizes inside
// the remote dir — `tar -C` would otherwise follow a sandbox-planted symlink
@ -510,6 +574,7 @@ async function syncInDirectoryMapping(input: {
timeoutSeconds,
"syncIn mkdir",
);
guardRoundTrips += 1;
await assertSandboxPathsConfined({
sandbox,
remoteDir,
@ -517,7 +582,14 @@ async function syncInDirectoryMapping(input: {
timeoutSeconds,
label: "inbound symlink-escape guard",
});
await sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds);
guardRoundTrips += 1;
await withProviderSpan({
name: "transfer",
wallMsAttr: SPAN_ATTR.transferWallMs,
attributes: { [SPAN_ATTR.transferGuardCount]: guardRoundTrips },
run: () =>
sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds),
});
// Bind validation and extraction into ONE sandbox invocation, then extract into
// an OPEN directory inode rather than a path string. `exec 9<"$_pc_real"` itself
// walks every ancestor of `$_pc_real` during the `open()` syscall, so a sandbox

View File

@ -33,8 +33,10 @@ import plugin, {
setDaytonaHandleFreshnessClockForTest,
__resetDaytonaSandboxHandleCacheForTest,
__getDaytonaWritableDirsForTest,
__setDaytonaPluginContextForTest,
buildBwrapCommand,
} from "./plugin.js";
import type { PluginContext } from "@paperclipai/plugin-sdk";
import manifest from "./manifest.js";
function createMockSandbox(overrides: {
@ -1157,6 +1159,41 @@ describe("Daytona sandbox provider plugin", () => {
}
});
it("sets metadata.cacheHit false on a client.get miss and true on a warm-handle hit", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
sandbox.process.executeCommand.mockResolvedValue({
exitCode: 0,
result: "ok",
artifacts: { stdout: "ok" },
});
mockGet.mockResolvedValue(sandbox);
const execParams = {
driverKey: "daytona" as const,
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
timeoutMs: 1000,
};
// First execute: the handle cache is empty, so the lookup calls `client.get`
// and reports a miss.
const first = await plugin.definition.onEnvironmentExecute?.(execParams);
expect(first!.metadata).toMatchObject({ cacheHit: false });
expect(mockGet).toHaveBeenCalledTimes(1);
// Second execute: the warm handle cache serves the handle, so the lookup
// makes no `client.get` round trip and reports a hit.
const second = await plugin.definition.onEnvironmentExecute?.(execParams);
expect(second!.metadata).toMatchObject({ cacheHit: true });
expect(mockGet).toHaveBeenCalledTimes(1);
});
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
process.env.DAYTONA_API_KEY = "host-key";
const sandbox = createMockSandbox();
@ -2632,6 +2669,117 @@ describe("daytona native file-sync hooks", () => {
});
});
// A recording tracer that captures every provider span the file sync opens.
// It satisfies the structural plugin tracer contract.
function createRecordingPluginTracer() {
const spans: Array<{
name: string;
attributes: Record<string, unknown>;
status: { code: number; message?: string } | null;
ended: boolean;
}> = [];
const tracer = {
startSpan(name: string, options?: { attributes?: Record<string, string | number | boolean> }) {
const span = {
name,
attributes: { ...(options?.attributes ?? {}) } as Record<string, unknown>,
status: null as { code: number; message?: string } | null,
ended: false,
setAttribute(key: string, value: unknown) {
span.attributes[key] = value;
},
setStatus(status: { code: number; message?: string }) {
span.status = status;
},
end() {
span.ended = true;
},
};
spans.push(span);
return span;
},
};
return { tracer, spans };
}
it("opens a transfer span with the guard round-trip count around the bulk file upload", async () => {
const hostDir = await makeHostDir();
const source = path.join(hostDir, "config.txt");
await fs.writeFile(source, "plain");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const { tracer, spans } = createRecordingPluginTracer();
const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-1",
files: [{ sourcePath: source, targetPath: `${REMOTE_DIR}/config.txt`, kind: "file" }],
},
],
});
} finally {
restore();
}
const transfer = spans.find((span) => span.name === "transfer");
expect(transfer).toBeDefined();
expect(transfer!.ended).toBe(true);
// The two serial guard round trips before the transfer: mkdir + confinement.
expect(transfer!.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(2);
expect(transfer!.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona");
expect(typeof transfer!.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number");
// A bulk file upload builds no host tarball, so it opens no pack span.
expect(spans.find((span) => span.name === "pack")).toBeUndefined();
});
it("opens a pack span and a transfer span around a directory mapping sync", async () => {
const hostDir = await makeHostDir();
const sourceDir = path.join(hostDir, "assets");
await fs.mkdir(sourceDir, { recursive: true });
await fs.writeFile(path.join(sourceDir, "a.txt"), "alpha");
const sandbox = createMockSandbox();
mockGet.mockResolvedValue(sandbox);
const { tracer, spans } = createRecordingPluginTracer();
const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext);
try {
await plugin.definition.onEnvironmentSyncIn?.({
driverKey: "daytona",
companyId: "company-1",
environmentId: "env-1",
config: { timeoutMs: 300000, reuseLease: false },
lease: syncLease(),
operations: [
{
operationId: "sync-op-dir",
files: [
{ sourcePath: sourceDir, targetPath: `${REMOTE_DIR}/.paperclip-runtime/assets`, kind: "directory" },
],
},
],
});
} finally {
restore();
}
const pack = spans.find((span) => span.name === "pack");
expect(pack).toBeDefined();
expect(pack!.ended).toBe(true);
expect(typeof pack!.attributes["paperclip.sandbox.startup.pack.wall_ms"]).toBe("number");
const transfer = spans.find((span) => span.name === "transfer");
expect(transfer).toBeDefined();
expect(transfer!.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(2);
});
it("syncIn tars a directory mapping host-side honoring excludes and the followSymlinks flag, then extracts it in-sandbox via a single quoted tar command", async () => {
const hostDir = await makeHostDir();
const sourceDir = path.join(hostDir, "assets");

View File

@ -9,8 +9,10 @@ import type {
Resources,
Sandbox,
} from "@daytonaio/sdk";
import { definePlugin } from "@paperclipai/plugin-sdk";
import { definePlugin, NOOP_PLUGIN_TRACER } from "@paperclipai/plugin-sdk";
import type {
PluginContext,
PluginTracer,
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentCancelInteractiveSetupParams,
PluginEnvironmentCancelInteractiveSetupResult,
@ -46,6 +48,33 @@ import { performSyncIn, performSyncOut } from "./file-sync.js";
// are deterministic. The timing path never calls `Date.now()` directly.
let timingNow: () => number = () => Date.now();
// The plugin context, hoisted to a module variable in `setup(ctx)`. The
// lifecycle hooks and the file-sync helpers have no closure over `ctx`, so they
// read the tracer through `getPluginTracer()`. Before `setup` runs (or in a
// test) the tracer is a no-op, so a span never throws.
let pluginContext: PluginContext | null = null;
/**
* Return the plugin tracer. It is the injected `ctx.tracer` after `setup`, or a
* no-op before it. A provider span opened through it records only when tracing
* is on and an active host trace context is present.
*/
export function getPluginTracer(): PluginTracer {
return pluginContext?.tracer ?? NOOP_PLUGIN_TRACER;
}
/**
* Test seam: set the module-level plugin context, and return a restore function.
* `plugin.test.ts` uses it to inject a recording tracer without running `setup`.
*/
export function __setDaytonaPluginContextForTest(ctx: PluginContext | null): () => void {
const previous = pluginContext;
pluginContext = ctx;
return () => {
pluginContext = previous;
};
}
/**
* Test seam: override the provider-timing clock and return a restore function.
* Not used in production, where the default wall clock always applies.
@ -998,6 +1027,11 @@ type SandboxHandleCacheEntry = {
type SandboxLookupOptions = {
bypassTeardownGate?: boolean;
// Report the cache decision at the handle lookup. `true` means the warm
// handle cache served the handle; `false` means the lookup called
// `client.get`. The caller uses this to set the explicit exec `cache_hit`
// flag, instead of the old `providerGetMs == 0` proxy.
onCacheDecision?: (cacheHit: boolean) => void;
};
type SandboxHandleTeardownGate = {
@ -1182,6 +1216,9 @@ const sandboxHandleCache = (() => {
const entry = entries.get(key);
if (entry) {
// The warm handle cache holds an entry, so this lookup serves the handle
// without a `client.get` round trip. Report the cache decision now.
options.onCacheDecision?.(true);
const sandbox = await entry.sandbox;
// Re-assert on every hit; evict + fail closed on any mismatch (C2).
try {
@ -1205,6 +1242,9 @@ const sandboxHandleCache = (() => {
}
return sandbox;
}
// The warm handle cache holds no entry, so this lookup calls `client.get`.
// Report the cache decision now, before the single-flight populate.
options.onCacheDecision?.(false);
// Single-flight: the first miss stores the in-flight promise under the
// composite key so concurrent misses on the same lease share one `client.get`
// instead of double-fetching. The promise lives only under this key (C5).
@ -1496,6 +1536,9 @@ async function executeOneShot(
const plugin = definePlugin({
async setup(ctx) {
// Hoist the context to a module variable so the lifecycle hooks and the
// file-sync helpers can read `ctx.tracer` — they have no closure over `ctx`.
pluginContext = ctx;
ctx.logger.info("Daytona sandbox provider plugin ready");
},
@ -2119,13 +2162,24 @@ const plugin = definePlugin({
// is a no-op for an already-started sandbox, so it is excluded from the get
// measurement.
const getStart = timingNow();
// Decide the explicit `cache_hit` flag at the true cache decision: the
// handle lookup reports whether the warm cache served the handle or the
// lookup called `client.get`. This replaces the old `providerGetMs == 0`
// proxy. The default `false` covers the theoretical case where the lookup
// reports nothing.
let cacheHit = false;
const sandbox = await getSandbox({
driverKey: params.driverKey,
companyId: params.companyId,
environmentId: params.environmentId,
providerLeaseId,
config,
}, { bypassTeardownGate: true });
}, {
bypassTeardownGate: true,
onCacheDecision: (hit) => {
cacheHit = hit;
},
});
const getDurationMs = timingNow() - getStart;
await ensureSandboxStarted(sandbox, toTimeoutSeconds(resolveTimeoutMs(params.timeoutMs, config)));
// Read the advisory bwrap flags from the lease metadata and read the
@ -2149,7 +2203,7 @@ const plugin = definePlugin({
}
return {
...result,
metadata: { ...(result.metadata ?? {}), getDurationMs },
metadata: { ...(result.metadata ?? {}), getDurationMs, cacheHit },
};
});
},

View File

@ -182,6 +182,14 @@ export interface HostServices {
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
};
/** Provides `span.record`. The context carries the host-minted `traceparent`. */
tracer: {
record(
params: WorkerToHostMethods["span.record"][0],
context?: WorkerHostCallContext,
): Promise<void>;
};
/** Provides `companies.list`, `companies.get`. */
companies: {
list(params: WorkerToHostMethods["companies.list"][0]): Promise<WorkerToHostMethods["companies.list"][1]>;
@ -416,6 +424,10 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
// Logger — always allowed
"log": null,
// Provider span sink — only a plugin that registers environment drivers may
// emit a provider span. The gate rejects a span from any other plugin.
"span.record": "environment.drivers.register",
// Companies
"companies.list": "companies.read",
"companies.get": "companies.read",
@ -788,6 +800,11 @@ export function createHostClientHandlers(
return services.logger.log(params);
}),
// Provider span sink. The context carries the host-minted `traceparent`.
"span.record": gated("span.record", async (params, context) => {
return services.tracer.record(params, context);
}),
// Companies
"companies.list": gated("companies.list", async (params, context) => {
const rows = await services.companies.list(params);

View File

@ -282,8 +282,13 @@ export type {
PluginMetricsClient,
PluginTelemetryClient,
PluginLogger,
PluginTracer,
PluginSpan,
} from "./types.js";
// Tracer no-op default (a value, so it re-exports here, not in the type block).
export { NOOP_PLUGIN_TRACER, NOOP_PLUGIN_SPAN } from "./types.js";
// Supporting types for context clients
export type {
ScopeKey,

View File

@ -291,6 +291,14 @@ export interface PluginInvocationScope {
export interface PluginInvocationContext {
id: string;
scope: PluginInvocationScope;
/**
* An optional W3C `traceparent` for the active host span. The host mints it
* per call from the active startup span. The worker treats it as opaque: it
* tags its provider span with it and never derives parentage from it. The host
* mints the parentage from its own invocation record, so a worker can never
* forge a parent.
*/
traceparent?: string;
}
/**
@ -300,6 +308,12 @@ export interface PluginInvocationContext {
export interface WorkerHostCallContext {
invocationScope?: PluginInvocationScope | null;
invalidInvocationScope?: boolean;
/**
* The W3C `traceparent` the host minted for the echoed invocation. The host
* recovers it from its own invocation record, not from the worker, so a worker
* can never forge a span parent. The span host handler validates and uses it.
*/
traceparent?: string;
}
// ---------------------------------------------------------------------------
@ -1274,6 +1288,25 @@ export interface WorkerToHostMethods {
result: void,
];
// Provider span sink. The worker sends a finished provider span; the host
// re-clamps the label and the attributes at its trust boundary, mints the
// parentage from its own invocation record, and records the span through the
// real tracer. The worker never sends the parent `traceparent`; the host
// recovers it from the echoed invocation id. The RPC is capability-gated.
"span.record": [
params: {
/** The bounded span name (for example `pack` or `transfer`). The host
* clamps it to a closed set, so a name never carries free-form data. */
name: string;
/** The span attributes. The host drops every key that is not on the closed
* plugin-span allowlist and re-clamps each remaining value. */
attributes?: Record<string, string | number | boolean>;
/** The optional span status. */
status?: { code: number; message?: string };
},
result: void,
];
// Companies (read)
"companies.list": [
params: { limit?: number; offset?: number },

View File

@ -45,6 +45,7 @@ import type {
PermissionKey,
PrincipalType,
} from "./types.js";
import { NOOP_PLUGIN_TRACER } from "./types.js";
import type {
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
@ -2502,6 +2503,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
logs.push({ level: "debug", message, meta });
},
},
tracer: NOOP_PLUGIN_TRACER,
};
const harness: TestHarness = {

View File

@ -1021,6 +1021,58 @@ export interface PluginLogger {
debug(message: string, meta?: Record<string, unknown>): void;
}
// ---------------------------------------------------------------------------
// Plugin tracer
// ---------------------------------------------------------------------------
/**
* `ctx.tracer` a minimal, OpenTelemetry-free span contract. The plugin worker
* builds a real span through this surface; the host records it through the real
* tracer. The shape is a subset of the `@opentelemetry/api` `Span` shape, so the
* plugin SDK never imports `@opentelemetry/api`.
*
* A span with no active host trace context is a no-op: it accepts the calls and
* ends without an effect. So a lifecycle hook can always open a span, and the
* span records nothing until tracing is on.
*/
export interface PluginSpan {
/** Set one bounded attribute. The host re-clamps every attribute at its trust
* boundary, so an out-of-allowlist attribute never reaches a recorded span. */
setAttribute(key: string, value: string | number | boolean): void;
/** Set the span status. The host maps it onto the recorded span. */
setStatus(status: { code: number; message?: string }): void;
/** End the span. The worker sends the span data to the host once, here. */
end(): void;
}
/**
* `ctx.tracer` a minimal, OpenTelemetry-free tracer contract. The plugin uses
* it the same way as `ctx.logger`. The default is a no-op that never throws, so
* a plugin span changes nothing until the host injects a live tracer and an
* active host trace context.
*/
export interface PluginTracer {
/** Start one span. `options.attributes` seeds the span attributes. */
startSpan(
name: string,
options?: { attributes?: Record<string, string | number | boolean> },
): PluginSpan;
}
/** A shared no-op span. It satisfies the span contract and does nothing, so a
* plugin with no injected tracer changes no behavior. */
export const NOOP_PLUGIN_SPAN: PluginSpan = {
setAttribute() {},
setStatus() {},
end() {},
};
/** The default tracer. It opens no real span, so a lifecycle hook that wraps
* work in a span behaves exactly as before when no live tracer is injected. */
export const NOOP_PLUGIN_TRACER: PluginTracer = {
startSpan: () => NOOP_PLUGIN_SPAN,
};
// ---------------------------------------------------------------------------
// Plugin metrics
// ---------------------------------------------------------------------------
@ -2053,4 +2105,8 @@ export interface PluginContext {
/** Structured logger. Output is captured and surfaced in the plugin health dashboard. */
logger: PluginLogger;
/** Tracer for provider spans. The default is a no-op; the host records a span
* only when tracing is on and an active host trace context is present. */
tracer: PluginTracer;
}

View File

@ -1392,6 +1392,46 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
notifyHost("log", { level: "debug", message, meta });
},
},
tracer: {
startSpan(
name: string,
options?: { attributes?: Record<string, string | number | boolean> },
) {
// Read the active host trace context from the per-call invocation
// channel. A `traceparent` means a host span is active, so this span
// may record. No `traceparent` means tracing is off: the span is a
// no-op, so a lifecycle hook can always wrap work in a span.
const hasTraceContext = Boolean(invocationContextStorage.getStore()?.traceparent);
const attributes: Record<string, string | number | boolean> = {
...(options?.attributes ?? {}),
};
let status: { code: number; message?: string } | undefined;
let ended = false;
return {
setAttribute(key: string, value: string | number | boolean): void {
attributes[key] = value;
},
setStatus(next: { code: number; message?: string }): void {
status = next;
},
end(): void {
if (ended) return;
ended = true;
if (!hasTraceContext) return;
// Send the finished span to the host once. The host re-clamps the
// name and the attributes, mints the parentage from its own
// invocation record, and records the span through the real tracer.
// Fire-and-forget: a span must never block or fail plugin work.
void callHost("span.record", {
name,
attributes,
...(status ? { status } : {}),
}).catch(() => undefined);
},
};
},
},
};
}

View File

@ -484,3 +484,133 @@ describe("worker configChanged cross-tenant guard", () => {
}
});
});
describe("worker provider tracer", () => {
it("default plugin tracer is a no-op that starts and ends a span without throwing", async () => {
const { NOOP_PLUGIN_TRACER } = await import("../src/types.js");
const span = NOOP_PLUGIN_TRACER.startSpan("pack", { attributes: { a: 1 } });
expect(() => {
span.setAttribute("b", 2);
span.setStatus({ code: 1 });
span.end();
}).not.toThrow();
});
// Drive a plugin data handler that opens a provider span, and capture the
// worker→host traffic. The host injects a `traceparent` on the invocation, so
// the worker must emit one `span.record` request that echoes the invocation id
// and carries the span name and attributes.
async function runSpanProbe(invocation: PluginInvocationContext) {
const hostToWorker = new PassThrough();
const workerToHost = new PassThrough();
const hostReadline = createInterface({ input: workerToHost });
const pending = new Map<string, (response: JsonRpcResponse) => void>();
const spanRecords: Array<{ params: unknown; invocationId?: string }> = [];
let nextRequestId = 1;
const plugin = definePlugin({
async setup(ctx) {
ctx.data.register("probe", async () => {
const span = ctx.tracer.startSpan("pack", {
attributes: { "paperclip.sandbox.startup.pack.wall_ms": 12 },
});
span.setAttribute("paperclip.sandbox.startup.provider", "daytona");
span.end();
return { ok: true };
});
},
});
const worker = startWorkerRpcHost({ plugin, stdin: hostToWorker, stdout: workerToHost });
function callWorker(method: string, params: unknown, inv?: PluginInvocationContext) {
const id = `host-${nextRequestId++}`;
const request = {
...createRequest(method, params, id),
...(inv ? { paperclipInvocation: inv } : {}),
};
const result = new Promise<unknown>((resolve, reject) => {
pending.set(id, (response) => {
if ("error" in response && response.error) {
reject(new Error(response.error.message));
return;
}
resolve((response as { result?: unknown }).result);
});
});
hostToWorker.write(serializeMessage(request));
return result;
}
hostReadline.on("line", (line) => {
const message = parseMessage(line);
if (isJsonRpcResponse(message)) {
pending.get(String(message.id))?.(message);
pending.delete(String(message.id));
return;
}
if (!isJsonRpcRequest(message)) return;
if (message.method === "span.record") {
spanRecords.push({
params: message.params,
invocationId: (message as { paperclipInvocationId?: string }).paperclipInvocationId,
});
hostToWorker.write(serializeMessage(createSuccessResponse(message.id, null)));
}
});
try {
await callWorker("initialize", {
manifest: {
id: "paperclip.tracer-test",
apiVersion: 1,
version: "1.0.0",
displayName: "Tracer test",
description: "Tracer test",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: { worker: "dist/worker.js" },
},
config: {},
instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
apiVersion: 1,
});
await callWorker("getData", { key: "probe", companyId: "company-a", params: {} }, invocation);
// Let the fire-and-forget span.record flush.
await new Promise((resolve) => setTimeout(resolve, 20));
return spanRecords;
} finally {
worker.stop();
hostReadline.close();
hostToWorker.destroy();
workerToHost.destroy();
}
}
it("emits one span.record with the name and attributes when a host trace context is active", async () => {
const spanRecords = await runSpanProbe({
id: "invocation-a",
scope: { companyId: "company-a" },
traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
});
expect(spanRecords).toHaveLength(1);
const record = spanRecords[0]!;
expect(record.invocationId).toBe("invocation-a");
expect(record.params).toMatchObject({
name: "pack",
attributes: {
"paperclip.sandbox.startup.pack.wall_ms": 12,
"paperclip.sandbox.startup.provider": "daytona",
},
});
});
it("emits no span.record when the invocation carries no traceparent (tracing off)", async () => {
const spanRecords = await runSpanProbe({
id: "invocation-a",
scope: { companyId: "company-a" },
});
expect(spanRecords).toHaveLength(0);
});
});

View File

@ -159,11 +159,73 @@ numeric attribute when the provider does not report the value.
| `paperclip.sandbox.startup.exec.sandbox_ms` | number | yes | The in-sandbox run time of the execution. |
| `paperclip.sandbox.startup.exec.network_ms` | number | yes | The transport time the host adds; `wall_ms wait_before_ms sandbox_ms`. |
| `paperclip.sandbox.startup.exec.critical_path` | boolean | no | Whether the execution sits on the startup critical path. |
| `paperclip.sandbox.startup.exec.cache_hit` | boolean | yes | Whether the provider served the sandbox handle from its warm cache. |
| `paperclip.sandbox.startup.outcome` | string | no | The execution outcome (`ok` or `failed`). |
The plugin decides the cache hit at the sandbox-handle lookup. The span no
longer infers a cache hit from `wait_before_ms == 0`. Paperclip omits the
`cache_hit` attribute when the provider does not report the value.
To add a span attribute, extend the `SANDBOX_STARTUP_SPAN_ATTRS` allowlist in
the code first. Keep the attribute low-cardinality and free of user content.
### Provider spans
A sandbox provider plugin also opens spans for its own sync steps. These spans
use the `sandbox.provider.` name prefix. They share the
`paperclip.sandbox.startup.` attribute prefix and obey the same opt-in and
no-user-content rules as the startup spans above.
The plugin worker runs in a separate process from the host. So the host treats
every field of a worker-sent span as untrusted input. The host re-clamps the
span name and every attribute at one boundary, the `span.record` host handler,
before it records the span.
| Span | Scope | Parent |
| --- | --- | --- |
| `sandbox.provider.pack` | The host-local pack step that builds the upload tarball. It makes no sandbox round trip. | the active startup step span |
| `sandbox.provider.transfer` | The transfer step that uploads the files to the sandbox. | the active startup step span |
| `sandbox.provider.other` | Any span name outside the known set. | the active startup step span |
The host clamps the span name to the closed set `pack` and `transfer`. The host
maps a known name to `sandbox.provider.<name>`. The host maps any other value to
`sandbox.provider.other`, so a span name never carries free-form data.
The `sandbox.provider.*` spans use this closed attribute allowlist. The host
drops every other key, so a command, an argument, a path, an id, a standard
output, or a standard error never rides a provider span. The host records only
the attributes that the producer sends for one span.
| Attribute | Type | Optional | Meaning |
| --- | --- | --- | --- |
| `paperclip.sandbox.startup.provider` | string | no | The normalized provider family. |
| `paperclip.sandbox.startup.outcome` | string | yes | The step outcome (`ok`, `skipped`, or `failed`). |
| `paperclip.sandbox.startup.pack.wall_ms` | number | yes | The host-local wall time of the pack step. It rides the `sandbox.provider.pack` span. |
| `paperclip.sandbox.startup.transfer.wall_ms` | number | yes | The wall time of the transfer step. It rides the `sandbox.provider.transfer` span. |
| `paperclip.sandbox.startup.transfer.guard.count` | number | yes | The number of serial guard round trips before one transfer. It rides the `sandbox.provider.transfer` span. |
The `span.record` host handler enforces the allowlist. It re-maps `provider`
through the provider-family normalizer. It keeps `outcome` only when the value
is `ok`, `skipped`, or `failed`. It keeps a numeric attribute only when the
value is a finite number. It drops a status message and keeps only the numeric
status code. The handler never throws, because observability must not change the
sync control flow.
The `span.record` host method needs the `environment.drivers.register`
capability. So only a plugin that registers an environment driver may emit a
provider span. The capability gate rejects a provider span from any other
plugin.
The host parents each provider span to the active startup step span. The host
mints a W3C `traceparent` from the active step and passes it to the plugin
worker on the per-call invocation channel. The worker tags its span with the
`traceparent` and treats the value as opaque. The worker never derives the
parent from it. The host recovers the `traceparent` from its own invocation
record, so a worker can never forge a parent. The host validates the
`traceparent` and rejects a missing or malformed value. With no active host
trace context the worker sends no span, so the whole provider-span path is a
no-op.
## Dimension Values
Telemetry dimension values must be primitives. Use only the value types allowed

View File

@ -515,6 +515,7 @@ describe("resolveEnvironmentExecutionTarget", () => {
A.execSandboxMs,
A.execNetworkMs,
A.execCriticalPath,
A.execCacheHit,
A.outcome,
]);
@ -598,6 +599,50 @@ describe("resolveEnvironmentExecutionTarget", () => {
expect(span.attributes[A.execWallMs] as number).toBeGreaterThanOrEqual(0);
});
it("carries the explicit exec cache_hit from result.metadata.cacheHit", async () => {
const { tracer, spans } = createRecordingExecTracer();
const runner = await runnerFor({
provider: "daytona",
execResult: {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok",
stderr: "",
metadata: { durationMs: 600, getDurationMs: 0, cacheHit: true },
},
tracer,
});
await runner.execute({ command: "echo", args: ["a"] });
const span = spans[0]!;
// The flag comes from the metadata boolean, not from a zero handle-fetch.
expect(span.attributes[A.execCacheHit]).toBe(true);
});
it("omits exec cache_hit when the provider reports no cacheHit metadata", async () => {
const { tracer, spans } = createRecordingExecTracer();
const runner = await runnerFor({
provider: "daytona",
execResult: {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "ok",
stderr: "",
metadata: { durationMs: 600, getDurationMs: 15 },
},
tracer,
});
await runner.execute({ command: "echo", args: ["a"] });
const span = spans[0]!;
// A provider that omits the boolean yields no attribute — never `false`.
expect(span.attributes).not.toHaveProperty(A.execCacheHit);
});
it("omits each duration attribute when a provider returns no timing (does not throw, keeps provider)", async () => {
const { tracer, spans } = createRecordingExecTracer();
const runner = await runnerFor({

View File

@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
import type { WorkerHostCallContext } from "../../../packages/plugins/sdk/src/protocol.js";
import { SANDBOX_STARTUP_SPAN_ATTRS as A } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import {
buildHostServices,
clampProviderSpanAttributes,
parseTraceparent,
} from "../services/plugin-host-services.js";
// Capture every span the host trust boundary hands to the real tracer.
const mockRecordSpan = vi.hoisted(() => vi.fn());
vi.mock("../instrumentation.js", () => ({
recordProviderPluginSpan: mockRecordSpan,
traceparentFromContextToken: () => undefined,
}));
function createEventBusStub() {
return {
forPlugin() {
return { emit: vi.fn(), subscribe: vi.fn() };
},
} as never;
}
// A well-formed W3C traceparent (the host mints it; the handler validates it).
const VALID_TRACEPARENT = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
function servicesFor() {
return buildHostServices({} as never, "plugin-record-id", "daytona", createEventBusStub());
}
function handlersFor(capabilities: readonly string[]) {
return createHostClientHandlers({
pluginId: "daytona",
capabilities: capabilities as never,
services: servicesFor(),
});
}
describe("plugin provider span host handler", () => {
beforeEach(() => {
mockRecordSpan.mockReset();
});
it("records a span with the clamped name and the allowlisted attributes", async () => {
const services = servicesFor();
await services.tracer.record(
{
name: "pack",
attributes: {
[A.provider]: "daytona",
[A.packWallMs]: 12,
},
},
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
);
expect(mockRecordSpan).toHaveBeenCalledTimes(1);
const call = mockRecordSpan.mock.calls[0]![0] as {
name: string;
parent: { traceId: string; spanId: string; traceFlags: number };
attributes: Record<string, unknown>;
};
expect(call.name).toBe("sandbox.provider.pack");
expect(call.parent).toEqual({
traceId: "0af7651916cd43dd8448eb211c80319c",
spanId: "b7ad6b7169203331",
traceFlags: 1,
});
expect(call.attributes[A.provider]).toBe("daytona");
expect(call.attributes[A.packWallMs]).toBe(12);
});
it("drops every forbidden attribute before the span reaches the tracer", async () => {
const services = servicesFor();
await services.tracer.record(
{
name: "transfer",
attributes: {
[A.transferGuardCount]: 2,
// Forbidden fields that must never ride a span.
[A.execCommand]: "bash",
command: "rm -rf /",
args: "--force",
stdout: "secret output",
stderr: "secret error",
path: "/etc/passwd",
extra: "leak",
},
},
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
);
expect(mockRecordSpan).toHaveBeenCalledTimes(1);
const attributes = (mockRecordSpan.mock.calls[0]![0] as { attributes: Record<string, unknown> })
.attributes;
// Only the allowlisted key survives; every forbidden key is dropped.
expect(attributes).toEqual({ [A.transferGuardCount]: 2 });
for (const forbidden of [A.execCommand, "command", "args", "stdout", "stderr", "path", "extra"]) {
expect(attributes).not.toHaveProperty(forbidden);
}
});
it("drops a status message (it could carry standard-stream text)", async () => {
const services = servicesFor();
await services.tracer.record(
{ name: "transfer", status: { code: 2, message: "secret error text" } },
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
);
const call = mockRecordSpan.mock.calls[0]![0] as { status?: { code: number; message?: string } };
expect(call.status).toEqual({ code: 2 });
expect(call.status).not.toHaveProperty("message");
});
it("clamps an unknown span name to sandbox.provider.other", async () => {
const services = servicesFor();
await services.tracer.record(
{ name: "rm -rf / --no-preserve-root" },
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
);
expect((mockRecordSpan.mock.calls[0]![0] as { name: string }).name).toBe(
"sandbox.provider.other",
);
});
it("rejects a malformed traceparent — no span is recorded", async () => {
const services = servicesFor();
for (const bad of [
undefined,
"not-a-traceparent",
"00-xyz-b7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331", // missing flags
"00-00000000000000000000000000000000-b7ad6b7169203331-01", // all-zero trace id
]) {
await services.tracer.record(
{ name: "pack" },
{ traceparent: bad } as WorkerHostCallContext,
);
}
expect(mockRecordSpan).not.toHaveBeenCalled();
});
it("rejects a span from a plugin that lacks the environment-driver capability", async () => {
const handlers = handlersFor([]);
await expect(
handlers["span.record"](
{ name: "pack" },
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
),
).rejects.toThrow(/capabilit/i);
expect(mockRecordSpan).not.toHaveBeenCalled();
});
it("admits a span from a plugin that holds the environment-driver capability", async () => {
const handlers = handlersFor(["environment.drivers.register"]);
await handlers["span.record"](
{ name: "pack", attributes: { [A.provider]: "daytona" } },
{ traceparent: VALID_TRACEPARENT } as WorkerHostCallContext,
);
expect(mockRecordSpan).toHaveBeenCalledTimes(1);
});
});
describe("parseTraceparent", () => {
it("accepts a well-formed traceparent and returns the parts", () => {
expect(parseTraceparent(VALID_TRACEPARENT)).toEqual({
traceId: "0af7651916cd43dd8448eb211c80319c",
spanId: "b7ad6b7169203331",
traceFlags: 1,
});
});
it("rejects malformed, all-zero, and forbidden-version traceparents", () => {
expect(parseTraceparent(undefined)).toBeNull();
expect(parseTraceparent("garbage")).toBeNull();
expect(parseTraceparent("00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01")).toBeNull();
expect(parseTraceparent("ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01")).toBeNull();
});
});
describe("clampProviderSpanAttributes", () => {
it("keeps only allowlisted keys and normalizes the provider family", () => {
expect(
clampProviderSpanAttributes({
[A.provider]: "some-operator-key",
[A.packWallMs]: 7,
[A.transferWallMs]: Number.NaN,
[A.execCommand]: "bash",
}),
).toEqual({
// An unknown provider key maps to `plugin`, never the raw key.
[A.provider]: "plugin",
[A.packWallMs]: 7,
// A non-finite number yields no attribute; `exec.command` is not allowed.
});
});
});

View File

@ -171,6 +171,86 @@ export function getStartupTraceContext(name = "paperclip.startup"): StartupTrace
}
}
/**
* The parsed parts of a W3C `traceparent`. The host builds a remote parent span
* context from these parts to parent a plugin span to the active host span.
*/
export interface ParsedTraceparent {
traceId: string;
spanId: string;
traceFlags: number;
}
/**
* Serialize an OTel context token to a W3C `traceparent` string. The host passes
* the token to the plugin worker per call, so the worker's provider span can
* parent to the active host span. The function reads the span context from the
* token and formats it by hand, so it needs no registered propagator. It returns
* `undefined` when `@opentelemetry/api` is absent, when the token holds no span
* context, or when the span context is invalid.
*/
export function traceparentFromContextToken(contextToken: unknown): string | undefined {
if (contextToken === undefined || contextToken === null) return undefined;
try {
const require = createRequire(import.meta.url);
const api = require("@opentelemetry/api") as {
trace?: { getSpanContext(context: unknown): { traceId: string; spanId: string; traceFlags: number } | undefined };
};
const spanContext = api.trace?.getSpanContext?.(contextToken);
if (!spanContext) return undefined;
const { traceId, spanId, traceFlags } = spanContext;
if (!/^[0-9a-f]{32}$/.test(traceId) || !/^[0-9a-f]{16}$/.test(spanId)) return undefined;
const flags = (traceFlags & 0xff).toString(16).padStart(2, "0");
return `00-${traceId}-${spanId}-${flags}`;
} catch {
return undefined;
}
}
/**
* Record a plugin-originated provider span through the real tracer, parented to
* a host span. The host handler validates and clamps the span data first (the
* trust boundary), then passes the parsed parent and the clamped attributes
* here. This function only does the OTel plumbing: it builds a remote parent
* span context, opens the span, sets its attributes and status, and ends it. It
* is a no-op when `@opentelemetry/api` is absent (the endpoint is unset) or when
* the parent parts are invalid. It never throws observability must not change
* control flow.
*/
export function recordProviderPluginSpan(input: {
name: string;
parent: ParsedTraceparent;
attributes: Record<string, string | number | boolean>;
status?: { code: number; message?: string };
}): void {
try {
const require = createRequire(import.meta.url);
const api = require("@opentelemetry/api") as {
trace?: {
getTracer(n: string): StartupTracerHandle;
setSpanContext(context: unknown, spanContext: unknown): unknown;
};
context?: { active(): unknown };
};
const trace = api.trace;
const context = api.context;
if (!trace?.getTracer || !trace.setSpanContext || !context?.active) return;
const remoteSpanContext = {
traceId: input.parent.traceId,
spanId: input.parent.spanId,
traceFlags: input.parent.traceFlags,
isRemote: true,
};
const parentContext = trace.setSpanContext(context.active(), remoteSpanContext);
const tracer = trace.getTracer("paperclip.startup");
const span = tracer.startSpan(input.name, { attributes: input.attributes }, parentContext);
if (input.status) span.setStatus(input.status);
span.end();
} catch {
// Observability must not change control flow.
}
}
/**
* Resolves once the OTel SDK has started (or once bootstrap has failed and
* logged, or immediately when the feature is off). Await before constructing

View File

@ -62,6 +62,13 @@ function toFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
/** Read a free-form metadata value as a boolean, or `undefined`. The provider
* cache-hit flag rides the exec result's untyped `metadata`, so a provider that
* omits or mistypes it yields no attribute never a misleading `false`. */
function toBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
/**
* The closed input for one `sandbox.exec` span. The seam builds it from the
* exec result and the active step context. Every field is already bounded or
@ -82,6 +89,8 @@ interface SandboxExecSpanInput {
sandboxMs: number | undefined;
/** Whether the execution sits on the startup critical path. */
criticalPath: boolean;
/** Whether the provider served the sandbox handle from its warm cache. */
cacheHit: boolean | undefined;
}
/**
@ -111,6 +120,13 @@ function setSandboxExecSpanAttributes(span: ExecSpan, input: SandboxExecSpanInpu
setFiniteNumberAttr(span, A.execNetworkMs, input.wallMs - input.waitBeforeMs - input.sandboxMs);
}
span.setAttribute(A.execCriticalPath, input.criticalPath);
// The explicit provider cache-hit flag, from `result.metadata.cacheHit`. The
// plugin decides it at the handle lookup, so the span no longer infers a
// cache hit from `wait_before_ms == 0`. A provider that omits it yields no
// attribute.
if (typeof input.cacheHit === "boolean") {
span.setAttribute(A.execCacheHit, input.cacheHit);
}
const failed = input.exitCode !== 0;
span.setAttribute(
A.outcome,
@ -325,6 +341,7 @@ export async function resolveEnvironmentExecutionTarget(input: {
waitBeforeMs: toFiniteNumber(result.metadata?.getDurationMs),
sandboxMs: toFiniteNumber(result.metadata?.durationMs),
criticalPath,
cacheHit: toBoolean(result.metadata?.cacheHit),
});
} catch {
// Observability must not change execution control flow.

View File

@ -78,6 +78,12 @@ import { getTelemetryClient } from "../telemetry.js";
import { accessService } from "./access.js";
import { authorizationService, type AuthorizationActor } from "./authorization.js";
import { redactEventPayload, sanitizeRecord } from "../redaction.js";
import type { WorkerHostCallContext } from "@paperclipai/plugin-sdk";
import {
normalizeProviderFamily,
SANDBOX_STARTUP_SPAN_ATTRS,
} from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import { recordProviderPluginSpan, type ParsedTraceparent } from "../instrumentation.js";
// ---------------------------------------------------------------------------
// SSRF protection for plugin HTTP fetch
@ -490,6 +496,124 @@ if (_logFlushInterval.unref) _logFlushInterval.unref();
/** Maximum time (ms) to keep a session event subscription alive before forcing cleanup. */
const SESSION_EVENT_SUBSCRIPTION_TIMEOUT_MS = 30 * 60 * 1_000; // 30 minutes
// ---------------------------------------------------------------------------
// Provider span trust boundary (the `span.record` host handler)
// ---------------------------------------------------------------------------
//
// The plugin worker runs in a separate process, so the host treats every field
// of a worker-sent span as untrusted input. The host re-clamps the span name
// and every attribute here, before it records the span. A worker-side or
// plugin-side helper is not sufficient; this is the single boundary.
const SPAN_ATTRS = SANDBOX_STARTUP_SPAN_ATTRS;
/** The closed set of provider span names a plugin may emit. */
const KNOWN_PROVIDER_SPAN_NAMES: ReadonlySet<string> = new Set(["pack", "transfer"]);
/** Clamp the span name to a closed, namespaced set. A known name maps to
* `sandbox.provider.<name>`; any other value maps to `sandbox.provider.other`,
* so a span name never carries free-form data. */
function clampProviderSpanName(raw: unknown): string {
const name = typeof raw === "string" && KNOWN_PROVIDER_SPAN_NAMES.has(raw) ? raw : "other";
return `sandbox.provider.${name}`;
}
/** The closed allowlist of attribute keys a provider span may carry. The host
* drops every other key, so a command, an argument, a path, an id, a standard
* output, a standard error, or an `extra` field can never ride a provider span. */
const PROVIDER_SPAN_ATTR_ALLOWLIST: ReadonlySet<string> = new Set<string>([
SPAN_ATTRS.provider,
SPAN_ATTRS.outcome,
SPAN_ATTRS.packWallMs,
SPAN_ATTRS.transferWallMs,
SPAN_ATTRS.transferGuardCount,
]);
/** The subset of allowed keys that carry a finite number. */
const PROVIDER_SPAN_NUMERIC_ATTRS: ReadonlySet<string> = new Set<string>([
SPAN_ATTRS.packWallMs,
SPAN_ATTRS.transferWallMs,
SPAN_ATTRS.transferGuardCount,
]);
/** The closed value set for the `outcome` attribute. */
const KNOWN_SPAN_OUTCOMES: ReadonlySet<string> = new Set(["ok", "skipped", "failed"]);
/**
* Re-clamp the worker-sent attributes at the trust boundary. Drop every key that
* is not on the allowlist. Re-map `provider` through `normalizeProviderFamily`,
* bound `outcome` to its closed set, and keep a numeric attribute only when it
* is a finite number. The result holds only bounded, low-cardinality values.
*/
export function clampProviderSpanAttributes(
raw: Record<string, unknown> | undefined,
): Record<string, string | number | boolean> {
const clamped: Record<string, string | number | boolean> = {};
if (!raw) return clamped;
for (const [key, value] of Object.entries(raw)) {
if (!PROVIDER_SPAN_ATTR_ALLOWLIST.has(key)) continue;
if (key === SPAN_ATTRS.provider) {
clamped[key] = normalizeProviderFamily(typeof value === "string" ? value : undefined);
continue;
}
if (key === SPAN_ATTRS.outcome) {
if (typeof value === "string" && KNOWN_SPAN_OUTCOMES.has(value)) clamped[key] = value;
continue;
}
if (PROVIDER_SPAN_NUMERIC_ATTRS.has(key)) {
if (typeof value === "number" && Number.isFinite(value)) clamped[key] = value;
continue;
}
}
return clamped;
}
/**
* Parse and validate a W3C `traceparent`. Return the parts, or `null` when the
* value is absent or malformed. The host mints the value, but this is the trust
* boundary, so it validates before use. It never logs the value.
*/
export function parseTraceparent(raw: string | undefined | null): ParsedTraceparent | null {
if (typeof raw !== "string") return null;
const match = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/.exec(raw);
if (!match) return null;
const [, version, traceId, spanId, flags] = match;
if (version === "ff") return null; // the W3C spec forbids version 0xff
if (traceId === "0".repeat(32)) return null; // an all-zero trace id is invalid
if (spanId === "0".repeat(16)) return null; // an all-zero span id is invalid
return { traceId, spanId, traceFlags: parseInt(flags, 16) };
}
/** Keep only the numeric status code. A status message could carry free-form
* text, so the host drops it never a standard-stream text on a span. */
function clampSpanStatus(
status: { code?: unknown; message?: unknown } | undefined,
): { code: number } | undefined {
if (!status || typeof status.code !== "number" || !Number.isFinite(status.code)) return undefined;
return { code: status.code };
}
/**
* Record a worker-sent provider span through the real tracer. This is the host
* trust boundary: it validates the host-minted `traceparent`, re-clamps the span
* name and every attribute, mints the parentage host-side, and drops a status
* message. It rejects a span with a missing or malformed `traceparent`. It never
* throws observability must not change control flow.
*/
export function recordWorkerProviderSpan(
params: { name: string; attributes?: Record<string, unknown>; status?: { code?: unknown; message?: unknown } },
context: WorkerHostCallContext | undefined,
): void {
const parent = parseTraceparent(context?.traceparent);
if (!parent) return; // reject a missing or malformed traceparent
recordProviderPluginSpan({
name: clampProviderSpanName(params.name),
parent,
attributes: clampProviderSpanAttributes(params.attributes),
...(clampSpanStatus(params.status) ? { status: clampSpanStatus(params.status) } : {}),
});
}
export function buildHostServices(
db: Db,
pluginId: string,
@ -1485,6 +1609,17 @@ export function buildHostServices(
},
},
tracer: {
async record(params, context) {
// The host trust boundary: validate the host-minted `traceparent`,
// re-clamp the span name and every attribute, mint the parentage
// host-side, and record the span through the real tracer. The capability
// gate in `createHostClientHandlers` already rejected an ungranted
// plugin before this runs.
recordWorkerProviderSpan(params, context);
},
},
companies: {
async list(params) {
return applyWindow((await companies.list()) as Company[], params);

View File

@ -52,7 +52,9 @@ import type {
WorkerToHostMethods,
InitializeParams,
} from "@paperclipai/plugin-sdk";
import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
import { logger } from "../middleware/logger.js";
import { traceparentFromContextToken } from "../instrumentation.js";
// ---------------------------------------------------------------------------
// Constants
@ -260,6 +262,10 @@ interface PendingRequest {
interface ActiveInvocation {
scope: PluginInvocationScope;
timer?: ReturnType<typeof setTimeout>;
// The host-minted W3C `traceparent` for the active startup span, or undefined
// when no startup span is active. The span host handler reads it to mint the
// parentage, so a worker never supplies the parent itself.
traceparent?: string;
}
// ---------------------------------------------------------------------------
@ -621,11 +627,20 @@ export function createPluginWorkerHandle(
}
function registerInvocation(scope: PluginInvocationScope, ttlMs?: number): PluginInvocationContext {
// Mint a W3C `traceparent` from the active startup span, so the worker's
// provider span can parent to it. The host keeps the value on its own record
// (below) and never trusts the worker to supply the parent. Outside a
// measured startup step there is no active span, so this is undefined.
const activeStep = getActiveStepContext();
const traceparent = activeStep
? traceparentFromContextToken(activeStep.parentContext)
: undefined;
const invocation: PluginInvocationContext = {
id: randomUUID(),
scope,
...(traceparent ? { traceparent } : {}),
};
const entry: ActiveInvocation = { scope };
const entry: ActiveInvocation = { scope, traceparent };
if (ttlMs !== undefined) {
entry.timer = setTimeout(() => {
activeInvocations.delete(invocation.id);
@ -703,7 +718,7 @@ export function createPluginWorkerHandle(
}
const entry = activeInvocations.get(invocationId);
if (!entry) return { invalidInvocationScope: true };
return { invocationScope: entry.scope };
return { invocationScope: entry.scope, traceparent: entry.traceparent };
}
/**