@threadbase-sh/streamer 1.29.0 → 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/cli.cjs +7239 -5574
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +248 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.js +248 -38
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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;
|
|
@@ -1076,6 +1081,23 @@ declare class StreamerServer {
|
|
|
1076
1081
|
private rescanForRefresh;
|
|
1077
1082
|
private findJsonlPath;
|
|
1078
1083
|
private readCwdFromJsonl;
|
|
1084
|
+
/**
|
|
1085
|
+
* Live Codex sessions keep `SessionResponse.conversationId === managed.id`
|
|
1086
|
+
* (stable deep-link / PTY key) and store the rollout UUID separately as
|
|
1087
|
+
* `boundConversationId`. REST history is indexed under the rollout UUID, so
|
|
1088
|
+
* resolve the placeholder → bound id before looking up the scanner.
|
|
1089
|
+
*/
|
|
1090
|
+
private resolveConversationLookupId;
|
|
1091
|
+
/** File path for a live managed session (placeholder id or bound Codex id). */
|
|
1092
|
+
private findLiveSessionFilePath;
|
|
1093
|
+
/** True when a conversation UUID is the bound rollout of a live PTY session. */
|
|
1094
|
+
private isBoundConversationLive;
|
|
1095
|
+
/**
|
|
1096
|
+
* Broadcast conversation JSONL lines to WS clients. Codex rollout lines are
|
|
1097
|
+
* normalized to the Claude `type:user|assistant` shape mobile understands;
|
|
1098
|
+
* Claude lines pass through unchanged so seq alignment stays intact.
|
|
1099
|
+
*/
|
|
1100
|
+
private broadcastConversationLines;
|
|
1079
1101
|
private findConversationByUuid;
|
|
1080
1102
|
private isConversationSnapshotStale;
|
|
1081
1103
|
private handleGetConversation;
|
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;
|
|
@@ -1076,6 +1081,23 @@ declare class StreamerServer {
|
|
|
1076
1081
|
private rescanForRefresh;
|
|
1077
1082
|
private findJsonlPath;
|
|
1078
1083
|
private readCwdFromJsonl;
|
|
1084
|
+
/**
|
|
1085
|
+
* Live Codex sessions keep `SessionResponse.conversationId === managed.id`
|
|
1086
|
+
* (stable deep-link / PTY key) and store the rollout UUID separately as
|
|
1087
|
+
* `boundConversationId`. REST history is indexed under the rollout UUID, so
|
|
1088
|
+
* resolve the placeholder → bound id before looking up the scanner.
|
|
1089
|
+
*/
|
|
1090
|
+
private resolveConversationLookupId;
|
|
1091
|
+
/** File path for a live managed session (placeholder id or bound Codex id). */
|
|
1092
|
+
private findLiveSessionFilePath;
|
|
1093
|
+
/** True when a conversation UUID is the bound rollout of a live PTY session. */
|
|
1094
|
+
private isBoundConversationLive;
|
|
1095
|
+
/**
|
|
1096
|
+
* Broadcast conversation JSONL lines to WS clients. Codex rollout lines are
|
|
1097
|
+
* normalized to the Claude `type:user|assistant` shape mobile understands;
|
|
1098
|
+
* Claude lines pass through unchanged so seq alignment stays intact.
|
|
1099
|
+
*/
|
|
1100
|
+
private broadcastConversationLines;
|
|
1079
1101
|
private findConversationByUuid;
|
|
1080
1102
|
private isConversationSnapshotStale;
|
|
1081
1103
|
private handleGetConversation;
|
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
|
|
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;
|
|
@@ -5292,6 +5301,89 @@ function sanitizeFilename(name) {
|
|
|
5292
5301
|
return cleaned;
|
|
5293
5302
|
}
|
|
5294
5303
|
|
|
5304
|
+
// src/utils/codexConversationLine.ts
|
|
5305
|
+
function extractCodexText(content) {
|
|
5306
|
+
if (typeof content === "string") return content.trim();
|
|
5307
|
+
if (!Array.isArray(content)) return "";
|
|
5308
|
+
return content.map((item) => {
|
|
5309
|
+
if (typeof item === "string") return item;
|
|
5310
|
+
const block = item;
|
|
5311
|
+
const t = block?.type;
|
|
5312
|
+
if ((t === "input_text" || t === "output_text" || t === "text") && typeof block.text === "string") {
|
|
5313
|
+
return block.text;
|
|
5314
|
+
}
|
|
5315
|
+
return "";
|
|
5316
|
+
}).filter(Boolean).join("").trim();
|
|
5317
|
+
}
|
|
5318
|
+
function normalizeCodexLineToClaudeShape(line) {
|
|
5319
|
+
let entry;
|
|
5320
|
+
try {
|
|
5321
|
+
entry = JSON.parse(line);
|
|
5322
|
+
} catch {
|
|
5323
|
+
return null;
|
|
5324
|
+
}
|
|
5325
|
+
if (entry.type !== "response_item") return null;
|
|
5326
|
+
const payload = entry.payload;
|
|
5327
|
+
if (payload?.type !== "message") return null;
|
|
5328
|
+
const role = payload.role;
|
|
5329
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
5330
|
+
const text = extractCodexText(payload.content);
|
|
5331
|
+
if (!text) return null;
|
|
5332
|
+
if (role === "user" && isCodexInjectedContext(text)) return null;
|
|
5333
|
+
const timestamp = typeof entry.timestamp === "string" ? entry.timestamp : (/* @__PURE__ */ new Date()).toISOString();
|
|
5334
|
+
const uuid = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : `codex-${role}-${timestamp}-${hashPrefix(text)}`;
|
|
5335
|
+
return JSON.stringify({
|
|
5336
|
+
type: role,
|
|
5337
|
+
uuid,
|
|
5338
|
+
timestamp,
|
|
5339
|
+
message: {
|
|
5340
|
+
role,
|
|
5341
|
+
content: [{ type: "text", text }]
|
|
5342
|
+
}
|
|
5343
|
+
});
|
|
5344
|
+
}
|
|
5345
|
+
function isCodexRolloutLine(line) {
|
|
5346
|
+
try {
|
|
5347
|
+
const entry = JSON.parse(line);
|
|
5348
|
+
return entry.type === "response_item" || entry.type === "event_msg" || entry.type === "session_meta" || entry.type === "turn_context";
|
|
5349
|
+
} catch {
|
|
5350
|
+
return false;
|
|
5351
|
+
}
|
|
5352
|
+
}
|
|
5353
|
+
function toClientConversationLines(lines) {
|
|
5354
|
+
if (lines.length === 0) return lines;
|
|
5355
|
+
const codex = lines.some(isCodexRolloutLine);
|
|
5356
|
+
if (!codex) return lines;
|
|
5357
|
+
const out = [];
|
|
5358
|
+
for (const line of lines) {
|
|
5359
|
+
const normalized = normalizeCodexLineToClaudeShape(line);
|
|
5360
|
+
if (normalized) out.push(normalized);
|
|
5361
|
+
}
|
|
5362
|
+
return out;
|
|
5363
|
+
}
|
|
5364
|
+
function isCodexInjectedContext(text) {
|
|
5365
|
+
if (text.startsWith("# AGENTS.md") || text.includes("<INSTRUCTIONS>")) return true;
|
|
5366
|
+
if (text.startsWith("<permissions instructions>") || text.includes("Filesystem sandboxing defines")) {
|
|
5367
|
+
return true;
|
|
5368
|
+
}
|
|
5369
|
+
if (text.includes("limit the options to at most 3")) return true;
|
|
5370
|
+
if (text.includes("You are working within the project boundary:")) return true;
|
|
5371
|
+
if (text.includes(
|
|
5372
|
+
"Do not read, write, or execute commands that access files or directories outside this boundary"
|
|
5373
|
+
)) {
|
|
5374
|
+
return true;
|
|
5375
|
+
}
|
|
5376
|
+
return false;
|
|
5377
|
+
}
|
|
5378
|
+
function hashPrefix(text) {
|
|
5379
|
+
let h = 0;
|
|
5380
|
+
const sample = text.slice(0, 64);
|
|
5381
|
+
for (let i = 0; i < sample.length; i++) {
|
|
5382
|
+
h = h * 31 + sample.charCodeAt(i) | 0;
|
|
5383
|
+
}
|
|
5384
|
+
return Math.abs(h).toString(36);
|
|
5385
|
+
}
|
|
5386
|
+
|
|
5295
5387
|
// src/utils/conversationEtag.ts
|
|
5296
5388
|
import { createHash as createHash2 } from "crypto";
|
|
5297
5389
|
function computeConversationEtag({
|
|
@@ -5449,6 +5541,8 @@ var WSHub = class {
|
|
|
5449
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.`;
|
|
5450
5542
|
var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
|
|
5451
5543
|
var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
|
|
5544
|
+
var DEFAULT_WS_AUTH_TIMEOUT_MS = 5e3;
|
|
5545
|
+
var WS_CLOSE_UNAUTHORIZED = 4401;
|
|
5452
5546
|
var REFRESH_TTL_MS = 2e3;
|
|
5453
5547
|
var START_READY_TIMEOUT_MS = 15e3;
|
|
5454
5548
|
function parseIncludeAgentsEnv(raw) {
|
|
@@ -5539,6 +5633,14 @@ var StreamerServer = class {
|
|
|
5539
5633
|
clientIdToWs = /* @__PURE__ */ new Map();
|
|
5540
5634
|
// Reverse map for cleanup on close
|
|
5541
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;
|
|
5542
5644
|
cache = null;
|
|
5543
5645
|
projectsRepo = null;
|
|
5544
5646
|
conversationsRepo = null;
|
|
@@ -5578,6 +5680,7 @@ var StreamerServer = class {
|
|
|
5578
5680
|
this.scanProfiles = config.scanProfiles;
|
|
5579
5681
|
this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
|
|
5580
5682
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
5683
|
+
this.wsAuthTimeoutMs = config.wsAuthTimeoutMs ?? DEFAULT_WS_AUTH_TIMEOUT_MS;
|
|
5581
5684
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
5582
5685
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
5583
5686
|
this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
|
|
@@ -5676,15 +5779,7 @@ var StreamerServer = class {
|
|
|
5676
5779
|
if (broadcast) this.wsHub.broadcast(m);
|
|
5677
5780
|
}
|
|
5678
5781
|
const seqs = this.pendingLineSeqs.get(filePath);
|
|
5679
|
-
this.
|
|
5680
|
-
type: "conversation_events",
|
|
5681
|
-
sessionId,
|
|
5682
|
-
lines,
|
|
5683
|
-
...seqs && seqs.length === lines.length ? { seqs } : {}
|
|
5684
|
-
});
|
|
5685
|
-
for (const line of lines) {
|
|
5686
|
-
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
5687
|
-
}
|
|
5782
|
+
this.broadcastConversationLines(sessionId, lines, seqs);
|
|
5688
5783
|
break;
|
|
5689
5784
|
}
|
|
5690
5785
|
}
|
|
@@ -5843,17 +5938,39 @@ var StreamerServer = class {
|
|
|
5843
5938
|
handlePairExchange: (req, res) => this.handlePairExchange(req, res),
|
|
5844
5939
|
handleBrowse: (url, res) => this.handleBrowse(url, res),
|
|
5845
5940
|
handleMkdir: (req, res) => this.handleMkdir(req, res),
|
|
5846
|
-
handleWsOpen: (ws) => {
|
|
5847
|
-
|
|
5848
|
-
|
|
5849
|
-
|
|
5850
|
-
if (this.cacheReady) {
|
|
5851
|
-
ws.send(JSON.stringify({ type: "cache_ready" }));
|
|
5941
|
+
handleWsOpen: (ws, preAuthed) => {
|
|
5942
|
+
if (preAuthed) {
|
|
5943
|
+
this.completeWsAuth(ws);
|
|
5944
|
+
return;
|
|
5852
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);
|
|
5853
5954
|
},
|
|
5854
5955
|
handleWsMessage: async (ws, raw) => {
|
|
5855
5956
|
try {
|
|
5856
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
|
+
}
|
|
5857
5974
|
if (msg.type === "register" && typeof msg.clientId === "string") {
|
|
5858
5975
|
const oldClientId = this.wsToClientId.get(ws);
|
|
5859
5976
|
if (oldClientId) this.clientIdToWs.delete(oldClientId);
|
|
@@ -5868,12 +5985,20 @@ var StreamerServer = class {
|
|
|
5868
5985
|
}
|
|
5869
5986
|
}
|
|
5870
5987
|
if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
|
|
5871
|
-
this.
|
|
5988
|
+
if (this.sessionSubscribers.get(msg.sessionId)?.has(ws)) {
|
|
5989
|
+
this.startGraceTimer(msg.sessionId, 0);
|
|
5990
|
+
}
|
|
5872
5991
|
}
|
|
5873
5992
|
} catch {
|
|
5874
5993
|
}
|
|
5875
5994
|
},
|
|
5876
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);
|
|
5877
6002
|
const clientId = this.wsToClientId.get(ws);
|
|
5878
6003
|
if (clientId) {
|
|
5879
6004
|
this.clientIdToWs.delete(clientId);
|
|
@@ -5943,6 +6068,20 @@ var StreamerServer = class {
|
|
|
5943
6068
|
this.wsHub.broadcast(payload);
|
|
5944
6069
|
}
|
|
5945
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
|
+
}
|
|
5946
6085
|
addSessionSubscriber(sessionId, ws) {
|
|
5947
6086
|
let subs = this.sessionSubscribers.get(sessionId);
|
|
5948
6087
|
if (!subs) {
|
|
@@ -6229,6 +6368,9 @@ var StreamerServer = class {
|
|
|
6229
6368
|
this.ptyManager.dispose();
|
|
6230
6369
|
this.fileWatcher.dispose();
|
|
6231
6370
|
this.wsHub.dispose();
|
|
6371
|
+
for (const timer of this.wsAuthPending.values()) clearTimeout(timer);
|
|
6372
|
+
this.wsAuthPending.clear();
|
|
6373
|
+
this.wsAuthed.clear();
|
|
6232
6374
|
this.pairTokens.dispose();
|
|
6233
6375
|
if (this.dbPool) {
|
|
6234
6376
|
await this.dbPool.end();
|
|
@@ -6665,11 +6807,62 @@ var StreamerServer = class {
|
|
|
6665
6807
|
rl.on("error", () => resolve2(null));
|
|
6666
6808
|
});
|
|
6667
6809
|
}
|
|
6810
|
+
/**
|
|
6811
|
+
* Live Codex sessions keep `SessionResponse.conversationId === managed.id`
|
|
6812
|
+
* (stable deep-link / PTY key) and store the rollout UUID separately as
|
|
6813
|
+
* `boundConversationId`. REST history is indexed under the rollout UUID, so
|
|
6814
|
+
* resolve the placeholder → bound id before looking up the scanner.
|
|
6815
|
+
*/
|
|
6816
|
+
resolveConversationLookupId(uuid) {
|
|
6817
|
+
const managed = this.sessionStore.getManaged(uuid);
|
|
6818
|
+
if (managed?.boundConversationId) return managed.boundConversationId;
|
|
6819
|
+
return uuid;
|
|
6820
|
+
}
|
|
6821
|
+
/** File path for a live managed session (placeholder id or bound Codex id). */
|
|
6822
|
+
findLiveSessionFilePath(uuid) {
|
|
6823
|
+
const direct = this.sessionFileMap.get(uuid);
|
|
6824
|
+
if (direct) return direct;
|
|
6825
|
+
for (const s of this.sessionStore.listManaged()) {
|
|
6826
|
+
if (s.boundConversationId === uuid) {
|
|
6827
|
+
return this.sessionFileMap.get(s.id) ?? null;
|
|
6828
|
+
}
|
|
6829
|
+
}
|
|
6830
|
+
return null;
|
|
6831
|
+
}
|
|
6832
|
+
/** True when a conversation UUID is the bound rollout of a live PTY session. */
|
|
6833
|
+
isBoundConversationLive(boundId) {
|
|
6834
|
+
for (const s of this.sessionStore.listManaged()) {
|
|
6835
|
+
if (s.boundConversationId === boundId && this.ptyManager.hasSession(s.id)) {
|
|
6836
|
+
return true;
|
|
6837
|
+
}
|
|
6838
|
+
}
|
|
6839
|
+
return false;
|
|
6840
|
+
}
|
|
6841
|
+
/**
|
|
6842
|
+
* Broadcast conversation JSONL lines to WS clients. Codex rollout lines are
|
|
6843
|
+
* normalized to the Claude `type:user|assistant` shape mobile understands;
|
|
6844
|
+
* Claude lines pass through unchanged so seq alignment stays intact.
|
|
6845
|
+
*/
|
|
6846
|
+
broadcastConversationLines(sessionId, lines, seqs) {
|
|
6847
|
+
const clientLines = toClientConversationLines(lines);
|
|
6848
|
+
if (clientLines.length === 0) return;
|
|
6849
|
+
const seqsOk = !!seqs && seqs.length === lines.length && clientLines.length === lines.length;
|
|
6850
|
+
this.wsHub.broadcast({
|
|
6851
|
+
type: "conversation_events",
|
|
6852
|
+
sessionId,
|
|
6853
|
+
lines: clientLines,
|
|
6854
|
+
...seqsOk ? { seqs } : {}
|
|
6855
|
+
});
|
|
6856
|
+
for (const line of clientLines) {
|
|
6857
|
+
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
6858
|
+
}
|
|
6859
|
+
}
|
|
6668
6860
|
async findConversationByUuid(uuid) {
|
|
6861
|
+
const lookupId = this.resolveConversationLookupId(uuid);
|
|
6669
6862
|
if (!this.scannerReady && !this.scanProfiles) {
|
|
6670
|
-
const filePath2 = this.findJsonlPath(uuid);
|
|
6863
|
+
const filePath2 = this.findJsonlPath(lookupId) ?? this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId);
|
|
6671
6864
|
if (filePath2) {
|
|
6672
|
-
const account = this.cache?.getMetaById(
|
|
6865
|
+
const account = this.cache?.getMetaById(lookupId)?.account ?? void 0;
|
|
6673
6866
|
const coldScanner = this.scanner ?? this.newScanner();
|
|
6674
6867
|
const page = await coldScanner.parseSingleFilePage(filePath2, account, {
|
|
6675
6868
|
limit: Number.MAX_SAFE_INTEGER
|
|
@@ -6679,9 +6872,27 @@ var StreamerServer = class {
|
|
|
6679
6872
|
return null;
|
|
6680
6873
|
}
|
|
6681
6874
|
const scanner = await this.getScanner(true);
|
|
6682
|
-
const fromIndex = await scanner.getConversation(
|
|
6875
|
+
const fromIndex = await scanner.getConversation(lookupId);
|
|
6683
6876
|
if (fromIndex) {
|
|
6684
|
-
if (this.ptyManager.hasSession(uuid)) {
|
|
6877
|
+
if (this.ptyManager.hasSession(uuid) || this.ptyManager.hasSession(lookupId) || this.isBoundConversationLive(lookupId)) {
|
|
6878
|
+
const livePath = this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId) ?? fromIndex.filePath ?? null;
|
|
6879
|
+
const isCodexLive = this.isBoundConversationLive(lookupId) || this.sessionStore.getManaged(uuid)?.provider === CODEX_CLI_PROVIDER;
|
|
6880
|
+
if (isCodexLive && livePath) {
|
|
6881
|
+
try {
|
|
6882
|
+
const account = this.cache?.getMetaById(lookupId)?.account ?? fromIndex.account ?? void 0;
|
|
6883
|
+
const page = await scanner.parseSingleFilePage(livePath, account, {
|
|
6884
|
+
limit: Number.MAX_SAFE_INTEGER
|
|
6885
|
+
});
|
|
6886
|
+
if (page?.conversation) return page.conversation;
|
|
6887
|
+
} catch (err) {
|
|
6888
|
+
this.log.warn("codex.live_reparse_failed", {
|
|
6889
|
+
event: "codex.live_reparse_failed",
|
|
6890
|
+
conversationId: lookupId,
|
|
6891
|
+
filePath: livePath,
|
|
6892
|
+
err
|
|
6893
|
+
});
|
|
6894
|
+
}
|
|
6895
|
+
}
|
|
6685
6896
|
return fromIndex;
|
|
6686
6897
|
}
|
|
6687
6898
|
if (fromIndex.filePath && this.isConversationSnapshotStale(fromIndex)) {
|
|
@@ -6701,12 +6912,12 @@ var StreamerServer = class {
|
|
|
6701
6912
|
return fromIndex;
|
|
6702
6913
|
}
|
|
6703
6914
|
if (this.scanProfiles) return null;
|
|
6704
|
-
const filePath = this.findJsonlPath(uuid);
|
|
6915
|
+
const filePath = this.findJsonlPath(lookupId) ?? this.findLiveSessionFilePath(uuid) ?? this.findLiveSessionFilePath(lookupId);
|
|
6705
6916
|
if (!filePath) return null;
|
|
6706
6917
|
this.scanner = null;
|
|
6707
6918
|
this.scannerReady = null;
|
|
6708
6919
|
const freshScanner = await this.getScanner();
|
|
6709
|
-
return freshScanner.getConversation(
|
|
6920
|
+
return freshScanner.getConversation(lookupId);
|
|
6710
6921
|
}
|
|
6711
6922
|
// True when the JSONL on disk is meaningfully newer than the scanned
|
|
6712
6923
|
// snapshot's last-activity timestamp — i.e. the file grew after the scan.
|
|
@@ -6787,7 +6998,9 @@ var StreamerServer = class {
|
|
|
6787
6998
|
res.end();
|
|
6788
6999
|
return;
|
|
6789
7000
|
}
|
|
6790
|
-
const filtered = conversation.messages
|
|
7001
|
+
const filtered = conversation.messages.filter(
|
|
7002
|
+
(m) => !(m.role === "user" && typeof m.text === "string" && isCodexInjectedContext(m.text))
|
|
7003
|
+
);
|
|
6791
7004
|
const total = filtered.length;
|
|
6792
7005
|
const hasAnchor = url.searchParams.has("anchor_index");
|
|
6793
7006
|
const hasAfter = url.searchParams.has("after_index");
|
|
@@ -6845,8 +7058,7 @@ var StreamerServer = class {
|
|
|
6845
7058
|
})
|
|
6846
7059
|
);
|
|
6847
7060
|
}
|
|
6848
|
-
const
|
|
6849
|
-
const page = indexWindow ?? (scanLimit > 0 && pagedScanner && typeof pagedScanner.getConversationPage === "function" ? await pagedScanner.getConversationPage(id, { beforeIndex, limit: scanLimit }) : null);
|
|
7061
|
+
const page = indexWindow;
|
|
6850
7062
|
if (indexWindow) indexTotal = indexWindow.total;
|
|
6851
7063
|
const start = page?.fromIndex ?? windowStart;
|
|
6852
7064
|
slice = page?.messages ?? filtered.slice(start, beforeIndex);
|
|
@@ -7285,12 +7497,16 @@ var StreamerServer = class {
|
|
|
7285
7497
|
return;
|
|
7286
7498
|
}
|
|
7287
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
|
+
});
|
|
7288
7504
|
this.wsHub.broadcast({
|
|
7289
7505
|
type: "permission",
|
|
7290
7506
|
sessionId,
|
|
7291
7507
|
...gate.prompt ? { prompt: gate.prompt } : {},
|
|
7292
7508
|
...gate.detail ? { detail: gate.detail } : {},
|
|
7293
|
-
options:
|
|
7509
|
+
options: safeOptions,
|
|
7294
7510
|
...gate.cursor !== void 0 ? { cursor: gate.cursor } : {}
|
|
7295
7511
|
});
|
|
7296
7512
|
}
|
|
@@ -7654,10 +7870,7 @@ var StreamerServer = class {
|
|
|
7654
7870
|
try {
|
|
7655
7871
|
const existing = readFileSync6(resolvedFilePath, "utf8").split("\n").filter(Boolean);
|
|
7656
7872
|
if (existing.length > 0) {
|
|
7657
|
-
this.
|
|
7658
|
-
for (const line of existing) {
|
|
7659
|
-
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
7660
|
-
}
|
|
7873
|
+
this.broadcastConversationLines(sessionId, existing);
|
|
7661
7874
|
}
|
|
7662
7875
|
} catch {
|
|
7663
7876
|
}
|
|
@@ -7758,10 +7971,7 @@ var StreamerServer = class {
|
|
|
7758
7971
|
try {
|
|
7759
7972
|
const existing = readFileSync6(candidatePath, "utf8").split("\n").filter(Boolean);
|
|
7760
7973
|
if (existing.length > 0) {
|
|
7761
|
-
this.
|
|
7762
|
-
for (const line of existing) {
|
|
7763
|
-
this.wsHub.broadcast({ type: "conversation_event", sessionId, line });
|
|
7764
|
-
}
|
|
7974
|
+
this.broadcastConversationLines(sessionId, existing);
|
|
7765
7975
|
}
|
|
7766
7976
|
} catch {
|
|
7767
7977
|
}
|