@ciphyrshq/sdk 2.6.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.js ADDED
@@ -0,0 +1,82 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Ambient trace/span context for the JavaScript SDK.
3
+ //
4
+ // WHY. Spans took `parentSpanId` as a hand-threaded option, so in practice
5
+ // nothing was ever nested: every span in a trace arrived parentless. The
6
+ // server derives the topology graph from parent links, so a flat trace draws
7
+ // no edges — the customer saw a row of disconnected agents and concluded
8
+ // topology was broken. It also meant a guard call inside a span had no span
9
+ // to point at, exactly the defect the Python SDK had (measured on
10
+ // production: every guard-path detection had a NULL span_id).
11
+ //
12
+ // AsyncLocalStorage, not a module global: a global is wrong the moment two
13
+ // requests are in flight, which is the normal shape of a Node agent. ALS is
14
+ // per-async-context and is inherited by anything the current task spawns.
15
+ //
16
+ // TWO WAYS IN, deliberately. `span.run(fn)` is a real scope and is what the
17
+ // docs recommend. But this SDK's public shape has always been
18
+ // `const s = trace.span(...); …; s.end()`, with no callback, so
19
+ // `enterWith` is used at construction and unwound at `end()`. That is what
20
+ // makes the existing style nest correctly without asking every user to
21
+ // rewrite their agent.
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+ import { AsyncLocalStorage } from 'node:async_hooks';
24
+
25
+ export const traceStorage = new AsyncLocalStorage();
26
+ export const spanStorage = new AsyncLocalStorage();
27
+
28
+ /** The trace/span an inbound request arrived under (see propagation.js). */
29
+ export const remoteStorage = new AsyncLocalStorage();
30
+
31
+ /**
32
+ * The active trace and span, if any.
33
+ *
34
+ * Prefers the span, which carries both ids — a guard call made inside span()
35
+ * should attribute to that span, not merely to its trace. Falls back to the
36
+ * remote parent so a guard call made straight from a request handler, with no
37
+ * local span open, still attributes to the distributed trace it belongs to.
38
+ * Returns an object with undefined fields rather than null so callers can
39
+ * spread it without branching.
40
+ */
41
+ export function activeIds() {
42
+ const span = spanStorage.getStore();
43
+ if (span?.trace_id) {
44
+ return { trace_id: span.trace_id, span_id: span.span_id };
45
+ }
46
+ const trace = traceStorage.getStore();
47
+ if (trace?.trace_id) {
48
+ return { trace_id: trace.trace_id, span_id: undefined };
49
+ }
50
+ const remote = remoteStorage.getStore();
51
+ if (remote?.trace_id) {
52
+ return { trace_id: remote.trace_id, span_id: remote.span_id };
53
+ }
54
+ return { trace_id: undefined, span_id: undefined };
55
+ }
56
+
57
+ /**
58
+ * The kind of the innermost LOCAL span ('agent' | 'tool' | 'llm' | 'retriever'),
59
+ * or undefined when no local span is open.
60
+ *
61
+ * Only spanStorage answers this, deliberately. A span kind never travels in
62
+ * Ciphyrs baggage, so a remote parent has none, and a trace is not a span —
63
+ * in both of those cases the honest answer is "I do not know what that span
64
+ * is", not "agent". The one caller that needs it is protect-tool.js: telling
65
+ * the gateway which span authorised a tool call is only true if the span is
66
+ * the tool span, and claiming it otherwise is worse than saying nothing.
67
+ */
68
+ export function activeSpanKind() {
69
+ return spanStorage.getStore()?.kind || undefined;
70
+ }
71
+
72
+ /** The agent name of the innermost active span, for outbound headers. */
73
+ export function activeAgent() {
74
+ return spanStorage.getStore()?.agent_name
75
+ || remoteStorage.getStore()?.agent_name
76
+ || undefined;
77
+ }
78
+
79
+ /** The remote parent, if this async context is serving an instrumented request. */
80
+ export function remoteParent() {
81
+ return remoteStorage.getStore() || undefined;
82
+ }
@@ -0,0 +1,100 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // One failure posture for the whole client.
3
+ //
4
+ // WHY. The three entry points that stand between a customer and their
5
+ // LLM/tool disagreed about what a Ciphyrs outage means. protectTool caught
6
+ // the transport error and ran the tool anyway (fail-OPEN); guard.wrap and
7
+ // scan.protect let the error propagate out of check()/mask(), so nothing ran
8
+ // (fail-CLOSED). One client, two opposite behaviours under one outage — and
9
+ // the unsafe one sat on the path that authorises tool execution, i.e. the
10
+ // path that moves money. Neither closed path offered any way to opt out, so
11
+ // a customer could not align them either.
12
+ //
13
+ // So the posture is one option, resolved here, and used identically by all
14
+ // three: `failOpen: true` means "a Ciphyrs outage must not stop my work",
15
+ // `failOpen: false` (the default, everywhere) means the work does not
16
+ // proceed. Settable on the client and overridable per call.
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+
19
+ /** What to tell a caller who just got blocked by an outage. */
20
+ export const FAIL_OPEN_HINT =
21
+ 'To run anyway while Ciphyrs is unreachable, pass { failOpen: true } on this call ' +
22
+ 'or construct the client with new CiphyrsClient({ failOpen: true }).'
23
+
24
+ /**
25
+ * Read one posture value: true, false, or undefined for "nobody chose".
26
+ *
27
+ * WHY this is not `Boolean(v)` / `!v`. A posture reaches us from
28
+ * configuration at least as often as from code — a JSON file, an env var, a
29
+ * database column, a `{ ...defaults }` spread — and those hand over `null` or
30
+ * a string, which plain truthiness reads the UNSAFE way:
31
+ *
32
+ * `!null` is true, so a `failClosed` column nobody has ever set resolved to
33
+ * fail OPEN — an unset value silently choosing the direction that runs the
34
+ * tool during an outage.
35
+ * `Boolean('false')` is true, so `failOpen: process.env.FAIL_OPEN` with the
36
+ * var set to "false" resolved to fail OPEN too — a written-down "no" read as
37
+ * a yes.
38
+ *
39
+ * Fail-open is the direction that costs the customer money or leaks their PII,
40
+ * so it is only ever reached by a value that unmistakably says so. Anything
41
+ * else — null, '', an object, NaN, a string we cannot read — is "no choice
42
+ * made", which falls through to the next level and ultimately to fail CLOSED.
43
+ * Note the asymmetry that made this urgent: a nullish `failOpen` already
44
+ * landed safely; only the inverted key landed unsafely.
45
+ */
46
+ const YES = new Set(['true', '1', 'yes', 'on'])
47
+ const NO = new Set(['false', '0', 'no', 'off'])
48
+
49
+ function readPosture(v) {
50
+ if (v === undefined || v === null) return undefined
51
+ if (typeof v === 'boolean') return v
52
+ // 0/1 is how a boolean survives MySQL and SQLite, so a number is a real
53
+ // choice — except NaN, which is what `Number(undefined)` produces.
54
+ if (typeof v === 'number') return Number.isFinite(v) ? v !== 0 : undefined
55
+ if (typeof v === 'string') {
56
+ const s = v.trim().toLowerCase()
57
+ if (YES.has(s)) return true
58
+ if (NO.has(s)) return false
59
+ return undefined // '' or anything unrecognised: not a choice
60
+ }
61
+ return undefined // objects, arrays, functions: not a posture
62
+ }
63
+
64
+ /**
65
+ * Should this call proceed when Ciphyrs cannot be reached?
66
+ *
67
+ * @param {object} [callOpts] Per-call options ({ failOpen }, plus legacy
68
+ * { failClosed } on the surfaces that opt in)
69
+ * @param {boolean} [clientDefault=false] The client-wide posture
70
+ * @param {object} [surface]
71
+ * @param {boolean} [surface.legacyFailClosed=false]
72
+ * Read the legacy `failClosed` key on this surface. OFF by default, and
73
+ * opted into by protectTool alone, because protectTool is the only
74
+ * surface `failClosed` ever did anything on. guard.wrap ignored the key
75
+ * outright and scan.protect forwarded it to mask(), which whitelists its
76
+ * fields, so it was inert there too. Reading it everywhere turned a key
77
+ * someone copy-pasted from a protectTool call site into a live switch on
78
+ * two new surfaces — and `{ failClosed: false }` on scan.protect means
79
+ * sending UNMASKED text to the customer's model. A resolver shared by
80
+ * three surfaces must not hand a fourth one the legacy key by default.
81
+ * @returns {boolean} true = fail open (run anyway), false = fail closed (do not run)
82
+ */
83
+ export function resolveFailOpen(callOpts, clientDefault = false, { legacyFailClosed = false } = {}) {
84
+ const o = (callOpts && typeof callOpts === 'object') ? callOpts : {}
85
+
86
+ const open = readPosture(o.failOpen)
87
+ if (open !== undefined) return open
88
+
89
+ // `failClosed` has shipped on protectTool since V60 and must keep working in
90
+ // BOTH directions there. A caller who wrote `failClosed: false` wrote down a
91
+ // request to fail open; silently re-reading that as "did not choose" and
92
+ // applying the new fail-closed default is precisely the upgrade surprise
93
+ // this option exists to prevent.
94
+ if (legacyFailClosed) {
95
+ const closed = readPosture(o.failClosed)
96
+ if (closed !== undefined) return !closed
97
+ }
98
+
99
+ return readPosture(clientDefault) === true
100
+ }
package/src/index.js CHANGED
@@ -9,5 +9,19 @@ export {
9
9
  CiphyrsJobTimeoutError,
10
10
  } from './errors.js';
11
11
  export { CiphyrsTracer, Trace, Span } from './tracer.js';
12
+ // 2.7 — cross-process trace propagation: what makes agents in separate
13
+ // processes appear as one connected topology instead of isolated nodes.
14
+ export {
15
+ inject, extract, withRemoteContext, currentContext,
16
+ instrumentFetch, instrumentHttp, autoInstrument,
17
+ expressMiddleware, fastifyPlugin, withPropagation,
18
+ w3cTraceId, w3cSpanId, TRACEPARENT, BAGGAGE,
19
+ } from './propagation.js';
20
+ // activeSpanKind is exported alongside the ids because "which span am I in?"
21
+ // is not answerable from the ids alone, and the answer decides whether a span
22
+ // id may be reported to the tool gate at all — see protect-tool.js.
23
+ export { activeIds, activeAgent, activeSpanKind, remoteParent } from './context.js';
12
24
  export { SecretDetector } from './secret-detector.js';
13
25
  export { EvalRunner } from './eval-runner.js';
26
+ // V60 — agentic governance: tool-call authorization
27
+ export { protectTool, ToolBlocked, ToolApprovalTimeout } from './protect-tool.js';
@@ -0,0 +1,388 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Trace-context propagation between agents (W3C `traceparent` + `baggage`)
3
+ //
4
+ // WHY THIS EXISTS. The topology graph is derived on the server: an edge
5
+ // A → B is drawn when a span of agent B names a span of agent A as its
6
+ // parent. Inside one process the SDK's AsyncLocalStorage provides that.
7
+ // Across processes nothing did — the SDK never wrote `traceparent` on an
8
+ // outbound call and never read it on an inbound one — so two agents that
9
+ // talked to each other constantly rendered as two disconnected nodes.
10
+ //
11
+ // Outbound: `instrumentFetch()` wraps global fetch and `instrumentHttp()`
12
+ // wraps node:http / node:https request+get, which together cover fetch,
13
+ // axios, node-fetch, got and superagent. Calls to the Ciphyrs API itself are
14
+ // never decorated (registerInternalOrigin, called by CiphyrsClient).
15
+ //
16
+ // Inbound: `expressMiddleware()`, `fastifyPlugin()` and the generic
17
+ // `withRemoteContext(headers, fn)` activate the caller's trace for the
18
+ // duration of a request. `tracer.trace()` then continues that trace and its
19
+ // first span is parented to the caller's span, so the edge exists as soon as
20
+ // both spans arrive, in either order (server-side V143).
21
+ //
22
+ // Anything else (queues, gRPC, a framework not listed): `inject(headers)`
23
+ // when you send, `withRemoteContext(headers, fn)` when you receive.
24
+ //
25
+ // ID SHAPE. `traceparent` requires 32-hex trace ids and 16-hex span ids.
26
+ // Since 2.7 the tracer generates ids in exactly that shape, so the header
27
+ // carries the REAL ids and an OpenTelemetry-instrumented peer joins the same
28
+ // trace. Ids that are not hex (one you supplied, or one from an older SDK)
29
+ // are hashed into the W3C fields while the exact originals travel in
30
+ // `baggage`, so a Ciphyrs peer still links precisely.
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+ import { createHash } from 'node:crypto';
33
+ import { createRequire } from 'node:module';
34
+ import { activeAgent, remoteParent, remoteStorage, spanStorage, traceStorage } from './context.js';
35
+
36
+ export const TRACEPARENT = 'traceparent';
37
+ export const BAGGAGE = 'baggage';
38
+
39
+ const B_TRACE = 'ciphyrs.trace_id';
40
+ const B_SPAN = 'ciphyrs.span_id';
41
+ const B_AGENT = 'ciphyrs.agent';
42
+ const B_PROJECT = 'ciphyrs.project';
43
+
44
+ const HEX32 = /^[0-9a-f]{32}$/;
45
+ const HEX16 = /^[0-9a-f]{16}$/;
46
+ const TRACEPARENT_RE = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
47
+
48
+ const ZERO32 = '0'.repeat(32);
49
+ const ZERO16 = '0'.repeat(16);
50
+
51
+ let defaultProject;
52
+ /** Set by CiphyrsTracer so baggage names the caller's project. */
53
+ export function setDefaultProject(name) { defaultProject = name || undefined; }
54
+
55
+ // Origins that belong to Ciphyrs itself. Calls there are the SDK reporting
56
+ // telemetry, not the agent doing work, and must not carry trace headers.
57
+ const internalOrigins = new Set(['https://www.ciphyrs.com', 'https://ciphyrs.com']);
58
+ export function registerInternalOrigin(url) {
59
+ try { internalOrigins.add(new URL(url).origin); } catch { /* not a URL — ignore */ }
60
+ }
61
+ function isInternal(url) {
62
+ try { return internalOrigins.has(new URL(url).origin); } catch { return false; }
63
+ }
64
+
65
+ // ── W3C field shaping ──────────────────────────────────────────────────────
66
+ export function w3cTraceId(traceId) {
67
+ const t = String(traceId).toLowerCase();
68
+ if (HEX32.test(t)) return t;
69
+ return createHash('sha256').update(String(traceId)).digest('hex').slice(0, 32);
70
+ }
71
+ export function w3cSpanId(spanId) {
72
+ const s = String(spanId).toLowerCase();
73
+ if (HEX16.test(s)) return s;
74
+ return createHash('sha256').update(String(spanId)).digest('hex').slice(0, 16);
75
+ }
76
+
77
+ // ── Outbound ───────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * The context an outbound call should carry: the local span if we are in one,
81
+ * else the trace, else the remote parent we are serving (pass-through).
82
+ */
83
+ export function currentContext() {
84
+ const span = spanStorage.getStore();
85
+ if (span?.trace_id) {
86
+ return { trace_id: span.trace_id, span_id: span.span_id, agent_name: span.agent_name, project: defaultProject };
87
+ }
88
+ const trace = traceStorage.getStore();
89
+ if (trace?.trace_id) {
90
+ return { trace_id: trace.trace_id, span_id: undefined, agent_name: activeAgent(), project: defaultProject };
91
+ }
92
+ const remote = remoteParent();
93
+ if (remote?.trace_id) {
94
+ return {
95
+ trace_id: remote.trace_id, span_id: remote.span_id,
96
+ agent_name: activeAgent() || remote.agent_name,
97
+ project: defaultProject || remote.project,
98
+ };
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ function mergeBaggage(existing, items) {
104
+ const kept = [];
105
+ for (const member of String(existing || '').split(',')) {
106
+ const m = member.trim();
107
+ if (!m) continue;
108
+ const key = m.split('=', 1)[0].trim();
109
+ if (key in items) continue; // ours — replaced, never duplicated
110
+ kept.push(m);
111
+ }
112
+ for (const [k, v] of Object.entries(items)) kept.push(`${k}=${encodeURIComponent(v)}`);
113
+ return kept.join(',');
114
+ }
115
+
116
+ function headerValues(ctx, existingBaggage) {
117
+ const spanHex = ctx.span_id ? w3cSpanId(ctx.span_id) : `${'0'.repeat(15)}1`;
118
+ const items = { [B_TRACE]: ctx.trace_id };
119
+ if (ctx.span_id) items[B_SPAN] = ctx.span_id;
120
+ if (ctx.agent_name) items[B_AGENT] = ctx.agent_name;
121
+ if (ctx.project) items[B_PROJECT] = ctx.project;
122
+ return {
123
+ [TRACEPARENT]: `00-${w3cTraceId(ctx.trace_id)}-${spanHex}-01`,
124
+ [BAGGAGE]: mergeBaggage(existingBaggage, items),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Add `traceparent` and `baggage` for the active context.
130
+ *
131
+ * Accepts (and returns) a plain object or a Headers/fetch-style object with
132
+ * get/set. Outside a trace it is a no-op, so it is safe to call always:
133
+ *
134
+ * await fetch(url, { headers: inject({ 'content-type': 'application/json' }) })
135
+ */
136
+ export function inject(headers = {}) {
137
+ const ctx = currentContext();
138
+ if (!ctx) return headers;
139
+ const settable = typeof headers?.set === 'function';
140
+ const existing = settable
141
+ ? headers.get?.(BAGGAGE)
142
+ : headers[BAGGAGE] ?? headers[BAGGAGE.toUpperCase()] ?? headers.Baggage;
143
+ const values = headerValues(ctx, existing);
144
+ for (const [k, v] of Object.entries(values)) {
145
+ if (settable) headers.set(k, v); else headers[k] = v;
146
+ }
147
+ return headers;
148
+ }
149
+
150
+ // ── Inbound ────────────────────────────────────────────────────────────────
151
+
152
+ function lookup(headers) {
153
+ const out = {};
154
+ if (!headers) return out;
155
+ const want = new Set([TRACEPARENT, BAGGAGE]);
156
+ const take = (k, v) => {
157
+ if (v == null) return;
158
+ let key = String(k).toLowerCase();
159
+ if (key.startsWith('http_')) key = key.slice(5).replace(/_/g, '-'); // CGI-style
160
+ if (want.has(key)) out[key] = Array.isArray(v) ? v[0] : String(v);
161
+ };
162
+ if (typeof headers.get === 'function') { // Headers / fetch Request
163
+ for (const key of want) take(key, headers.get(key));
164
+ return out;
165
+ }
166
+ if (typeof headers.forEach === 'function' && !Array.isArray(headers)) { // Map-like
167
+ headers.forEach((v, k) => take(k, v));
168
+ return out;
169
+ }
170
+ if (Array.isArray(headers)) { // [[k, v], …]
171
+ for (const pair of headers) if (Array.isArray(pair)) take(pair[0], pair[1]);
172
+ return out;
173
+ }
174
+ for (const [k, v] of Object.entries(headers)) take(k, v); // node req.headers
175
+ return out;
176
+ }
177
+
178
+ function parseBaggage(raw) {
179
+ const out = {};
180
+ for (const member of String(raw || '').split(',')) {
181
+ const m = member.trim();
182
+ if (!m || !m.includes('=')) continue;
183
+ const kv = m.split(';', 1)[0];
184
+ const idx = kv.indexOf('=');
185
+ const k = kv.slice(0, idx).trim();
186
+ if (k) out[k] = decodeURIComponent(kv.slice(idx + 1).trim());
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Read the caller's trace context from inbound headers. Returns undefined
193
+ * when nothing usable is present. Ciphyrs baggage (exact ids) wins over
194
+ * `traceparent` (hashed/hex ids).
195
+ */
196
+ export function extract(headers) {
197
+ const h = lookup(headers);
198
+ if (!Object.keys(h).length) return undefined;
199
+ const bag = parseBaggage(h[BAGGAGE]);
200
+ let traceId = bag[B_TRACE] || undefined;
201
+ let spanId = bag[B_SPAN] || undefined;
202
+ let sampled = true;
203
+ const tp = h[TRACEPARENT];
204
+ if (tp) {
205
+ const m = TRACEPARENT_RE.exec(String(tp).trim().toLowerCase());
206
+ if (m) {
207
+ const [, , tpTrace, tpSpan, flags] = m;
208
+ if (tpTrace !== ZERO32) {
209
+ traceId = traceId || tpTrace;
210
+ spanId = spanId || (tpSpan !== ZERO16 ? tpSpan : undefined);
211
+ }
212
+ const f = parseInt(flags, 16);
213
+ sampled = Number.isNaN(f) ? true : Boolean(f & 0x01);
214
+ }
215
+ }
216
+ if (!traceId) return undefined;
217
+ return {
218
+ trace_id: traceId,
219
+ span_id: spanId,
220
+ agent_name: bag[B_AGENT] || undefined,
221
+ project: bag[B_PROJECT] || undefined,
222
+ sampled,
223
+ };
224
+ }
225
+
226
+ /**
227
+ * Run `fn` as the continuation of a caller's trace:
228
+ *
229
+ * await withRemoteContext(message.headers, async () => {
230
+ * const t = tracer.trace('handle job'); // continues the trace
231
+ * await t.span('worker-agent').run(async () => {}) // parented to the caller
232
+ * })
233
+ *
234
+ * Accepts headers in any shape `extract` takes, or a ready context object.
235
+ * With nothing usable it simply runs `fn`.
236
+ */
237
+ export function withRemoteContext(headersOrCtx, fn) {
238
+ const ctx = headersOrCtx && headersOrCtx.trace_id ? headersOrCtx : extract(headersOrCtx);
239
+ if (!ctx) return fn();
240
+ return remoteStorage.run(ctx, fn);
241
+ }
242
+
243
+ // ── Client instrumentation ─────────────────────────────────────────────────
244
+
245
+ let fetchPatched = false;
246
+ /** Wrap global fetch so calls made inside a span carry the headers. Idempotent. */
247
+ export function instrumentFetch() {
248
+ if (fetchPatched || typeof globalThis.fetch !== 'function') return false;
249
+ const original = globalThis.fetch;
250
+ globalThis.fetch = function ciphyrsFetch(input, init = {}) {
251
+ try {
252
+ const url = typeof input === 'string' ? input : (input?.url ?? String(input));
253
+ if (!init?.ciphyrsInternal && !isInternal(url) && currentContext()) {
254
+ // A Request object's headers are immutable through init, so rebuild.
255
+ if (typeof Request !== 'undefined' && input instanceof Request) {
256
+ const headers = new Headers(input.headers);
257
+ inject(headers);
258
+ return original.call(this, new Request(input, { headers }), init);
259
+ }
260
+ const headers = init.headers instanceof Headers ? init.headers : new Headers(init.headers || {});
261
+ inject(headers);
262
+ return original.call(this, input, { ...init, headers });
263
+ }
264
+ } catch { /* never break the customer's call */ }
265
+ return original.call(this, input, init);
266
+ };
267
+ globalThis.fetch.__ciphyrsOriginal = original;
268
+ fetchPatched = true;
269
+ return true;
270
+ }
271
+
272
+ let httpPatched = false;
273
+ /**
274
+ * Wrap node:http / node:https request+get, which is how axios, node-fetch,
275
+ * got and superagent actually send. Idempotent; returns false if already done.
276
+ */
277
+ export function instrumentHttp() {
278
+ if (httpPatched) return false;
279
+ let http, https;
280
+ try {
281
+ // A static `import` of node:http would be evaluated in every environment
282
+ // that loads this module, including browsers/bundlers where it does not
283
+ // exist. createRequire keeps the dependency lazy and Node-only.
284
+ const req = createRequire(import.meta.url);
285
+ http = req('node:http');
286
+ https = req('node:https');
287
+ } catch {
288
+ return false; // not Node, or no CJS resolver
289
+ }
290
+ const patch = (mod, name, scheme) => {
291
+ const original = mod[name];
292
+ if (typeof original !== 'function' || original.__ciphyrs) return;
293
+ const wrapped = function ciphyrsRequest(...args) {
294
+ try {
295
+ const ctx = currentContext();
296
+ if (ctx) {
297
+ // Signatures: (url[, options][, cb]) and (options[, cb]).
298
+ let urlStr = null;
299
+ let optIdx = -1;
300
+ if (typeof args[0] === 'string' || args[0] instanceof URL) {
301
+ urlStr = String(args[0]);
302
+ if (args[1] && typeof args[1] === 'object') optIdx = 1;
303
+ } else if (args[0] && typeof args[0] === 'object') {
304
+ optIdx = 0;
305
+ const o = args[0];
306
+ const host = o.host || o.hostname || 'localhost';
307
+ urlStr = `${o.protocol || scheme}//${host}${o.path || '/'}`;
308
+ }
309
+ if (!isInternal(urlStr)) {
310
+ if (optIdx === -1) {
311
+ // No options object to carry headers — add one.
312
+ const opts = {};
313
+ inject(opts.headers = {});
314
+ args.splice(typeof args[1] === 'function' ? 1 : args.length, 0, opts);
315
+ } else {
316
+ const opts = args[optIdx];
317
+ opts.headers = opts.headers || {};
318
+ inject(opts.headers);
319
+ }
320
+ }
321
+ }
322
+ } catch { /* never break the customer's call */ }
323
+ return original.apply(this, args);
324
+ };
325
+ wrapped.__ciphyrs = true;
326
+ wrapped.__ciphyrsOriginal = original;
327
+ mod[name] = wrapped;
328
+ };
329
+ patch(http, 'request', 'http:');
330
+ patch(http, 'get', 'http:');
331
+ patch(https, 'request', 'https:');
332
+ patch(https, 'get', 'https:');
333
+ httpPatched = true;
334
+ return true;
335
+ }
336
+
337
+ /** Instrument every supported outbound client. Called by CiphyrsTracer. */
338
+ export function autoInstrument() {
339
+ return { fetch: instrumentFetch(), http: instrumentHttp() };
340
+ }
341
+
342
+ // ── Server integrations ────────────────────────────────────────────────────
343
+
344
+ /**
345
+ * Express / Connect middleware:
346
+ *
347
+ * app.use(expressMiddleware())
348
+ */
349
+ export function expressMiddleware() {
350
+ return function ciphyrsPropagation(req, res, next) {
351
+ const ctx = extract(req.headers);
352
+ if (!ctx) return next();
353
+ return remoteStorage.run(ctx, () => next());
354
+ };
355
+ }
356
+
357
+ /**
358
+ * Fastify plugin:
359
+ *
360
+ * await app.register(fastifyPlugin)
361
+ *
362
+ * Uses an onRequest hook wrapped around the rest of the lifecycle via
363
+ * AsyncLocalStorage.run, which Fastify propagates to handlers.
364
+ */
365
+ export async function fastifyPlugin(app) {
366
+ app.addHook('onRequest', (req, reply, done) => {
367
+ const ctx = extract(req.headers);
368
+ if (!ctx) return done();
369
+ return remoteStorage.run(ctx, () => done());
370
+ });
371
+ }
372
+ fastifyPlugin[Symbol.for('skip-override')] = true;
373
+
374
+ /**
375
+ * Raw node http handler wrapper:
376
+ *
377
+ * http.createServer(withPropagation((req, res) => { … }))
378
+ */
379
+ export function withPropagation(handler) {
380
+ return function ciphyrsHandler(req, res) {
381
+ const ctx = extract(req.headers);
382
+ if (!ctx) return handler(req, res);
383
+ return remoteStorage.run(ctx, () => handler(req, res));
384
+ };
385
+ }
386
+
387
+ /** @internal — exported for tests. */
388
+ export const _parseBaggage = parseBaggage;