just-bash-mcp / src /runtime /SessionManager.ts
victor's picture
victor HF Staff
Enable network, python, javascript capabilities
350d89b verified
import { WorkerClient } from "./WorkerClient.js";
import { ResourceStore } from "./resourceStore.js";
import type { Config } from "../config.js";
import { newId } from "../utils/ids.js";
import { ToolError } from "../utils/errors.js";
export interface CreateSessionOptions {
initialFiles?: Record<string, string>;
cwd?: string;
ttlSeconds?: number;
capabilities?: {
network?: boolean;
python?: boolean;
javascript?: boolean;
};
ownerTokenHash?: string;
}
export interface SessionRecord {
id: string;
worker: WorkerClient;
cwd: string;
initialFiles: Record<string, string>;
capabilities: { network: boolean; python: boolean; javascript: boolean };
createdAt: number;
lastActiveAt: number;
expiresAt: number;
idleTtlMs: number;
valid: boolean;
invalidReason?: string;
execQueue: Promise<unknown>;
ownerTokenHash?: string;
}
export class SessionManager {
private sessions = new Map<string, SessionRecord>();
private resources: ResourceStore;
private sweepTimer: NodeJS.Timeout | null = null;
private draining = false;
constructor(private readonly cfg: Config) {
this.resources = new ResourceStore(
cfg.maxExecsRetainedSession,
cfg.maxResourceBytesSession,
cfg.maxResourceBytesGlobal,
);
}
get resourceStore() {
return this.resources;
}
get config() {
return this.cfg;
}
count(): number {
return this.sessions.size;
}
startSweeper() {
if (this.sweepTimer) return;
this.sweepTimer = setInterval(() => this.sweep(), this.cfg.sessionSweepIntervalSeconds * 1000);
// Don't keep the event loop alive only for sweeping
if (typeof this.sweepTimer.unref === "function") this.sweepTimer.unref();
}
stopSweeper() {
if (this.sweepTimer) clearInterval(this.sweepTimer);
this.sweepTimer = null;
}
setDraining(value: boolean) {
this.draining = value;
}
isDraining() {
return this.draining;
}
async createSession(opts: CreateSessionOptions): Promise<SessionRecord> {
if (this.draining) {
throw new ToolError("INTERNAL", "Server is draining; cannot create sessions");
}
if (this.sessions.size >= this.cfg.maxSessions) {
throw new ToolError("QUOTA_EXCEEDED", `Max sessions reached (${this.cfg.maxSessions})`);
}
if (opts.ownerTokenHash) {
let perTokenCount = 0;
for (const s of this.sessions.values()) {
if (s.ownerTokenHash === opts.ownerTokenHash) perTokenCount++;
}
if (perTokenCount >= this.cfg.maxSessionsPerToken) {
throw new ToolError(
"QUOTA_EXCEEDED",
`Max sessions per token reached (${this.cfg.maxSessionsPerToken})`,
);
}
}
const cwd = opts.cwd ?? "/home/user";
// Default each capability to "whatever the server allows". Callers can still
// explicitly opt out by passing `false`. They cannot opt in beyond what the server allows.
const askedNetwork = opts.capabilities?.network ?? this.cfg.enableNetwork;
const askedPython = opts.capabilities?.python ?? this.cfg.enablePython;
const askedJs = opts.capabilities?.javascript ?? this.cfg.enableJavaScript;
const capabilities = {
network: askedNetwork && this.cfg.enableNetwork,
python: askedPython && this.cfg.enablePython,
javascript: askedJs && this.cfg.enableJavaScript,
};
const ttlSeconds = Math.min(
Math.max(opts.ttlSeconds ?? this.cfg.sessionIdleTtlSeconds, 30),
this.cfg.sessionIdleTtlSeconds * 4,
);
const id = newId("jb");
const worker = new WorkerClient({ workerMaxOldGenMb: this.cfg.workerMaxOldGenMb });
try {
await worker.init({
sessionId: id,
cwd,
initialFiles: opts.initialFiles ?? {},
capabilities,
networkConfig: capabilities.network
? {
dangerouslyAllowFullInternetAccess:
this.cfg.dangerouslyAllowFullInternetAccess,
allowedUrlPrefixes: this.cfg.allowedUrlPrefixes,
allowedMethods: this.cfg.allowedMethods,
}
: undefined,
executionLimits: {
maxCallDepth: 50,
maxCommandCount: 5000,
maxLoopIterations: 5000,
maxAwkIterations: 5000,
maxSedIterations: 5000,
},
maxSessionFsBytes: this.cfg.maxSessionFsBytes,
maxSingleFileBytes: this.cfg.maxSingleFileBytes,
maxWriteBatchBytes: this.cfg.maxWriteBatchBytes,
maxReadBytesPerCall: this.cfg.maxReadBytesPerCall,
maxCapturedOutputBytesExec: this.cfg.maxCapturedOutputBytesExec,
});
} catch (e) {
await worker.terminate("init_failed");
throw e;
}
const now = Date.now();
const rec: SessionRecord = {
id,
worker,
cwd,
initialFiles: opts.initialFiles ?? {},
capabilities,
createdAt: now,
lastActiveAt: now,
expiresAt: now + ttlSeconds * 1000,
idleTtlMs: ttlSeconds * 1000,
valid: true,
execQueue: Promise.resolve(),
ownerTokenHash: opts.ownerTokenHash,
};
this.sessions.set(id, rec);
return rec;
}
get(id: string): SessionRecord | null {
const s = this.sessions.get(id);
if (!s) return null;
if (Date.now() > s.expiresAt) {
void this.delete(id, "expired");
return null;
}
if (!s.valid) {
return s; // caller will see invalid
}
return s;
}
require(id: string): SessionRecord {
const s = this.get(id);
if (!s) throw new ToolError("UNKNOWN_SESSION", `Unknown or expired session_id`);
if (!s.valid)
throw new ToolError(
"SESSION_INVALID",
`Session is invalid: ${s.invalidReason ?? "terminated"}`,
);
return s;
}
touch(id: string) {
const s = this.sessions.get(id);
if (!s) return;
s.lastActiveAt = Date.now();
s.expiresAt = s.lastActiveAt + s.idleTtlMs;
}
async delete(id: string, reason: string = "deleted"): Promise<boolean> {
const s = this.sessions.get(id);
if (!s) return false;
this.sessions.delete(id);
this.resources.deleteSession(id);
await s.worker.terminate(reason);
return true;
}
async reset(id: string, mode: "empty" | "initial_files"): Promise<SessionRecord> {
const s = this.require(id);
const initialFiles = mode === "initial_files" ? s.initialFiles : {};
await s.worker.terminate("reset");
const newWorker = new WorkerClient({ workerMaxOldGenMb: this.cfg.workerMaxOldGenMb });
try {
await newWorker.init({
sessionId: s.id,
cwd: s.cwd,
initialFiles,
capabilities: s.capabilities,
networkConfig: s.capabilities.network
? {
dangerouslyAllowFullInternetAccess:
this.cfg.dangerouslyAllowFullInternetAccess,
allowedUrlPrefixes: this.cfg.allowedUrlPrefixes,
allowedMethods: this.cfg.allowedMethods,
}
: undefined,
executionLimits: {
maxCallDepth: 50,
maxCommandCount: 5000,
maxLoopIterations: 5000,
maxAwkIterations: 5000,
maxSedIterations: 5000,
},
maxSessionFsBytes: this.cfg.maxSessionFsBytes,
maxSingleFileBytes: this.cfg.maxSingleFileBytes,
maxWriteBatchBytes: this.cfg.maxWriteBatchBytes,
maxReadBytesPerCall: this.cfg.maxReadBytesPerCall,
maxCapturedOutputBytesExec: this.cfg.maxCapturedOutputBytesExec,
});
} catch (e) {
await newWorker.terminate("reset_init_failed");
this.sessions.delete(s.id);
throw e;
}
s.worker = newWorker;
s.lastActiveAt = Date.now();
s.expiresAt = s.lastActiveAt + s.idleTtlMs;
s.valid = true;
s.invalidReason = undefined;
s.execQueue = Promise.resolve();
this.resources.deleteSession(s.id);
return s;
}
invalidate(id: string, reason: string) {
const s = this.sessions.get(id);
if (!s) return;
s.valid = false;
s.invalidReason = reason;
this.resources.deleteSession(id);
void s.worker.terminate(reason);
}
// Serialize exec calls per session.
runOnSessionQueue<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
const s = this.require(sessionId);
const next = s.execQueue.then(fn, fn);
s.execQueue = next.catch(() => undefined);
return next;
}
sweep() {
const now = Date.now();
for (const s of Array.from(this.sessions.values())) {
if (now > s.expiresAt || !s.valid) {
void this.delete(s.id, !s.valid ? `invalid:${s.invalidReason}` : "expired");
}
}
this.resources.sweepExpired();
}
async shutdown(graceMs: number): Promise<void> {
this.setDraining(true);
this.stopSweeper();
// Wait for in-flight execs per session
const deadline = Date.now() + graceMs;
while (Date.now() < deadline) {
let inflight = 0;
for (const s of this.sessions.values()) {
// Probe queue
const start = Date.now();
let resolved = false;
await Promise.race([
s.execQueue.then(() => {
resolved = true;
}),
new Promise((r) => setTimeout(r, 50)),
]);
if (!resolved && Date.now() - start >= 50) inflight++;
}
if (inflight === 0) break;
}
for (const s of Array.from(this.sessions.values())) {
await this.delete(s.id, "shutdown");
}
}
}