Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions docker/playground/proxy/index.js
Original file line number Diff line number Diff line change
@@ -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.',
);
Expand All @@ -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(() => {
Expand All @@ -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) => {
Expand Down
58 changes: 58 additions & 0 deletions docker/playground/proxy/proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions docker/playground/proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
17 changes: 15 additions & 2 deletions docker/playground/proxy/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {createdAt:number, calls:number[], memoryCount:number, ip:string, generated:boolean, llmCalls?:number[], llmSeeded?:boolean}>} */
this.onExpire = opts.onExpire || null;
/** @type {Map<string, {createdAt:number, calls:number[], memoryCount:number, ip:string, generated:boolean, llmCalls?:number[], llmSeeded?:boolean, namespaces?:Set<string>}>} */
this.sessions = new Map();
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading