| /** | ||
| * Local delivery (--deliver / --deliver-exec) | ||
| * | ||
| * Mirrors server-answered captures to the developer's own machine: an HTTP | ||
| * POST to a local server, a shell command per request, or both. The cloud | ||
| * endpoint has already answered the provider by the time an entry lands | ||
| * here, so delivery is strictly best-effort - a dead local server or a | ||
| * crashing command never affects the endpoint, and every capture stays in | ||
| * the log for `otterkit replay`. | ||
| */ | ||
| import { RequestEntry } from './request-log.js'; | ||
| /** | ||
| * Normalize a --deliver target to a base URL. Accepts a bare port ("3000"), | ||
| * host:port, or a full http(s) URL with an optional path prefix. | ||
| * Returns null when the value parses to none of those. | ||
| */ | ||
| export declare function parseDeliverTarget(raw: string): string | null; | ||
| export type DeliveryResult = { | ||
| kind: 'http'; | ||
| status: number; | ||
| durationMs: number; | ||
| } | { | ||
| kind: 'exec'; | ||
| exitCode: number | null; | ||
| durationMs: number; | ||
| timedOut?: boolean; | ||
| } | { | ||
| kind: 'skipped'; | ||
| reason: 'unverified'; | ||
| } | { | ||
| kind: 'dropped'; | ||
| count: number; | ||
| } | { | ||
| kind: 'failed'; | ||
| via: 'http' | 'exec'; | ||
| error: string; | ||
| suppressedSinceLastNotice?: number; | ||
| }; | ||
| interface DelivererConfig { | ||
| /** Base URL from parseDeliverTarget - HTTP delivery when set. */ | ||
| target?: string; | ||
| /** Shell command run per capture - exec delivery when set. */ | ||
| exec?: string; | ||
| /** Only deliver captures whose signature verified valid. */ | ||
| verifiedOnly?: boolean; | ||
| /** Endpoint subdomain, sent along as X-OtterKit-Endpoint / OTTERKIT_ENDPOINT. */ | ||
| endpoint: string; | ||
| onResult: (entry: RequestEntry, result: DeliveryResult) => void; | ||
| } | ||
| /** | ||
| * Sequential delivery queue: one capture fully delivered (HTTP, then exec) | ||
| * before the next starts. Ordering matches arrival, and a replay burst can | ||
| * never stampede the local server. | ||
| */ | ||
| export declare class Deliverer { | ||
| private config; | ||
| private queue; | ||
| private pumping; | ||
| private droppedSinceNotice; | ||
| private consecutiveHttpFailures; | ||
| private suppressedSinceNotice; | ||
| private lastFailureNoticeAt; | ||
| constructor(config: DelivererConfig); | ||
| push(entry: RequestEntry): void; | ||
| /** Let in-flight work finish, up to graceMs. Pending queue is abandoned. */ | ||
| drain(graceMs: number): Promise<void>; | ||
| private pump; | ||
| private buildMetadata; | ||
| private deliverHttp; | ||
| private deliverExec; | ||
| } | ||
| export {}; |
+244
| /** | ||
| * Local delivery (--deliver / --deliver-exec) | ||
| * | ||
| * Mirrors server-answered captures to the developer's own machine: an HTTP | ||
| * POST to a local server, a shell command per request, or both. The cloud | ||
| * endpoint has already answered the provider by the time an entry lands | ||
| * here, so delivery is strictly best-effort - a dead local server or a | ||
| * crashing command never affects the endpoint, and every capture stays in | ||
| * the log for `otterkit replay`. | ||
| */ | ||
| import { spawn } from 'node:child_process'; | ||
| /** Headers recomputed by fetch or meaningless outside the original hop. */ | ||
| const STRIP_HEADERS = new Set(['host', 'connection', 'upgrade', 'transfer-encoding', 'content-length']); | ||
| const HTTP_TIMEOUT_MS = 10_000; | ||
| const HTTP_RETRY_DELAY_MS = 500; | ||
| const EXEC_TIMEOUT_MS = 30_000; | ||
| /** Oldest entries are dropped past this - bounds a standby-replay burst. */ | ||
| const QUEUE_CAP = 500; | ||
| /** After this many consecutive failures, collapse per-line errors... */ | ||
| const FAILURE_SUPPRESS_AFTER = 3; | ||
| /** ...into one notice per this window. */ | ||
| const FAILURE_NOTICE_INTERVAL_MS = 30_000; | ||
| /** | ||
| * Normalize a --deliver target to a base URL. Accepts a bare port ("3000"), | ||
| * host:port, or a full http(s) URL with an optional path prefix. | ||
| * Returns null when the value parses to none of those. | ||
| */ | ||
| export function parseDeliverTarget(raw) { | ||
| const value = raw.trim(); | ||
| if (/^\d+$/.test(value)) { | ||
| const port = parseInt(value, 10); | ||
| if (port < 1 || port > 65535) | ||
| return null; | ||
| return `http://127.0.0.1:${port}`; | ||
| } | ||
| const withScheme = /^https?:\/\//i.test(value) ? value : `http://${value}`; | ||
| try { | ||
| const url = new URL(withScheme); | ||
| if (url.search || url.hash || url.username || url.password) | ||
| return null; | ||
| const prefix = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, ''); | ||
| return `${url.origin}${prefix}`; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| } | ||
| /** | ||
| * Sequential delivery queue: one capture fully delivered (HTTP, then exec) | ||
| * before the next starts. Ordering matches arrival, and a replay burst can | ||
| * never stampede the local server. | ||
| */ | ||
| export class Deliverer { | ||
| config; | ||
| queue = []; | ||
| pumping = false; | ||
| droppedSinceNotice = 0; | ||
| consecutiveHttpFailures = 0; | ||
| suppressedSinceNotice = 0; | ||
| lastFailureNoticeAt = 0; | ||
| constructor(config) { | ||
| this.config = config; | ||
| } | ||
| push(entry) { | ||
| if (this.config.verifiedOnly && entry.headers['x-otterkit-verified'] !== 'valid') { | ||
| this.config.onResult(entry, { kind: 'skipped', reason: 'unverified' }); | ||
| return; | ||
| } | ||
| if (this.queue.length >= QUEUE_CAP) { | ||
| this.queue.shift(); | ||
| this.droppedSinceNotice++; | ||
| } | ||
| else if (this.droppedSinceNotice > 0) { | ||
| this.config.onResult(entry, { kind: 'dropped', count: this.droppedSinceNotice }); | ||
| this.droppedSinceNotice = 0; | ||
| } | ||
| this.queue.push(entry); | ||
| void this.pump(); | ||
| } | ||
| /** Let in-flight work finish, up to graceMs. Pending queue is abandoned. */ | ||
| async drain(graceMs) { | ||
| const deadline = Date.now() + graceMs; | ||
| while (this.pumping && Date.now() < deadline) { | ||
| await sleep(50); | ||
| } | ||
| } | ||
| async pump() { | ||
| if (this.pumping) | ||
| return; | ||
| this.pumping = true; | ||
| try { | ||
| let entry; | ||
| while ((entry = this.queue.shift()) !== undefined) { | ||
| if (this.config.target) | ||
| await this.deliverHttp(entry); | ||
| if (this.config.exec) | ||
| await this.deliverExec(entry); | ||
| } | ||
| } | ||
| finally { | ||
| this.pumping = false; | ||
| } | ||
| } | ||
| buildMetadata(entry) { | ||
| const verified = entry.headers['x-otterkit-verified']; | ||
| return { | ||
| 'X-OtterKit-Request-Id': entry.id ?? '', | ||
| 'X-OtterKit-Received-At': entry.ts, | ||
| 'X-OtterKit-Endpoint': this.config.endpoint, | ||
| ...(verified ? { 'X-OtterKit-Verified': verified } : {}), | ||
| ...(entry.replayed ? { 'X-OtterKit-Replayed': 'true' } : {}), | ||
| }; | ||
| } | ||
| async deliverHttp(entry) { | ||
| const start = Date.now(); | ||
| const url = `${this.config.target}${entry.path}`; | ||
| const headers = new Headers(); | ||
| for (const [key, value] of Object.entries(entry.headers)) { | ||
| if (STRIP_HEADERS.has(key.toLowerCase())) | ||
| continue; | ||
| headers.set(key, value); | ||
| } | ||
| for (const [key, value] of Object.entries(this.buildMetadata(entry))) { | ||
| if (value) | ||
| headers.set(key, value); | ||
| } | ||
| const body = entry.body && !['GET', 'HEAD'].includes(entry.method) | ||
| ? Buffer.from(entry.body, 'base64') | ||
| : undefined; | ||
| let lastError = ''; | ||
| for (let attempt = 0; attempt < 2; attempt++) { | ||
| if (attempt > 0) | ||
| await sleep(HTTP_RETRY_DELAY_MS); | ||
| const abort = new AbortController(); | ||
| const timer = setTimeout(() => abort.abort(), HTTP_TIMEOUT_MS); | ||
| try { | ||
| const response = await fetch(url, { | ||
| method: entry.method, | ||
| headers, | ||
| body, | ||
| signal: abort.signal, | ||
| }); | ||
| // Any HTTP status is a delivery - the local server saw the request. | ||
| await response.arrayBuffer().catch(() => { }); | ||
| this.consecutiveHttpFailures = 0; | ||
| this.config.onResult(entry, { | ||
| kind: 'http', | ||
| status: response.status, | ||
| durationMs: Date.now() - start, | ||
| }); | ||
| return; | ||
| } | ||
| catch (err) { | ||
| lastError = describeFetchError(err); | ||
| } | ||
| finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| this.consecutiveHttpFailures++; | ||
| if (this.consecutiveHttpFailures <= FAILURE_SUPPRESS_AFTER || | ||
| Date.now() - this.lastFailureNoticeAt >= FAILURE_NOTICE_INTERVAL_MS) { | ||
| this.lastFailureNoticeAt = Date.now(); | ||
| this.config.onResult(entry, { | ||
| kind: 'failed', | ||
| via: 'http', | ||
| error: lastError, | ||
| suppressedSinceLastNotice: this.suppressedSinceNotice || undefined, | ||
| }); | ||
| this.suppressedSinceNotice = 0; | ||
| } | ||
| else { | ||
| this.suppressedSinceNotice++; | ||
| } | ||
| } | ||
| deliverExec(entry) { | ||
| const start = Date.now(); | ||
| return new Promise(resolve => { | ||
| const verified = entry.headers['x-otterkit-verified'] ?? ''; | ||
| let child; | ||
| try { | ||
| child = spawn(this.config.exec, { | ||
| shell: true, | ||
| stdio: ['pipe', 'ignore', 'ignore'], | ||
| env: { | ||
| ...process.env, | ||
| OTTERKIT_METHOD: entry.method, | ||
| OTTERKIT_PATH: entry.path, | ||
| OTTERKIT_REQUEST_ID: entry.id ?? '', | ||
| OTTERKIT_VERIFIED: verified, | ||
| OTTERKIT_RECEIVED_AT: entry.ts, | ||
| OTTERKIT_ENDPOINT: this.config.endpoint, | ||
| OTTERKIT_HEADERS: JSON.stringify(entry.headers), | ||
| }, | ||
| }); | ||
| } | ||
| catch (err) { | ||
| this.config.onResult(entry, { | ||
| kind: 'failed', | ||
| via: 'exec', | ||
| error: err instanceof Error ? err.message : 'spawn failed', | ||
| }); | ||
| resolve(); | ||
| return; | ||
| } | ||
| let timedOut = false; | ||
| const timer = setTimeout(() => { | ||
| timedOut = true; | ||
| child.kill('SIGKILL'); | ||
| }, EXEC_TIMEOUT_MS); | ||
| child.on('error', err => { | ||
| clearTimeout(timer); | ||
| this.config.onResult(entry, { kind: 'failed', via: 'exec', error: err.message }); | ||
| resolve(); | ||
| }); | ||
| child.on('close', exitCode => { | ||
| clearTimeout(timer); | ||
| this.config.onResult(entry, { | ||
| kind: 'exec', | ||
| exitCode, | ||
| durationMs: Date.now() - start, | ||
| timedOut: timedOut || undefined, | ||
| }); | ||
| resolve(); | ||
| }); | ||
| if (entry.body) { | ||
| child.stdin?.write(Buffer.from(entry.body, 'base64')); | ||
| } | ||
| child.stdin?.end(); | ||
| }); | ||
| } | ||
| } | ||
| function describeFetchError(err) { | ||
| if (err instanceof Error) { | ||
| if (err.name === 'AbortError') | ||
| return `timed out after ${HTTP_TIMEOUT_MS / 1000}s`; | ||
| const cause = err.cause; | ||
| return cause?.code ?? err.message; | ||
| } | ||
| return 'delivery failed'; | ||
| } | ||
| function sleep(ms) { | ||
| return new Promise(resolve => setTimeout(resolve, ms)); | ||
| } |
@@ -20,2 +20,8 @@ export interface DaemonLaunchOptions { | ||
| store?: boolean; | ||
| /** Local delivery (webhook only): mirror captures to this base URL. */ | ||
| deliver?: string; | ||
| /** Local delivery (webhook only): run this command per capture. */ | ||
| deliverExec?: string; | ||
| /** Only deliver captures whose signature verified valid. */ | ||
| verifiedOnly?: boolean; | ||
| /** Config profile name (`otterkit up`). */ | ||
@@ -22,0 +28,0 @@ profile?: string; |
@@ -35,2 +35,11 @@ /** | ||
| } | ||
| // Delivery config via env like the credentials - exec commands may embed | ||
| // secrets and must never show up in `ps`. | ||
| if (mode === 'webhook' && options.deliver) | ||
| env.OTTERKIT_DELIVER = options.deliver; | ||
| if (mode === 'webhook' && options.deliverExec) | ||
| env.OTTERKIT_DELIVER_EXEC = options.deliverExec; | ||
| if (mode === 'webhook' && options.verifiedOnly && (options.deliver || options.deliverExec)) { | ||
| env.OTTERKIT_DELIVER_VERIFIED_ONLY = '1'; | ||
| } | ||
| // Webhook endpoints are server-answered: the response config is endpoint | ||
@@ -71,2 +80,3 @@ // config, applied via the agent API before the daemon even spawns. | ||
| auth: options.auth ? true : undefined, | ||
| deliver: mode === 'webhook' && (options.deliver || options.deliverExec) ? true : undefined, | ||
| profile: options.profile, | ||
@@ -73,0 +83,0 @@ }); |
@@ -13,3 +13,7 @@ #!/usr/bin/env node | ||
| * verification - via env like the auth credentials, never argv. | ||
| * Env: OTTERKIT_DELIVER / OTTERKIT_DELIVER_EXEC / OTTERKIT_DELIVER_VERIFIED_ONLY | ||
| * arm local delivery - via env because exec commands may embed secrets. | ||
| * Delivery outcomes are silent here (stdio is detached); the capture log | ||
| * remains the record. | ||
| */ | ||
| export {}; |
@@ -13,2 +13,6 @@ #!/usr/bin/env node | ||
| * verification - via env like the auth credentials, never argv. | ||
| * Env: OTTERKIT_DELIVER / OTTERKIT_DELIVER_EXEC / OTTERKIT_DELIVER_VERIFIED_ONLY | ||
| * arm local delivery - via env because exec commands may embed secrets. | ||
| * Delivery outcomes are silent here (stdio is detached); the capture log | ||
| * remains the record. | ||
| */ | ||
@@ -18,2 +22,3 @@ import { TunnelClient } from './tunnel-client.js'; | ||
| import { logRequest } from './request-log.js'; | ||
| import { Deliverer } from './deliver.js'; | ||
| const args = process.argv.slice(2); | ||
@@ -33,2 +38,13 @@ const captureMode = args.includes('--capture'); | ||
| : undefined; | ||
| const deliverTarget = process.env.OTTERKIT_DELIVER; | ||
| const deliverExec = process.env.OTTERKIT_DELIVER_EXEC; | ||
| const deliverer = captureMode && (deliverTarget || deliverExec) | ||
| ? new Deliverer({ | ||
| target: deliverTarget, | ||
| exec: deliverExec, | ||
| verifiedOnly: process.env.OTTERKIT_DELIVER_VERIFIED_ONLY === '1', | ||
| endpoint: subdomain ?? '', | ||
| onResult: () => { }, | ||
| }) | ||
| : null; | ||
| async function run() { | ||
@@ -53,2 +69,3 @@ const client = new TunnelClient({ | ||
| logRequest(subdomain, entry).catch(() => { }); | ||
| deliverer?.push(entry); | ||
| } | ||
@@ -73,2 +90,3 @@ : undefined, | ||
| clearTimeout(ttlTimer); | ||
| await deliverer?.drain(5000); | ||
| await client.disconnect(); | ||
@@ -75,0 +93,0 @@ removeDaemon(subdomain); |
+2
-0
@@ -21,2 +21,4 @@ /** | ||
| auth?: boolean; | ||
| /** Local delivery is armed. The target/command live only in the worker env. */ | ||
| deliver?: boolean; | ||
| /** Config profile name this daemon was started from (`otterkit up`). */ | ||
@@ -23,0 +25,0 @@ profile?: string; |
+26
-0
@@ -38,3 +38,29 @@ export interface StoredRequest { | ||
| }>; | ||
| export interface SchemaInferResult { | ||
| subdomain: string; | ||
| analyzed: number; | ||
| skipped: { | ||
| nonJson: number; | ||
| binary: number; | ||
| empty: number; | ||
| truncated: number; | ||
| overBudget: number; | ||
| }; | ||
| groups: { | ||
| path: string; | ||
| discriminator?: string; | ||
| discriminatorValue?: string; | ||
| count: number; | ||
| name: string; | ||
| jsonSchema: unknown; | ||
| typescript: string; | ||
| }[]; | ||
| } | ||
| /** | ||
| * Infer JSON Schema + TypeScript types from a session's stored request | ||
| * bodies. Same token-authed surface and error mapping as fetchHistory; the | ||
| * inference runs server-side over the stored history. | ||
| */ | ||
| export declare function fetchSchema(subdomain: string, filters?: Omit<HistoryFilters, 'before'>): Promise<SchemaInferResult>; | ||
| /** | ||
| * Toggle email-on-arrival for a webhook session the account owns. Same | ||
@@ -41,0 +67,0 @@ * token-authed API surface as fetchHistory; the throttling (1 email/15min, |
+29
-0
@@ -52,2 +52,31 @@ /** | ||
| /** | ||
| * Infer JSON Schema + TypeScript types from a session's stored request | ||
| * bodies. Same token-authed surface and error mapping as fetchHistory; the | ||
| * inference runs server-side over the stored history. | ||
| */ | ||
| export async function fetchSchema(subdomain, filters = {}) { | ||
| const token = getToken(); | ||
| if (!token) | ||
| throw new HistoryError('not_logged_in', 'Run `otterkit login` first.'); | ||
| const params = new URLSearchParams(); | ||
| if (filters.limit) | ||
| params.set('limit', String(filters.limit)); | ||
| if (filters.method) | ||
| params.set('method', filters.method); | ||
| if (filters.path) | ||
| params.set('q', filters.path); | ||
| const qs = params.toString(); | ||
| const resp = await fetch(`${API_SERVER}/api/me/webhooks/${encodeURIComponent(subdomain)}/schema${qs ? `?${qs}` : ''}`, { headers: { Authorization: `Bearer ${token}` } }); | ||
| const json = (await resp.json().catch(() => ({}))); | ||
| if (resp.status === 401) | ||
| throw new HistoryError('invalid_token', 'Run `otterkit login` again.'); | ||
| if (resp.status === 404) { | ||
| throw new HistoryError('not_found', `No webhook session "${subdomain}" on this account. CLI sessions store history only with --store.`); | ||
| } | ||
| if (!resp.ok || !json.data) { | ||
| throw new HistoryError(json.error ?? `schema_infer_failed_${resp.status}`); | ||
| } | ||
| return json.data; | ||
| } | ||
| /** | ||
| * Toggle email-on-arrival for a webhook session the account owns. Same | ||
@@ -54,0 +83,0 @@ * token-authed API surface as fetchHistory; the throttling (1 email/15min, |
+32
-1
@@ -28,3 +28,3 @@ /** | ||
| import { createWait, listWaits, cancelWait, sessionToken } from './waits.js'; | ||
| import { fetchHistory } from './history.js'; | ||
| import { fetchHistory, fetchSchema } from './history.js'; | ||
| const { version } = createRequire(import.meta.url)('../package.json'); | ||
@@ -498,2 +498,33 @@ const API_SERVER = process.env.OTTERKIT_API_URL || 'https://api.otterkit.com'; | ||
| }); | ||
| server.registerTool('schema_infer', { | ||
| title: 'Infer request types from stored history', | ||
| description: 'Generate JSON Schema (draft-07) and TypeScript types from the request bodies OtterKit ' + | ||
| 'stored server-side for a webhook endpoint - "what does this provider actually send". ' + | ||
| 'Groups by path (and by a detected event field like type/event), reports field ' + | ||
| 'optionality as present-in-N/M counts. Needs stored history: portal endpoints always ' + | ||
| 'store; CLI webhooks only with --store.', | ||
| inputSchema: { | ||
| subdomain: z.string().describe('Webhook endpoint whose stored bodies to analyze'), | ||
| limit: z | ||
| .number() | ||
| .int() | ||
| .min(1) | ||
| .max(1000) | ||
| .optional() | ||
| .describe('Analyze up to N newest requests (default 500)'), | ||
| method: z.string().optional().describe('Only this HTTP method'), | ||
| path: z.string().optional().describe('Only paths containing this substring'), | ||
| }, | ||
| }, async (args) => { | ||
| try { | ||
| return ok(await fetchSchema(args.subdomain, { | ||
| limit: args.limit, | ||
| method: args.method, | ||
| path: args.path, | ||
| })); | ||
| } | ||
| catch (e) { | ||
| return mapError(e); | ||
| } | ||
| }); | ||
| server.registerTool('account_status', { | ||
@@ -500,0 +531,0 @@ title: 'Account and credit balance', |
@@ -10,2 +10,4 @@ /** | ||
| ts: string; | ||
| /** Server-minted request id (absent in logs from older CLI versions). */ | ||
| id?: string; | ||
| method: string; | ||
@@ -25,2 +27,4 @@ path: string; | ||
| buffered?: boolean; | ||
| /** Standby replay (arrived while disconnected), as opposed to a live mirror. */ | ||
| replayed?: boolean; | ||
| } | ||
@@ -27,0 +31,0 @@ export declare function ensureRequestsDir(): Promise<void>; |
@@ -254,2 +254,3 @@ /** | ||
| ts: new Date(req.receivedAt ?? Date.now()).toISOString(), | ||
| id: req.requestId, | ||
| method: req.method, | ||
@@ -262,2 +263,3 @@ path: req.path, | ||
| buffered: true, | ||
| replayed: req.buffered === true, | ||
| // The server produced the response; the log records what it served. | ||
@@ -275,2 +277,3 @@ response: { headers: {}, body: null }, | ||
| ts: new Date().toISOString(), | ||
| id: req.requestId, | ||
| method: req.method, | ||
@@ -277,0 +280,0 @@ path: req.path, |
+0
-7
@@ -65,9 +65,2 @@ export interface WaitView { | ||
| /** | ||
| * Enable the session's public read-only inspector link (idempotent - an | ||
| * existing link is returned, not rotated). Auth is the connect token. | ||
| */ | ||
| export declare function enableShare(subdomain: string, token: string): Promise<{ | ||
| shareUrl: string; | ||
| }>; | ||
| /** | ||
| * Replace the endpoint's dynamic response rules (full array; null clears). | ||
@@ -74,0 +67,0 @@ * The default response is the no-match fallback. |
+0
-12
@@ -51,14 +51,2 @@ /** | ||
| /** | ||
| * Enable the session's public read-only inspector link (idempotent - an | ||
| * existing link is returned, not rotated). Auth is the connect token. | ||
| */ | ||
| export async function enableShare(subdomain, token) { | ||
| const resp = await fetch(`${TUNNEL_SERVER}/api/agent/tunnels/${subdomain}/share?token=${encodeURIComponent(token)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); | ||
| const data = (await resp.json().catch(() => ({}))); | ||
| if (!resp.ok || !data.shareUrl) { | ||
| throw new WaitError(data.error || `share failed (${resp.status})`, resp.status); | ||
| } | ||
| return { shareUrl: data.shareUrl }; | ||
| } | ||
| /** | ||
| * Replace the endpoint's dynamic response rules (full array; null clears). | ||
@@ -65,0 +53,0 @@ * The default response is the no-match fallback. |
+1
-1
| { | ||
| "name": "otterkit", | ||
| "version": "0.27.0", | ||
| "version": "0.29.0", | ||
| "description": "OtterKit CLI - provision and connect tunnels for AI agents", | ||
@@ -5,0 +5,0 @@ "mcpName": "io.github.useotterkit/otterkit", |
Sorry, the diff of this file is too big to display
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
257353
7.98%46
4.55%6013
8.73%27
28.57%21
10.53%