+15
-1
@@ -28,4 +28,15 @@ /** | ||
| profile?: string; | ||
| /** | ||
| * Session archive: set when the session ends (stopped, expired, or the | ||
| * owning process died). Ended entries and their capture logs are kept for | ||
| * inspection/replay until the retention sweep prunes them. | ||
| */ | ||
| endedAt?: string; | ||
| } | ||
| export declare function addDaemon(entry: DaemonEntry): void; | ||
| /** | ||
| * A session ended (stopped, expired, or its process died): archive the entry | ||
| * instead of deleting it, keeping the capture log for inspection/replay. | ||
| * The retention sweep in cleanupDaemons prunes old archives. | ||
| */ | ||
| export declare function removeDaemon(subdomain: string): void; | ||
@@ -37,3 +48,6 @@ /** | ||
| /** | ||
| * Clean up dead/expired daemons from the state file | ||
| * Reconcile the registry: archive sessions that ended, prune old archives | ||
| * (and their capture logs) past retention. Returns the ACTIVE entries - | ||
| * callers that manage running sessions keep their old semantics; ended | ||
| * entries stay in the file for `status`, the Mac app, and inspection. | ||
| */ | ||
@@ -40,0 +54,0 @@ export declare function cleanupDaemons(): DaemonEntry[]; |
+61
-11
@@ -8,3 +8,3 @@ /** | ||
| */ | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; | ||
| import { homedir } from 'node:os'; | ||
@@ -14,2 +14,6 @@ import { join } from 'node:path'; | ||
| const STATE_FILE = join(STATE_DIR, 'daemons.json'); | ||
| /** Ended sessions are kept this long... */ | ||
| const ENDED_RETENTION_MS = 7 * 86_400_000; | ||
| /** ...and at most this many (newest first). */ | ||
| const ENDED_RETENTION_COUNT = 20; | ||
| function ensureStateDir() { | ||
@@ -37,9 +41,20 @@ if (!existsSync(STATE_DIR)) { | ||
| export function addDaemon(entry) { | ||
| const daemons = loadDaemons(); | ||
| // A reused (reserved) subdomain replaces any archived entry for it - one | ||
| // registry row per subdomain, always the current life. | ||
| const daemons = loadDaemons().filter(d => d.subdomain !== entry.subdomain); | ||
| daemons.push(entry); | ||
| saveDaemons(daemons); | ||
| } | ||
| /** | ||
| * A session ended (stopped, expired, or its process died): archive the entry | ||
| * instead of deleting it, keeping the capture log for inspection/replay. | ||
| * The retention sweep in cleanupDaemons prunes old archives. | ||
| */ | ||
| export function removeDaemon(subdomain) { | ||
| const daemons = loadDaemons().filter(d => d.subdomain !== subdomain); | ||
| saveDaemons(daemons); | ||
| const daemons = loadDaemons(); | ||
| const entry = daemons.find(d => d.subdomain === subdomain); | ||
| if (entry && !entry.endedAt) { | ||
| entry.endedAt = new Date().toISOString(); | ||
| saveDaemons(daemons); | ||
| } | ||
| } | ||
@@ -59,3 +74,6 @@ /** | ||
| /** | ||
| * Clean up dead/expired daemons from the state file | ||
| * Reconcile the registry: archive sessions that ended, prune old archives | ||
| * (and their capture logs) past retention. Returns the ACTIVE entries - | ||
| * callers that manage running sessions keep their old semantics; ended | ||
| * entries stay in the file for `status`, the Mac app, and inspection. | ||
| */ | ||
@@ -65,11 +83,43 @@ export function cleanupDaemons() { | ||
| const now = Date.now(); | ||
| const alive = daemons.filter(d => { | ||
| // 'never' parses to NaN - a TTL-less daemon is never expiry-pruned. | ||
| let changed = false; | ||
| for (const d of daemons) { | ||
| if (d.endedAt) | ||
| continue; | ||
| // 'never' parses to NaN - a TTL-less session is never expiry-archived. | ||
| const exp = new Date(d.expiresAt).getTime(); | ||
| const expired = Number.isFinite(exp) && exp <= now; | ||
| const running = isProcessAlive(d.pid); | ||
| return running && !expired; | ||
| }); | ||
| saveDaemons(alive); | ||
| return alive; | ||
| // A dead process ends a TUNNEL (nothing serves without it). A webhook | ||
| // endpoint is server-answered and stays live without any local process - | ||
| // only expiry (or an explicit stop) ends it. | ||
| const ended = expired || (!running && d.port !== 0); | ||
| if (ended) { | ||
| d.endedAt = new Date().toISOString(); | ||
| changed = true; | ||
| } | ||
| } | ||
| // Retention sweep over the archive: newest ENDED_RETENTION_COUNT within | ||
| // ENDED_RETENTION_MS survive; pruned entries take their capture log along. | ||
| const ended = daemons.filter(d => d.endedAt).sort((a, b) => (a.endedAt < b.endedAt ? 1 : -1)); | ||
| const keep = new Set(ended | ||
| .filter(d => now - new Date(d.endedAt).getTime() < ENDED_RETENTION_MS) | ||
| .slice(0, ENDED_RETENTION_COUNT) | ||
| .map(d => d.subdomain)); | ||
| const kept = []; | ||
| for (const d of daemons) { | ||
| if (d.endedAt && !keep.has(d.subdomain)) { | ||
| changed = true; | ||
| try { | ||
| unlinkSync(join(STATE_DIR, 'requests', `${d.subdomain}.jsonl`)); | ||
| } | ||
| catch { | ||
| /* no log or already gone */ | ||
| } | ||
| continue; | ||
| } | ||
| kept.push(d); | ||
| } | ||
| if (changed) | ||
| saveDaemons(kept); | ||
| return kept.filter(d => !d.endedAt); | ||
| } | ||
@@ -76,0 +126,0 @@ /** |
+8
-2
@@ -136,4 +136,10 @@ /** | ||
| : undefined; | ||
| // v6-only dev servers: when delivering to default localhost, also try | ||
| // the v6 loopback before calling it a failure. | ||
| const urls = [url, url]; | ||
| if (url.startsWith('http://127.0.0.1:')) { | ||
| urls.push(url.replace('http://127.0.0.1:', 'http://[::1]:')); | ||
| } | ||
| let lastError = ''; | ||
| for (let attempt = 0; attempt < 2; attempt++) { | ||
| for (let attempt = 0; attempt < urls.length; attempt++) { | ||
| if (attempt > 0) | ||
@@ -144,3 +150,3 @@ await sleep(HTTP_RETRY_DELAY_MS); | ||
| try { | ||
| const response = await fetch(url, { | ||
| const response = await fetch(urls[attempt], { | ||
| method: entry.method, | ||
@@ -147,0 +153,0 @@ headers, |
@@ -184,3 +184,9 @@ /** | ||
| const startTime = Date.now(); | ||
| const targetUrl = `http://${this.config.targetHost}:${this.config.targetPort}${req.path}`; | ||
| // Dev servers often bind IPv6 localhost only (`app.listen(port)` → | ||
| // [::1] on modern macOS): when targeting default localhost, fall back | ||
| // to the v6 loopback if the v4 dial is refused. | ||
| const hosts = this.config.targetHost === '127.0.0.1' || this.config.targetHost === 'localhost' | ||
| ? ['127.0.0.1', '[::1]'] | ||
| : [this.config.targetHost]; | ||
| const targetUrl = `http://${hosts[0]}:${this.config.targetPort}${req.path}`; | ||
| try { | ||
@@ -201,8 +207,20 @@ // Decode base64 body if present | ||
| } | ||
| // Forward to local server | ||
| const response = await fetch(targetUrl, { | ||
| method: req.method, | ||
| headers, | ||
| body: ['GET', 'HEAD'].includes(req.method) ? undefined : body, | ||
| }); | ||
| // Forward to local server (each loopback family in turn) | ||
| let response; | ||
| let lastError; | ||
| for (const host of hosts) { | ||
| try { | ||
| response = await fetch(`http://${host}:${this.config.targetPort}${req.path}`, { | ||
| method: req.method, | ||
| headers, | ||
| body: ['GET', 'HEAD'].includes(req.method) ? undefined : body, | ||
| }); | ||
| break; | ||
| } | ||
| catch (err) { | ||
| lastError = err; | ||
| } | ||
| } | ||
| if (!response) | ||
| throw lastError; | ||
| // Read response body | ||
@@ -209,0 +227,0 @@ const responseBody = await response.arrayBuffer(); |
+1
-1
| { | ||
| "name": "otterkit", | ||
| "version": "0.30.0", | ||
| "version": "0.31.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
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
271004
1.58%6325
1.41%