@threadbase-sh/streamer 1.29.1 → 1.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -321,6 +321,7 @@ interface ServerConfig {
321
321
  codexRoots?: string[];
322
322
  scannerPersistent?: boolean;
323
323
  ptyGracePeriodMs?: number;
324
+ wsAuthTimeoutMs?: number;
324
325
  cacheDir?: string;
325
326
  tailSize?: number;
326
327
  directoryScanDebounceMs?: number;
@@ -859,7 +860,7 @@ type ApiDeps = {
859
860
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
860
861
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
861
862
  handleMkdir: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
862
- handleWsOpen: (ws: WebSocket) => void;
863
+ handleWsOpen: (ws: WebSocket, preAuthed: boolean) => void;
863
864
  handleWsMessage: (ws: WebSocket, raw: unknown) => void;
864
865
  handleWsClose: (ws: WebSocket) => void;
865
866
  agentClient: AgentClient | null;
@@ -1015,6 +1016,9 @@ declare class StreamerServer {
1015
1016
  private sessionSubscribers;
1016
1017
  private clientIdToWs;
1017
1018
  private wsToClientId;
1019
+ private wsAuthed;
1020
+ private wsAuthPending;
1021
+ private wsAuthTimeoutMs;
1018
1022
  private cache;
1019
1023
  private projectsRepo;
1020
1024
  private conversationsRepo;
@@ -1043,6 +1047,7 @@ declare class StreamerServer {
1043
1047
  * a full broadcast if no match exists (old clients, or no WS registered yet).
1044
1048
  */
1045
1049
  private broadcastOrUnicastSessionList;
1050
+ private completeWsAuth;
1046
1051
  private addSessionSubscriber;
1047
1052
  private startGraceTimer;
1048
1053
  get port(): number;
package/dist/index.d.ts CHANGED
@@ -321,6 +321,7 @@ interface ServerConfig {
321
321
  codexRoots?: string[];
322
322
  scannerPersistent?: boolean;
323
323
  ptyGracePeriodMs?: number;
324
+ wsAuthTimeoutMs?: number;
324
325
  cacheDir?: string;
325
326
  tailSize?: number;
326
327
  directoryScanDebounceMs?: number;
@@ -859,7 +860,7 @@ type ApiDeps = {
859
860
  handlePairExchange: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
860
861
  handleBrowse: (url: URL, res: ServerResponse) => Promise<void>;
861
862
  handleMkdir: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
862
- handleWsOpen: (ws: WebSocket) => void;
863
+ handleWsOpen: (ws: WebSocket, preAuthed: boolean) => void;
863
864
  handleWsMessage: (ws: WebSocket, raw: unknown) => void;
864
865
  handleWsClose: (ws: WebSocket) => void;
865
866
  agentClient: AgentClient | null;
@@ -1015,6 +1016,9 @@ declare class StreamerServer {
1015
1016
  private sessionSubscribers;
1016
1017
  private clientIdToWs;
1017
1018
  private wsToClientId;
1019
+ private wsAuthed;
1020
+ private wsAuthPending;
1021
+ private wsAuthTimeoutMs;
1018
1022
  private cache;
1019
1023
  private projectsRepo;
1020
1024
  private conversationsRepo;
@@ -1043,6 +1047,7 @@ declare class StreamerServer {
1043
1047
  * a full broadcast if no match exists (old clients, or no WS registered yet).
1044
1048
  */
1045
1049
  private broadcastOrUnicastSessionList;
1050
+ private completeWsAuth;
1046
1051
  private addSessionSubscriber;
1047
1052
  private startGraceTimer;
1048
1053
  get port(): number;
package/dist/index.js CHANGED
@@ -164,7 +164,7 @@ function verifySignature(rawBody, signature, secret) {
164
164
  }
165
165
  }
166
166
  function isWithinSkew(timestampHeader, skewSeconds) {
167
- if (!timestampHeader) return true;
167
+ if (!timestampHeader) return false;
168
168
  const t = Number(timestampHeader);
169
169
  if (!Number.isFinite(t)) return false;
170
170
  const now = Math.floor(Date.now() / 1e3);
@@ -2551,7 +2551,7 @@ function isLocalRequest(remoteAddr) {
2551
2551
  const addr = remoteAddr ?? "";
2552
2552
  return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
2553
2553
  }
2554
- var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz"]);
2554
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set(["/healthz", "/ws"]);
2555
2555
  var PUBLIC_POST_PATHS = /* @__PURE__ */ new Set(["/api/pair/exchange", "/api/__update"]);
2556
2556
  var PUBLIC_POST_PREFIXES = ["/internal/sessions/"];
2557
2557
  var authMiddleware = (deps) => async (c, next) => {
@@ -3014,14 +3014,16 @@ var createWsRoutes = (deps, upgradeWebSocket) => {
3014
3014
  const app = new Hono10();
3015
3015
  app.get(
3016
3016
  "/ws",
3017
- upgradeWebSocket(() => {
3017
+ upgradeWebSocket((c) => {
3018
+ const key = c.req.query("key");
3019
+ const preAuthed = typeof key === "string" && validateApiKey(key, deps.apiKey);
3018
3020
  let openWs = null;
3019
3021
  return {
3020
3022
  onOpen(_evt, ws) {
3021
3023
  const raw = ws.raw;
3022
3024
  if (!raw) return;
3023
3025
  openWs = raw;
3024
- deps.handleWsOpen(raw);
3026
+ deps.handleWsOpen(raw, preAuthed);
3025
3027
  },
3026
3028
  onMessage(evt, _ws) {
3027
3029
  if (openWs) deps.handleWsMessage(openWs, evt.data);
@@ -4928,6 +4930,13 @@ function deriveProjectChatTitle(input) {
4928
4930
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
4929
4931
  }
4930
4932
 
4933
+ // src/services/questions/permissionAnswerKeys.ts
4934
+ var ANSWER_KEYS_ALLOWLIST = /^(?:\r|[yn]\r|\x03|\d+\r)$/;
4935
+ function sanitizeAnswerKeys(keys) {
4936
+ if (keys === void 0) return void 0;
4937
+ return ANSWER_KEYS_ALLOWLIST.test(keys) ? keys : void 0;
4938
+ }
4939
+
4931
4940
  // src/services/questions/detectAskUserQuestion.ts
4932
4941
  function normalizeContent2(raw) {
4933
4942
  if (Array.isArray(raw)) return raw;
@@ -5532,6 +5541,8 @@ var WSHub = class {
5532
5541
  var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project boundary: ${browseRoot}. Do not read, write, or execute commands that access files or directories outside this boundary.`;
5533
5542
  var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
5534
5543
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
5544
+ var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
5545
+ var WS_CLOSE_UNAUTHORIZED = 4401;
5535
5546
  var REFRESH_TTL_MS = 2e3;
5536
5547
  var START_READY_TIMEOUT_MS = 15e3;
5537
5548
  function parseIncludeAgentsEnv(raw) {
@@ -5622,6 +5633,14 @@ var StreamerServer = class {
5622
5633
  clientIdToWs = /* @__PURE__ */ new Map();
5623
5634
  // Reverse map for cleanup on close
5624
5635
  wsToClientId = /* @__PURE__ */ new Map();
5636
+ // M1 — WS auth. Sockets that have authenticated (via ?key= at upgrade OR a
5637
+ // { type: "auth", token } first message). Only authed sockets are added to
5638
+ // the hub and receive broadcasts.
5639
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
5640
+ wsAuthed = /* @__PURE__ */ new Set();
5641
+ // Keyless sockets awaiting their first-message auth handshake → close timer.
5642
+ wsAuthPending = /* @__PURE__ */ new Map();
5643
+ wsAuthTimeoutMs;
5625
5644
  cache = null;
5626
5645
  projectsRepo = null;
5627
5646
  conversationsRepo = null;
@@ -5661,6 +5680,7 @@ var StreamerServer = class {
5661
5680
  this.scanProfiles = config.scanProfiles;
5662
5681
  this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
5663
5682
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
5683
+ this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
5664
5684
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
5665
5685
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
5666
5686
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
@@ -5918,17 +5938,39 @@ var StreamerServer = class {
5918
5938
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
5919
5939
  handleBrowse: (url, res) => this.handleBrowse(url, res),
5920
5940
  handleMkdir: (req, res) => this.handleMkdir(req, res),
5921
- handleWsOpen: (ws) => {
5922
- this.wsHub.addClient(ws);
5923
- const sessions = this.sessionStore.list(this.ptyAttachedIds());
5924
- ws.send(JSON.stringify({ type: "session_list", sessions }));
5925
- if (this.cacheReady) {
5926
- ws.send(JSON.stringify({ type: "cache_ready" }));
5941
+ handleWsOpen: (ws, preAuthed) => {
5942
+ if (preAuthed) {
5943
+ this.completeWsAuth(ws);
5944
+ return;
5927
5945
  }
5946
+ const timer = setTimeout(() => {
5947
+ this.wsAuthPending.delete(ws);
5948
+ try {
5949
+ ws.close(WS_CLOSE_UNAUTHORIZED, "auth timeout");
5950
+ } catch {
5951
+ }
5952
+ }, this.wsAuthTimeoutMs);
5953
+ this.wsAuthPending.set(ws, timer);
5928
5954
  },
5929
5955
  handleWsMessage: async (ws, raw) => {
5930
5956
  try {
5931
5957
  const msg = JSON.parse(String(raw));
5958
+ if (!this.wsAuthed.has(ws)) {
5959
+ if (msg.type === "auth" && typeof msg.token === "string") {
5960
+ const t = this.wsAuthPending.get(ws);
5961
+ if (t) clearTimeout(t);
5962
+ this.wsAuthPending.delete(ws);
5963
+ if (validateApiKey(msg.token, this.apiKey)) {
5964
+ this.completeWsAuth(ws);
5965
+ } else {
5966
+ try {
5967
+ ws.close(WS_CLOSE_UNAUTHORIZED, "unauthorized");
5968
+ } catch {
5969
+ }
5970
+ }
5971
+ }
5972
+ return;
5973
+ }
5932
5974
  if (msg.type === "register" && typeof msg.clientId === "string") {
5933
5975
  const oldClientId = this.wsToClientId.get(ws);
5934
5976
  if (oldClientId) this.clientIdToWs.delete(oldClientId);
@@ -5943,12 +5985,20 @@ var StreamerServer = class {
5943
5985
  }
5944
5986
  }
5945
5987
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
5946
- this.startGraceTimer(msg.sessionId, 0);
5988
+ if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
5989
+ this.startGraceTimer(msg.sessionId, 0);
5990
+ }
5947
5991
  }
5948
5992
  } catch {
5949
5993
  }
5950
5994
  },
5951
5995
  handleWsClose: (ws) => {
5996
+ const pendingTimer = this.wsAuthPending.get(ws);
5997
+ if (pendingTimer) {
5998
+ clearTimeout(pendingTimer);
5999
+ this.wsAuthPending.delete(ws);
6000
+ }
6001
+ this.wsAuthed.delete(ws);
5952
6002
  const clientId = this.wsToClientId.get(ws);
5953
6003
  if (clientId) {
5954
6004
  this.clientIdToWs.delete(clientId);
@@ -6018,6 +6068,20 @@ var StreamerServer = class {
6018
6068
  this.wsHub.broadcast(payload);
6019
6069
  }
6020
6070
  }
6071
+ // M1: finalize a WebSocket auth (via ?key= at upgrade or a first-message
6072
+ // handshake) — register it with the hub and send the initial snapshot. Only
6073
+ // authed sockets reach this, so no unauthenticated client ever receives a
6074
+ // broadcast.
6075
+ // Plan: https://github.com/RonenMars/threadbase-streamer/blob/a251353bfa417bd48ce3f15086bc336a2c622629/docs/plans/2026-06-24-security-hardening.md#L40
6076
+ completeWsAuth(ws) {
6077
+ this.wsAuthed.add(ws);
6078
+ this.wsHub.addClient(ws);
6079
+ const sessions = this.sessionStore.list(this.ptyAttachedIds());
6080
+ ws.send(JSON.stringify({ type: "session_list", sessions }));
6081
+ if (this.cacheReady) {
6082
+ ws.send(JSON.stringify({ type: "cache_ready" }));
6083
+ }
6084
+ }
6021
6085
  addSessionSubscriber(sessionId, ws) {
6022
6086
  let subs = this.sessionSubscribers.get(sessionId);
6023
6087
  if (!subs) {
@@ -6304,6 +6368,9 @@ var StreamerServer = class {
6304
6368
  this.ptyManager.dispose();
6305
6369
  this.fileWatcher.dispose();
6306
6370
  this.wsHub.dispose();
6371
+ for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
6372
+ this.wsAuthPending.clear();
6373
+ this.wsAuthed.clear();
6307
6374
  this.pairTokens.dispose();
6308
6375
  if (this.dbPool) {
6309
6376
  await this.dbPool.end();
@@ -7430,12 +7497,16 @@ var StreamerServer = class {
7430
7497
  return;
7431
7498
  }
7432
7499
  this.pendingPermission.set(sessionId, gate);
7500
+ const safeOptions = gate.options.map((o) => {
7501
+ const answerKeys = sanitizeAnswerKeys(o.answerKeys);
7502
+ return answerKeys === void 0 ? { index: o.index, label: o.label } : { ...o, answerKeys };
7503
+ });
7433
7504
  this.wsHub.broadcast({
7434
7505
  type: "permission",
7435
7506
  sessionId,
7436
7507
  ...gate.prompt ? { prompt: gate.prompt } : {},
7437
7508
  ...gate.detail ? { detail: gate.detail } : {},
7438
- options: gate.options,
7509
+ options: safeOptions,
7439
7510
  ...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
7440
7511
  });
7441
7512
  }