diff --git a/docker/playground/proxy/index.js b/docker/playground/proxy/index.js index 8c89044..97b9888 100644 --- a/docker/playground/proxy/index.js +++ b/docker/playground/proxy/index.js @@ -1,17 +1,78 @@ 'use strict'; +const http = require('http'); const { config } = require('./config'); const { createServer } = require('./server'); const { SessionStore } = require('./sessions'); +const { NS_PREFIX } = require('./namespace'); // ============================================================================= // Entrypoint — wires config + session store + HTTP server and starts listening. // ============================================================================= +function deleteNamespace(ns) { + const url = new URL(config.upstreamUrl + '/v1/namespaces/' + encodeURIComponent(ns)); + const headers = { 'Content-Type': 'application/json' }; + if (config.rootApiKey) headers['authorization'] = `Bearer ${config.rootApiKey}`; + + const req = http.request({ + protocol: url.protocol, + hostname: url.hostname, + port: url.port || 80, + method: 'DELETE', + path: url.pathname, + headers, + timeout: 10000, + }); + req.on('error', () => {}); + req.on('timeout', () => req.destroy()); + req.end(); +} + +function purgeStaleNamespaces() { + const url = new URL(config.upstreamUrl + '/v1/namespaces'); + const headers = { 'Content-Type': 'application/json' }; + if (config.rootApiKey) headers['authorization'] = `Bearer ${config.rootApiKey}`; + + const req = http.request({ + protocol: url.protocol, + hostname: url.hostname, + port: url.port || 80, + method: 'GET', + path: url.pathname, + headers, + timeout: 15000, + }, (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + try { + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const nsList = Array.isArray(body) ? body : (body.namespaces || []); + const prefix = '_dakera_agent_' + NS_PREFIX + '-'; + let purged = 0; + for (const entry of nsList) { + const name = typeof entry === 'string' ? entry : (entry.name || entry.id || ''); + if (name.startsWith(prefix)) { + deleteNamespace(name); + purged++; + } + } + if (purged > 0) { + console.log(`[sandbox-proxy] startup purge: deleting ${purged} stale playground namespace(s)`); + } + } catch (_) {} + }); + }); + req.on('error', (e) => { + console.warn(`[sandbox-proxy] startup purge failed: ${e.message}`); + }); + req.on('timeout', () => req.destroy()); + req.end(); +} + function main() { if (!config.rootApiKey) { - // Fail loud rather than silently forwarding unauthenticated requests to an - // auth-enabled engine (would produce confusing upstream 401s). console.warn( '[sandbox-proxy] WARNING: DAKERA_ROOT_API_KEY is not set — forwarded requests will be unauthenticated.', ); @@ -23,6 +84,13 @@ function main() { ttlMs: config.sessionTtlMs, maxSessionsPerIp: config.maxSessionsPerIp, llmRateLimit: config.llmRateLimitPer10Min, + onExpire: (_sessionId, session) => { + if (!session.namespaces) return; + for (const ns of session.namespaces) { + deleteNamespace('_dakera_agent_' + ns); + console.log(`[sandbox-proxy] expired session — deleting namespace ${ns}`); + } + }, }); const sweep = setInterval(() => { @@ -37,6 +105,8 @@ function main() { `[sandbox-proxy] v${config.version} listening on ${config.host}:${config.port} -> ${config.upstreamUrl} ` + `| rate=${config.rateLimitPerMin}/min cap=${config.memoryCapPerSession} ttl=${config.sessionTtlMs / 1000}s`, ); + // BUG 6: Purge historical playground namespaces on startup + setTimeout(() => purgeStaleNamespaces(), 2000); }); const shutdown = (sig) => { diff --git a/docker/playground/proxy/proxy.test.js b/docker/playground/proxy/proxy.test.js index 1c9a25e..28d82ef 100644 --- a/docker/playground/proxy/proxy.test.js +++ b/docker/playground/proxy/proxy.test.js @@ -227,6 +227,64 @@ test('sweep removes expired sessions', () => { assert.equal(store.size, 0); }); +test('sweep calls onExpire with tracked namespaces (DAK-6975)', () => { + let now = 0; + const expired = []; + const store = new SessionStore({ + rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now, + onExpire: (id, session) => { expired.push({ id, namespaces: [...session.namespaces] }); }, + }); + const res = store.resolve('pg_testexpire', '5.5.5.5'); + assert.equal(res.ok, true); + store.trackNamespace(res.session, 'playground-demo-abc123'); + store.trackNamespace(res.session, 'playground-demo-def456'); + now = 200; + assert.equal(store.sweep(), 1); + assert.equal(expired.length, 1); + assert.equal(expired[0].id, 'pg_testexpire'); + assert.deepStrictEqual(expired[0].namespaces.sort(), ['playground-demo-abc123', 'playground-demo-def456']); +}); + +test('resolve calls onExpire when session is expired on re-resolve (DAK-6975)', () => { + let now = 0; + const expired = []; + const store = new SessionStore({ + rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now, + onExpire: (id, session) => { expired.push(id); }, + }); + const res = store.resolve('pg_resolveexp', '6.6.6.6'); + store.trackNamespace(res.session, 'playground-demo-aaa'); + now = 200; + const res2 = store.resolve('pg_resolveexp', '6.6.6.6'); + assert.equal(res2.ok, true); + assert.equal(expired.length, 1); + assert.equal(expired[0], 'pg_resolveexp'); +}); + +test('trackNamespace adds to session.namespaces set (DAK-6975)', () => { + let now = 0; + const store = new SessionStore({ rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now }); + const res = store.resolve('pg_tracktest1', '7.7.7.7'); + assert.equal(res.session.namespaces, undefined); + store.trackNamespace(res.session, 'ns-a'); + store.trackNamespace(res.session, 'ns-b'); + store.trackNamespace(res.session, 'ns-a'); + assert.equal(res.session.namespaces.size, 2); +}); + +test('sweep does not call onExpire when no namespaces tracked (DAK-6975)', () => { + let now = 0; + let called = false; + const store = new SessionStore({ + rateLimitPerMin: 10, memoryCap: 50, ttlMs: 100, maxSessionsPerIp: 0, now: () => now, + onExpire: () => { called = true; }, + }); + store.resolve('pg_nonamespace', '8.8.8.8'); + now = 200; + store.sweep(); + assert.equal(called, false); +}); + // --------------------------------------------------------------------------- // unit: CORS (req #5) // --------------------------------------------------------------------------- diff --git a/docker/playground/proxy/server.js b/docker/playground/proxy/server.js index 39545d2..935dfb0 100644 --- a/docker/playground/proxy/server.js +++ b/docker/playground/proxy/server.js @@ -408,6 +408,7 @@ function createServer(config, store) { if (rewritten.clientAgentId !== null) { bodyBuf = rewritten.body; rewrite = { namespace: rewritten.namespace, restoreTo: rewritten.clientAgentId }; + store.trackNamespace(resolved.session, rewritten.namespace); } } diff --git a/docker/playground/proxy/sessions.js b/docker/playground/proxy/sessions.js index fc2088d..cb7be4c 100644 --- a/docker/playground/proxy/sessions.js +++ b/docker/playground/proxy/sessions.js @@ -39,7 +39,8 @@ class SessionStore { this.maxSessionsPerIp = opts.maxSessionsPerIp; this.llmRateLimit = opts.llmRateLimit !== undefined ? opts.llmRateLimit : 5; this.now = opts.now || Date.now; - /** @type {Map} */ + this.onExpire = opts.onExpire || null; + /** @type {Map}>} */ this.sessions = new Map(); } @@ -63,6 +64,9 @@ class SessionStore { let s = this.sessions.get(key); if (s && this._expired(s)) { // req #3: 30-min auto-expiry — drop stale state, start fresh. + if (this.onExpire && s.namespaces && s.namespaces.size > 0) { + try { this.onExpire(key, s); } catch (_) {} + } this.sessions.delete(key); s = undefined; } @@ -151,11 +155,20 @@ class SessionStore { return { ok: true, remaining: this.llmRateLimit - session.llmCalls.length }; } - /** Evict expired sessions; returns the number removed. */ + /** Track a namespace used by this session (for cleanup on expiry). */ + trackNamespace(session, namespace) { + if (!session.namespaces) session.namespaces = new Set(); + session.namespaces.add(namespace); + } + + /** Evict expired sessions; calls onExpire for engine cleanup. Returns count removed. */ sweep() { let removed = 0; for (const [k, s] of this.sessions) { if (this._expired(s)) { + if (this.onExpire && s.namespaces && s.namespaces.size > 0) { + try { this.onExpire(k, s); } catch (_) {} + } this.sessions.delete(k); removed += 1; }