From 5da9ec06a109b9f57bbb296490b1aa96783bb62e Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:20:41 +0100 Subject: [PATCH 01/17] Bind to 127.0.0.1 by default, add --expose flag for 0.0.0.0 Prevents the server from being accessible on the network by default. Use --expose to opt in to binding on all interfaces. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/index.ts | 2 ++ src/cli/serve.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 3648c7d..0dc603d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -116,6 +116,7 @@ async function main(): Promise { port, share: args.includes("--share"), headless: args.includes("--headless"), + expose: args.includes("--expose"), save: getFlag("save"), load: getFlag("load"), }); @@ -135,6 +136,7 @@ async function main(): Promise { port, share: args.includes("--share"), quiet: true, + expose: args.includes("--expose"), save: getFlag("save"), load: getFlag("load"), }); diff --git a/src/cli/serve.ts b/src/cli/serve.ts index bb1781a..1796fb0 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -50,6 +50,8 @@ export interface ServeOptions { save?: string; /** Path to load room state from (JSON file). Implies save to the same file. */ load?: string; + /** Bind to 0.0.0.0 instead of 127.0.0.1. Required for non-localhost access. */ + expose?: boolean; } export interface ServeResult { @@ -598,7 +600,7 @@ export async function serve(options: ServeOptions): Promise { }); await new Promise((resolve) => { - httpServer.listen(port, "0.0.0.0", () => resolve()); + httpServer.listen(port, options.expose ? "0.0.0.0" : "127.0.0.1", () => resolve()); }); // Start tunnel if --share From 3a4722571e5999b985053b1ac149145f77e2a845 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:21:23 +0100 Subject: [PATCH 02/17] Add integer bounds checking and message length validation Clamp count params to safe ranges (/messages [1,100], /events/history [1,200], /search [1,50]). Reject messages exceeding 50k chars. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/serve.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 1796fb0..7e3b135 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -140,6 +140,11 @@ export async function serve(options: ServeOptions): Promise { return null; } + function clampInt(val: string | null, def: number, min: number, max: number): number { + const n = parseInt(val ?? String(def), 10); + return isNaN(n) ? def : Math.max(min, Math.min(max, n)); + } + function jsonError(res: ServerResponse, status: number, error: string): void { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error })); @@ -248,7 +253,7 @@ export async function serve(options: ServeOptions): Promise { // ── GET /messages ──────────────────────────────────────────────────── if (url.pathname === "/messages") { if (!session) return jsonError(res, 401, "Invalid session token"); - const count = parseInt(url.searchParams.get("count") ?? "30", 10); + const count = clampInt(url.searchParams.get("count"), 30, 1, 100); const cursor = url.searchParams.get("cursor") ?? null; const result = await room.listMessages(count, cursor); res.writeHead(200, { "Content-Type": "application/json" }); @@ -260,7 +265,7 @@ export async function serve(options: ServeOptions): Promise { if (url.pathname === "/events/history") { if (!session) return jsonError(res, 401, "Invalid session token"); const category = url.searchParams.get("category") ?? null; - const count = parseInt(url.searchParams.get("count") ?? "50", 10); + const count = clampInt(url.searchParams.get("count"), 50, 1, 200); const cursor = url.searchParams.get("cursor") ?? null; const result = await room.listEvents(category as any, count, cursor); res.writeHead(200, { "Content-Type": "application/json" }); @@ -273,7 +278,7 @@ export async function serve(options: ServeOptions): Promise { if (!session) return jsonError(res, 401, "Invalid session token"); const query = url.searchParams.get("query") ?? ""; if (!query) return jsonError(res, 400, "Missing query parameter"); - const count = parseInt(url.searchParams.get("count") ?? "10", 10); + const count = clampInt(url.searchParams.get("count"), 10, 1, 50); const cursor = url.searchParams.get("cursor") ?? null; const result = await room.searchMessages(query, count, cursor); res.writeHead(200, { "Content-Type": "application/json" }); @@ -379,6 +384,7 @@ export async function serve(options: ServeOptions): Promise { const content = String(body.content ?? ""); const replyTo = body.replyTo ? String(body.replyTo) : undefined; if (!content) return jsonError(res, 400, "Empty message"); + if (content.length > 50_000) return jsonError(res, 400, "Message too long (max 50000 chars)"); const p = participants.get(sessionToken); if (!p) return jsonError(res, 403, "Not a participant"); From 8f74a9dc8285f5db2e32185323ef63a2303d5350 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:22:30 +0100 Subject: [PATCH 03/17] Add path containment for --save/--load file paths Validate that save/load paths end with .json and are under cwd or tmpdir. Defense-in-depth check also added to FileBackedStorage. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/serve.ts | 16 +++++++++++++++- src/core/storage.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 7e3b135..3d0cf24 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -11,7 +11,7 @@ import { spawn, execFileSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { join as pathJoin } from "node:path"; +import { join as pathJoin, resolve, sep } from "node:path"; import { Room } from "../core/room.js"; import { InMemoryStorage, FileBackedStorage } from "../core/storage.js"; @@ -79,6 +79,16 @@ async function enrichAndSend(res: ServerResponse, event: RoomEvent, room: Room): // ── Main serve command ─────────────────────────────────────────────────────── +function validateSavePath(p: string): string { + const resolved = resolve(p); + if (!resolved.endsWith(".json")) throw new Error("Save/load path must end with .json"); + const cwd = process.cwd(); + const tmp = tmpdir(); + if (!resolved.startsWith(cwd + sep) && !resolved.startsWith(tmp + sep)) + throw new Error(`Path must be under ${cwd} or ${tmp}`); + return resolved; +} + export async function serve(options: ServeOptions): Promise { const roomName = options.room ?? randomRoomName(); const port = options.port ?? 7890; @@ -88,6 +98,10 @@ export async function serve(options: ServeOptions): Promise { let publicUrl = serverUrl; let tunnelProcess: ChildProcess | null = null; + // Validate save/load paths + if (options.save) options.save = validateSavePath(options.save); + if (options.load) options.load = validateSavePath(options.load); + // Create room with persistence (default: tmp folder) const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); // YYYY-MM-DDTHH-MM-SS const savePath = options.save ?? options.load ?? pathJoin(tmpdir(), `stoops-${roomName}-${timestamp}.json`); diff --git a/src/core/storage.ts b/src/core/storage.ts index b5fa9f9..9fc1d42 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -41,7 +41,8 @@ */ import { writeFile, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { resolve, sep } from "node:path"; +import { tmpdir } from "node:os"; import type { RoomEvent } from "./events.js"; import type { EventCategory, Message, PaginatedResult } from "./types.js"; @@ -254,6 +255,12 @@ export class FileBackedStorage extends InMemoryStorage { constructor(filePath: string) { super(); this._filePath = resolve(filePath); + // Defense-in-depth: ensure path is under cwd or tmp + const cwd = process.cwd(); + const tmp = tmpdir(); + if (!this._filePath.startsWith(cwd + sep) && !this._filePath.startsWith(tmp + sep)) { + throw new Error(`FileBackedStorage path must be under ${cwd} or ${tmp}`); + } } async addMessage(message: Message): Promise { From 60c8a5a1518ed102dd16961ec973e72a57233137 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:27:52 +0100 Subject: [PATCH 04/17] Replace wildcard CORS with origin validation Remove Access-Control-Allow-Origin: * and validate origins against an allowlist (localhost, tunnel URL, --cors-origin values). Add OPTIONS preflight handler. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/index.ts | 2 ++ src/cli/serve.ts | 53 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/cli/index.ts b/src/cli/index.ts index 0dc603d..8afea9a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -117,6 +117,7 @@ async function main(): Promise { share: args.includes("--share"), headless: args.includes("--headless"), expose: args.includes("--expose"), + corsOrigins: getAllFlags("cors-origin"), save: getFlag("save"), load: getFlag("load"), }); @@ -137,6 +138,7 @@ async function main(): Promise { share: args.includes("--share"), quiet: true, expose: args.includes("--expose"), + corsOrigins: getAllFlags("cors-origin"), save: getFlag("save"), load: getFlag("load"), }); diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 3d0cf24..b4e8901 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -52,6 +52,8 @@ export interface ServeOptions { load?: string; /** Bind to 0.0.0.0 instead of 127.0.0.1. Required for non-localhost access. */ expose?: boolean; + /** Allowed CORS origins (in addition to localhost and tunnel URL). */ + corsOrigins?: string[]; } export interface ServeResult { @@ -169,11 +171,56 @@ export async function serve(options: ServeOptions): Promise { res.end(JSON.stringify({ ok: true, ...data })); } + // ── CORS helper ──────────────────────────────────────────────────────── + + const allowedOrigins = new Set([ + `http://127.0.0.1:${port}`, + `http://localhost:${port}`, + ...(options.corsOrigins ?? []), + ]); + + function addCorsOrigin(origin: string): void { + allowedOrigins.add(origin); + } + + function getCorsHeaders(req: IncomingMessage): Record { + const origin = req.headers.origin; + if (!origin) return {}; + if (allowedOrigins.has(origin)) { + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Accept", + "Access-Control-Max-Age": "86400", + Vary: "Origin", + }; + } + return {}; + } + // ── HTTP API ──────────────────────────────────────────────────────────── const httpServer = createServer(async (req, res) => { const url = new URL(req.url ?? "/", `http://localhost:${port}`); + // ── CORS preflight ─────────────────────────────────────────────────── + if (req.method === "OPTIONS") { + const corsHeaders = getCorsHeaders(req); + if (Object.keys(corsHeaders).length > 0) { + res.writeHead(204, corsHeaders); + } else { + res.writeHead(204); + } + res.end(); + return; + } + + // Apply CORS headers to all responses + const corsHeaders = getCorsHeaders(req); + for (const [k, v] of Object.entries(corsHeaders)) { + res.setHeader(k, v); + } + // ── SSE event stream ─────────────────────────────────────────────────── // ⚠️ MUST accept POST — DO NOT change to GET-only. // Cloudflare Quick Tunnels buffer GET streaming responses and only flush @@ -196,7 +243,6 @@ export async function serve(options: ServeOptions): Promise { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", }); res.flushHeaders(); @@ -628,7 +674,10 @@ export async function serve(options: ServeOptions): Promise { tunnelProcess = await startTunnel(port); if (tunnelProcess) { const tunnelUrl = await waitForTunnelUrl(tunnelProcess); - if (tunnelUrl) publicUrl = tunnelUrl; + if (tunnelUrl) { + publicUrl = tunnelUrl; + addCorsOrigin(tunnelUrl); + } } } From f07e4692a52e31a09fa7133acb6ad2c3a66e64e5 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:31:27 +0100 Subject: [PATCH 05/17] Migrate token passing from query params/body to Authorization headers Add extractSessionToken() helper on server that checks Authorization: Bearer first, falls back to query param/body for backward compat. Update all clients (join.ts, remote-room-data-source.ts, runtime-setup.ts) to send tokens via Authorization header. POST /join accepts body.shareToken with body.token as deprecated alias. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/agent/remote-room-data-source.ts | 29 +++++++++++------- src/cli/join.ts | 46 ++++++++++++++-------------- src/cli/runtime-setup.ts | 24 +++++++-------- src/cli/serve.ts | 19 ++++++++---- 4 files changed, 66 insertions(+), 52 deletions(-) diff --git a/src/agent/remote-room-data-source.ts b/src/agent/remote-room-data-source.ts index 8059467..274863c 100644 --- a/src/agent/remote-room-data-source.ts +++ b/src/agent/remote-room-data-source.ts @@ -70,7 +70,8 @@ export class RemoteRoomDataSource implements RoomDataSource { async getMessage(id: string): Promise { try { const res = await fetch( - `${this._serverUrl}/message/${encodeURIComponent(id)}?token=${this._sessionToken}`, + `${this._serverUrl}/message/${encodeURIComponent(id)}`, + { headers: { Authorization: `Bearer ${this._sessionToken}` } }, ); if (!res.ok) return null; const data = (await res.json()) as { message: Message }; @@ -85,11 +86,13 @@ export class RemoteRoomDataSource implements RoomDataSource { limit = 10, cursor: string | null = null, ): Promise> { - const params = new URLSearchParams({ token: this._sessionToken, query, count: String(limit) }); + const params = new URLSearchParams({ query, count: String(limit) }); if (cursor) params.set("cursor", cursor); try { - const res = await fetch(`${this._serverUrl}/search?${params}`); + const res = await fetch(`${this._serverUrl}/search?${params}`, { + headers: { Authorization: `Bearer ${this._sessionToken}` }, + }); if (!res.ok) return { items: [], has_more: false, next_cursor: null }; return (await res.json()) as PaginatedResult; } catch { @@ -101,11 +104,13 @@ export class RemoteRoomDataSource implements RoomDataSource { limit = 30, cursor: string | null = null, ): Promise> { - const params = new URLSearchParams({ token: this._sessionToken, count: String(limit) }); + const params = new URLSearchParams({ count: String(limit) }); if (cursor) params.set("cursor", cursor); try { - const res = await fetch(`${this._serverUrl}/messages?${params}`); + const res = await fetch(`${this._serverUrl}/messages?${params}`, { + headers: { Authorization: `Bearer ${this._sessionToken}` }, + }); if (!res.ok) return { items: [], has_more: false, next_cursor: null }; return (await res.json()) as PaginatedResult; } catch { @@ -118,12 +123,14 @@ export class RemoteRoomDataSource implements RoomDataSource { limit = 50, cursor: string | null = null, ): Promise> { - const params = new URLSearchParams({ token: this._sessionToken, count: String(limit) }); + const params = new URLSearchParams({ count: String(limit) }); if (category) params.set("category", category); if (cursor) params.set("cursor", cursor); try { - const res = await fetch(`${this._serverUrl}/events/history?${params}`); + const res = await fetch(`${this._serverUrl}/events/history?${params}`, { + headers: { Authorization: `Bearer ${this._sessionToken}` }, + }); if (!res.ok) return { items: [], has_more: false, next_cursor: null }; return (await res.json()) as PaginatedResult; } catch { @@ -136,13 +143,13 @@ export class RemoteRoomDataSource implements RoomDataSource { replyToId?: string, image?: { url: string; mimeType: string; sizeBytes: number } | null, ): Promise { - const body: Record = { token: this._sessionToken, content }; + const body: Record = { content }; if (replyToId) body.replyTo = replyToId; if (image) body.image = image; const res = await fetch(`${this._serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this._sessionToken}` }, body: JSON.stringify(body), }); @@ -167,8 +174,8 @@ export class RemoteRoomDataSource implements RoomDataSource { async emitEvent(event: RoomEvent): Promise { const res = await fetch(`${this._serverUrl}/event`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: this._sessionToken, event }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${this._sessionToken}` }, + body: JSON.stringify({ event }), }); if (!res.ok) { diff --git a/src/cli/join.ts b/src/cli/join.ts index d81c00e..54d83a2 100644 --- a/src/cli/join.ts +++ b/src/cli/join.ts @@ -95,8 +95,8 @@ export async function join(options: JoinOptions): Promise { try { await fetch(`${serverUrl}/disconnect`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({}), }); } catch { // Server may be down @@ -123,8 +123,8 @@ export async function join(options: JoinOptions): Promise { try { await fetch(`${serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, content }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ content }), }); } catch { /* server may be down */ } }); @@ -194,7 +194,7 @@ export async function join(options: JoinOptions): Promise { // ── /who ────────────────────────────────────────────────────── case "who": { try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string; type: string; authority?: string }> }; const lines = data.participants.map((p) => { @@ -224,7 +224,7 @@ export async function join(options: JoinOptions): Promise { // Look up participant by name try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -232,8 +232,8 @@ export async function join(options: JoinOptions): Promise { const kickRes = await fetch(`${serverUrl}/kick`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id }), }); if (!kickRes.ok) { systemEvent(`Failed to kick: ${await kickRes.text()}`); return; } systemEvent(`Kicked ${targetName}.`); @@ -250,7 +250,7 @@ export async function join(options: JoinOptions): Promise { if (!targetName) { systemEvent("Usage: /mute "); return; } try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -258,8 +258,8 @@ export async function join(options: JoinOptions): Promise { const authRes = await fetch(`${serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id, authority: "guest" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id, authority: "guest" }), }); if (!authRes.ok) { systemEvent(`Failed to mute: ${await authRes.text()}`); return; } systemEvent(`Muted ${targetName} (guest).`); @@ -276,7 +276,7 @@ export async function join(options: JoinOptions): Promise { if (!targetName) { systemEvent("Usage: /unmute "); return; } try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -284,8 +284,8 @@ export async function join(options: JoinOptions): Promise { const authRes = await fetch(`${serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id, authority: "member" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id, authority: "member" }), }); if (!authRes.ok) { systemEvent(`Failed to unmute: ${await authRes.text()}`); return; } systemEvent(`Unmuted ${targetName} (member).`); @@ -303,7 +303,7 @@ export async function join(options: JoinOptions): Promise { if (!targetName || !mode) { systemEvent("Usage: /setmode "); return; } try { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { headers: { Authorization: `Bearer ${sessionToken}` } }); if (!res.ok) { systemEvent("Failed to get participant list."); return; } const data = (await res.json()) as { participants: Array<{ id: string; name: string }> }; const target = data.participants.find((p) => p.name.toLowerCase() === targetName.toLowerCase()); @@ -311,8 +311,8 @@ export async function join(options: JoinOptions): Promise { const modeRes = await fetch(`${serverUrl}/set-mode`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, participantId: target.id, mode }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ participantId: target.id, mode }), }); if (!modeRes.ok) { systemEvent(`Failed to set mode: ${await modeRes.text()}`); return; } systemEvent(`Set ${targetName} to ${mode}.`); @@ -332,13 +332,13 @@ export async function join(options: JoinOptions): Promise { } try { - const body: Record = { token: sessionToken }; - if (targetAuthority) body.authority = targetAuthority; + const shareBody: Record = {}; + if (targetAuthority) shareBody.authority = targetAuthority; const res = await fetch(`${serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify(shareBody), }); if (!res.ok) { systemEvent(`Failed: ${await res.text()}`); return; } const data = (await res.json()) as { links: Record }; @@ -382,8 +382,8 @@ export async function join(options: JoinOptions): Promise { try { await fetch(`${serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, content }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ content }), }); } catch { // Server may be down — silently fail diff --git a/src/cli/runtime-setup.ts b/src/cli/runtime-setup.ts index 7cbf529..c2a8fc6 100644 --- a/src/cli/runtime-setup.ts +++ b/src/cli/runtime-setup.ts @@ -103,8 +103,8 @@ export async function setupAgentRuntime(options: AgentRuntimeOptions): Promise { // ── Auth helper ────────────────────────────────────────────────────────── + function extractSessionToken(req: IncomingMessage, url: URL, body?: Record): string | null { + const h = req.headers.authorization; + if (h?.startsWith("Bearer ")) return h.slice(7); + // Fallback: query param / body (deprecated) + return url.searchParams.get("token") ?? (body?.token ? String(body.token) : null); + } + function getSession(token: string | null) { if (!token) return null; const p = participants.get(token); @@ -282,7 +289,7 @@ export async function serve(options: ServeOptions): Promise { // ── GET endpoints ───────────────────────────────────────────────────── if (req.method === "GET") { - const sessionToken = url.searchParams.get("token"); + const sessionToken = extractSessionToken(req, url); const session = getSession(sessionToken); // ── GET /participants ──────────────────────────────────────────────── @@ -354,8 +361,8 @@ export async function serve(options: ServeOptions): Promise { // ── POST /join ────────────────────────────────────────────────────── if (url.pathname === "/join") { - // Accept share token OR legacy type-based join - const shareToken = String(body.token ?? ""); + // Accept share token (body.shareToken preferred, body.token as fallback) + const shareToken = String(body.shareToken ?? body.token ?? ""); const legacyType = String(body.type ?? ""); let authority: AuthorityLevel; @@ -434,7 +441,7 @@ export async function serve(options: ServeOptions): Promise { // ── All remaining POST endpoints require a session token ──────────── - const sessionToken = String(body.token ?? ""); + const sessionToken = extractSessionToken(req, url, body) ?? ""; const session = getSession(sessionToken); // ── POST /message ─────────────────────────────────────────────────── @@ -615,8 +622,8 @@ export async function serve(options: ServeOptions): Promise { // ── POST /disconnect ──────────────────────────────────────────────── if (url.pathname === "/disconnect") { - // Accept either session token or legacy participantId/agentId - const token = String(body.token ?? ""); + // Accept Authorization header, body.token, or legacy participantId/agentId + const token = extractSessionToken(req, url, body) ?? ""; const legacyId = String(body.participantId ?? body.agentId ?? ""); let targetToken = token; From 06e1e655961c72b13688a6bec85a30cf194504b3 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:32:10 +0100 Subject: [PATCH 06/17] Obfuscate tokens in console banner output Show first 4 + last 4 chars of tokens in the TUI banner instead of raw values. Headless JSON keeps raw tokens for machine consumption but prints a warning to stderr. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/serve.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index ad8f51a..c2b05ed 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -692,8 +692,14 @@ export async function serve(options: ServeOptions): Promise { const adminToken = tokens.generateShareToken("admin", "admin")!; const memberToken = tokens.generateShareToken("admin", "member")!; + function obfuscate(token: string): string { + if (token.length <= 8) return "****"; + return token.slice(0, 4) + "..." + token.slice(-4); + } + if (options.headless) { process.stdout.write(JSON.stringify({ serverUrl, publicUrl, roomName, adminToken, memberToken, savePath }) + "\n"); + process.stderr.write("Warning: raw tokens in stdout — do not share this output.\n"); } else if (!options.quiet) { let version = process.env.npm_package_version ?? ""; if (!version) { @@ -705,8 +711,8 @@ export async function serve(options: ServeOptions): Promise { version = "unknown"; } } - const adminUrl = buildShareUrl(publicUrl, adminToken); - const joinUrl = buildShareUrl(publicUrl, memberToken); + const adminUrlObfuscated = buildShareUrl(publicUrl, obfuscate(adminToken)); + const joinUrlObfuscated = buildShareUrl(publicUrl, obfuscate(memberToken)); console.log(` stoops v${version} @@ -715,10 +721,12 @@ export async function serve(options: ServeOptions): Promise { Server: ${serverUrl}${publicUrl !== serverUrl ? `\n Tunnel: ${publicUrl}` : ""} Saving: ${savePath} - Join: stoops join ${joinUrl} - Admin: stoops join ${adminUrl} - Claude: stoops run claude --name MyClaude → then tell agent to join: ${joinUrl} - Codex: stoops run codex --name MyCodex → then tell agent to join: ${joinUrl} + Join: stoops join ${joinUrlObfuscated} + Admin: stoops join ${adminUrlObfuscated} + Claude: stoops run claude --name MyClaude → then tell agent to join (use /share in TUI for full URL) + Codex: stoops run codex --name MyCodex → then tell agent to join (use /share in TUI for full URL) + + Use /share in the TUI or --headless to get full join URLs. `); } From a07443d883e0e139f690b3f0a69d0f2b3fa78e31 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:33:08 +0100 Subject: [PATCH 07/17] Add token expiration, rotation, and revocation Session tokens expire after 24h, share tokens after 1h (configurable). Add rotateSessionToken() and pruneExpired() to TokenManager. Add POST /rotate-token endpoint. Run pruning sweep every 5 minutes. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/auth.ts | 97 ++++++++++++++++++++++++++++++++++++++++++++---- src/cli/serve.ts | 31 ++++++++++++++++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 70f21b6..ebe9c37 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -14,14 +14,40 @@ import type { AuthorityLevel } from "../core/types.js"; interface SessionData { participantId: string; authority: AuthorityLevel; + createdAt: number; + expiresAt: number; } +interface ShareTokenData { + authority: AuthorityLevel; + createdAt: number; + expiresAt: number; +} + +export interface TokenManagerOptions { + /** Session token TTL in ms. Default: 24 hours. */ + sessionTtlMs?: number; + /** Share token TTL in ms. Default: 1 hour. */ + shareTtlMs?: number; +} + +const DEFAULT_SESSION_TTL = 24 * 60 * 60 * 1000; // 24h +const DEFAULT_SHARE_TTL = 60 * 60 * 1000; // 1h + export class TokenManager { - /** share token hash → authority level */ - private _shareTokens = new Map(); + /** share token → share data */ + private _shareTokens = new Map(); /** session token → participant data */ private _sessionTokens = new Map(); + private _sessionTtlMs: number; + private _shareTtlMs: number; + + constructor(options?: TokenManagerOptions) { + this._sessionTtlMs = options?.sessionTtlMs ?? DEFAULT_SESSION_TTL; + this._shareTtlMs = options?.shareTtlMs ?? DEFAULT_SHARE_TTL; + } + /** * Generate a share token at the given authority tier. * Callers can only generate tokens at their own tier or below. @@ -29,25 +55,48 @@ export class TokenManager { generateShareToken(callerAuthority: AuthorityLevel, targetAuthority: AuthorityLevel): string | null { if (!canGrant(callerAuthority, targetAuthority)) return null; const token = randomBytes(16).toString("hex"); - this._shareTokens.set(token, targetAuthority); + const now = Date.now(); + this._shareTokens.set(token, { + authority: targetAuthority, + createdAt: now, + expiresAt: now + this._shareTtlMs, + }); return token; } - /** Validate a share token and return its authority level. */ + /** Validate a share token and return its authority level. Deletes if expired. */ validateShareToken(token: string): AuthorityLevel | null { - return this._shareTokens.get(token) ?? null; + const data = this._shareTokens.get(token); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this._shareTokens.delete(token); + return null; + } + return data.authority; } /** Create a session token for a participant. */ createSessionToken(participantId: string, authority: AuthorityLevel): string { const token = randomBytes(16).toString("hex"); - this._sessionTokens.set(token, { participantId, authority }); + const now = Date.now(); + this._sessionTokens.set(token, { + participantId, + authority, + createdAt: now, + expiresAt: now + this._sessionTtlMs, + }); return token; } - /** Validate a session token and return participant data. */ + /** Validate a session token and return participant data. Deletes if expired. */ validateSessionToken(token: string): SessionData | null { - return this._sessionTokens.get(token) ?? null; + const data = this._sessionTokens.get(token); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this._sessionTokens.delete(token); + return null; + } + return data; } /** Revoke a session token (on disconnect). */ @@ -70,6 +119,38 @@ export class TokenManager { } return null; } + + /** Rotate a session token: create new with same participant data, delete old. */ + rotateSessionToken(oldToken: string): { newToken: string; data: SessionData } | null { + const data = this._sessionTokens.get(oldToken); + if (!data) return null; + if (Date.now() > data.expiresAt) { + this._sessionTokens.delete(oldToken); + return null; + } + this._sessionTokens.delete(oldToken); + const newToken = randomBytes(16).toString("hex"); + const now = Date.now(); + const newData: SessionData = { + participantId: data.participantId, + authority: data.authority, + createdAt: now, + expiresAt: now + this._sessionTtlMs, + }; + this._sessionTokens.set(newToken, newData); + return { newToken, data: newData }; + } + + /** Sweep expired tokens from both maps. */ + pruneExpired(): void { + const now = Date.now(); + for (const [token, data] of this._shareTokens) { + if (now > data.expiresAt) this._shareTokens.delete(token); + } + for (const [token, data] of this._sessionTokens) { + if (now > data.expiresAt) this._sessionTokens.delete(token); + } + } } /** Authority tier ordering: admin > member > guest. */ diff --git a/src/cli/serve.ts b/src/cli/serve.ts index c2b05ed..3df22d7 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -620,6 +620,33 @@ export async function serve(options: ServeOptions): Promise { return; } + // ── POST /rotate-token ────────────────────────────────────────────── + if (url.pathname === "/rotate-token") { + if (!session || !sessionToken) return jsonError(res, 401, "Invalid session token"); + const result = tokens.rotateSessionToken(sessionToken); + if (!result) return jsonError(res, 401, "Token expired or invalid"); + + // Move participant/guest entry to new token + const p = participants.get(sessionToken); + if (p) { + participants.delete(sessionToken); + p.sessionToken = result.newToken; + participants.set(result.newToken, p); + idToSession.set(p.id, result.newToken); + } + const g = guests.get(sessionToken); + if (g) { + guests.delete(sessionToken); + g.sessionToken = result.newToken; + guests.set(result.newToken, g); + idToSession.set(g.id, result.newToken); + } + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ sessionToken: result.newToken })); + return; + } + // ── POST /disconnect ──────────────────────────────────────────────── if (url.pathname === "/disconnect") { // Accept Authorization header, body.token, or legacy participantId/agentId @@ -676,6 +703,10 @@ export async function serve(options: ServeOptions): Promise { httpServer.listen(port, options.expose ? "0.0.0.0" : "127.0.0.1", () => resolve()); }); + // Prune expired tokens every 5 minutes + const pruneInterval = setInterval(() => tokens.pruneExpired(), 5 * 60 * 1000); + pruneInterval.unref(); + // Start tunnel if --share if (options.share) { tunnelProcess = await startTunnel(port); From 595b494e2fb9426ddb8594e35b4c2527f3feac61 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:34:34 +0100 Subject: [PATCH 08/17] Add in-memory rate limiting per endpoint Rate limits: /join 10/min per IP, /message 30/min per session, /share 10/min per session, GET 60/min per session, SSE 5/min per IP. Returns 429 with Retry-After header when exceeded. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/serve.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 3df22d7..4d64eb0 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -205,6 +205,44 @@ export async function serve(options: ServeOptions): Promise { return {}; } + // ── Rate limiter ──────────────────────────────────────────────────────── + + class RateLimiter { + private _buckets = new Map(); + constructor(private _max: number, private _windowMs: number) {} + check(key: string): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const bucket = this._buckets.get(key); + if (!bucket || now >= bucket.resetAt) { + this._buckets.set(key, { count: 1, resetAt: now + this._windowMs }); + return { allowed: true }; + } + if (bucket.count >= this._max) { + return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) }; + } + bucket.count++; + return { allowed: true }; + } + } + + const joinLimiter = new RateLimiter(10, 60_000); // 10/min per IP + const messageLimiter = new RateLimiter(30, 60_000); // 30/min per session + const shareLimiter = new RateLimiter(10, 60_000); // 10/min per session + const getLimiter = new RateLimiter(60, 60_000); // 60/min per session + const sseLimiter = new RateLimiter(5, 60_000); // 5/min per IP + + function getClientIp(req: IncomingMessage): string { + return req.socket.remoteAddress ?? "unknown"; + } + + function rateLimitError(res: ServerResponse, retryAfter: number): void { + res.writeHead(429, { + "Content-Type": "application/json", + "Retry-After": String(retryAfter), + }); + res.end(JSON.stringify({ error: "Too many requests" })); + } + // ── HTTP API ──────────────────────────────────────────────────────────── const httpServer = createServer(async (req, res) => { @@ -234,6 +272,9 @@ export async function serve(options: ServeOptions): Promise { // when the connection closes. POST streams in real-time. (cloudflared#1449) // https://github.com/cloudflare/cloudflared/issues/1449 if (url.pathname === "/events" && (req.method === "GET" || req.method === "POST")) { + const sseCheck = sseLimiter.check(getClientIp(req)); + if (!sseCheck.allowed) return rateLimitError(res, sseCheck.retryAfter!); + const authHeader = req.headers.authorization; const sessionToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) @@ -291,6 +332,10 @@ export async function serve(options: ServeOptions): Promise { if (req.method === "GET") { const sessionToken = extractSessionToken(req, url); const session = getSession(sessionToken); + if (sessionToken) { + const getCheck = getLimiter.check(sessionToken); + if (!getCheck.allowed) return rateLimitError(res, getCheck.retryAfter!); + } // ── GET /participants ──────────────────────────────────────────────── if (url.pathname === "/participants") { @@ -361,6 +406,9 @@ export async function serve(options: ServeOptions): Promise { // ── POST /join ────────────────────────────────────────────────────── if (url.pathname === "/join") { + const joinCheck = joinLimiter.check(getClientIp(req)); + if (!joinCheck.allowed) return rateLimitError(res, joinCheck.retryAfter!); + // Accept share token (body.shareToken preferred, body.token as fallback) const shareToken = String(body.shareToken ?? body.token ?? ""); const legacyType = String(body.type ?? ""); @@ -447,6 +495,10 @@ export async function serve(options: ServeOptions): Promise { // ── POST /message ─────────────────────────────────────────────────── if (url.pathname === "/message") { if (!session) return jsonError(res, 401, "Invalid session token"); + if (sessionToken) { + const msgCheck = messageLimiter.check(sessionToken); + if (!msgCheck.allowed) return rateLimitError(res, msgCheck.retryAfter!); + } if (session.authority === "guest") return jsonError(res, 403, "Guests cannot send messages"); const content = String(body.content ?? ""); const replyTo = body.replyTo ? String(body.replyTo) : undefined; @@ -595,6 +647,10 @@ export async function serve(options: ServeOptions): Promise { // ── POST /share ───────────────────────────────────────────────────── if (url.pathname === "/share") { if (!session) return jsonError(res, 401, "Invalid session token"); + if (sessionToken) { + const shareCheck = shareLimiter.check(sessionToken); + if (!shareCheck.allowed) return rateLimitError(res, shareCheck.retryAfter!); + } if (session.authority === "guest") return jsonError(res, 403, "Guests cannot create share links"); const targetAuthority = (body.authority as AuthorityLevel) ?? undefined; From 65cf93d279da771d8f1940fed035c60bc1b75570 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 13:35:32 +0100 Subject: [PATCH 09/17] Update tests for header-based auth, add security test suite Migrate all integration test helpers and inline fetch calls to use Authorization headers. Add tests/security.test.ts covering token expiry, rotation, pruning, integer bounds, message length, rate limiting, backward compat, and bind address. Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 81 ++--------- tests/integration.test.ts | 67 +++++---- tests/security.test.ts | 278 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 323 insertions(+), 103 deletions(-) create mode 100644 tests/security.test.ts diff --git a/package-lock.json b/package-lock.json index de4ba29..6e51b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -116,13 +116,6 @@ "node": ">=6.9.0" } }, - "node_modules/@cfworker/json-schema": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", - "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT", - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -937,54 +930,6 @@ "@langchain/core": "^1.1.29" } }, - "node_modules/@langchain/core": { - "version": "1.1.29", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.29.tgz", - "integrity": "sha512-BPoegTtIdZX4gl2kxcSXAlLrrJFl1cxeRsk9DM/wlIuvyPrFwjWqrEK5NwF5diDt5XSArhQxIFaifGAl4F7fgw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@cfworker/json-schema": "^4.0.2", - "ansi-styles": "^5.0.0", - "camelcase": "6", - "decamelize": "1.2.0", - "js-tiktoken": "^1.0.12", - "langsmith": ">=0.5.0 <1.0.0", - "mustache": "^4.2.0", - "p-queue": "^6.6.2", - "uuid": "^11.1.0", - "zod": "^3.25.76 || ^4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@langchain/langgraph": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.0.tgz", - "integrity": "sha512-wyqKIzXTAfXX3L1d8R7icM+HmRQBTbuNLWtUlpRJ/JP/ux1ei/sOSt6p8f90ARoOP2iJVlM70wOBYWaGErdBlA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.0", - "@langchain/langgraph-sdk": "~1.6.5", - "@standard-schema/spec": "1.1.0", - "uuid": "^10.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@langchain/core": "^1.1.16", - "zod": "^3.25.32 || ^4.2.0", - "zod-to-json-schema": "^3.x" - }, - "peerDependenciesMeta": { - "zod-to-json-schema": { - "optional": true - } - } - }, "node_modules/@langchain/langgraph-checkpoint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz", @@ -1095,20 +1040,6 @@ "uuid": "dist-node/bin/uuid" } }, - "node_modules/@langchain/langgraph/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "optional": true, - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@langchain/mcp-adapters": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@langchain/mcp-adapters/-/mcp-adapters-1.1.3.tgz", @@ -1583,6 +1514,7 @@ "integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1593,6 +1525,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2378,6 +2311,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2490,6 +2424,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2771,6 +2706,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -3445,6 +3381,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3503,6 +3440,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3612,6 +3550,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4290,6 +4229,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -4339,6 +4279,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4398,6 +4339,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -4694,6 +4636,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/tests/integration.test.ts b/tests/integration.test.ts index a43fbc1..fe3b627 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -121,8 +121,8 @@ async function httpSend( ): Promise<{ ok: boolean; messageId?: string }> { const res = await fetch(`${serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken, content }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({ content }), }); return res.json() as Promise<{ ok: boolean; messageId?: string }>; } @@ -130,8 +130,8 @@ async function httpSend( async function httpDisconnect(serverUrl: string, sessionToken: string): Promise { await fetch(`${serverUrl}/disconnect`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${sessionToken}` }, + body: JSON.stringify({}), }).catch(() => {}); } @@ -139,7 +139,9 @@ async function httpParticipants( serverUrl: string, sessionToken: string, ): Promise> { - const res = await fetch(`${serverUrl}/participants?token=${sessionToken}`); + const res = await fetch(`${serverUrl}/participants`, { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); if (!res.ok) throw new Error(`Failed to get participants: ${await res.text()}`); const data = (await res.json()) as { participants: Array<{ id: string; name: string; type: string; authority?: string }> }; return data.participants; @@ -297,8 +299,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const admin = await httpJoin(server.serverUrl, server.adminToken, { name: "Admin" }); const shareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken, authority: "guest" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({ authority: "guest" }), }); const shareData = (await shareRes.json()) as { links: Record }; const guestUrl = shareData.links.guest; @@ -319,7 +321,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Search for the message const searchRes = await fetch( - `${server.serverUrl}/search?token=${alice.sessionToken}&query=unique123`, + `${server.serverUrl}/search?query=unique123`, + { headers: { Authorization: `Bearer ${alice.sessionToken}` } }, ); const searchData = (await searchRes.json()) as { items: Array<{ content: string }> }; expect(searchData.items.length).toBeGreaterThanOrEqual(1); @@ -352,8 +355,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const admin = await httpJoin(server.serverUrl, server.adminToken, { name: "Admin" }); const shareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken, authority: "guest" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({ authority: "guest" }), }); const shareData = (await shareRes.json()) as { links: Record }; const guestToken = new URL(shareData.links.guest).searchParams.get("token")!; @@ -363,8 +366,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const sendRes = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: obs.sessionToken, content: "should fail" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${obs.sessionToken}` }, + body: JSON.stringify({ content: "should fail" }), }); expect(sendRes.status).toBe(403); }, 15_000); @@ -380,8 +383,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const kickRes = await fetch(`${server.serverUrl}/kick`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, participantId: bob.participantId }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ participantId: bob.participantId }), }); expect(kickRes.status).toBe(403); }, 15_000); @@ -397,8 +400,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const kickRes = await fetch(`${server.serverUrl}/kick`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken, participantId: target.participantId }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({ participantId: target.participantId }), }); expect(kickRes.ok).toBe(true); @@ -418,17 +421,16 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Verify Alice can send before mute const send1 = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, content: "before mute" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: "before mute" }), }); expect(send1.ok).toBe(true); // Mute Alice (demote to guest) const muteRes = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, body: JSON.stringify({ - token: admin.sessionToken, participantId: alice.participantId, authority: "guest", }), @@ -438,17 +440,16 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Alice should now be blocked from sending const send2 = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, content: "should fail" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: "should fail" }), }); expect(send2.status).toBe(403); // Unmute Alice (restore to member) const unmuteRes = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, body: JSON.stringify({ - token: admin.sessionToken, participantId: alice.participantId, authority: "member", }), @@ -458,8 +459,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { // Alice should be able to send again const send3 = await fetch(`${server.serverUrl}/message`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: alice.sessionToken, content: "after unmute" }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: "after unmute" }), }); expect(send3.ok).toBe(true); }, 15_000); @@ -491,8 +492,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const admin = await httpJoin(server.serverUrl, server.adminToken, { name: "Admin" }); const adminShareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: admin.sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, + body: JSON.stringify({}), }); const adminLinks = ((await adminShareRes.json()) as { links: Record }).links; expect(adminLinks.admin).toBeTruthy(); @@ -503,8 +504,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const part = await httpJoin(server.serverUrl, server.memberToken, { name: "Part" }); const partShareRes = await fetch(`${server.serverUrl}/share`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: part.sessionToken }), + headers: { "Content-Type": "application/json", Authorization: `Bearer ${part.sessionToken}` }, + body: JSON.stringify({}), }); const partLinks = ((await partShareRes.json()) as { links: Record }).links; expect(partLinks.admin).toBeUndefined(); @@ -543,9 +544,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const res = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${admin.sessionToken}` }, body: JSON.stringify({ - token: admin.sessionToken, participantId: admin.participantId, authority: "guest", }), @@ -564,9 +564,8 @@ describe.skipIf(!HAS_BUILD)("Integration", () => { const res = await fetch(`${server.serverUrl}/set-authority`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, body: JSON.stringify({ - token: alice.sessionToken, participantId: bob.participantId, authority: "guest", }), diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 0000000..5954489 --- /dev/null +++ b/tests/security.test.ts @@ -0,0 +1,278 @@ +/** + * Security tests for stoops — exercises hardening features. + * + * Requires a built CLI at dist/cli/index.js. Run `npm run build` first. + */ + +import { describe, test, expect, afterEach } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { TokenManager } from "../src/cli/auth.js"; + +const CLI_PATH = resolve(__dirname, "../dist/cli/index.js"); +const NODE = process.execPath; + +const HAS_BUILD = existsSync(CLI_PATH); + +// ── Test helpers (duplicated from integration for isolation) ───────────── + +interface ServerHandle { + process: ChildProcess; + serverUrl: string; + publicUrl: string; + roomName: string; + adminToken: string; + memberToken: string; + cleanup: () => void; +} + +let nextPort = 19900 + Math.floor(Math.random() * 100); + +function getPort(): number { + return nextPort++; +} + +async function startServer(opts?: { port?: number; extraArgs?: string[] }): Promise { + const port = opts?.port ?? getPort(); + const args = ["serve", "--headless", "--port", String(port), ...(opts?.extraArgs ?? [])]; + + const child = spawn(NODE, [CLI_PATH, ...args], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Server startup timed out")), 10_000); + let buffer = ""; + + child.stdout!.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + const newlineIdx = buffer.indexOf("\n"); + if (newlineIdx === -1) return; + const line = buffer.slice(0, newlineIdx); + + try { + const data = JSON.parse(line); + clearTimeout(timer); + resolve({ + process: child, + serverUrl: data.serverUrl, + publicUrl: data.publicUrl, + roomName: data.roomName, + adminToken: data.adminToken, + memberToken: data.memberToken, + cleanup: () => { child.kill("SIGTERM"); }, + }); + } catch { + clearTimeout(timer); + reject(new Error(`Failed to parse server output: ${line}`)); + } + }); + + child.on("error", (err) => { clearTimeout(timer); reject(err); }); + child.on("exit", (code) => { clearTimeout(timer); reject(new Error(`Server exited with code ${code}`)); }); + }); +} + +interface JoinResponse { + sessionToken: string; + participantId: string; + roomName: string; + roomId: string; + authority: string; +} + +async function httpJoin( + serverUrl: string, + token: string, + opts?: { name?: string; type?: string }, +): Promise { + const body: Record = { token }; + if (opts?.name) body.name = opts.name; + if (opts?.type) body.type = opts.type; + + const res = await fetch(`${serverUrl}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`Join failed: ${await res.text()}`); + return res.json() as Promise; +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe.skipIf(!HAS_BUILD)("Security", () => { + const servers: ServerHandle[] = []; + + afterEach(async () => { + for (const s of servers.splice(0)) s.cleanup(); + await new Promise((r) => setTimeout(r, 100)); + }); + + // ── Token expiry ─────────────────────────────────────────────────────── + + test("TokenManager: expired session token is rejected", () => { + const tm = new TokenManager({ sessionTtlMs: 1, shareTtlMs: 1 }); + const token = tm.createSessionToken("p1", "member"); + // Token has 1ms TTL — wait a bit + const start = Date.now(); + while (Date.now() - start < 5) { /* spin */ } + expect(tm.validateSessionToken(token)).toBeNull(); + }); + + test("TokenManager: expired share token is rejected", () => { + const tm = new TokenManager({ sessionTtlMs: 60_000, shareTtlMs: 1 }); + const token = tm.generateShareToken("admin", "member")!; + expect(token).toBeTruthy(); + const start = Date.now(); + while (Date.now() - start < 5) { /* spin */ } + expect(tm.validateShareToken(token)).toBeNull(); + }); + + // ── Token rotation ──────────────────────────────────────────────────── + + test("TokenManager: rotated token works, old token rejected", () => { + const tm = new TokenManager(); + const oldToken = tm.createSessionToken("p1", "member"); + expect(tm.validateSessionToken(oldToken)).toBeTruthy(); + + const result = tm.rotateSessionToken(oldToken); + expect(result).toBeTruthy(); + expect(result!.newToken).not.toBe(oldToken); + + // Old token should be gone + expect(tm.validateSessionToken(oldToken)).toBeNull(); + // New token should work + expect(tm.validateSessionToken(result!.newToken)).toBeTruthy(); + expect(tm.validateSessionToken(result!.newToken)!.participantId).toBe("p1"); + }); + + // ── Token pruning ───────────────────────────────────────────────────── + + test("TokenManager: pruneExpired removes expired tokens", () => { + const tm = new TokenManager({ sessionTtlMs: 1, shareTtlMs: 1 }); + tm.createSessionToken("p1", "member"); + tm.generateShareToken("admin", "member"); + const start = Date.now(); + while (Date.now() - start < 5) { /* spin */ } + tm.pruneExpired(); + // After pruning, findSessionByParticipant should return null + expect(tm.findSessionByParticipant("p1")).toBeNull(); + }); + + // ── Integer bounds ──────────────────────────────────────────────────── + + test("count parameter is clamped to max", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + + // count=999999 should be clamped, not error + const res = await fetch( + `${server.serverUrl}/messages?count=999999`, + { headers: { Authorization: `Bearer ${alice.sessionToken}` } }, + ); + expect(res.ok).toBe(true); + }, 15_000); + + // ── Message length ──────────────────────────────────────────────────── + + test("message >50k chars is rejected", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + const longContent = "x".repeat(50_001); + + const res = await fetch(`${server.serverUrl}/message`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${alice.sessionToken}` }, + body: JSON.stringify({ content: longContent }), + }); + expect(res.status).toBe(400); + const data = (await res.json()) as { error: string }; + expect(data.error).toContain("too long"); + }, 15_000); + + // ── Rate limiting ───────────────────────────────────────────────────── + + test("burst of join requests returns 429", async () => { + const server = await startServer(); + servers.push(server); + + // 10 joins should be fine (limit is 10/min), 11th should fail + const results: number[] = []; + for (let i = 0; i < 12; i++) { + const res = await fetch(`${server.serverUrl}/join`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: server.memberToken, name: `User${i}`, type: "human" }), + }); + results.push(res.status); + } + + expect(results.filter((s) => s === 200).length).toBe(10); + expect(results.filter((s) => s === 429).length).toBeGreaterThanOrEqual(1); + }, 15_000); + + // ── Backward compat: query param auth still works ───────────────────── + + test("legacy ?token= query param still works for GET", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + + // Legacy style: token in query param + const res = await fetch(`${server.serverUrl}/participants?token=${alice.sessionToken}`); + expect(res.ok).toBe(true); + const data = (await res.json()) as { participants: unknown[] }; + expect(data.participants.length).toBeGreaterThanOrEqual(1); + }, 15_000); + + // ── Token rotation via HTTP endpoint ─────────────────────────────────── + + test("POST /rotate-token returns new token, old token rejected", async () => { + const server = await startServer(); + servers.push(server); + + const alice = await httpJoin(server.serverUrl, server.memberToken, { name: "Alice" }); + const oldToken = alice.sessionToken; + + // Rotate + const rotateRes = await fetch(`${server.serverUrl}/rotate-token`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${oldToken}` }, + body: JSON.stringify({}), + }); + expect(rotateRes.ok).toBe(true); + const rotateData = (await rotateRes.json()) as { sessionToken: string }; + expect(rotateData.sessionToken).toBeTruthy(); + expect(rotateData.sessionToken).not.toBe(oldToken); + + // New token works + const listRes = await fetch(`${server.serverUrl}/participants`, { + headers: { Authorization: `Bearer ${rotateData.sessionToken}` }, + }); + expect(listRes.ok).toBe(true); + + // Old token rejected + const oldRes = await fetch(`${server.serverUrl}/participants`, { + headers: { Authorization: `Bearer ${oldToken}` }, + }); + expect(oldRes.status).toBe(401); + }, 15_000); + + // ── Bind address ─────────────────────────────────────────────────────── + + test("server binds to 127.0.0.1 by default", async () => { + const server = await startServer(); + servers.push(server); + + // serverUrl should be 127.0.0.1 + expect(server.serverUrl).toContain("127.0.0.1"); + }, 15_000); +}); From 8a24d068d788e886ce8cf2d652d750cf1546842c Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Sat, 14 Mar 2026 14:07:02 +0100 Subject: [PATCH 10/17] Document security model, --expose flag, and private network setup Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 95b1a13..8286931 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,15 @@ npx stoops run codex --name MyCodex # their agent Two humans, two agents, one room. +### Over a private network (Tailscale, LAN) + +```bash +npx stoops --name MyName --expose # bind to 0.0.0.0 +npx stoops join http://:7890?token= # from another machine +``` + +`--expose` makes the server reachable beyond localhost. Without it, the server only accepts connections from `127.0.0.1`. You can run multiple rooms on different ports (`--port 7891`, `--port 7892`, etc.) — each is independent with its own participants and tokens. + ### Watch mode ```bash @@ -99,11 +108,11 @@ Share links encode permissions. The host gets admin and member links at startup. ## All commands ```bash -npx stoops [--name ] [--room ] [--port ] [--share] # host + join -npx stoops serve [--room ] [--port ] [--share] # server only -npx stoops join [--name ] [--guest] # join a room -npx stoops run claude [--name ] [--admin] [-- ] # Claude Code -npx stoops run codex [--name ] [--admin] [-- ] # Codex +npx stoops [--name ] [--room ] [--port ] [--share] [--expose] # host + join +npx stoops serve [--room ] [--port ] [--share] [--expose] # server only +npx stoops join [--name ] [--guest] # join a room +npx stoops run claude [--name ] [--admin] [-- ] # Claude Code +npx stoops run codex [--name ] [--admin] [-- ] # Codex ``` Room state auto-saves. Use `--save file.json` / `--load file.json` for a specific file. @@ -139,6 +148,10 @@ Room state auto-saves. Use `--save file.json` / `--load file.json` for a specifi - **Codex** — `npm install -g @openai/codex` (for `run codex`) - **cloudflared** — `brew install cloudflared` (optional, for `--share`) +## Security + +Localhost-only by default (`--expose` opts in to network access). All API calls use `Authorization: Bearer` tokens. Share links expire after 1 hour, sessions after 24 hours. CORS restricted to localhost and tunnel URL (`--cors-origin` to add more). Rate limiting on joins and messages. Input size limits enforced. + ## Contributing Issues and PRs welcome. See [GitHub Issues](https://github.com/stoops-io/stoops/issues). From 73eb50ec4b9e16ee0bbc4bdf2de11d7c59a3c7bb Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Mon, 16 Mar 2026 09:07:22 +0100 Subject: [PATCH 11/17] Add ambient type stubs for optional @langchain/* dependencies The DTS build fails when @langchain/* packages aren't installed because TypeScript can't resolve types for the dynamic imports. These stub declarations provide minimal ambient types so the build succeeds regardless of whether the optional packages are present. Co-Authored-By: Claude Opus 4.6 --- src/langgraph/langchain.d.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/langgraph/langchain.d.ts diff --git a/src/langgraph/langchain.d.ts b/src/langgraph/langchain.d.ts new file mode 100644 index 0000000..b313eb7 --- /dev/null +++ b/src/langgraph/langchain.d.ts @@ -0,0 +1,29 @@ +// Ambient module declarations for optional @langchain/* dependencies. +// These are dynamically imported at runtime and validated in start(). +// The stubs let the DTS build succeed without the packages installed. + +declare module "@langchain/langgraph" { + export const StateGraph: any; + export const MemorySaver: any; + export const MessagesValue: any; + export const StateSchema: any; + export const START: any; + export const END: any; +} + +declare module "@langchain/langgraph/prebuilt" { + export const ToolNode: any; +} + +declare module "langchain/chat_models/universal" { + export function initChatModel(...args: any[]): any; +} + +declare module "@langchain/mcp-adapters" { + export const MultiServerMCPClient: any; +} + +declare module "@langchain/core/messages" { + export const HumanMessage: any; + export const SystemMessage: any; +} From babc5b2bc7d3b744df4ca09a4fed3456321606ad Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Mon, 16 Mar 2026 09:33:31 +0100 Subject: [PATCH 12/17] Use LAN IP in share URLs when --expose is set When --expose binds the server to 0.0.0.0, share links were still generated with 127.0.0.1, making them unusable from other machines. Now resolves the first non-internal IPv4 address for the serverUrl. Co-Authored-By: Claude Opus 4.6 --- src/cli/serve.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 4d64eb0..c07a37f 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -10,7 +10,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht import { spawn, execFileSync, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createRequire } from "node:module"; -import { tmpdir } from "node:os"; +import { tmpdir, networkInterfaces } from "node:os"; import { join as pathJoin, resolve, sep } from "node:path"; import { Room } from "../core/room.js"; @@ -91,10 +91,20 @@ function validateSavePath(p: string): string { return resolved; } +function getLanIp(): string | null { + for (const ifaces of Object.values(networkInterfaces())) { + for (const iface of ifaces ?? []) { + if (iface.family === "IPv4" && !iface.internal) return iface.address; + } + } + return null; +} + export async function serve(options: ServeOptions): Promise { const roomName = options.room ?? randomRoomName(); const port = options.port ?? 7890; - const serverUrl = `http://127.0.0.1:${port}`; + const lanIp = options.expose ? getLanIp() : null; + const serverUrl = `http://${lanIp ?? "127.0.0.1"}:${port}`; const log = options.headless ? () => {} : logServer; let publicUrl = serverUrl; From 74a3e58bd3f350010fec9cffe7424ec5c437f4d4 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Mon, 16 Mar 2026 09:46:14 +0100 Subject: [PATCH 13/17] Show actual error message when stoops join fails The catch-all was hiding the real failure reason (e.g. 403, network errors) behind a generic "Cannot reach server" message. Co-Authored-By: Claude Opus 4.6 --- src/cli/join.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/join.ts b/src/cli/join.ts index 54d83a2..b210b3c 100644 --- a/src/cli/join.ts +++ b/src/cli/join.ts @@ -79,8 +79,9 @@ export async function join(options: JoinOptions): Promise { roomName = String(data.roomName); authority = (data.authority as AuthorityLevel) ?? "member"; participants = (data.participants as Array<{ id: string; name: string; type: string; authority?: string }>) ?? []; - } catch { + } catch (err) { console.error(`Cannot reach stoops server at ${serverUrl}. Is it running?`); + console.error(` Error: ${err instanceof Error ? err.message : err}`); process.exit(1); } From 4472f5724faf1486f4ca9845dddde6ae0633146a Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Mon, 16 Mar 2026 09:54:47 +0100 Subject: [PATCH 14/17] Update README: Node 20+ requirement, LAN setup notes Node 18 doesn't support the /v regex flag used by string-width. Added default port and firewall hint to the private network section. Co-Authored-By: Claude Opus 4.6 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8286931..65181f4 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ npx stoops --name MyName --expose # bind to 0.0.0.0 npx stoops join http://:7890?token= # from another machine ``` -`--expose` makes the server reachable beyond localhost. Without it, the server only accepts connections from `127.0.0.1`. You can run multiple rooms on different ports (`--port 7891`, `--port 7892`, etc.) — each is independent with its own participants and tokens. +`--expose` makes the server reachable beyond localhost. Without it, the server only accepts connections from `127.0.0.1`. The default port is 7890 — use `--port` to change it (e.g. `--port 7891`). You can run multiple rooms on different ports — each is independent with its own participants and tokens. Make sure the port is open in your firewall (`sudo ufw allow 7890`). ### Watch mode @@ -142,7 +142,7 @@ Room state auto-saves. Use `--save file.json` / `--load file.json` for a specifi ## Prerequisites -- **Node.js** 18+ +- **Node.js** 20+ - **tmux** — `brew install tmux` (macOS) / `sudo apt install tmux` (Linux) - **Claude Code** — `npm install -g @anthropic-ai/claude-code` (for `run claude`) - **Codex** — `npm install -g @openai/codex` (for `run codex`) From f45df5b553905c70839469f786e872c0011f5ac7 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Mon, 16 Mar 2026 12:27:34 +0100 Subject: [PATCH 15/17] Add npm start command for running CLI from build Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 20 ++++++++++++++++---- package.json | 3 ++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6e51b1c..f3d26d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -961,9 +961,9 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.6.5.tgz", - "integrity": "sha512-JjprmbhgCnoNJ9DUKcvrEU+C9FfKsNGyT3ooqWxAY5Cx2qofhXmDJOpTCqqbxfDHPKG0RjTs5HgVK3WW5M6Big==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.7.2.tgz", + "integrity": "sha512-8ad5OTwqc15J/DxLNJYLn3IC2mpfow09nxJdszxhwm3KgsolGZIUV6g7m67C2p4j3cbQZD5USHt3hKEL0ahqoA==", "license": "MIT", "optional": true, "dependencies": { @@ -973,11 +973,17 @@ "uuid": "^13.0.0" }, "peerDependencies": { + "@angular/core": "^18.0.0 || ^19.0.0 || ^20.0.0", "@langchain/core": "^1.1.16", "react": "^18 || ^19", - "react-dom": "^18 || ^19" + "react-dom": "^18 || ^19", + "svelte": "^4.0.0 || ^5.0.0", + "vue": "^3.0.0" }, "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, "@langchain/core": { "optional": true }, @@ -986,6 +992,12 @@ }, "react-dom": { "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true } } }, diff --git a/package.json b/package.json index 63a9a37..66c5e68 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "watch": "tsup --watch", "test": "vitest run", "test:watch": "vitest", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "start": "node dist/cli/index.js" }, "author": "Izzat Alsharif ", "license": "MIT", From 1d506b0c169b80d18a46f95c3ee73618c2304c60 Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Mon, 16 Mar 2026 12:30:43 +0100 Subject: [PATCH 16/17] Add "Run from source" section to README Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index 65181f4..3ca9492 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,49 @@ Room state auto-saves. Use `--save file.json` / `--load file.json` for a specifi Localhost-only by default (`--expose` opts in to network access). All API calls use `Authorization: Bearer` tokens. Share links expire after 1 hour, sessions after 24 hours. CORS restricted to localhost and tunnel URL (`--cors-origin` to add more). Rate limiting on joins and messages. Input size limits enforced. +## Run from source + +If you're working off a branch or want to run without installing from npm: + +```bash +git clone && cd stoops-cli +npm install +npm run build +``` + +**Terminal 1 — host a room on your LAN:** + +```bash +npm start -- --name Jordan --room office --expose +``` + +You'll see share links printed in the TUI: + +``` +admin: stoops join http://172.16.10.96:7890/?token= +member: stoops join http://172.16.10.96:7890/?token= +guest: stoops join http://172.16.10.96:7890/?token= +``` + +Send the member link to your team. Admin link gives kick/mute powers. Guest link is read-only. + +**Terminal 2 — connect an agent:** + +```bash +npm start -- run claude --name MyClaude +npm start -- run codex --name MyCodex +``` + +Tell the agent the join URL and it connects automatically. + +**From another machine — join the room:** + +```bash +npm start -- join http://172.16.10.96:7890/?token= --name Alice +``` + +All args after `--` are passed through, so anything from the `npx stoops` examples works the same way with `npm start --`. + ## Contributing Issues and PRs welcome. See [GitHub Issues](https://github.com/stoops-io/stoops/issues). From 61ef49563bf0ea915500f1259b9514f49ad62a2e Mon Sep 17 00:00:00 2001 From: gorlitzer Date: Mon, 16 Mar 2026 14:09:59 +0100 Subject: [PATCH 17/17] Add SSE heartbeat to prevent idle connection drops Sends a :heartbeat comment every 30s to keep connections alive through proxies, firewalls, and OS-level TCP idle timeouts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/serve.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cli/serve.ts b/src/cli/serve.ts index c07a37f..c27f4a5 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -312,6 +312,12 @@ export async function serve(options: ServeOptions): Promise { sseConnections.set(session.id, res); + // Heartbeat every 30s to keep the connection alive through + // proxies, firewalls, and OS-level TCP idle timeouts. + const heartbeat = setInterval(() => { + res.write(":heartbeat\n\n"); + }, 30_000); + // Send recent history so the joiner has context const history = await room.listEvents(undefined, 50); for (const event of [...history.items].reverse()) { @@ -332,6 +338,7 @@ export async function serve(options: ServeOptions): Promise { // Cleanup on client disconnect req.on("close", () => { + clearInterval(heartbeat); sseConnections.delete(session.id); }); return;