@cygnuxlabs/tracekit 0.0.0-stage → 1.2.2
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/README.md +294 -2
- package/bin/tracekit.mjs +30 -0
- package/dist/v1.d.ts +97 -0
- package/dist/v1.js +535 -0
- package/dist/v2/adapters/claude-agent-sdk.d.ts +45 -0
- package/dist/v2/adapters/claude-agent-sdk.js +142 -0
- package/dist/v2/adapters/gate.d.ts +62 -0
- package/dist/v2/adapters/gate.js +195 -0
- package/dist/v2/adapters/openai-agents.d.ts +85 -0
- package/dist/v2/adapters/openai-agents.js +114 -0
- package/dist/v2/adapters/vercel-ai.d.ts +51 -0
- package/dist/v2/adapters/vercel-ai.js +132 -0
- package/dist/v2/client.d.ts +159 -0
- package/dist/v2/client.js +749 -0
- package/dist/v2/executor.d.ts +49 -0
- package/dist/v2/executor.js +57 -0
- package/dist/v2/jcs.d.ts +5 -0
- package/dist/v2/jcs.js +12 -0
- package/dist/v2/launcher.d.ts +10 -0
- package/dist/v2/launcher.js +50 -0
- package/dist/v2/rpc_schema.d.ts +3 -0
- package/dist/v2/rpc_schema.js +43 -0
- package/dist/v2/validate.d.ts +6 -0
- package/dist/v2/validate.js +70 -0
- package/dist/verify/cli.d.ts +1 -0
- package/dist/verify/cli.js +46 -0
- package/dist/verify/detect.d.ts +41 -0
- package/dist/verify/detect.js +289 -0
- package/dist/verify/index.d.ts +34 -0
- package/dist/verify/index.js +930 -0
- package/dist/verify/jcs.d.ts +19 -0
- package/dist/verify/jcs.js +154 -0
- package/dist/verify/primitives.d.ts +62 -0
- package/dist/verify/primitives.js +298 -0
- package/dist/verify/schema.d.ts +11 -0
- package/dist/verify/schema.js +98 -0
- package/dist/verify/tracekit.event.v2.json +4588 -0
- package/dist/verify/zip.d.ts +17 -0
- package/dist/verify/zip.js +145 -0
- package/package.json +42 -4
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native client for the v2 signer RPC (tracekit/sdk/client.py in TypeScript), Node built-ins only.
|
|
3
|
+
*
|
|
4
|
+
* import { Client, withRun } from "@cygnuxlabs/tracekit/v2";
|
|
5
|
+
* const run = await new Client().registerRun("my-agent");
|
|
6
|
+
* const d = await run.decide("call-1", "Bash", '{"command": "ls"}');
|
|
7
|
+
* if (d.decision === "allow") await run.complete("call-1", "ok");
|
|
8
|
+
* await run.close();
|
|
9
|
+
*
|
|
10
|
+
* The signer is the Unix socket in $TRACEKIT_SIGNER, `tcp://host:port` with $TRACEKIT_SIGNER_TOKEN (the loopback dev
|
|
11
|
+
* transport, mutual HMAC proof of the token), or an `https://host:port` URL (each call is one POST /v2/rpc;
|
|
12
|
+
* $TRACEKIT_SIGNER_TOKEN_FILE names a bearer token file re-read per call, $TRACEKIT_SIGNER_CERT/_KEY a client
|
|
13
|
+
* certificate for mTLS, $TRACEKIT_SIGNER_CA the signer's CA). In system mode the signer is the one the root-owned
|
|
14
|
+
* /etc/tracekit/client.json names, and a $TRACEKIT_SIGNER naming another is refused. Without either, the same-user dev
|
|
15
|
+
* signer of the runtime dir is used, started with `tracekit up --json` (found as src/v2/launcher.ts says) when none answers.
|
|
16
|
+
* Requests go out in order on one connection and are answered in order; `approval_wait` gets a connection of its own.
|
|
17
|
+
* Every request is validated against the RPC contract before it is sent. Calls that change state carry a `request_id`
|
|
18
|
+
* and are resent with it when the connection drops. Event calls carry this client's `stream` and a `client_seq` per run;
|
|
19
|
+
* a run's events go out one at a time, each once the one before it is answered, so they reach the signer in order.
|
|
20
|
+
* The client writes nothing to disk.
|
|
21
|
+
*/
|
|
22
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
23
|
+
import { execFile } from "node:child_process";
|
|
24
|
+
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
25
|
+
import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
26
|
+
import { Agent, request } from "node:https";
|
|
27
|
+
import { createConnection } from "node:net";
|
|
28
|
+
import { homedir } from "node:os";
|
|
29
|
+
import { basename, extname, join } from "node:path";
|
|
30
|
+
import { TracekitAgents } from "./adapters/openai-agents.js";
|
|
31
|
+
import { tracekitAI } from "./adapters/vercel-ai.js";
|
|
32
|
+
import { argsDigest } from "./jcs.js";
|
|
33
|
+
import { INSTALL_HINT, signerCommand, signerEnv } from "./launcher.js";
|
|
34
|
+
import { REQUESTS, RPC_VERSION } from "./rpc_schema.js";
|
|
35
|
+
import { validate } from "./validate.js";
|
|
36
|
+
export { RPC_VERSION };
|
|
37
|
+
export { argsDigest, canonicalize, digest, strictParse, StrictJSONError } from "./jcs.js";
|
|
38
|
+
export const SYSTEM_CONFIG = "/etc/tracekit/client.json";
|
|
39
|
+
const CONNECT_TIMEOUT_MS = 2000;
|
|
40
|
+
const RETRIES = 3;
|
|
41
|
+
export class SignerUnavailable extends Error {
|
|
42
|
+
name = "SignerUnavailable";
|
|
43
|
+
}
|
|
44
|
+
/** The signer answered but does not speak this client's protocol version. It is left running. */
|
|
45
|
+
export class Incompatible extends Error {
|
|
46
|
+
name = "Incompatible";
|
|
47
|
+
}
|
|
48
|
+
/** A refusal from the signer (or of a request that fails the contract before it is sent). */
|
|
49
|
+
export class RPCError extends Error {
|
|
50
|
+
code;
|
|
51
|
+
retryAfterMs;
|
|
52
|
+
name = "RPCError";
|
|
53
|
+
constructor(code, message, retryAfterMs) {
|
|
54
|
+
super(`${code}: ${message}`);
|
|
55
|
+
this.code = code;
|
|
56
|
+
this.retryAfterMs = retryAfterMs;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
class ConnectionLost extends Error {
|
|
60
|
+
}
|
|
61
|
+
const newId = () => randomUUID().replaceAll("-", "");
|
|
62
|
+
const hmac = (token, label, nonce) => createHmac("sha256", token).update(`${label}\n${nonce}`).digest("hex");
|
|
63
|
+
function warn(message) {
|
|
64
|
+
process.emitWarning(message, "TracekitWarning");
|
|
65
|
+
}
|
|
66
|
+
function withTimeout(p, ms, onTimeout) {
|
|
67
|
+
let timer;
|
|
68
|
+
const t = new Promise((_, reject) => {
|
|
69
|
+
timer = setTimeout(() => {
|
|
70
|
+
onTimeout();
|
|
71
|
+
reject(new SignerUnavailable(`no answer from the signer within ${ms / 1000}s`));
|
|
72
|
+
}, ms);
|
|
73
|
+
});
|
|
74
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
75
|
+
}
|
|
76
|
+
/** Newline-delimited JSON frames on one socket; answers are matched to requests in order. */
|
|
77
|
+
class Conn {
|
|
78
|
+
sock;
|
|
79
|
+
pending = [];
|
|
80
|
+
buf = "";
|
|
81
|
+
constructor(sock) {
|
|
82
|
+
this.sock = sock;
|
|
83
|
+
sock.setEncoding("utf8");
|
|
84
|
+
sock.unref(); // an idle connection does not keep the process alive
|
|
85
|
+
sock.on("data", (d) => {
|
|
86
|
+
this.buf += d;
|
|
87
|
+
for (let n; (n = this.buf.indexOf("\n")) >= 0;) {
|
|
88
|
+
const line = this.buf.slice(0, n), p = this.pending.shift();
|
|
89
|
+
this.buf = this.buf.slice(n + 1);
|
|
90
|
+
let frame;
|
|
91
|
+
try {
|
|
92
|
+
frame = JSON.parse(line);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
frame = null;
|
|
96
|
+
}
|
|
97
|
+
if (!p || typeof frame !== "object" || frame === null) { // an answer nobody asked for, or not a frame
|
|
98
|
+
if (p)
|
|
99
|
+
this.pending.unshift(p);
|
|
100
|
+
return void sock.destroy();
|
|
101
|
+
}
|
|
102
|
+
if (!this.pending.length)
|
|
103
|
+
sock.unref();
|
|
104
|
+
p.resolve(frame);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
sock.on("error", () => { }); // "close" follows
|
|
108
|
+
sock.on("close", () => {
|
|
109
|
+
for (const p of this.pending.splice(0))
|
|
110
|
+
p.reject(new ConnectionLost());
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
write(frame) {
|
|
114
|
+
this.sock.write(JSON.stringify(frame) + "\n");
|
|
115
|
+
}
|
|
116
|
+
send(frame) {
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
if (this.sock.destroyed)
|
|
119
|
+
return reject(new ConnectionLost());
|
|
120
|
+
const line = JSON.stringify(frame) + "\n"; // may throw: before anything waits for an answer
|
|
121
|
+
this.pending.push({ resolve, reject });
|
|
122
|
+
this.sock.ref();
|
|
123
|
+
this.sock.write(line);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function open(sock) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
sock.setTimeout(CONNECT_TIMEOUT_MS, () => sock.destroy(new Error("connect timed out")));
|
|
130
|
+
sock.once("connect", () => {
|
|
131
|
+
sock.setTimeout(0);
|
|
132
|
+
resolve(sock);
|
|
133
|
+
});
|
|
134
|
+
sock.once("error", reject);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function check(hello, where) {
|
|
138
|
+
const proto = hello?.proto;
|
|
139
|
+
if (!(Array.isArray(proto) && proto.length === 2 && proto[0] <= RPC_VERSION && RPC_VERSION <= proto[1]))
|
|
140
|
+
throw new Incompatible(`the Tracekit signer ${hello?.version ?? "?"} (pid ${hello?.pid ?? "?"}, protocol ` +
|
|
141
|
+
`${JSON.stringify(proto)}) at ${where} cannot serve this client (protocol ${RPC_VERSION}). It was left running. ` +
|
|
142
|
+
"Stop it with `tracekit down`, or replace it with `tracekit up --replace`");
|
|
143
|
+
}
|
|
144
|
+
/** A connection to the signer at `path` (a Unix socket or tcp://host:port) and its hello, after the version check. */
|
|
145
|
+
export async function connect(path, token) {
|
|
146
|
+
let conn, hello;
|
|
147
|
+
try {
|
|
148
|
+
if (path.startsWith("tcp://")) {
|
|
149
|
+
const hostPort = path.slice(6), k = hostPort.lastIndexOf(":"), host = hostPort.slice(0, k);
|
|
150
|
+
if (!["127.0.0.1", "::1", "localhost"].includes(host)) // frames after the handshake are plaintext
|
|
151
|
+
throw new SignerUnavailable(`${path}: tcp:// signers must be on loopback (127.0.0.1, ::1 or localhost)`);
|
|
152
|
+
token ||= process.env.TRACEKIT_SIGNER_TOKEN;
|
|
153
|
+
if (!token)
|
|
154
|
+
throw new SignerUnavailable(`${path}: tcp:// signers need TRACEKIT_SIGNER_TOKEN`);
|
|
155
|
+
conn = new Conn(await open(createConnection({ host, port: Number(hostPort.slice(k + 1)) })));
|
|
156
|
+
// the signer proves it holds the token over our nonce before we prove it over its own (transport/tcp_dev.py)
|
|
157
|
+
const nonce = randomBytes(16).toString("hex");
|
|
158
|
+
const reply = await withTimeout(conn.send({ method: "hello", nonce }), CONNECT_TIMEOUT_MS, () => conn.sock.destroy());
|
|
159
|
+
const proof = Buffer.from(String(reply.proof)), want = Buffer.from(hmac(token, "signer", nonce));
|
|
160
|
+
if (proof.length !== want.length || !timingSafeEqual(proof, want))
|
|
161
|
+
throw new Error("dev token proof mismatch");
|
|
162
|
+
if (typeof reply.nonce !== "string" || reply.nonce.length < 16 || reply.nonce.length > 128)
|
|
163
|
+
throw new Error("handshake needs a nonce of 16-128 characters");
|
|
164
|
+
conn.write({ proof: hmac(token, "client", reply.nonce) });
|
|
165
|
+
}
|
|
166
|
+
else if (process.platform !== "win32") {
|
|
167
|
+
conn = new Conn(await open(createConnection(path)));
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
throw new SignerUnavailable(`this platform has no Unix sockets: name the signer tcp://host:port, not ${path}`);
|
|
171
|
+
}
|
|
172
|
+
const c = conn;
|
|
173
|
+
hello = await withTimeout(c.send({ method: "hello" }), CONNECT_TIMEOUT_MS, () => c.sock.destroy());
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
conn?.sock.destroy();
|
|
177
|
+
if (e instanceof SignerUnavailable && !conn)
|
|
178
|
+
throw e;
|
|
179
|
+
throw new SignerUnavailable(`no signer answering at ${path}: ${e.message}`);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
check(hello, path);
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
conn.sock.destroy();
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
return { conn, hello };
|
|
189
|
+
}
|
|
190
|
+
/** The root-owned system-mode client config, or null when there is none; throws when it exists but can't be trusted. */
|
|
191
|
+
export function systemConfig(path = SYSTEM_CONFIG) {
|
|
192
|
+
if (process.platform === "win32")
|
|
193
|
+
return null; // no root-owned config there, as in the Python client
|
|
194
|
+
let st;
|
|
195
|
+
try {
|
|
196
|
+
st = statSync(path);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
if (e.code === "ENOENT")
|
|
200
|
+
return null;
|
|
201
|
+
throw new Error(`${path} cannot be read: ${e.message}`);
|
|
202
|
+
}
|
|
203
|
+
if (st.uid !== 0 || st.mode & 0o022)
|
|
204
|
+
throw new Error(`${path} must be owned by root and not group- or world-writable`);
|
|
205
|
+
let cfg;
|
|
206
|
+
try {
|
|
207
|
+
cfg = JSON.parse(readFileSync(path, "utf8"));
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
throw new Error(`${path} cannot be parsed: ${e.message}`);
|
|
211
|
+
}
|
|
212
|
+
if (typeof cfg !== "object" || cfg === null || Array.isArray(cfg))
|
|
213
|
+
throw new Error(`${path} must hold a JSON object`);
|
|
214
|
+
return cfg;
|
|
215
|
+
}
|
|
216
|
+
/** $TRACEKIT_SIGNER, or in system mode the system signer: there a $TRACEKIT_SIGNER that names another is refused. */
|
|
217
|
+
export function defaultSigner(env = process.env, system = systemConfig()) {
|
|
218
|
+
const mine = env.TRACEKIT_SIGNER || null, theirs = system?.signer || null;
|
|
219
|
+
if (theirs && mine && mine !== theirs)
|
|
220
|
+
throw new SignerUnavailable(`TRACEKIT_SIGNER=${mine.slice(0, 256)} is not the system signer ${theirs}; refused`);
|
|
221
|
+
return theirs || mine;
|
|
222
|
+
}
|
|
223
|
+
/** The dev signer's runtime dir, as tracekit/sdk/autospawn.py `runtime_dir` names it. */
|
|
224
|
+
export function runtimeDir(env = process.env) {
|
|
225
|
+
if (env.TRACEKIT_RUNTIME_DIR)
|
|
226
|
+
return env.TRACEKIT_RUNTIME_DIR;
|
|
227
|
+
if (process.platform === "win32")
|
|
228
|
+
return join(env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "tracekit", "run");
|
|
229
|
+
if (process.platform === "darwin") {
|
|
230
|
+
const d = join(homedir(), "Library/Application Support/tracekit/run");
|
|
231
|
+
return Buffer.byteLength(join(d, "signer.sock")) > 103 ? `/tmp/tk-${process.getuid()}` : d; // macOS: 104-byte socket paths
|
|
232
|
+
}
|
|
233
|
+
return env.XDG_RUNTIME_DIR ? join(env.XDG_RUNTIME_DIR, "tracekit") : `/tmp/tracekit-${process.getuid()}`;
|
|
234
|
+
}
|
|
235
|
+
/** The runtime dir of the same-user dev signer; refused in system mode, as tracekit/sdk/autospawn.py `ensure` does. */
|
|
236
|
+
export function devRuntimeDir(system = SYSTEM_CONFIG) {
|
|
237
|
+
if (existsSync(system))
|
|
238
|
+
throw new SignerUnavailable("dev auto-spawn is off when TRACEKIT_SIGNER is set or in system mode");
|
|
239
|
+
return runtimeDir();
|
|
240
|
+
}
|
|
241
|
+
/** [path, token] of the dev signer in runtime dir `d`; SignerUnavailable while there is none or the dir isn't private. */
|
|
242
|
+
function devAddress(d) {
|
|
243
|
+
try {
|
|
244
|
+
if (process.platform !== "win32") {
|
|
245
|
+
const st = lstatSync(d);
|
|
246
|
+
if (!st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077)
|
|
247
|
+
throw new SignerUnavailable(`${d} must be a directory owned by you with mode 0700`);
|
|
248
|
+
return [join(d, "signer.sock")];
|
|
249
|
+
}
|
|
250
|
+
const ep = JSON.parse(readFileSync(join(d, "endpoint.json"), "utf8"));
|
|
251
|
+
if (!Number.isInteger(ep?.port) || typeof ep?.token !== "string")
|
|
252
|
+
throw new Error("no port and token yet");
|
|
253
|
+
return [`tcp://127.0.0.1:${ep.port}`, ep.token];
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
if (e instanceof SignerUnavailable)
|
|
257
|
+
throw e;
|
|
258
|
+
throw new SignerUnavailable(`no dev signer in ${d}: ${e.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** The URL for the run or approval `frame` names, from the signer's `url`. A central signer's replica
|
|
262
|
+
* (docs/deploy-kubernetes.md) prefixes its ids with its route, `<service>-<n>.`: such an id goes to host `<service>-<n>`
|
|
263
|
+
* in the signer's domain, as the Python client does. */
|
|
264
|
+
export function routeUrl(url, frame) {
|
|
265
|
+
const host = url.hostname, dot = host.indexOf("."), first = dot < 0 ? host : host.slice(0, dot);
|
|
266
|
+
const id = String(frame.run_id ?? frame.approval_id ?? frame.analyzes ?? frame.parent?.run_id ?? ""), cut = id.indexOf(".");
|
|
267
|
+
const route = cut < 0 ? "" : id.slice(0, cut);
|
|
268
|
+
if (!route || !new RegExp("^" + first.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "-[0-9]+$").test(route))
|
|
269
|
+
return url;
|
|
270
|
+
const out = new URL(url.href);
|
|
271
|
+
out.hostname = dot < 0 ? route : route + host.slice(dot);
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
class Https {
|
|
275
|
+
agent;
|
|
276
|
+
url;
|
|
277
|
+
hello = null;
|
|
278
|
+
constructor(signer) {
|
|
279
|
+
const env = process.env, file = (p) => (p ? readFileSync(p) : undefined);
|
|
280
|
+
this.url = new URL("/v2/rpc", signer);
|
|
281
|
+
const cert = env.TRACEKIT_SIGNER_CERT; // its key in TRACEKIT_SIGNER_KEY, or in the same file
|
|
282
|
+
this.agent = new Agent({ keepAlive: true, ca: file(env.TRACEKIT_SIGNER_CA), cert: file(cert),
|
|
283
|
+
key: cert ? file(env.TRACEKIT_SIGNER_KEY || cert) : undefined });
|
|
284
|
+
}
|
|
285
|
+
/** The answer frame; ConnectionLost when the request may not have arrived, SignerUnavailable on a timeout. */
|
|
286
|
+
post(frame, timeoutMs) {
|
|
287
|
+
const body = JSON.stringify(frame), tokenFile = process.env.TRACEKIT_SIGNER_TOKEN_FILE;
|
|
288
|
+
const headers = { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) };
|
|
289
|
+
if (tokenFile) {
|
|
290
|
+
try {
|
|
291
|
+
headers.Authorization = "Bearer " + readFileSync(tokenFile, "utf8").trim(); // re-read: a rotated token is picked up
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
throw new SignerUnavailable(`cannot read the signer token file: ${e.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return new Promise((resolve, reject) => {
|
|
298
|
+
const req = request(routeUrl(this.url, frame), { method: "POST", agent: this.agent, headers, timeout: timeoutMs }, (res) => {
|
|
299
|
+
let data = "";
|
|
300
|
+
res.setEncoding("utf8");
|
|
301
|
+
res.on("data", (d) => (data += d));
|
|
302
|
+
res.on("error", () => reject(new ConnectionLost()));
|
|
303
|
+
res.on("end", () => {
|
|
304
|
+
try {
|
|
305
|
+
const f = JSON.parse(data);
|
|
306
|
+
if (typeof f !== "object" || f === null)
|
|
307
|
+
throw new Error();
|
|
308
|
+
resolve(f);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
reject(new ConnectionLost());
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
req.on("timeout", () => {
|
|
316
|
+
reject(new SignerUnavailable(`no answer from the signer within ${timeoutMs / 1000}s`));
|
|
317
|
+
req.destroy();
|
|
318
|
+
});
|
|
319
|
+
req.on("error", () => reject(new ConnectionLost()));
|
|
320
|
+
req.end(body);
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
async rpc(frame, timeoutMs) {
|
|
324
|
+
if (!this.hello) {
|
|
325
|
+
const hello = await this.post({ method: "hello" }, timeoutMs);
|
|
326
|
+
if (hello.error)
|
|
327
|
+
throw new RPCError(hello.error.code, hello.error.message ?? "");
|
|
328
|
+
check(hello, this.url.origin);
|
|
329
|
+
this.hello = hello;
|
|
330
|
+
}
|
|
331
|
+
return this.post(frame, timeoutMs);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Methods take the request object of an RPC and return its response, or reject with RPCError (a refusal),
|
|
335
|
+
* SignerUnavailable or Incompatible. */
|
|
336
|
+
export class Client {
|
|
337
|
+
signer;
|
|
338
|
+
stream = newId();
|
|
339
|
+
timeoutMs;
|
|
340
|
+
hello = null;
|
|
341
|
+
https;
|
|
342
|
+
conn = null;
|
|
343
|
+
seqs = new Map();
|
|
344
|
+
order = new Map(); // run_id -> settles once its last event so far is answered
|
|
345
|
+
inflight = new Set();
|
|
346
|
+
constructor(opts = {}) {
|
|
347
|
+
this.signer = opts.signer ?? defaultSigner();
|
|
348
|
+
this.timeoutMs = opts.timeoutMs ?? 30_000;
|
|
349
|
+
this.https = this.signer?.startsWith("https://") ? new Https(this.signer) : null;
|
|
350
|
+
}
|
|
351
|
+
/** Register a run (`agent` is a name or {name, version}, `parent` the handle of the run starting it, such as a
|
|
352
|
+
* sub-agent's parent: the signer records the link); the handle holds its run token. */
|
|
353
|
+
async registerRun(agent, fields = {}, parent) {
|
|
354
|
+
return new RunHandle(this, await this.call("register_run", {
|
|
355
|
+
agent: typeof agent === "string" ? { name: agent } : agent, ...fields,
|
|
356
|
+
...(parent ? { parent: { run_id: parent.runId, run_token: parent.runToken } } : {}),
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
/** `request_id`, `stream` and `client_seq` are filled in when missing. */
|
|
360
|
+
call(method, req = {}) {
|
|
361
|
+
return this.waitUntil(this.send(method, { ...req }));
|
|
362
|
+
}
|
|
363
|
+
/** Resolves once every call made so far, and every promise given to waitUntil, has settled. */
|
|
364
|
+
async flush() {
|
|
365
|
+
while (this.inflight.size)
|
|
366
|
+
await Promise.allSettled([...this.inflight]);
|
|
367
|
+
}
|
|
368
|
+
/** Have flush() and close() wait for `p` too (for serverless hosts that freeze the process after a response). */
|
|
369
|
+
waitUntil(p) {
|
|
370
|
+
this.inflight.add(p);
|
|
371
|
+
const done = () => this.inflight.delete(p);
|
|
372
|
+
p.then(done, done);
|
|
373
|
+
return p;
|
|
374
|
+
}
|
|
375
|
+
/** Waits for what is in flight, then closes the connection. */
|
|
376
|
+
async close() {
|
|
377
|
+
await this.flush();
|
|
378
|
+
const conn = this.conn;
|
|
379
|
+
this.conn = null;
|
|
380
|
+
conn?.then((c) => c.sock.destroy(), () => { });
|
|
381
|
+
}
|
|
382
|
+
async send(method, req) {
|
|
383
|
+
const schema = REQUESTS[method];
|
|
384
|
+
if (!schema)
|
|
385
|
+
throw new RPCError("invalid_request", `unknown method ${method.slice(0, 64)}`);
|
|
386
|
+
if ("request_id" in schema.properties)
|
|
387
|
+
req.request_id ??= newId();
|
|
388
|
+
let claim = "client_seq" in schema.properties && !("client_seq" in req);
|
|
389
|
+
if (claim)
|
|
390
|
+
Object.assign(req, { stream: this.stream, client_seq: this.seqs.get(req.run_id) ?? 0 });
|
|
391
|
+
const used = this.seqs.get(req.run_id);
|
|
392
|
+
// the last client_seq this stream used: one the signer never received becomes its client_counter_gap
|
|
393
|
+
if (method === "close_run" && used && !("last_client_seq" in req))
|
|
394
|
+
req.last_client_seq = [{ stream: this.stream, client_seq: used - 1 }];
|
|
395
|
+
const errs = validate(schema, req);
|
|
396
|
+
if (errs.length)
|
|
397
|
+
throw new RPCError("invalid_request", errs.join("; "));
|
|
398
|
+
try {
|
|
399
|
+
JSON.stringify(req);
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
throw new RPCError("invalid_request", `the ${method} request is not JSON: ${e.message}`);
|
|
403
|
+
}
|
|
404
|
+
const frame = () => {
|
|
405
|
+
if (claim) {
|
|
406
|
+
req.client_seq = this.seqs.get(req.run_id) ?? 0;
|
|
407
|
+
this.seqs.set(req.run_id, req.client_seq + 1);
|
|
408
|
+
claim = false;
|
|
409
|
+
}
|
|
410
|
+
return { method, ...req };
|
|
411
|
+
};
|
|
412
|
+
const timeout = this.timeoutMs + (req.timeout_ms ?? 0), deadline = Date.now() + timeout;
|
|
413
|
+
let release = () => { };
|
|
414
|
+
if (claim) {
|
|
415
|
+
try {
|
|
416
|
+
release = await this.turn(req.run_id, timeout);
|
|
417
|
+
}
|
|
418
|
+
catch (e) {
|
|
419
|
+
frame(); // used up: the signer's client_counter_gap covers a call it never saw
|
|
420
|
+
throw e;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
for (let i = 0; i < RETRIES; i++) {
|
|
425
|
+
const left = Math.max(deadline - Date.now(), 1);
|
|
426
|
+
let reply;
|
|
427
|
+
try {
|
|
428
|
+
if (this.https) {
|
|
429
|
+
reply = await this.https.rpc(frame(), left);
|
|
430
|
+
this.hello = this.https.hello;
|
|
431
|
+
}
|
|
432
|
+
else if ("timeout_ms" in schema.properties) {
|
|
433
|
+
reply = await this.poll(frame(), left);
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
const conn = await this.connection();
|
|
437
|
+
reply = await withTimeout(conn.send(frame()), left, () => conn.sock.destroy());
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (e) {
|
|
441
|
+
if (e instanceof ConnectionLost)
|
|
442
|
+
continue;
|
|
443
|
+
if (e instanceof SignerUnavailable)
|
|
444
|
+
frame(); // used up: the signer's client_counter_gap covers a call it never saw
|
|
445
|
+
throw e;
|
|
446
|
+
}
|
|
447
|
+
if (reply.error) {
|
|
448
|
+
const e = reply.error;
|
|
449
|
+
if (["run_closed", "unknown_run"].includes(e.code))
|
|
450
|
+
this.seqs.delete(req.run_id); // no more events for it
|
|
451
|
+
throw new RPCError(e.code, e.message ?? "", e.retry_after_ms);
|
|
452
|
+
}
|
|
453
|
+
if (method === "close_run")
|
|
454
|
+
this.seqs.delete(req.run_id);
|
|
455
|
+
return reply;
|
|
456
|
+
}
|
|
457
|
+
throw new SignerUnavailable(`lost the connection to the signer ${RETRIES} times`);
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
release();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Waits up to `ms` for the run's event before this one to be answered, so a run's events reach the signer in
|
|
464
|
+
* client_seq order on every transport and retry. -> what ends this event's turn; SignerUnavailable after `ms`. */
|
|
465
|
+
async turn(runId, ms) {
|
|
466
|
+
const prev = this.order.get(runId) ?? Promise.resolve();
|
|
467
|
+
let done;
|
|
468
|
+
const mine = new Promise((r) => (done = r)), tail = prev.then(() => mine);
|
|
469
|
+
this.order.set(runId, tail);
|
|
470
|
+
const release = () => {
|
|
471
|
+
done();
|
|
472
|
+
if (this.order.get(runId) === tail)
|
|
473
|
+
this.order.delete(runId);
|
|
474
|
+
};
|
|
475
|
+
try {
|
|
476
|
+
await withTimeout(prev, ms, () => { });
|
|
477
|
+
}
|
|
478
|
+
catch (e) {
|
|
479
|
+
release();
|
|
480
|
+
throw e;
|
|
481
|
+
}
|
|
482
|
+
return release;
|
|
483
|
+
}
|
|
484
|
+
/** One request on a connection of its own, closed after the answer. */
|
|
485
|
+
async poll(frame, timeout) {
|
|
486
|
+
const { conn } = await this.dial();
|
|
487
|
+
try {
|
|
488
|
+
return await withTimeout(conn.send(frame), timeout, () => { });
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
conn.sock.destroy();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
connection() {
|
|
495
|
+
if (!this.conn) {
|
|
496
|
+
const p = this.dial().then(({ conn }) => {
|
|
497
|
+
conn.sock.on("close", () => this.conn === p && (this.conn = null));
|
|
498
|
+
return conn;
|
|
499
|
+
}, (e) => {
|
|
500
|
+
if (this.conn === p)
|
|
501
|
+
this.conn = null;
|
|
502
|
+
throw e;
|
|
503
|
+
});
|
|
504
|
+
this.conn = p;
|
|
505
|
+
}
|
|
506
|
+
return this.conn;
|
|
507
|
+
}
|
|
508
|
+
async dial() {
|
|
509
|
+
let c;
|
|
510
|
+
if (this.signer) {
|
|
511
|
+
c = await connect(this.signer);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
const d = devRuntimeDir();
|
|
515
|
+
try {
|
|
516
|
+
c = await connect(...devAddress(d));
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
if (!(e instanceof SignerUnavailable))
|
|
520
|
+
throw e;
|
|
521
|
+
await startDevSigner();
|
|
522
|
+
c = await connect(...devAddress(d));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
this.hello = c.hello;
|
|
526
|
+
return c;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** `tracekit up --json`: find or start the same-user dev signer (it takes the locks, not this client). */
|
|
530
|
+
function startDevSigner() {
|
|
531
|
+
const cmd = signerCommand();
|
|
532
|
+
if (!cmd)
|
|
533
|
+
return Promise.reject(new SignerUnavailable(`no dev signer, and ${INSTALL_HINT}`));
|
|
534
|
+
return new Promise((resolve, reject) => {
|
|
535
|
+
execFile(cmd[0], [...cmd.slice(1), "up", "--json"], { timeout: 30_000, env: signerEnv(cmd) }, (err, _out, stderr) => err ? reject(new SignerUnavailable(`no dev signer, and \`${cmd.join(" ")} up\` failed: ${stderr.trim() || err.message}`)) : resolve());
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/** Whether a call of `toolClass` may run while the signer cannot be reached: its entry in register_run's
|
|
539
|
+
* `fail_modes`, else their `default`, else closed. A refusal from the signer is never a reason to fail open. */
|
|
540
|
+
export function failOpen(failModes, toolClass) {
|
|
541
|
+
const modes = failModes ?? {};
|
|
542
|
+
return ((toolClass !== undefined && Object.hasOwn(modes, toolClass) ? modes[toolClass] : modes.default) ?? "closed") === "open";
|
|
543
|
+
}
|
|
544
|
+
const NEW_WORK = new Set(["decide", "model_event", "observe", "state_write"]); // to the current run
|
|
545
|
+
const BY_CALL = new Set(["complete", "approval_request", "approval_consume"]); // to the run the call was decided in
|
|
546
|
+
const KEEP = 4096; // tool calls and approvals whose run is remembered
|
|
547
|
+
function remember(m, k, v) {
|
|
548
|
+
m.delete(k);
|
|
549
|
+
m.set(k, v);
|
|
550
|
+
if (m.size > KEEP)
|
|
551
|
+
m.delete(m.keys().next().value);
|
|
552
|
+
}
|
|
553
|
+
/** A registered run. Its methods fill in run_id and run_token; `fields` adds optional request fields. */
|
|
554
|
+
export class RunHandle {
|
|
555
|
+
client;
|
|
556
|
+
runId;
|
|
557
|
+
runToken;
|
|
558
|
+
failModes;
|
|
559
|
+
registered;
|
|
560
|
+
closed = false;
|
|
561
|
+
/** The agent name instrument()'s process run renews itself as once the signer closed it after it was idle; null:
|
|
562
|
+
* never renewed (a run the app registered itself, or one it closed). */
|
|
563
|
+
renewAs = null;
|
|
564
|
+
decided = new Map();
|
|
565
|
+
at = new Map(); // tool call or approval id -> its run
|
|
566
|
+
renewing = null;
|
|
567
|
+
constructor(client, registered) {
|
|
568
|
+
this.client = client;
|
|
569
|
+
this.registered = registered;
|
|
570
|
+
this.runId = registered.run_id;
|
|
571
|
+
this.runToken = registered.run_token;
|
|
572
|
+
this.failModes = registered.fail_modes;
|
|
573
|
+
}
|
|
574
|
+
/** With $TRACEKIT_EGRESS_PROXY set (an egress observer, docs/egress.md#attribution): HTTPS_PROXY and HTTP_PROXY,
|
|
575
|
+
* both cases, naming it with this run's id and token as credentials, for the commands the run starts
|
|
576
|
+
* (`spawn(cmd, args, { env: { ...process.env, ...run.proxyEnv } })`) or an HTTP client's proxy, so the observer
|
|
577
|
+
* attributes their connections to this run. {} without it. This process's own environment is left as it is. */
|
|
578
|
+
get proxyEnv() {
|
|
579
|
+
const proxy = process.env.TRACEKIT_EGRESS_PROXY;
|
|
580
|
+
if (!proxy)
|
|
581
|
+
return {};
|
|
582
|
+
const u = new URL(proxy);
|
|
583
|
+
u.username = encodeURIComponent(this.runId);
|
|
584
|
+
u.password = encodeURIComponent(this.runToken);
|
|
585
|
+
return Object.fromEntries(["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"].map((k) => [k, u.href]));
|
|
586
|
+
}
|
|
587
|
+
/** Any per-run RPC with this run's id and token. A call's complete and approvals go to the run it was decided in;
|
|
588
|
+
* new work refused because the signer closed a renewable run goes to a new run (renewAs). */
|
|
589
|
+
async call(method, fields = {}) {
|
|
590
|
+
const key = BY_CALL.has(method) ? fields.tool_call_id : method === "approval_wait" || method === "approval_abandon" ? fields.approval_id : undefined;
|
|
591
|
+
let run = (key !== undefined && this.at.get(key)) || { run_id: this.runId, run_token: this.runToken }, out;
|
|
592
|
+
try {
|
|
593
|
+
out = await this.client.call(method, { ...run, ...fields });
|
|
594
|
+
}
|
|
595
|
+
catch (e) {
|
|
596
|
+
if (!(e instanceof RPCError) || !["run_closed", "unknown_run"].includes(e.code) || !NEW_WORK.has(method)
|
|
597
|
+
|| this.renewAs === null || this.closed)
|
|
598
|
+
throw e;
|
|
599
|
+
await this.renew(run.run_id, e);
|
|
600
|
+
run = { run_id: this.runId, run_token: this.runToken };
|
|
601
|
+
out = await this.client.call(method, { ...run, ...fields }); // once: what the new run answers is the answer
|
|
602
|
+
}
|
|
603
|
+
if (method === "decide")
|
|
604
|
+
remember(this.at, fields.tool_call_id, run);
|
|
605
|
+
if (method === "approval_request" && out.approval_id)
|
|
606
|
+
remember(this.at, out.approval_id, run);
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
renew(closed, e) {
|
|
610
|
+
if (closed !== this.runId)
|
|
611
|
+
return Promise.resolve(); // renewed already
|
|
612
|
+
this.renewing ??= (async () => {
|
|
613
|
+
const agent = { agent: { name: this.renewAs } };
|
|
614
|
+
let r;
|
|
615
|
+
try { // linked to the closed run while it is still closing
|
|
616
|
+
r = await this.client.call("register_run", { ...agent, parent: { run_id: this.runId, run_token: this.runToken } });
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
if (!(err instanceof RPCError))
|
|
620
|
+
throw err;
|
|
621
|
+
r = await this.client.call("register_run", agent);
|
|
622
|
+
}
|
|
623
|
+
warn(`tracekit: the signer closed run ${this.runId} (${e.message}); recording in a new run ${r.run_id}. A server should open a run per conversation`);
|
|
624
|
+
[this.registered, this.runId, this.runToken, this.failModes] = [r, r.run_id, r.run_token, r.fail_modes];
|
|
625
|
+
})().finally(() => (this.renewing = null));
|
|
626
|
+
return this.renewing;
|
|
627
|
+
}
|
|
628
|
+
/** `args` is the model's raw arguments string, or an already parsed value. Only when the signer can't be reached
|
|
629
|
+
* does the run's fail mode for `fields.tool_class_hint` decide; a refusal rejects. */
|
|
630
|
+
async decide(toolCallId, tool, args, fields = {}) {
|
|
631
|
+
let d;
|
|
632
|
+
try {
|
|
633
|
+
d = await this.call("decide", { tool_call_id: toolCallId, tool, args, args_source: typeof args === "string" ? "raw" : "parsed", ...fields });
|
|
634
|
+
}
|
|
635
|
+
catch (e) {
|
|
636
|
+
if (!(e instanceof SignerUnavailable))
|
|
637
|
+
throw e;
|
|
638
|
+
const open = failOpen(this.failModes, fields.tool_class_hint);
|
|
639
|
+
warn(`tracekit: ${tool} ${open ? "runs unrecorded (fail open)" : "is denied (fail closed)"}: ${e.message}`);
|
|
640
|
+
return { decision: open ? "allow" : "deny", rule_ids: [], reason: `signer unavailable: ${e.message}`.slice(0, 1024), unavailable: true };
|
|
641
|
+
}
|
|
642
|
+
try { // a denied call is never completed: nothing to remember; others are capped, as `at` is
|
|
643
|
+
if (d.decision !== "deny")
|
|
644
|
+
remember(this.decided, toolCallId, { decision_id: d.decision_id, args_digest: argsDigest(tool, args) });
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// the signer has no args digest to bind either; complete needs explicit fields
|
|
648
|
+
}
|
|
649
|
+
return d;
|
|
650
|
+
}
|
|
651
|
+
/** Never rejects: a failure to record a call that already ran is a warning. Defaults decision_id and args_digest to
|
|
652
|
+
* those of the last `decide` for this tool call. */
|
|
653
|
+
async complete(toolCallId, status = "ok", fields = {}) {
|
|
654
|
+
const decided = this.decided.get(toolCallId);
|
|
655
|
+
this.decided.delete(toolCallId);
|
|
656
|
+
try {
|
|
657
|
+
return await this.call("complete", { tool_call_id: toolCallId, status, ...decided, ...fields });
|
|
658
|
+
}
|
|
659
|
+
catch (e) {
|
|
660
|
+
warn(`tracekit: the result of ${toolCallId} was not recorded: ${e.message}`);
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
this.at.delete(toolCallId);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
approvalRequest(toolCallId, fields = {}) {
|
|
668
|
+
return this.call("approval_request", { tool_call_id: toolCallId, ...fields });
|
|
669
|
+
}
|
|
670
|
+
/** Waits up to `timeoutMs` (signer cap 300 s) for a decision, on a connection of its own. */
|
|
671
|
+
approvalWait(approvalId, timeoutMs) {
|
|
672
|
+
return this.call("approval_wait", { approval_id: approvalId, ...(timeoutMs === undefined ? {} : { timeout_ms: timeoutMs }) });
|
|
673
|
+
}
|
|
674
|
+
/** Records content that entered the run other than as a tool result (a mail, a ticket, a page the app fetched) for
|
|
675
|
+
* the signer's provenance index; the task or user prompt goes in with trust "trusted". Only its commitment is recorded. */
|
|
676
|
+
observe(value, source, trust = "untrusted", fields = {}) {
|
|
677
|
+
return this.call("observe", { value, source, trust, ...fields });
|
|
678
|
+
}
|
|
679
|
+
stateWrite(key, valueDigest, fields = {}) {
|
|
680
|
+
return this.call("state_write", { key, value_digest: valueDigest, ...fields });
|
|
681
|
+
}
|
|
682
|
+
modelEvent(provider, model, phase, fields = {}) {
|
|
683
|
+
return this.call("model_event", { provider, model, phase, ...fields });
|
|
684
|
+
}
|
|
685
|
+
async close(reason) {
|
|
686
|
+
if (!this.closed) {
|
|
687
|
+
this.renewAs = null; // a call refused as closed while this one is in flight must not register a new run
|
|
688
|
+
await this.call("close_run", reason ? { reason } : {});
|
|
689
|
+
this.closed = true;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const current = new AsyncLocalStorage();
|
|
694
|
+
/** Runs `fn` with `handle` as the current run of everything it awaits. */
|
|
695
|
+
export function withRun(handle, fn) {
|
|
696
|
+
return current.run(handle, fn);
|
|
697
|
+
}
|
|
698
|
+
export function currentRun() {
|
|
699
|
+
return current.getStore();
|
|
700
|
+
}
|
|
701
|
+
let instrumented = null;
|
|
702
|
+
const LEAVE_MS = 2000; // the most an exiting process waits for the signer
|
|
703
|
+
const once = (fn) => {
|
|
704
|
+
let p = null;
|
|
705
|
+
return () => (p ??= fn());
|
|
706
|
+
};
|
|
707
|
+
/** Closes `run` as the process exits, unless approvals of it are still waiting (requested, or approved and not yet
|
|
708
|
+
* consumed): then it stays open for another process to resume (`new RunHandle(client, { run_id, run_token })`) until
|
|
709
|
+
* the signer closes it after idle_s. Never rejects; waits at most LEAVE_MS. */
|
|
710
|
+
async function leave(run) {
|
|
711
|
+
const work = (async () => {
|
|
712
|
+
const open = (state) => run.client.call("approval_list", { run_id: run.runId, state, limit: 1 })
|
|
713
|
+
.then((r) => r.approvals.length > 0);
|
|
714
|
+
// requested, or approved and not yet consumed; a listing that fails (no grant) waits for nothing
|
|
715
|
+
if (await Promise.all([open("requested"), open("approved")]).then((w) => w.some(Boolean), () => false))
|
|
716
|
+
warn(`tracekit: run ${run.runId} left open: approvals of it are waiting for a person or to be resumed`);
|
|
717
|
+
else
|
|
718
|
+
await run.close();
|
|
719
|
+
await run.client.close();
|
|
720
|
+
})().catch(() => { });
|
|
721
|
+
await Promise.race([work, new Promise((r) => setTimeout(r, LEAVE_MS).unref())]);
|
|
722
|
+
}
|
|
723
|
+
/** One call: registers a run (with the same-user dev signer, started if none answers, unless a signer is configured as
|
|
724
|
+
* described above), closes it when the process is about to exit, and returns it with the Vercel AI SDK and OpenAI
|
|
725
|
+
* Agents adapters bound to it. A second call returns the same. ES modules can't be patched from outside, so the
|
|
726
|
+
* adapters are handed to you to pass to the framework, rather than wired as `tracekit.instrument()` does in Python.
|
|
727
|
+
* `agent` names the run (default: $TRACEKIT_AGENT, else the script's name). */
|
|
728
|
+
export function instrument(agent, opts = {}) {
|
|
729
|
+
const name = agent ?? process.env.TRACEKIT_AGENT ?? basename(process.argv[1] ?? "node", extname(process.argv[1] ?? ""));
|
|
730
|
+
instrumented ??= Promise.resolve().then(() => new Client(opts).registerRun(name)).then((run) => {
|
|
731
|
+
run.renewAs = name;
|
|
732
|
+
const end = once(() => leave(run));
|
|
733
|
+
process.once("beforeExit", () => void end());
|
|
734
|
+
for (const sig of ["SIGINT", "SIGTERM"])
|
|
735
|
+
process.prependOnceListener(sig, () => {
|
|
736
|
+
// run first and decide before anything is awaited: a signal another listener (the app's, a library's)
|
|
737
|
+
// handles is theirs, so the run stays as it is (beforeExit still closes it); else close, then the default action
|
|
738
|
+
if (!process.listenerCount(sig))
|
|
739
|
+
void end().finally(() => process.kill(process.pid, sig));
|
|
740
|
+
});
|
|
741
|
+
// lean: process.exit() and an uncaught exception leave the run open until the signer's idle_s (no async on exit);
|
|
742
|
+
// close it synchronously from an exit hook if open runs pile up
|
|
743
|
+
return { run, ai: tracekitAI(run), agents: new TracekitAgents(run) };
|
|
744
|
+
}, (e) => {
|
|
745
|
+
instrumented = null;
|
|
746
|
+
throw e;
|
|
747
|
+
});
|
|
748
|
+
return instrumented;
|
|
749
|
+
}
|