@execlave/sdk 1.0.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/dist/client.js ADDED
@@ -0,0 +1,985 @@
1
+ "use strict";
2
+ /**
3
+ * Execlave SDK — Main client.
4
+ *
5
+ * Provides the Execlave class for agent registration, tracing, and governance.
6
+ * Implements non-blocking trace ingestion with an in-memory circular buffer
7
+ * and a background flush interval.
8
+ */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.Execlave = void 0;
44
+ const http_1 = require("./http");
45
+ const agent_1 = require("./agent");
46
+ const trace_1 = require("./trace");
47
+ const errors_1 = require("./errors");
48
+ const crypto_1 = require("crypto");
49
+ const MAX_BUFFER_SIZE = 10000;
50
+ // ---------------------------------------------------------------------------
51
+ // PII Patterns (mirrors processing service)
52
+ // ---------------------------------------------------------------------------
53
+ const PII_PATTERNS = {
54
+ email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi,
55
+ ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
56
+ credit_card: /\b(?:\d{4}[- ]?){3}\d{4}\b/g,
57
+ phone_us: /\b(?:\+1[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/g,
58
+ ip_address: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
59
+ api_key: /\b(?:sk|pk|ag)_[a-zA-Z0-9]{20,}\b/g,
60
+ };
61
+ // ---------------------------------------------------------------------------
62
+ // Injection Patterns (common prompt-injection signatures)
63
+ // ---------------------------------------------------------------------------
64
+ const INJECTION_PATTERNS = [
65
+ /ignore\s+(all\s+)?previous\s+instructions/i,
66
+ /ignore\s+(all\s+)?above\s+instructions/i,
67
+ /disregard\s+(all\s+)?previous/i,
68
+ /you\s+are\s+now\s+(?:a\s+)?(?:DAN|evil|unrestricted)/i,
69
+ /forget\s+(all\s+)?(?:previous|earlier|your)\s+(?:instructions|rules|guidelines)/i,
70
+ /system\s*:\s*you\s+are/i,
71
+ /\[SYSTEM\]|\[INST\]|\[\/INST\]/i,
72
+ /<\|(?:system|im_start|im_end)\|>/i,
73
+ /(?:reveal|show|display|print|output)\s+(?:your\s+)?(?:system\s+)?(?:prompt|instructions|rules)/i,
74
+ /(?:act|behave|respond)\s+as\s+(?:if|though)\s+(?:you\s+(?:are|were|have))/i,
75
+ /do\s+anything\s+now/i,
76
+ /jailbreak/i,
77
+ /bypass\s+(?:your\s+)?(?:filters?|restrictions?|safety|guidelines?)/i,
78
+ ];
79
+ /**
80
+ * Main entry point for the Execlave JavaScript SDK.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const ag = new Execlave({ apiKey: 'exe_prod_xxx', environment: 'production' });
85
+ * const agent = await exe.registerAgent({ agentId: 'my-bot', name: 'My Bot' });
86
+ *
87
+ * const trace = exe.startTrace({ agentId: 'my-bot' });
88
+ * trace.setInput(question);
89
+ * const answer = await llm.call(question);
90
+ * trace.setOutput(answer).setModel('gpt-4').setTokens(100, 200);
91
+ * trace.finish();
92
+ *
93
+ * // Before process exit
94
+ * await exe.shutdown();
95
+ * ```
96
+ */
97
+ class Execlave {
98
+ constructor(config = {}) {
99
+ this._otelExporter = null;
100
+ this._otelReady = null;
101
+ this._state = 'INITIALIZING';
102
+ this._buffer = [];
103
+ this._agents = new Map();
104
+ this._flushTimer = null;
105
+ this._pollTimer = null;
106
+ this._heartbeatTimer = null;
107
+ this._socket = null;
108
+ // Circuit breaker state
109
+ this._cbFailures = 0;
110
+ this._cbThreshold = 3;
111
+ this._cbOpen = false;
112
+ this._cbOpenAt = 0;
113
+ this._cbResetAfterMs = 60000;
114
+ // Policy cache
115
+ this._policyCache = new Map();
116
+ this._policyCacheTtlMs = 60000;
117
+ // Trace quota-exhausted cache (fail fast for 60 seconds)
118
+ this._quotaExceeded = null;
119
+ this._quotaCacheTtlMs = 60000;
120
+ // Enforcement outage behaviour
121
+ this._enforcementOnOutage = 'fail_open';
122
+ // Plan limit behaviour
123
+ this._planLimitBehavior = 'fail_open';
124
+ this._heartbeatIntervalMs = 600000;
125
+ this._apiKey = config.apiKey ?? process.env.EXECLAVE_API_KEY ?? '';
126
+ if (!this._apiKey) {
127
+ throw new Error('apiKey must be provided or EXECLAVE_API_KEY env var must be set');
128
+ }
129
+ this._baseUrl = (config.baseUrl ?? process.env.EXECLAVE_BASE_URL ?? 'https://api.execlave.com').replace(/\/+$/, '');
130
+ this._apiVersion = config.apiVersion !== undefined ? config.apiVersion || undefined : 'v1';
131
+ this._environment = config.environment ?? 'production';
132
+ this._asyncMode = config.asyncMode ?? true;
133
+ this._batchSize = config.batchSize ?? 100;
134
+ this._flushIntervalMs = config.flushIntervalMs ?? 10000;
135
+ this._debug = config.debug ?? false;
136
+ this._enableControlChannel = config.enableControlChannel ?? true;
137
+ this._pollIntervalMs = config.pollIntervalMs ?? 15000;
138
+ this._privacy = config.privacy ?? { enabled: false };
139
+ this._enableInjectionScan = config.enableInjectionScan ?? true;
140
+ this._mode = config.mode ?? 'native';
141
+ this._otlpEndpoint = config.otlpEndpoint;
142
+ this._enforcementOnOutage = config.enforcementOnOutage ?? 'fail_open';
143
+ this._planLimitBehavior = config.planLimitBehavior ?? 'fail_open';
144
+ this._heartbeatIntervalMs = config.heartbeatIntervalMs ?? 600000;
145
+ this._policyCacheTtlMs = config.policyCacheTtlMs ?? 60000;
146
+ // Initialise OTel exporter when running in OTLP mode
147
+ if (this._mode === 'otlp') {
148
+ if (!this._otlpEndpoint) {
149
+ throw new Error('otlpEndpoint is required when mode is "otlp"');
150
+ }
151
+ this._otelReady = Promise.resolve().then(() => __importStar(require('./otel'))).then(({ OTelExporter }) => OTelExporter.create(this._otlpEndpoint, this._apiKey, `Execlave-${this._environment}`))
152
+ .then((exp) => {
153
+ this._otelExporter = exp;
154
+ });
155
+ }
156
+ // Start background flush
157
+ if (this._asyncMode) {
158
+ this._flushTimer = setInterval(() => {
159
+ this._doFlush().catch(this._logError.bind(this));
160
+ }, this._flushIntervalMs);
161
+ // Unref so the timer doesn't prevent process exit
162
+ if (this._flushTimer && typeof this._flushTimer === 'object' && 'unref' in this._flushTimer) {
163
+ this._flushTimer.unref();
164
+ }
165
+ }
166
+ // Start background status polling
167
+ if (this._enableControlChannel) {
168
+ this._pollTimer = setInterval(() => {
169
+ this._statusPoll().catch(this._logError.bind(this));
170
+ }, this._pollIntervalMs);
171
+ if (this._pollTimer && typeof this._pollTimer === 'object' && 'unref' in this._pollTimer) {
172
+ this._pollTimer.unref();
173
+ }
174
+ // Attempt WebSocket connection for real-time control (<500ms latency)
175
+ this._connectWebSocket();
176
+ // Heartbeat timer
177
+ this._heartbeatTimer = setInterval(() => {
178
+ this._sendHeartbeats().catch(this._logError.bind(this));
179
+ }, this._heartbeatIntervalMs);
180
+ if (this._heartbeatTimer && typeof this._heartbeatTimer === 'object' && 'unref' in this._heartbeatTimer) {
181
+ this._heartbeatTimer.unref();
182
+ }
183
+ }
184
+ this._state = 'ACTIVE';
185
+ this._log('Execlave SDK initialized (env=%s, async=%s)', this._environment, this._asyncMode);
186
+ // Register graceful shutdown handlers for SIGTERM/SIGINT
187
+ const gracefulShutdown = () => {
188
+ this.shutdown().catch(() => { }).finally(() => process.exit(0));
189
+ };
190
+ process.once('SIGTERM', gracefulShutdown);
191
+ process.once('SIGINT', gracefulShutdown);
192
+ }
193
+ // ========================================================================
194
+ // API path helper
195
+ // ========================================================================
196
+ /**
197
+ * Build a versioned API path.
198
+ *
199
+ * If `apiVersion` is set (e.g. `'v1'`), returns `/api/v1${path}`.
200
+ * Otherwise falls back to the legacy `/api${path}` format.
201
+ */
202
+ apiPath(path) {
203
+ if (this._apiVersion) {
204
+ return `/api/${this._apiVersion}${path}`;
205
+ }
206
+ return `/api${path}`;
207
+ }
208
+ // ========================================================================
209
+ // Public API
210
+ // ========================================================================
211
+ /** Check if the Execlave API is reachable. */
212
+ async ping() {
213
+ try {
214
+ // Use unversioned /health (the versioned /api/v1/health doesn't exist)
215
+ const resp = await (0, http_1.request)({
216
+ method: 'GET',
217
+ url: `${this._baseUrl}/health`,
218
+ headers: { Authorization: `Bearer ${this._apiKey}` },
219
+ timeout: 5000,
220
+ });
221
+ return resp.status === 200;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
227
+ /**
228
+ * Register (or re-register) an AI agent. Idempotent — call on startup.
229
+ *
230
+ * @returns An Agent object with prompt management methods.
231
+ */
232
+ async registerAgent(opts) {
233
+ const payload = {
234
+ agentId: opts.agentId,
235
+ name: opts.name,
236
+ type: opts.type ?? 'chatbot',
237
+ platform: opts.platform ?? 'custom',
238
+ environment: opts.environment ?? this._environment,
239
+ };
240
+ if (opts.description)
241
+ payload.description = opts.description;
242
+ if (opts.ownerEmail)
243
+ payload.ownerEmail = opts.ownerEmail;
244
+ if (opts.allowedDataSources)
245
+ payload.allowedDataSources = opts.allowedDataSources;
246
+ if (opts.allowedActions)
247
+ payload.allowedActions = opts.allowedActions;
248
+ if (opts.requiresHumanApprovalFor)
249
+ payload.requiresHumanApprovalFor = opts.requiresHumanApprovalFor;
250
+ if (opts.tags)
251
+ payload.tags = opts.tags;
252
+ if (opts.metadata)
253
+ payload.metadata = opts.metadata;
254
+ try {
255
+ const resp = await this._request('POST', this.apiPath('/agents'), payload);
256
+ const data = (resp.data ?? resp);
257
+ const agent = new agent_1.Agent(this, data);
258
+ this._agents.set(opts.agentId, agent);
259
+ return agent;
260
+ }
261
+ catch (err) {
262
+ // If agent already exists, try to fetch it
263
+ if (err instanceof errors_1.ExeclaveError && err.message.includes('already exists')) {
264
+ const listResp = await this._request('GET', `${this.apiPath('/agents')}?search=${encodeURIComponent(opts.agentId)}`);
265
+ const agents = (listResp.data ?? []);
266
+ const match = agents.find((a) => a.agentId === opts.agentId);
267
+ if (match) {
268
+ const agent = new agent_1.Agent(this, match);
269
+ this._agents.set(opts.agentId, agent);
270
+ return agent;
271
+ }
272
+ }
273
+ throw err;
274
+ }
275
+ }
276
+ /**
277
+ * Start a manual trace. Call `trace.finish()` when done.
278
+ *
279
+ * @returns A Trace handle with chainable setters.
280
+ */
281
+ startTrace(opts = {}) {
282
+ this._ensureNotShutdown();
283
+ const resolvedAgentId = opts.agentId ?? this._firstAgentId();
284
+ if (this._state === 'PAUSED') {
285
+ throw new errors_1.AgentPausedError(resolvedAgentId ?? 'unknown');
286
+ }
287
+ return new trace_1.Trace(this, {
288
+ agentId: resolvedAgentId,
289
+ traceId: opts.traceId,
290
+ sessionId: opts.sessionId,
291
+ userId: opts.userId,
292
+ metadata: opts.metadata,
293
+ });
294
+ }
295
+ /**
296
+ * Wrap an async function with automatic tracing.
297
+ *
298
+ * @example
299
+ * ```ts
300
+ * const tracedAnswer = exe.wrap(async (question: string) => {
301
+ * return await llm.call(question);
302
+ * }, { agentId: 'my-bot' });
303
+ *
304
+ * const answer = await tracedAnswer('Hello?');
305
+ * ```
306
+ */
307
+ wrap(fn, opts = {}) {
308
+ return async (...args) => {
309
+ const trace = this.startTrace(opts);
310
+ trace.setInput(args.length === 1 ? args[0] : args);
311
+ try {
312
+ const result = await fn(...args);
313
+ trace.setOutput(result);
314
+ trace.finish('success');
315
+ return result;
316
+ }
317
+ catch (err) {
318
+ const error = err;
319
+ trace.finish('error', error.message, error.name);
320
+ throw err;
321
+ }
322
+ };
323
+ }
324
+ /**
325
+ * Check the current status of a registered agent.
326
+ *
327
+ * @returns 'active', 'paused', or 'error'.
328
+ */
329
+ async checkAgentStatus(agentId) {
330
+ const agent = agentId ? this._agents.get(agentId) : this._firstAgent();
331
+ if (!agent)
332
+ return 'unknown';
333
+ try {
334
+ const resp = await this._request('GET', this.apiPath(`/agents/${agent.id}/status-poll`));
335
+ const status = resp.data?.status ?? 'active';
336
+ agent.status = status;
337
+ return status;
338
+ }
339
+ catch {
340
+ return 'error';
341
+ }
342
+ }
343
+ /** Flush all buffered traces to the API. */
344
+ async flush() {
345
+ await this._doFlush();
346
+ }
347
+ /**
348
+ * Pre-execution policy enforcement.
349
+ *
350
+ * Call this **before** running the LLM to check whether policies allow execution.
351
+ * Throws `PolicyBlockedError` if any policy with `enforcement_mode='block'` is violated.
352
+ * Returns warnings for `warn`-mode violations.
353
+ *
354
+ * @example
355
+ * ```ts
356
+ * try {
357
+ * const result = await exe.enforcePolicy({
358
+ * agentId: agent.id,
359
+ * input: userQuestion,
360
+ * tools: ['web_search'],
361
+ * });
362
+ * if (result.warnings?.length) console.warn('Policy warnings:', result.warnings);
363
+ * // Safe to proceed
364
+ * const answer = await llm.call(userQuestion);
365
+ * } catch (err) {
366
+ * if (err instanceof PolicyBlockedError) {
367
+ * return 'Sorry, I cannot process that request.';
368
+ * }
369
+ * }
370
+ * ```
371
+ */
372
+ async enforcePolicy(opts) {
373
+ this._ensureNotShutdown();
374
+ this._throwIfQuotaExceeded();
375
+ // 1. Check cache
376
+ const cacheKey = this._policyCacheKey(opts.agentId, opts.input);
377
+ const cached = this._policyCacheGet(cacheKey);
378
+ if (cached) {
379
+ this._log('Policy cache hit for %s', opts.agentId);
380
+ return cached;
381
+ }
382
+ // 2. Check circuit breaker
383
+ if (this._cbIsOpen()) {
384
+ if (this._enforcementOnOutage === 'fail_closed') {
385
+ throw new errors_1.EnforcementUnavailableError(this._cbFailures, this._cbLastError);
386
+ }
387
+ this._log('Circuit breaker open — fail_open, allowing execution for %s', opts.agentId);
388
+ return { allowed: true };
389
+ }
390
+ // 3. Build payload and make HTTP call
391
+ // Resolve external agentId to internal UUID if we have a cached agent
392
+ const resolvedAgentId = this._resolveAgentId(opts.agentId);
393
+ const payload = {
394
+ agentId: resolvedAgentId,
395
+ input: opts.input,
396
+ environment: opts.environment ?? this._environment,
397
+ metadata: opts.metadata,
398
+ estimatedCost: opts.estimatedCost,
399
+ tools: opts.tools,
400
+ };
401
+ const url = `${this._baseUrl}${this.apiPath('/policies/enforce')}`;
402
+ let resp;
403
+ try {
404
+ resp = await (0, http_1.request)({
405
+ method: 'POST',
406
+ url,
407
+ headers: { Authorization: `Bearer ${this._apiKey}` },
408
+ body: payload,
409
+ resolveOnClientError: true,
410
+ });
411
+ }
412
+ catch (err) {
413
+ // Network failure → circuit breaker
414
+ this._cbRecordFailure(err.message ?? String(err));
415
+ if (this._enforcementOnOutage === 'fail_closed' && this._cbIsOpen()) {
416
+ throw new errors_1.EnforcementUnavailableError(this._cbFailures, err.message);
417
+ }
418
+ this._log('Network error in enforcePolicy (fail_open): %s', err.message);
419
+ return { allowed: true };
420
+ }
421
+ // 4. Record success in circuit breaker
422
+ this._cbRecordSuccess();
423
+ // 5. Handle response codes
424
+ // 403 → blocked by policy
425
+ if (resp.status === 403 && resp.data?.allowed === false) {
426
+ throw new errors_1.PolicyBlockedError(resp.data.violations ?? []);
427
+ }
428
+ // 202 → require approval (never cached)
429
+ if (resp.status === 202 && resp.data?.approvalRequestId) {
430
+ const approvalRequestId = resp.data.approvalRequestId;
431
+ return this._pollApprovalDecision(approvalRequestId);
432
+ }
433
+ // 402 → plan quota exhausted
434
+ if (resp.status === 402) {
435
+ const quotaError = this._quotaErrorFromBody(resp.data);
436
+ this._setQuotaExceeded(quotaError);
437
+ if (this._planLimitBehavior === 'fail_open') {
438
+ this._log(`[warn] Plan limit exceeded for ${quotaError.resource} (${quotaError.current}/${quotaError.max}) — continuing unmonitored`);
439
+ return { allowed: true, warnings: [{ policyId: 'plan_limit', policyName: 'Plan Limit', policyType: 'plan_limit', message: quotaError.message, enforcementMode: 'warn' }] };
440
+ }
441
+ throw new errors_1.PlanLimitExceededError(quotaError.resource, quotaError.current, quotaError.max, quotaError.message);
442
+ }
443
+ // Other client errors
444
+ if (resp.status >= 400) {
445
+ throw new errors_1.ExeclaveError(`Enforce policy failed (${resp.status}): ${resp.data?.error?.message ?? 'Unknown error'}`);
446
+ }
447
+ // 6. Cache and return
448
+ const result = resp.data;
449
+ this._policyCacheSet(cacheKey, result);
450
+ return result;
451
+ }
452
+ // ========================================================================
453
+ // Circuit Breaker Helpers
454
+ // ========================================================================
455
+ _cbRecordSuccess() {
456
+ this._cbFailures = 0;
457
+ this._cbOpen = false;
458
+ this._cbLastError = undefined;
459
+ }
460
+ _cbRecordFailure(errorMsg) {
461
+ this._cbFailures++;
462
+ this._cbLastError = errorMsg;
463
+ if (this._cbFailures >= this._cbThreshold) {
464
+ this._cbOpen = true;
465
+ this._cbOpenAt = Date.now();
466
+ this._log('Circuit breaker OPEN after %d failures (mode=%s)', this._cbFailures, this._enforcementOnOutage);
467
+ }
468
+ }
469
+ _cbIsOpen() {
470
+ if (!this._cbOpen)
471
+ return false;
472
+ // Half-open: allow retry after reset period
473
+ if (Date.now() - this._cbOpenAt > this._cbResetAfterMs) {
474
+ this._log('Circuit breaker half-open — retrying');
475
+ return false;
476
+ }
477
+ return true;
478
+ }
479
+ // ========================================================================
480
+ // Policy Cache Helpers
481
+ // ========================================================================
482
+ _policyCacheKey(agentId, input) {
483
+ const hash = (0, crypto_1.createHash)('sha256').update(`${agentId}:${input}`).digest('hex').slice(0, 16);
484
+ return `policy:${hash}`;
485
+ }
486
+ _policyCacheGet(key) {
487
+ const entry = this._policyCache.get(key);
488
+ if (entry && entry.expiresAt > Date.now())
489
+ return entry.response;
490
+ if (entry)
491
+ this._policyCache.delete(key);
492
+ return null;
493
+ }
494
+ _policyCacheSet(key, response) {
495
+ this._policyCache.set(key, { response, expiresAt: Date.now() + this._policyCacheTtlMs });
496
+ // Evict old entries (keep max 500)
497
+ if (this._policyCache.size > 500) {
498
+ const entries = [...this._policyCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
499
+ for (let i = 0; i < 100 && i < entries.length; i++) {
500
+ this._policyCache.delete(entries[i][0]);
501
+ }
502
+ }
503
+ }
504
+ _toInt(value, fallback = 0) {
505
+ const parsed = Number(value);
506
+ return Number.isFinite(parsed) ? parsed : fallback;
507
+ }
508
+ _quotaErrorFromBody(body) {
509
+ const err = body?.error ?? {};
510
+ return new errors_1.QuotaExceededError(String(err.resource ?? 'unknown'), this._toInt(err.current, 0), this._toInt(err.max, 0), String(err.message ?? ''));
511
+ }
512
+ _setQuotaExceeded(error) {
513
+ // Cache only trace quota; this drives fail-fast for enforce/trace hot paths.
514
+ if (error.resource !== 'maxTracesPerMonth')
515
+ return;
516
+ this._quotaExceeded = {
517
+ error,
518
+ expiresAt: Date.now() + this._quotaCacheTtlMs,
519
+ };
520
+ }
521
+ _getCachedQuotaExceeded() {
522
+ if (!this._quotaExceeded)
523
+ return null;
524
+ if (Date.now() >= this._quotaExceeded.expiresAt) {
525
+ this._quotaExceeded = null;
526
+ return null;
527
+ }
528
+ return this._quotaExceeded.error;
529
+ }
530
+ _throwIfQuotaExceeded() {
531
+ const cached = this._getCachedQuotaExceeded();
532
+ if (!cached)
533
+ return;
534
+ if (this._planLimitBehavior === 'fail_open')
535
+ return;
536
+ throw new errors_1.PlanLimitExceededError(cached.resource, cached.current, cached.max, cached.message);
537
+ }
538
+ // ========================================================================
539
+ // Heartbeat
540
+ // ========================================================================
541
+ async _sendHeartbeats() {
542
+ for (const [, agent] of this._agents) {
543
+ try {
544
+ await (0, http_1.request)({
545
+ method: 'POST',
546
+ url: `${this._baseUrl}${this.apiPath(`/agents/${agent.id}/heartbeat`)}`,
547
+ headers: { Authorization: `Bearer ${this._apiKey}` },
548
+ body: { lastPolicyCheckAt: null },
549
+ timeout: 10000,
550
+ });
551
+ this._log('Heartbeat sent for agent %s', agent.id);
552
+ }
553
+ catch (err) {
554
+ this._log('Heartbeat failed for agent %s: %s', agent.id, err.message);
555
+ }
556
+ }
557
+ }
558
+ async _pollApprovalDecision(approvalRequestId) {
559
+ const startedAt = Date.now();
560
+ const timeoutMs = 30 * 60 * 1000;
561
+ const pollIntervalMs = 5000;
562
+ while (Date.now() - startedAt < timeoutMs) {
563
+ const url = `${this._baseUrl}${this.apiPath(`/approvals/${approvalRequestId}`)}`;
564
+ const resp = await (0, http_1.request)({
565
+ method: 'GET',
566
+ url,
567
+ headers: { Authorization: `Bearer ${this._apiKey}` },
568
+ resolveOnClientError: true,
569
+ });
570
+ if (resp.status >= 400) {
571
+ throw new errors_1.ExeclaveError(`Approval polling failed (${resp.status}): ${resp.data?.error?.message ?? 'Unknown error'}`);
572
+ }
573
+ const approval = resp.data?.data;
574
+ if (!approval) {
575
+ throw new errors_1.ExeclaveError('Approval polling returned no approval payload');
576
+ }
577
+ if (approval.status === 'approved') {
578
+ return { allowed: true, approvalRequestId };
579
+ }
580
+ if (approval.status === 'denied') {
581
+ throw new errors_1.PolicyDeniedError(approvalRequestId, approval.decisionReason);
582
+ }
583
+ if (approval.status === 'expired') {
584
+ throw new errors_1.ApprovalTimeoutError(approvalRequestId);
585
+ }
586
+ await this._sleep(pollIntervalMs);
587
+ }
588
+ throw new errors_1.ApprovalTimeoutError(approvalRequestId);
589
+ }
590
+ /**
591
+ * Check if one agent is authorized to call another.
592
+ *
593
+ * @returns Authorization result. Throws `ExeclaveAuthError` on 403.
594
+ */
595
+ async authorizeAgentCall(opts) {
596
+ this._ensureNotShutdown();
597
+ const resp = await this._request('POST', this.apiPath('/agents/authorize'), {
598
+ callerAgentId: opts.callerAgentId,
599
+ calleeAgentId: opts.calleeAgentId,
600
+ action: opts.action,
601
+ });
602
+ return resp;
603
+ }
604
+ /**
605
+ * Discover agents by capability.
606
+ *
607
+ * @param capability Optional capability to filter by (e.g. 'send_email').
608
+ * If omitted, returns all agents with capabilities.
609
+ */
610
+ async discoverAgents(capability) {
611
+ this._ensureNotShutdown();
612
+ const qs = capability ? `?capability=${encodeURIComponent(capability)}` : '';
613
+ const resp = await this._request('GET', `${this.apiPath('/agents/discover')}${qs}`);
614
+ return (resp.data ?? []);
615
+ }
616
+ /**
617
+ * Return current plan usage and limits.
618
+ */
619
+ async checkUsage() {
620
+ this._ensureNotShutdown();
621
+ const resp = await this._requestRaw('GET', this.apiPath('/billing/usage'));
622
+ const data = (resp.data?.data ?? resp.data ?? {});
623
+ const nestedUsage = data.usage ?? {};
624
+ const pickBucket = (resource) => {
625
+ const fromNested = nestedUsage?.[resource];
626
+ if (fromNested && typeof fromNested === 'object') {
627
+ return {
628
+ current: this._toInt(fromNested.current, 0),
629
+ max: this._toInt(fromNested.max, 0),
630
+ };
631
+ }
632
+ const fromTopLevel = data?.[resource] ?? {};
633
+ return {
634
+ current: this._toInt(fromTopLevel.current, 0),
635
+ max: this._toInt(fromTopLevel.max, 0),
636
+ };
637
+ };
638
+ return {
639
+ plan: String(data.plan ?? 'unknown'),
640
+ agents: pickBucket('agents'),
641
+ traces: pickBucket('traces'),
642
+ users: pickBucket('users'),
643
+ policies: pickBucket('policies'),
644
+ upgradeUrl: String(data.upgradeUrl ?? '') || 'https://www.execlave.com/dashboard/billing',
645
+ };
646
+ }
647
+ /** Flush remaining traces and shut down the SDK. */
648
+ async shutdown() {
649
+ this._state = 'SHUTDOWN';
650
+ if (this._flushTimer) {
651
+ clearInterval(this._flushTimer);
652
+ this._flushTimer = null;
653
+ }
654
+ if (this._pollTimer) {
655
+ clearInterval(this._pollTimer);
656
+ this._pollTimer = null;
657
+ }
658
+ if (this._socket) {
659
+ this._socket.disconnect();
660
+ this._socket = null;
661
+ }
662
+ await this._doFlush();
663
+ await this._otelExporter?.shutdown();
664
+ this._log('Execlave SDK shut down');
665
+ }
666
+ // ========================================================================
667
+ // Internal — called by Trace and Agent
668
+ // ========================================================================
669
+ /** @internal */
670
+ _bufferTrace(payload) {
671
+ this._throwIfQuotaExceeded();
672
+ // Client-side PII scrubbing
673
+ if (this._privacy.enabled) {
674
+ this._applyPrivacy(payload);
675
+ }
676
+ // Client-side injection scanning
677
+ if (this._enableInjectionScan) {
678
+ const injection = this._scanInjection(payload);
679
+ if (injection.detected) {
680
+ if (!payload.metadata)
681
+ payload.metadata = {};
682
+ payload.metadata.injection_scan = injection;
683
+ }
684
+ }
685
+ // Circular buffer — drop oldest when full
686
+ if (this._buffer.length >= MAX_BUFFER_SIZE) {
687
+ this._buffer.shift();
688
+ }
689
+ this._buffer.push(payload);
690
+ this._log('Buffered trace %s (size: %d)', payload.traceId, this._buffer.length);
691
+ // If sync mode or buffer is full, flush immediately
692
+ if (!this._asyncMode || this._buffer.length >= this._batchSize) {
693
+ this._doFlush().catch(this._logError.bind(this));
694
+ }
695
+ }
696
+ /** @internal */
697
+ _apiPath(path) {
698
+ return this.apiPath(path);
699
+ }
700
+ /** @internal */
701
+ async _request(method, path, body) {
702
+ const resp = await this._requestRaw(method, path, body);
703
+ return resp.data;
704
+ }
705
+ async _requestRaw(method, path, body, resolveOnClientError = false) {
706
+ const url = `${this._baseUrl}${path}`;
707
+ return (0, http_1.request)({
708
+ method,
709
+ url,
710
+ headers: {
711
+ Authorization: `Bearer ${this._apiKey}`,
712
+ },
713
+ body,
714
+ resolveOnClientError,
715
+ });
716
+ }
717
+ // ========================================================================
718
+ // Private
719
+ // ========================================================================
720
+ /**
721
+ * Resolve an external agentId string to the internal UUID.
722
+ * The API endpoints like /policies/enforce expect the internal UUID,
723
+ * but users naturally pass the external agentId (e.g. "my-bot").
724
+ * This looks up the cached Agent and returns its UUID (.id).
725
+ * If no match is found, returns the original value unchanged.
726
+ */
727
+ _resolveAgentId(agentId) {
728
+ const agent = this._agents.get(agentId);
729
+ if (agent) {
730
+ return agent.id; // internal UUID
731
+ }
732
+ // Maybe the caller already passed a UUID — return as-is
733
+ return agentId;
734
+ }
735
+ async _doFlush() {
736
+ if (this._buffer.length === 0)
737
+ return;
738
+ const batch = this._buffer.splice(0, this._buffer.length);
739
+ // OTLP mode — delegate to OTel exporter
740
+ if (this._mode === 'otlp') {
741
+ if (this._otelReady)
742
+ await this._otelReady;
743
+ if (this._otelExporter) {
744
+ for (let i = 0; i < batch.length; i += this._batchSize) {
745
+ const chunk = batch.slice(i, i + this._batchSize);
746
+ try {
747
+ this._otelExporter.exportTraces(chunk);
748
+ this._log('Exported %d traces via OTLP', chunk.length);
749
+ }
750
+ catch (err) {
751
+ this._logError(`OTLP export failed for ${chunk.length} traces: ${err.message}`);
752
+ }
753
+ }
754
+ }
755
+ else {
756
+ this._logError('OTel exporter not ready — dropping traces');
757
+ }
758
+ return;
759
+ }
760
+ // Native mode — POST to Execlave API
761
+ for (let i = 0; i < batch.length; i += this._batchSize) {
762
+ const chunk = batch.slice(i, i + this._batchSize);
763
+ let retries = 0;
764
+ while (retries < 3) {
765
+ try {
766
+ const resp = await this._requestRaw('POST', this.apiPath('/traces/ingest'), { traces: chunk }, true);
767
+ if (resp.status === 402) {
768
+ const quotaError = this._quotaErrorFromBody(resp.data);
769
+ this._setQuotaExceeded(quotaError);
770
+ this._logError(`Trace quota exceeded while flushing ${chunk.length} traces: ${quotaError.message}`);
771
+ break;
772
+ }
773
+ if (resp.status >= 400) {
774
+ throw new errors_1.ExeclaveError(`Trace ingestion failed (${resp.status}): ${resp.data?.error?.message ?? 'Unknown error'}`);
775
+ }
776
+ this._log('Flushed %d traces', chunk.length);
777
+ break;
778
+ }
779
+ catch (err) {
780
+ retries++;
781
+ if (retries >= 3) {
782
+ this._logError(`Failed to flush ${chunk.length} traces after 3 retries: ${err.message}`);
783
+ }
784
+ else {
785
+ await this._sleep(2 ** retries * 500);
786
+ }
787
+ }
788
+ }
789
+ }
790
+ }
791
+ async _statusPoll() {
792
+ for (const [agentId, agent] of this._agents) {
793
+ try {
794
+ const resp = await this._request('GET', this.apiPath(`/agents/${agent.id}/status-poll`));
795
+ const newStatus = resp.data?.status ?? 'active';
796
+ if (newStatus === 'paused' && this._state === 'ACTIVE') {
797
+ this._state = 'PAUSED';
798
+ agent.status = 'paused';
799
+ this._log('Agent %s has been PAUSED via kill switch', agentId);
800
+ }
801
+ else if (newStatus === 'active' && this._state === 'PAUSED') {
802
+ this._state = 'ACTIVE';
803
+ agent.status = 'active';
804
+ this._log('Agent %s has been RESUMED', agentId);
805
+ }
806
+ agent.status = newStatus;
807
+ }
808
+ catch {
809
+ this._log('Status poll failed for agent %s', agentId);
810
+ }
811
+ }
812
+ }
813
+ // ========================================================================
814
+ // Privacy & Injection Scanning
815
+ // ========================================================================
816
+ _hashPii(value) {
817
+ return (0, crypto_1.createHash)('sha256').update(value).digest('hex').slice(0, 16);
818
+ }
819
+ _toText(data) {
820
+ if (data == null)
821
+ return '';
822
+ if (typeof data === 'string')
823
+ return data;
824
+ if (typeof data === 'object') {
825
+ if (Array.isArray(data))
826
+ return data.map(String).join(' ');
827
+ return Object.values(data).map(String).join(' ');
828
+ }
829
+ return String(data);
830
+ }
831
+ _scrubText(text) {
832
+ if (!text)
833
+ return text ?? '';
834
+ let result = text;
835
+ for (const [piiType, pattern] of Object.entries(PII_PATTERNS)) {
836
+ result = result.replace(new RegExp(pattern.source, pattern.flags), `[${piiType.toUpperCase()}_REDACTED]`);
837
+ }
838
+ return result;
839
+ }
840
+ _applyPrivacy(payload) {
841
+ const scrubFields = this._privacy.scrubFields ?? ['input', 'output'];
842
+ const hashPii = this._privacy.hashPii ?? true;
843
+ const piiSummary = {};
844
+ for (const field of scrubFields) {
845
+ const value = payload[field];
846
+ if (!value)
847
+ continue;
848
+ const text = this._toText(value);
849
+ if (!text)
850
+ continue;
851
+ // Detect PII
852
+ for (const [piiType, pattern] of Object.entries(PII_PATTERNS)) {
853
+ const matches = text.match(new RegExp(pattern.source, pattern.flags));
854
+ if (matches && matches.length > 0) {
855
+ if (!piiSummary[piiType])
856
+ piiSummary[piiType] = { count: 0, hashes: [] };
857
+ piiSummary[piiType].count += matches.length;
858
+ if (hashPii) {
859
+ piiSummary[piiType].hashes.push(...matches.map((m) => this._hashPii(m)));
860
+ }
861
+ }
862
+ }
863
+ // Replace PII with placeholders
864
+ if (typeof value === 'string') {
865
+ payload[field] = this._scrubText(value);
866
+ }
867
+ else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
868
+ const scrubbed = {};
869
+ for (const [k, v] of Object.entries(value)) {
870
+ scrubbed[k] = typeof v === 'string' ? this._scrubText(v) : v;
871
+ }
872
+ payload[field] = scrubbed;
873
+ }
874
+ }
875
+ if (Object.keys(piiSummary).length > 0) {
876
+ if (!payload.metadata)
877
+ payload.metadata = {};
878
+ payload.metadata.pii_detected = piiSummary;
879
+ payload.metadata.pii_scrubbed = true;
880
+ }
881
+ }
882
+ _scanInjection(payload) {
883
+ const text = this._toText(payload.input);
884
+ if (!text)
885
+ return { detected: false, risk_level: 'none', patterns_matched: [] };
886
+ const matched = [];
887
+ for (const pattern of INJECTION_PATTERNS) {
888
+ if (pattern.test(text)) {
889
+ matched.push(pattern.source);
890
+ }
891
+ }
892
+ const count = matched.length;
893
+ let risk;
894
+ if (count === 0)
895
+ risk = 'none';
896
+ else if (count === 1)
897
+ risk = 'low';
898
+ else if (count <= 3)
899
+ risk = 'medium';
900
+ else if (count <= 5)
901
+ risk = 'high';
902
+ else
903
+ risk = 'critical';
904
+ return { detected: count > 0, risk_level: risk, patterns_matched: matched };
905
+ }
906
+ // ========================================================================
907
+ // Helpers
908
+ // ========================================================================
909
+ _firstAgentId() {
910
+ const first = this._agents.values().next();
911
+ return first.done ? undefined : first.value.agentId;
912
+ }
913
+ _firstAgent() {
914
+ const first = this._agents.values().next();
915
+ return first.done ? undefined : first.value;
916
+ }
917
+ _ensureNotShutdown() {
918
+ if (this._state === 'SHUTDOWN') {
919
+ throw new errors_1.ExeclaveError('SDK has been shut down. Call not allowed.');
920
+ }
921
+ }
922
+ _sleep(ms) {
923
+ return new Promise((resolve) => setTimeout(resolve, ms));
924
+ }
925
+ /**
926
+ * Connect to the Socket.IO /sdk namespace for real-time control channel.
927
+ * Falls back silently to HTTP polling if socket.io-client is not installed.
928
+ */
929
+ _connectWebSocket() {
930
+ try {
931
+ // Dynamic require — socket.io-client is an optional peer dependency
932
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
933
+ const { io } = require('socket.io-client');
934
+ // Use the first registered agent's agentId if available
935
+ const agentId = this._firstAgentId();
936
+ this._socket = io(`${this._baseUrl}/sdk`, {
937
+ auth: {
938
+ apiKey: this._apiKey,
939
+ agentId,
940
+ },
941
+ transports: ['websocket'],
942
+ reconnection: true,
943
+ reconnectionDelay: 2000,
944
+ reconnectionAttempts: 10,
945
+ });
946
+ this._socket.on('agent.status_updated', (data) => {
947
+ const agent = data.agentId ? this._agents.get(data.agentId) : undefined;
948
+ if (data.status === 'paused' && this._state === 'ACTIVE') {
949
+ this._state = 'PAUSED';
950
+ if (agent)
951
+ agent.status = 'paused';
952
+ this._log('Agent %s PAUSED via WebSocket kill switch (reason: %s)', data.agentId, data.reason ?? 'none');
953
+ }
954
+ else if (data.status === 'active' && this._state === 'PAUSED') {
955
+ this._state = 'ACTIVE';
956
+ if (agent)
957
+ agent.status = 'active';
958
+ this._log('Agent %s RESUMED via WebSocket', data.agentId);
959
+ }
960
+ });
961
+ this._socket.on('connect', () => {
962
+ this._log('WebSocket control channel connected');
963
+ });
964
+ this._socket.on('connect_error', (err) => {
965
+ // Silently fall back to HTTP polling — no user action needed
966
+ this._log('WebSocket connect error: %s — falling back to HTTP polling', err.message);
967
+ });
968
+ }
969
+ catch {
970
+ // socket.io-client not installed — HTTP polling continues as fallback
971
+ }
972
+ }
973
+ _log(msg, ...args) {
974
+ if (this._debug) {
975
+ console.debug(`[Execlave] ${msg}`, ...args);
976
+ }
977
+ }
978
+ _logError(msg) {
979
+ if (this._debug) {
980
+ console.error(`[Execlave] ${msg}`);
981
+ }
982
+ }
983
+ }
984
+ exports.Execlave = Execlave;
985
+ //# sourceMappingURL=client.js.map