arkna-sdk 0.1.0

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,948 @@
1
+ "use strict";
2
+ /**
3
+ * ARKNA SDK Client
4
+ *
5
+ * Core client for the ARKNA AI Trust Gateway.
6
+ * Zero runtime dependencies — uses Node 18+ built-in fetch.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.ArknaClient = void 0;
43
+ const fs = __importStar(require("fs"));
44
+ const path = __importStar(require("path"));
45
+ const crypto = __importStar(require("crypto"));
46
+ const enforcement_1 = require("./enforcement");
47
+ const license_1 = require("./license");
48
+ /** Default request timeout (30s) */
49
+ const DEFAULT_TIMEOUT_MS = 30_000;
50
+ /** Tools cache TTL (5 minutes) */
51
+ const TOOLS_CACHE_TTL_MS = 5 * 60 * 1000;
52
+ /**
53
+ * Read .arkna/.env from current or parent directories.
54
+ * Returns parsed key-value pairs, or empty object if not found.
55
+ */
56
+ function readArknaEnv(startDir) {
57
+ let dir = startDir || process.cwd();
58
+ const root = path.parse(dir).root;
59
+ while (dir !== root) {
60
+ const envPath = path.join(dir, '.arkna', '.env');
61
+ try {
62
+ const content = fs.readFileSync(envPath, 'utf-8');
63
+ const vars = {};
64
+ for (const line of content.split(/\r?\n/)) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed || trimmed.startsWith('#'))
67
+ continue;
68
+ const eqIdx = trimmed.indexOf('=');
69
+ if (eqIdx === -1)
70
+ continue;
71
+ const key = trimmed.substring(0, eqIdx).trim();
72
+ let value = trimmed.substring(eqIdx + 1).trim();
73
+ // Strip surrounding quotes
74
+ if ((value.startsWith('"') && value.endsWith('"')) ||
75
+ (value.startsWith("'") && value.endsWith("'"))) {
76
+ value = value.slice(1, -1);
77
+ }
78
+ vars[key] = value;
79
+ }
80
+ return vars;
81
+ }
82
+ catch {
83
+ // File not found — walk up
84
+ }
85
+ dir = path.dirname(dir);
86
+ }
87
+ return {};
88
+ }
89
+ /** Status codes that are safe to retry (transient server/rate-limit errors) */
90
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
91
+ class ArknaClient {
92
+ token;
93
+ baseUrl;
94
+ timeoutMs;
95
+ maxRetries;
96
+ retryDelayMs;
97
+ agentId;
98
+ // Tools cache
99
+ toolsCache = null;
100
+ toolsCacheTime = 0;
101
+ // Enforcement guard (mandatory when governance.json exists)
102
+ enforcementGuard = null;
103
+ // Secret for nonce-based enforcement proof (generated per session)
104
+ enforcementSecret = '';
105
+ // Last nonce received from server (for enforcement proof)
106
+ lastNonce = null;
107
+ // Compiled license manager (V3: agent-side evaluation + stamp generation)
108
+ licenseManager = new license_1.LicenseManager();
109
+ licenseFetched = false;
110
+ constructor(config) {
111
+ // Resolution priority: explicit > .arkna/.env > env vars
112
+ const arknaEnv = readArknaEnv();
113
+ this.token = config?.token
114
+ || arknaEnv['ARKNA_TOKEN']
115
+ || process.env['ARKNA_TOKEN']
116
+ || '';
117
+ const rawUrl = config?.gatewayUrl
118
+ || arknaEnv['ARKNA_URL']
119
+ || process.env['ARKNA_URL']
120
+ || '';
121
+ // Normalize: strip trailing /api or /api/gateway and ensure /api/gateway
122
+ this.baseUrl = normalizeGatewayUrl(rawUrl);
123
+ this.agentId = config?.agentId
124
+ || arknaEnv['ARKNA_AGENT_ID']
125
+ || process.env['ARKNA_AGENT_ID']
126
+ || undefined;
127
+ this.timeoutMs = config?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
128
+ this.maxRetries = config?.maxRetries ?? 3;
129
+ this.retryDelayMs = config?.retryDelayMs ?? 500;
130
+ if (!this.token) {
131
+ throw new Error('ARKNA token not found. Set ARKNA_TOKEN env var, pass { token } to constructor, or run `arkna connect`.');
132
+ }
133
+ if (!this.baseUrl) {
134
+ throw new Error('ARKNA gateway URL not found. Set ARKNA_URL env var, pass { gatewayUrl } to constructor, or run `arkna connect`.');
135
+ }
136
+ // Enable enforcement — mandatory when governance contract exists.
137
+ // Patches fetch/http to intercept direct API calls to governed domains.
138
+ // Generates an enforcement secret used for nonce-based proof on heartbeats.
139
+ this.enforcementSecret = crypto.randomBytes(32).toString('hex');
140
+ try {
141
+ this.enforcementGuard = (0, enforcement_1.enableEnforcement)(this, {
142
+ mode: config?.enforcementMode,
143
+ });
144
+ }
145
+ catch {
146
+ // No governance.json found — enforcement not active.
147
+ // Compliance checker will detect this via missing heartbeat metadata.
148
+ }
149
+ }
150
+ /** Whether HTTP enforcement is active (governed domain interception) */
151
+ get enforcementActive() {
152
+ return this.enforcementGuard !== null && this.enforcementGuard.contract !== null;
153
+ }
154
+ /** Number of intercepted direct API calls since client creation */
155
+ get interceptedCalls() {
156
+ return this.enforcementGuard?.interceptedCalls ?? 0;
157
+ }
158
+ // ────────────────────────────────────────────────────────────
159
+ // Tools
160
+ // ────────────────────────────────────────────────────────────
161
+ /** List available tools for this agent (cached 5 min). */
162
+ async tools(forceRefresh = false) {
163
+ if (!forceRefresh && this.toolsCache && Date.now() - this.toolsCacheTime < TOOLS_CACHE_TTL_MS) {
164
+ return this.toolsCache;
165
+ }
166
+ const resp = await this.request('GET', '/tools');
167
+ this.toolsCache = resp.tools;
168
+ this.toolsCacheTime = Date.now();
169
+ return resp.tools;
170
+ }
171
+ // ────────────────────────────────────────────────────────────
172
+ // Execute
173
+ // ────────────────────────────────────────────────────────────
174
+ /**
175
+ * Execute a tool through the ARKNA governance gateway.
176
+ *
177
+ * @param toolName - Tool identifier (e.g. 'send_email', 'check_calendly_availability')
178
+ * @param parameters - Tool input parameters
179
+ * @param opts - Optional: custom requestId, dryRun mode
180
+ * @returns ActionResult with decision, result, and signed permit
181
+ */
182
+ async execute(toolName, parameters, opts) {
183
+ const requestId = opts?.requestId || `req_${crypto.randomUUID().replace(/-/g, '').substring(0, 12)}`;
184
+ const body = {
185
+ tool: toolName,
186
+ parameters,
187
+ request_id: requestId,
188
+ };
189
+ if (opts?.dryRun) {
190
+ body.dry_run = true;
191
+ }
192
+ // ── Compiled License Fast Path ──
193
+ // If we have a valid license and the action is covered, generate a stamp
194
+ // and send it with the request. Gateway validates in <1ms instead of 100-400ms.
195
+ if (!opts?.dryRun) {
196
+ await this.ensureLicense();
197
+ if (this.licenseManager.isLicensed(toolName, parameters)) {
198
+ const stamp = this.licenseManager.generateStamp(toolName, parameters);
199
+ if (stamp) {
200
+ return this.requestRaw('POST', '/actions', body, { 'X-Arkna-Stamp': stamp });
201
+ }
202
+ }
203
+ }
204
+ // POST /actions returns 200 (ALLOW), 202 (NEEDS_APPROVAL), or 403 (DENY).
205
+ // All three are valid governance decisions — not errors.
206
+ return this.requestRaw('POST', '/actions', body);
207
+ }
208
+ // ────────────────────────────────────────────────────────────
209
+ // Heartbeat
210
+ // ────────────────────────────────────────────────────────────
211
+ /** Send a single heartbeat. Returns kill switch status.
212
+ * Automatically includes enforcement metadata and cryptographic proof
213
+ * that enforcement is genuinely active (nonce-based, server-verified).
214
+ * @param metadata - Optional metadata to include
215
+ * @param tools - Updated tool declarations (synced on each heartbeat)
216
+ */
217
+ async heartbeat(metadata, tools) {
218
+ // Auto-enrich with enforcement telemetry
219
+ const enriched = { ...metadata };
220
+ enriched.enforcement_active = this.enforcementActive;
221
+ enriched.intercepted_calls = this.interceptedCalls;
222
+ enriched.sdk_version = '0.1.0';
223
+ if (this.enforcementGuard?.contract) {
224
+ enriched.enforcement_mode = this.enforcementGuard.contract.enforcement_mode;
225
+ enriched.contract_version = this.enforcementGuard.contract.version;
226
+ // Send contract signature prefix so backend can verify contract integrity
227
+ enriched.contract_signature_prefix = this.enforcementGuard.contract.signature.substring(0, 16);
228
+ }
229
+ // Nonce-based enforcement proof: proves enforcement is genuinely running.
230
+ // Both sides use SHA256(secret) as the HMAC key so the backend can verify.
231
+ const secretHash = crypto.createHash('sha256').update(this.enforcementSecret).digest('hex');
232
+ if (this.lastNonce && this.enforcementActive) {
233
+ enriched.enforcement_proof = crypto
234
+ .createHmac('sha256', secretHash)
235
+ .update(this.lastNonce)
236
+ .digest('hex');
237
+ enriched.enforcement_proof_nonce = this.lastNonce;
238
+ }
239
+ // Send the secret hash on first heartbeat so backend can verify future proofs
240
+ if (!this.lastNonce) {
241
+ enriched.enforcement_secret_hash = secretHash;
242
+ }
243
+ const body = { metadata: enriched };
244
+ if (tools) {
245
+ body.tools = tools;
246
+ }
247
+ const result = await this.request('POST', '/heartbeat', body);
248
+ // Store nonce from server for next heartbeat proof
249
+ if (result.enforcement_nonce) {
250
+ this.lastNonce = result.enforcement_nonce;
251
+ }
252
+ return result;
253
+ }
254
+ /**
255
+ * Collect system telemetry (CPU, memory). Returns empty object on failure.
256
+ * Used by startHeartbeat when enriched=true. Safe on all platforms.
257
+ */
258
+ collectSystemTelemetry() {
259
+ try {
260
+ const os = require('os');
261
+ const loadAvg = os.loadavg();
262
+ const totalMem = os.totalmem();
263
+ const freeMem = os.freemem();
264
+ return {
265
+ system: {
266
+ load_1m: Math.round(loadAvg[0] * 100) / 100,
267
+ load_5m: Math.round(loadAvg[1] * 100) / 100,
268
+ load_15m: Math.round(loadAvg[2] * 100) / 100,
269
+ memory_used_pct: Math.round((1 - freeMem / totalMem) * 100),
270
+ memory_total_mb: Math.round(totalMem / (1024 * 1024)),
271
+ platform: os.platform(),
272
+ uptime_hours: Math.round(os.uptime() / 3600 * 10) / 10,
273
+ },
274
+ };
275
+ }
276
+ catch {
277
+ return {};
278
+ }
279
+ }
280
+ /**
281
+ * Start a background heartbeat interval.
282
+ * @param intervalMs - Interval in ms (default: 60000 = 1 min)
283
+ * @param options.enriched - Include system telemetry (CPU, memory) in heartbeats
284
+ * @returns Handle with stop() method
285
+ */
286
+ startHeartbeat(intervalMs = 60_000, options) {
287
+ const enriched = options?.enriched ?? false;
288
+ const sendHeartbeat = () => {
289
+ const extra = enriched ? this.collectSystemTelemetry() : {};
290
+ this.heartbeat(extra).catch(() => {
291
+ // Heartbeat failures are non-critical
292
+ });
293
+ };
294
+ const id = setInterval(sendHeartbeat, intervalMs);
295
+ // Send first heartbeat immediately
296
+ sendHeartbeat();
297
+ return {
298
+ stop: () => clearInterval(id),
299
+ };
300
+ }
301
+ // ────────────────────────────────────────────────────────────
302
+ // Registration
303
+ // ────────────────────────────────────────────────────────────
304
+ /**
305
+ * Register this agent with the ARKNA gateway.
306
+ * @param name - Agent display name
307
+ * @param platform - Agent platform (e.g. 'crewai', 'langchain', 'custom')
308
+ * @param opts - Optional: description, capabilities, reply_url, tools
309
+ */
310
+ async register(name, platform, opts) {
311
+ const body = {
312
+ name,
313
+ platform,
314
+ description: opts?.description,
315
+ capabilities: opts?.capabilities,
316
+ reply_url: opts?.reply_url,
317
+ metadata: opts?.metadata,
318
+ };
319
+ if (opts?.tools) {
320
+ body.tools = opts.tools;
321
+ }
322
+ const result = await this.request('POST', '/register', body);
323
+ // Store agent ID from registration
324
+ if (result.agent?.id) {
325
+ this.agentId = result.agent.id;
326
+ }
327
+ return result;
328
+ }
329
+ // ────────────────────────────────────────────────────────────
330
+ // Declared Tools
331
+ // ────────────────────────────────────────────────────────────
332
+ /**
333
+ * Declare this agent's native tools for ARKNA governance tracking.
334
+ * Convenience method that sends a heartbeat with a tools declaration.
335
+ * ARKNA auto-classifies read-only tools and queues others for admin approval.
336
+ */
337
+ async declareTools(tools) {
338
+ return this.heartbeat(undefined, tools);
339
+ }
340
+ // ────────────────────────────────────────────────────────────
341
+ // Governance
342
+ // ────────────────────────────────────────────────────────────
343
+ /** Fetch the full governance envelope (tools, policies, permissions, constitution). */
344
+ async governance() {
345
+ return this.request('GET', '/governance');
346
+ }
347
+ // ────────────────────────────────────────────────────────────
348
+ // Messages (SSE + Reply)
349
+ // ────────────────────────────────────────────────────────────
350
+ /**
351
+ * Listen for visitor messages via SSE.
352
+ * Calls the callback for each message event.
353
+ * Returns an AbortController to stop listening.
354
+ */
355
+ onMessage(callback) {
356
+ const controller = new AbortController();
357
+ const listen = async () => {
358
+ try {
359
+ const resp = await fetch(`${this.baseUrl}/messages/stream`, {
360
+ headers: {
361
+ 'X-Integration-Token': this.token,
362
+ },
363
+ signal: controller.signal,
364
+ });
365
+ if (!resp.ok || !resp.body) {
366
+ throw new Error(`SSE connection failed: ${resp.status}`);
367
+ }
368
+ const reader = resp.body.getReader();
369
+ const decoder = new TextDecoder();
370
+ let buffer = '';
371
+ let currentEvent = '';
372
+ let dataLines = [];
373
+ while (true) {
374
+ const { done, value } = await reader.read();
375
+ if (done)
376
+ break;
377
+ buffer += decoder.decode(value, { stream: true });
378
+ const lines = buffer.split('\n');
379
+ buffer = lines.pop() || '';
380
+ for (const line of lines) {
381
+ if (line.startsWith('event:')) {
382
+ currentEvent = line.substring(6).trim();
383
+ }
384
+ else if (line.startsWith('data:')) {
385
+ // SSE spec: accumulate multiple data: lines
386
+ dataLines.push(line.substring(5).trim());
387
+ }
388
+ else if (line === '') {
389
+ // Empty line = end of event. Dispatch accumulated data.
390
+ if (dataLines.length > 0) {
391
+ const dataStr = dataLines.join('\n');
392
+ try {
393
+ const data = JSON.parse(dataStr);
394
+ callback({ event: currentEvent || 'message', data });
395
+ }
396
+ catch {
397
+ // Skip malformed JSON
398
+ }
399
+ }
400
+ currentEvent = '';
401
+ dataLines = [];
402
+ }
403
+ }
404
+ }
405
+ }
406
+ catch (err) {
407
+ if (err instanceof Error && err.name === 'AbortError')
408
+ return;
409
+ // Reconnect after 5s on error
410
+ if (!controller.signal.aborted) {
411
+ setTimeout(listen, 5000);
412
+ }
413
+ }
414
+ };
415
+ listen();
416
+ return controller;
417
+ }
418
+ /**
419
+ * Reply to a visitor message.
420
+ * @param messageId - The message_id from the SSE event
421
+ * @param content - Reply text content
422
+ */
423
+ async reply(messageId, content) {
424
+ return this.request('POST', `/messages/${messageId}/reply`, { content });
425
+ }
426
+ // ────────────────────────────────────────────────────────────
427
+ // Action Status Polling
428
+ // ────────────────────────────────────────────────────────────
429
+ /** Poll for the result of a queued action (may return denied status). */
430
+ async getActionStatus(requestId) {
431
+ return this.requestRaw('GET', `/actions/${requestId}`);
432
+ }
433
+ // ────────────────────────────────────────────────────────────
434
+ // Agent Settings
435
+ // ────────────────────────────────────────────────────────────
436
+ /** Update agent settings (e.g. reply_url). */
437
+ async updateAgent(settings) {
438
+ return this.request('PATCH', '/agent', settings);
439
+ }
440
+ /** Push an agent manifest (self-description, capabilities, tools). */
441
+ async pushManifest(manifest) {
442
+ return this.request('POST', '/manifest', manifest);
443
+ }
444
+ // ────────────────────────────────────────────────────────────
445
+ // Standing Permits
446
+ // ────────────────────────────────────────────────────────────
447
+ /** List active standing permits for this agent. */
448
+ async standingPermits() {
449
+ return this.request('GET', '/standing-permits');
450
+ }
451
+ // ────────────────────────────────────────────────────────────
452
+ // Agent Context (Adaptive Governance)
453
+ // ────────────────────────────────────────────────────────────
454
+ /** Agent context — experience log, semantic graph, working context. */
455
+ context = {
456
+ /** Store an experience log entry. */
457
+ store: async (entry) => {
458
+ return this.request('POST', '/context', entry);
459
+ },
460
+ /** Recall experience log entries with optional filters. */
461
+ recall: async (opts) => {
462
+ const params = new URLSearchParams();
463
+ if (opts?.type)
464
+ params.set('type', opts.type);
465
+ if (opts?.session)
466
+ params.set('session', opts.session);
467
+ if (opts?.since)
468
+ params.set('since', opts.since);
469
+ if (opts?.limit)
470
+ params.set('limit', String(opts.limit));
471
+ if (opts?.offset)
472
+ params.set('offset', String(opts.offset));
473
+ const qs = params.toString();
474
+ const result = await this.request('GET', `/context/recall${qs ? `?${qs}` : ''}`);
475
+ return result.episodes;
476
+ },
477
+ /** Build working context (experience + semantic + shared). */
478
+ working: async (opts) => {
479
+ const params = new URLSearchParams();
480
+ if (opts?.query)
481
+ params.set('query', opts.query);
482
+ if (opts?.max_tokens)
483
+ params.set('max_tokens', String(opts.max_tokens));
484
+ if (opts?.include_shared === false)
485
+ params.set('include_shared', 'false');
486
+ const qs = params.toString();
487
+ return this.request('GET', `/context/working${qs ? `?${qs}` : ''}`);
488
+ },
489
+ /** Query the semantic knowledge graph. */
490
+ graph: async (opts) => {
491
+ const params = new URLSearchParams();
492
+ if (opts?.query)
493
+ params.set('query', opts.query);
494
+ if (opts?.node_type)
495
+ params.set('node_type', opts.node_type);
496
+ if (opts?.limit)
497
+ params.set('limit', String(opts.limit));
498
+ const qs = params.toString();
499
+ const result = await this.request('GET', `/context/graph${qs ? `?${qs}` : ''}`);
500
+ return result.nodes;
501
+ },
502
+ /** Share a semantic node with another agent. */
503
+ share: async (nodeId, targetAgentId) => {
504
+ await this.request('POST', '/context/share', { node_id: nodeId, target_agent_id: targetAgentId });
505
+ },
506
+ };
507
+ /** @deprecated Use client.context instead */
508
+ get memory() { return this.context; }
509
+ // ────────────────────────────────────────────────────────────
510
+ // Session Management
511
+ // ────────────────────────────────────────────────────────────
512
+ /**
513
+ * Close a session explicitly.
514
+ * @param sessionId - The session ID to close
515
+ * @param status - 'completed' or 'abandoned'
516
+ * @returns CloseSessionResult with sessionId and status
517
+ */
518
+ async closeSession(sessionId, status) {
519
+ const result = await this.ingestionRequest('PATCH', `/sessions/${sessionId}`, { status });
520
+ return { sessionId: result.session_id, status: result.status };
521
+ }
522
+ // ────────────────────────────────────────────────────────────
523
+ // Step Recording
524
+ // ────────────────────────────────────────────────────────────
525
+ /**
526
+ * Record multiple steps in a single batch request.
527
+ * @param runId - The run ID to record steps for
528
+ * @param steps - Array of step inputs (max 100)
529
+ * @returns StepBatchResult with step IDs, sequences, and hashes
530
+ */
531
+ async recordStepBatch(runId, steps) {
532
+ return this.ingestionRequest('POST', `/runs/${runId}/steps/batch`, { steps });
533
+ }
534
+ // ────────────────────────────────────────────────────────────
535
+ // Approval Polling
536
+ // ────────────────────────────────────────────────────────────
537
+ /**
538
+ * Wait for a queued action to be approved or denied.
539
+ * Polls getActionStatus with exponential backoff until resolved or timeout.
540
+ *
541
+ * @param requestId - The request_id from an execute() that returned NEEDS_APPROVAL
542
+ * @param opts - Optional: timeoutMs (default 300000 = 5 min), pollIntervalMs (default 2000)
543
+ * @returns The final ActionResult once resolved
544
+ * @throws Error if timeout is reached
545
+ */
546
+ async waitForApproval(requestId, opts) {
547
+ const timeoutMs = opts?.timeoutMs ?? 300_000;
548
+ const basePollMs = opts?.pollIntervalMs ?? 2_000;
549
+ const start = Date.now();
550
+ let attempt = 0;
551
+ while (Date.now() - start < timeoutMs) {
552
+ const result = await this.getActionStatus(requestId);
553
+ // Resolved: executed or denied
554
+ if (result.status === 'executed' || result.status === 'denied') {
555
+ return result;
556
+ }
557
+ // Still queued — backoff with cap
558
+ attempt++;
559
+ const delay = Math.min(basePollMs * Math.pow(1.5, attempt), 30_000);
560
+ await this.sleep(delay);
561
+ }
562
+ throw new Error(`Approval timeout: action ${requestId} not resolved within ${timeoutMs}ms`);
563
+ }
564
+ // ────────────────────────────────────────────────────────────
565
+ // License Management
566
+ // ────────────────────────────────────────────────────────────
567
+ /**
568
+ * Fetch the compiled license from the gateway. Called lazily on first execute.
569
+ * Non-fatal: if fetching fails, falls through to full pipeline.
570
+ */
571
+ async fetchLicense() {
572
+ try {
573
+ const resp = await this.request('GET', '/license');
574
+ if (resp.license && resp.stamp_secret) {
575
+ this.licenseManager.setLicense(resp.license, resp.stamp_secret);
576
+ }
577
+ this.licenseFetched = true;
578
+ }
579
+ catch {
580
+ // Non-fatal — agent continues without license (full pipeline)
581
+ this.licenseFetched = true;
582
+ }
583
+ }
584
+ /** Ensure we've attempted to fetch the license at least once. */
585
+ async ensureLicense() {
586
+ if (!this.licenseFetched) {
587
+ await this.fetchLicense();
588
+ }
589
+ }
590
+ // ────────────────────────────────────────────────────────────
591
+ // Internal HTTP Helpers
592
+ // ────────────────────────────────────────────────────────────
593
+ /** Whether a status code is safe to retry */
594
+ isRetryable(status) {
595
+ return RETRYABLE_STATUSES.has(status);
596
+ }
597
+ /** Calculate retry delay with exponential backoff + jitter, respecting Retry-After */
598
+ getRetryDelay(attempt, retryAfterHeader) {
599
+ // Respect Retry-After header if present
600
+ if (retryAfterHeader) {
601
+ const retryAfterSec = parseInt(retryAfterHeader, 10);
602
+ if (!isNaN(retryAfterSec) && retryAfterSec > 0) {
603
+ return retryAfterSec * 1000;
604
+ }
605
+ }
606
+ // Exponential backoff: base * 2^attempt + jitter
607
+ const exponential = this.retryDelayMs * Math.pow(2, attempt);
608
+ const jitter = Math.random() * this.retryDelayMs;
609
+ return exponential + jitter;
610
+ }
611
+ /** Sleep helper */
612
+ sleep(ms) {
613
+ return new Promise(resolve => setTimeout(resolve, ms));
614
+ }
615
+ /**
616
+ * Standard request — throws on non-2xx responses.
617
+ * Used for tools, heartbeat, register, governance, etc.
618
+ * Retries on transient errors (429, 5xx) with exponential backoff.
619
+ */
620
+ async request(method, path, body) {
621
+ const url = `${this.baseUrl}${path}`;
622
+ const headers = {
623
+ 'X-Integration-Token': this.token,
624
+ };
625
+ if (body !== undefined) {
626
+ headers['Content-Type'] = 'application/json';
627
+ }
628
+ let lastError;
629
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
630
+ const controller = new AbortController();
631
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
632
+ try {
633
+ const resp = await fetch(url, {
634
+ method,
635
+ headers,
636
+ body: body !== undefined ? JSON.stringify(body) : undefined,
637
+ signal: controller.signal,
638
+ });
639
+ clearTimeout(timeout);
640
+ const json = await resp.json();
641
+ if (!resp.ok) {
642
+ const errMsg = json.error
643
+ || json.message
644
+ || `HTTP ${resp.status}`;
645
+ const err = new Error(errMsg);
646
+ err.status = resp.status;
647
+ err.body = json;
648
+ // Retry on transient errors
649
+ if (this.isRetryable(resp.status) && attempt < this.maxRetries) {
650
+ lastError = err;
651
+ const delay = this.getRetryDelay(attempt, resp.headers.get('retry-after'));
652
+ await this.sleep(delay);
653
+ continue;
654
+ }
655
+ throw err;
656
+ }
657
+ return json;
658
+ }
659
+ catch (err) {
660
+ clearTimeout(timeout);
661
+ // Retry on timeout/network errors
662
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TypeError') && attempt < this.maxRetries) {
663
+ lastError = err;
664
+ const delay = this.getRetryDelay(attempt);
665
+ await this.sleep(delay);
666
+ continue;
667
+ }
668
+ // Rethrow retryable errors with status (from above) or non-retryable errors
669
+ throw err;
670
+ }
671
+ }
672
+ // Should not reach here, but safety net
673
+ throw lastError || new Error('Request failed after retries');
674
+ }
675
+ /**
676
+ * Raw request — returns parsed JSON for ANY response with a JSON body.
677
+ * Used for execute() where 200 (ALLOW), 202 (NEEDS_APPROVAL), and 403 (DENY)
678
+ * are all valid governance decisions, not errors.
679
+ * Only throws on network/timeout errors or non-JSON responses.
680
+ * Retries on transient errors but NEVER retries governance decisions.
681
+ */
682
+ async requestRaw(method, path, body, extraHeaders) {
683
+ const url = `${this.baseUrl}${path}`;
684
+ const headers = {
685
+ 'X-Integration-Token': this.token,
686
+ ...extraHeaders,
687
+ };
688
+ if (body !== undefined) {
689
+ headers['Content-Type'] = 'application/json';
690
+ }
691
+ let lastError;
692
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
693
+ const controller = new AbortController();
694
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
695
+ try {
696
+ const resp = await fetch(url, {
697
+ method,
698
+ headers,
699
+ body: body !== undefined ? JSON.stringify(body) : undefined,
700
+ signal: controller.signal,
701
+ });
702
+ clearTimeout(timeout);
703
+ const json = await resp.json();
704
+ // 200 (ALLOW), 202 (NEEDS_APPROVAL), 403 (DENY) are all valid decisions — never retry
705
+ if (resp.status === 200 || resp.status === 202 || resp.status === 403) {
706
+ return json;
707
+ }
708
+ // Other errors (400, 401, 429, 500) are real errors
709
+ const errMsg = json.error
710
+ || json.message
711
+ || `HTTP ${resp.status}`;
712
+ const err = new Error(errMsg);
713
+ err.status = resp.status;
714
+ err.body = json;
715
+ // Retry on transient errors
716
+ if (this.isRetryable(resp.status) && attempt < this.maxRetries) {
717
+ lastError = err;
718
+ const delay = this.getRetryDelay(attempt, resp.headers.get('retry-after'));
719
+ await this.sleep(delay);
720
+ continue;
721
+ }
722
+ throw err;
723
+ }
724
+ catch (err) {
725
+ clearTimeout(timeout);
726
+ // Retry on timeout/network errors
727
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TypeError') && attempt < this.maxRetries) {
728
+ lastError = err;
729
+ const delay = this.getRetryDelay(attempt);
730
+ await this.sleep(delay);
731
+ continue;
732
+ }
733
+ throw err;
734
+ }
735
+ }
736
+ throw lastError || new Error('Request failed after retries');
737
+ }
738
+ // ────────────────────────────────────────────────────────────
739
+ // Webhook Signature Verification
740
+ // ────────────────────────────────────────────────────────────
741
+ /**
742
+ * Verify an ARKNA webhook signature.
743
+ *
744
+ * @param body - Raw request body string
745
+ * @param signatureHeader - Value of X-Arkna-Signature header (e.g. "v1=abc123...")
746
+ * @param timestampHeader - Value of X-Arkna-Timestamp header (epoch seconds)
747
+ * @param secret - Your webhook_secret (from token creation response)
748
+ * @param toleranceSeconds - Max age of the request in seconds (default: 300 = 5 min)
749
+ * @returns true if signature is valid and timestamp is within tolerance
750
+ */
751
+ static verifyWebhookSignature(body, signatureHeader, timestampHeader, secret, toleranceSeconds = 300) {
752
+ // Check timestamp freshness (replay protection)
753
+ const ts = parseInt(timestampHeader, 10);
754
+ if (isNaN(ts))
755
+ return false;
756
+ const now = Math.floor(Date.now() / 1000);
757
+ if (Math.abs(now - ts) > toleranceSeconds)
758
+ return false;
759
+ // Compute expected signature
760
+ const payload = `${timestampHeader}.${body}`;
761
+ const expected = crypto
762
+ .createHmac('sha256', secret)
763
+ .update(payload)
764
+ .digest('hex');
765
+ // Extract signature value (strip "v1=" prefix)
766
+ const sig = signatureHeader.startsWith('v1=')
767
+ ? signatureHeader.slice(3)
768
+ : signatureHeader;
769
+ // Constant-time comparison
770
+ try {
771
+ return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex'));
772
+ }
773
+ catch {
774
+ return false;
775
+ }
776
+ }
777
+ // ────────────────────────────────────────────────────────────
778
+ // Observability — Run/Step/Tool Capture
779
+ // ────────────────────────────────────────────────────────────
780
+ /** Get the ingestion API base URL (derived from gateway URL) */
781
+ get ingestionBaseUrl() {
782
+ // baseUrl ends with /api/gateway — strip /gateway for /api, then append /v1
783
+ return this.baseUrl.replace(/\/gateway$/, '/v1');
784
+ }
785
+ /**
786
+ * Convert camelCase keys to snake_case (shallow, one level deep).
787
+ * Leaves keys that are already snake_case unchanged.
788
+ */
789
+ static camelToSnake(obj) {
790
+ const result = {};
791
+ for (const [key, value] of Object.entries(obj)) {
792
+ if (value === undefined)
793
+ continue;
794
+ const snakeKey = key.replace(/[A-Z]/g, (ch) => '_' + ch.toLowerCase());
795
+ result[snakeKey] = value;
796
+ }
797
+ return result;
798
+ }
799
+ /**
800
+ * Convert snake_case keys to camelCase (shallow, one level deep).
801
+ */
802
+ static snakeToCamel(obj) {
803
+ const result = {};
804
+ for (const [key, value] of Object.entries(obj)) {
805
+ const camelKey = key.replace(/_([a-z])/g, (_, ch) => ch.toUpperCase());
806
+ result[camelKey] = value;
807
+ }
808
+ return result;
809
+ }
810
+ /**
811
+ * Request to the ingestion API (/api/v1/...).
812
+ * Same retry/timeout logic as the gateway request method.
813
+ */
814
+ async ingestionRequest(method, path, body) {
815
+ const url = `${this.ingestionBaseUrl}${path}`;
816
+ const headers = {
817
+ 'X-Integration-Token': this.token,
818
+ };
819
+ if (body !== undefined) {
820
+ headers['Content-Type'] = 'application/json';
821
+ }
822
+ let lastError;
823
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
824
+ const controller = new AbortController();
825
+ const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
826
+ try {
827
+ const resp = await fetch(url, {
828
+ method,
829
+ headers,
830
+ body: body !== undefined ? JSON.stringify(body) : undefined,
831
+ signal: controller.signal,
832
+ });
833
+ clearTimeout(timeout);
834
+ const json = await resp.json();
835
+ if (!resp.ok) {
836
+ const errMsg = json.error
837
+ || json.message
838
+ || `HTTP ${resp.status}`;
839
+ const err = new Error(errMsg);
840
+ err.status = resp.status;
841
+ err.body = json;
842
+ if (this.isRetryable(resp.status) && attempt < this.maxRetries) {
843
+ lastError = err;
844
+ const delay = this.getRetryDelay(attempt, resp.headers.get('retry-after'));
845
+ await this.sleep(delay);
846
+ continue;
847
+ }
848
+ throw err;
849
+ }
850
+ return json;
851
+ }
852
+ catch (err) {
853
+ clearTimeout(timeout);
854
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TypeError') && attempt < this.maxRetries) {
855
+ lastError = err;
856
+ const delay = this.getRetryDelay(attempt);
857
+ await this.sleep(delay);
858
+ continue;
859
+ }
860
+ throw err;
861
+ }
862
+ }
863
+ throw lastError || new Error('Request failed after retries');
864
+ }
865
+ /** Start a new agent run. Returns a handle with the run ID. */
866
+ async startRun(options = {}) {
867
+ const opts = { triggerType: 'api', ...options };
868
+ const body = ArknaClient.camelToSnake(opts);
869
+ const resp = await this.ingestionRequest('POST', '/runs', body);
870
+ return ArknaClient.snakeToCamel(resp);
871
+ }
872
+ /** Record a step within a run. */
873
+ async recordStep(runId, options) {
874
+ const body = ArknaClient.camelToSnake(options);
875
+ const resp = await this.ingestionRequest('POST', `/runs/${runId}/steps`, body);
876
+ return ArknaClient.snakeToCamel(resp);
877
+ }
878
+ /** Record a tool call within a run. */
879
+ async recordToolCall(runId, options) {
880
+ const opts = { status: 'success', ...options };
881
+ const body = ArknaClient.camelToSnake(opts);
882
+ const resp = await this.ingestionRequest('POST', `/runs/${runId}/tools`, body);
883
+ return ArknaClient.snakeToCamel(resp);
884
+ }
885
+ /** Complete or fail a run. */
886
+ async completeRun(runId, options) {
887
+ const body = ArknaClient.camelToSnake(options);
888
+ const resp = await this.ingestionRequest('PATCH', `/runs/${runId}`, body);
889
+ return ArknaClient.snakeToCamel(resp);
890
+ }
891
+ /** Capture a context snapshot for a run. */
892
+ async captureContext(runId, options) {
893
+ const body = ArknaClient.camelToSnake(options);
894
+ const resp = await this.ingestionRequest('POST', `/runs/${runId}/context`, body);
895
+ return ArknaClient.snakeToCamel(resp);
896
+ }
897
+ /** Create a new session to group related runs. */
898
+ async createSession(metadata) {
899
+ const body = metadata ? { metadata } : {};
900
+ const resp = await this.ingestionRequest('POST', '/sessions', body);
901
+ return ArknaClient.snakeToCamel(resp);
902
+ }
903
+ /**
904
+ * Convenience: trace a complete run with automatic lifecycle management.
905
+ * Starts a run, calls your function, records the result, completes the run.
906
+ */
907
+ async trace(name, fn, options) {
908
+ const run = await this.startRun({ triggerType: 'api', ...options, input: name });
909
+ try {
910
+ const result = await fn({
911
+ runId: run.runId,
912
+ step: (opts) => this.recordStep(run.runId, opts),
913
+ tool: (opts) => this.recordToolCall(run.runId, opts),
914
+ });
915
+ await this.completeRun(run.runId, {
916
+ status: 'completed',
917
+ output: typeof result === 'string' ? result : JSON.stringify(result),
918
+ });
919
+ return result;
920
+ }
921
+ catch (error) {
922
+ await this.completeRun(run.runId, {
923
+ status: 'failed',
924
+ errorType: error.name || 'Error',
925
+ errorMessage: error.message,
926
+ }).catch(() => { });
927
+ throw error;
928
+ }
929
+ }
930
+ }
931
+ exports.ArknaClient = ArknaClient;
932
+ // ────────────────────────────────────────────────────────────
933
+ // URL normalization
934
+ // ────────────────────────────────────────────────────────────
935
+ function normalizeGatewayUrl(raw) {
936
+ if (!raw)
937
+ return '';
938
+ let url = raw.replace(/\/+$/, '');
939
+ // If it already ends with /api/gateway, use as-is
940
+ if (url.endsWith('/api/gateway'))
941
+ return url;
942
+ // If it ends with /api, append /gateway
943
+ if (url.endsWith('/api'))
944
+ return url + '/gateway';
945
+ // Otherwise append /api/gateway
946
+ return url + '/api/gateway';
947
+ }
948
+ //# sourceMappingURL=client.js.map