alvin-bot 5.3.0 → 5.5.0

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.
@@ -17,13 +17,113 @@ import { prepareForExecution, handleStartupCatchup, calculateNextRunFrom, } from
17
17
  import { resolveJobByNameOrId } from "./cron-resolver.js";
18
18
  import { bootWasExpectedRestart } from "./watchdog.js";
19
19
  // ── Storage ─────────────────────────────────────────────
20
+ /** Allowed job types — must stay in sync with the JobType union above. */
21
+ const ALLOWED_JOB_TYPES = new Set(["reminder", "shell", "ai-query", "http", "message"]);
22
+ /**
23
+ * M4: Per-entry structural validation for a raw parsed cron-jobs.json entry.
24
+ *
25
+ * Rules:
26
+ * - Must be a non-null object
27
+ * - `id`, `name`, `schedule`, `createdBy` must be non-empty strings
28
+ * - `type` must be one of the allowed JobType values
29
+ * - `payload` must be an object (not null)
30
+ * - `target` must be an object with `platform` (string) and `chatId` (string)
31
+ * - `enabled` must be a boolean
32
+ * - `createdAt`, `runCount` must be numbers
33
+ * - `oneShot` must be a boolean
34
+ * - Nullable fields (lastRunAt, lastResult, lastError, nextRunAt) can be
35
+ * null or their expected types — lenient check, just must not be undefined
36
+ *
37
+ * Returns a validated CronJob (cast) on success, null on failure.
38
+ * Logs a calm warning for every skipped entry.
39
+ */
40
+ function validateCronJobEntry(raw, index) {
41
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
42
+ console.warn(`[cron] skipping entry #${index}: not an object`);
43
+ return null;
44
+ }
45
+ const e = raw;
46
+ if (typeof e.id !== "string" || !e.id) {
47
+ console.warn(`[cron] skipping entry #${index}: missing or invalid 'id'`);
48
+ return null;
49
+ }
50
+ if (typeof e.name !== "string" || !e.name) {
51
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): missing or invalid 'name'`);
52
+ return null;
53
+ }
54
+ if (typeof e.type !== "string" || !ALLOWED_JOB_TYPES.has(e.type)) {
55
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): unknown or missing job type '${e.type}'`);
56
+ return null;
57
+ }
58
+ if (typeof e.schedule !== "string" || !e.schedule) {
59
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): missing or invalid 'schedule'`);
60
+ return null;
61
+ }
62
+ if (!e.payload || typeof e.payload !== "object" || Array.isArray(e.payload)) {
63
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'payload' must be an object`);
64
+ return null;
65
+ }
66
+ if (!e.target || typeof e.target !== "object" || Array.isArray(e.target)) {
67
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'target' must be an object`);
68
+ return null;
69
+ }
70
+ const t = e.target;
71
+ if (typeof t.platform !== "string" || typeof t.chatId !== "string") {
72
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'target.platform' and 'target.chatId' must be strings`);
73
+ return null;
74
+ }
75
+ if (typeof e.enabled !== "boolean") {
76
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'enabled' must be a boolean`);
77
+ return null;
78
+ }
79
+ if (typeof e.createdAt !== "number") {
80
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'createdAt' must be a number`);
81
+ return null;
82
+ }
83
+ if (typeof e.runCount !== "number") {
84
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'runCount' must be a number`);
85
+ return null;
86
+ }
87
+ if (typeof e.oneShot !== "boolean") {
88
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'oneShot' must be a boolean`);
89
+ return null;
90
+ }
91
+ // Lenient nullable fields
92
+ if (e.createdBy !== undefined && typeof e.createdBy !== "string") {
93
+ console.warn(`[cron] skipping entry #${index} (id=${e.id}): 'createdBy' must be a string`);
94
+ return null;
95
+ }
96
+ return raw;
97
+ }
20
98
  function loadJobs() {
99
+ let raw;
21
100
  try {
22
- return JSON.parse(fs.readFileSync(CRON_FILE, "utf-8"));
101
+ raw = fs.readFileSync(CRON_FILE, "utf-8");
23
102
  }
24
103
  catch {
25
104
  return [];
26
105
  }
106
+ let parsed;
107
+ try {
108
+ parsed = JSON.parse(raw);
109
+ }
110
+ catch (err) {
111
+ console.warn("[cron] cron-jobs.json is not valid JSON — starting with empty job list:", err instanceof Error ? err.message : String(err));
112
+ return [];
113
+ }
114
+ if (!Array.isArray(parsed)) {
115
+ console.warn("[cron] cron-jobs.json root is not an array — starting with empty job list");
116
+ return [];
117
+ }
118
+ // M4: Per-entry validation — one bad entry must NOT crash the whole list
119
+ const jobs = [];
120
+ for (let i = 0; i < parsed.length; i++) {
121
+ const validated = validateCronJobEntry(parsed[i], i);
122
+ if (validated !== null) {
123
+ jobs.push(validated);
124
+ }
125
+ }
126
+ return jobs;
27
127
  }
28
128
  function saveJobs(jobs) {
29
129
  const dir = dirname(CRON_FILE);
@@ -54,27 +154,87 @@ function nextCronRun(expression, after = new Date()) {
54
154
  if (parts.length !== 5)
55
155
  return null;
56
156
  const [minExpr, hourExpr, dayExpr, monthExpr, weekdayExpr] = parts;
57
- function parseField(expr, min, max) {
58
- if (expr === "*")
59
- return Array.from({ length: max - min + 1 }, (_, i) => i + min);
60
- if (expr.includes("/")) {
61
- const [, step] = expr.split("/");
62
- const s = parseInt(step);
63
- return Array.from({ length: max - min + 1 }, (_, i) => i + min).filter(v => v % s === 0);
157
+ /**
158
+ * Parse a single cron field token (no commas — commas handled by parseField).
159
+ * Supports: `*`, `a`, `a-b`, `a-b/s`, `*\/s`, `a/s`.
160
+ * Returns null for invalid/garbage tokens.
161
+ */
162
+ function parseFieldToken(token, min, max) {
163
+ const fullRange = () => Array.from({ length: max - min + 1 }, (_, i) => i + min);
164
+ if (token.includes("/")) {
165
+ const slashIdx = token.indexOf("/");
166
+ const basePart = token.slice(0, slashIdx);
167
+ const stepPart = token.slice(slashIdx + 1);
168
+ const step = parseInt(stepPart, 10);
169
+ if (!Number.isFinite(step) || step <= 0)
170
+ return null;
171
+ let base;
172
+ if (basePart === "*") {
173
+ base = fullRange();
174
+ }
175
+ else if (basePart.includes("-")) {
176
+ const dashParts = basePart.split("-");
177
+ if (dashParts.length !== 2)
178
+ return null;
179
+ const a = parseInt(dashParts[0], 10);
180
+ const b = parseInt(dashParts[1], 10);
181
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
182
+ return null;
183
+ base = Array.from({ length: b - a + 1 }, (_, i) => i + a);
184
+ }
185
+ else {
186
+ const a = parseInt(basePart, 10);
187
+ if (!Number.isFinite(a) || a < min || a > max)
188
+ return null;
189
+ base = [a];
190
+ }
191
+ const baseStart = base[0];
192
+ return base.filter((v) => (v - baseStart) % step === 0);
64
193
  }
65
- if (expr.includes(","))
66
- return expr.split(",").map(Number);
67
- if (expr.includes("-")) {
68
- const [a, b] = expr.split("-").map(Number);
194
+ if (token === "*")
195
+ return fullRange();
196
+ if (token.includes("-")) {
197
+ const dashParts = token.split("-");
198
+ if (dashParts.length !== 2)
199
+ return null;
200
+ const a = parseInt(dashParts[0], 10);
201
+ const b = parseInt(dashParts[1], 10);
202
+ if (!Number.isFinite(a) || !Number.isFinite(b) || a > b || a < min || b > max)
203
+ return null;
69
204
  return Array.from({ length: b - a + 1 }, (_, i) => i + a);
70
205
  }
71
- return [parseInt(expr)];
206
+ const v = parseInt(token, 10);
207
+ if (!Number.isFinite(v) || v < min || v > max)
208
+ return null;
209
+ return [v];
210
+ }
211
+ /**
212
+ * Parse a cron field expression (may contain commas) into a sorted array of valid integers.
213
+ * Returns null if any token is invalid/garbage (the expression is rejected).
214
+ */
215
+ function parseField(expr, min, max) {
216
+ const tokens = expr.split(",").filter((t) => t.length > 0);
217
+ if (tokens.length === 0)
218
+ return null;
219
+ const result = new Set();
220
+ for (const token of tokens) {
221
+ const vals = parseFieldToken(token, min, max);
222
+ if (vals === null)
223
+ return null;
224
+ for (const v of vals)
225
+ result.add(v);
226
+ }
227
+ const arr = [...result].sort((a, b) => a - b);
228
+ return arr.length > 0 ? arr : null;
72
229
  }
73
230
  const minutes = parseField(minExpr, 0, 59);
74
231
  const hours = parseField(hourExpr, 0, 23);
75
232
  const days = parseField(dayExpr, 1, 31);
76
233
  const months = parseField(monthExpr, 1, 12);
77
234
  const weekdays = parseField(weekdayExpr, 0, 6); // 0=Sun
235
+ // Any field returning null means the expression is invalid — reject it
236
+ if (!minutes || !hours || !days || !months || !weekdays)
237
+ return null;
78
238
  // Search forward up to 366 days
79
239
  const candidate = new Date(after);
80
240
  candidate.setSeconds(0, 0);
@@ -112,6 +272,10 @@ let notifyCallback = null;
112
272
  export function setNotifyCallback(fn) {
113
273
  notifyCallback = fn;
114
274
  }
275
+ /** @internal exported for unit-test use only */
276
+ export async function runJob(job) {
277
+ return executeJob(job);
278
+ }
115
279
  async function executeJob(job) {
116
280
  try {
117
281
  switch (job.type) {
@@ -162,11 +326,36 @@ async function executeJob(job) {
162
326
  const url = job.payload.url || "";
163
327
  const method = job.payload.method || "GET";
164
328
  const headers = job.payload.headers || {};
165
- const fetchOpts = { method, headers };
329
+ // M1: SSRF guard reject private/internal destinations before fetching.
330
+ // Runs even when EXEC_SECURITY=deny (it's a separate, independent control).
331
+ // We validate EVERY redirect hop manually (redirect:"manual") so a
332
+ // public host cannot 302 us into an internal address (post-redirect SSRF).
333
+ const { assertSsrfSafe, SsrfBlockedError: SsrfBlockedErrorCron } = await import("./ssrf-guard.js");
334
+ await assertSsrfSafe(url);
335
+ const baseOpts = { method, headers };
166
336
  if (job.payload.body && method !== "GET") {
167
- fetchOpts.body = job.payload.body;
337
+ baseOpts.body = job.payload.body;
338
+ }
339
+ const MAX_CRON_REDIRECTS = 10;
340
+ let currentUrl = url;
341
+ let res;
342
+ for (let hop = 0;; hop++) {
343
+ res = await fetch(currentUrl, { ...baseOpts, redirect: "manual" });
344
+ // Not a redirect — we have the final response
345
+ if (res.status < 300 || res.status >= 400)
346
+ break;
347
+ const loc = res.headers.get("location");
348
+ if (!loc)
349
+ break; // no Location header — treat as final response
350
+ if (hop >= MAX_CRON_REDIRECTS) {
351
+ throw new SsrfBlockedErrorCron(url, `too many redirects (> ${MAX_CRON_REDIRECTS})`);
352
+ }
353
+ const next = new URL(loc, currentUrl).href;
354
+ // Re-validate each redirect target before following — closes the
355
+ // post-redirect SSRF bypass.
356
+ await assertSsrfSafe(next);
357
+ currentUrl = next;
168
358
  }
169
- const res = await fetch(url, fetchOpts);
170
359
  const text = await res.text();
171
360
  const output = `HTTP ${res.status}: ${text.slice(0, 2000)}`;
172
361
  if (notifyCallback) {
@@ -10,6 +10,9 @@ import crypto from "crypto";
10
10
  import { DELIVERY_QUEUE_FILE } from "../paths.js";
11
11
  // ── State ───────────────────────────────────────────────
12
12
  let senders = {};
13
+ /** Re-entrancy guard: prevents overlapping processQueue() invocations.
14
+ * Mirrors the `runningJobs` Set pattern used by cron.ts. */
15
+ let inFlight = false;
13
16
  // ── File I/O ────────────────────────────────────────────
14
17
  function readQueue() {
15
18
  try {
@@ -67,8 +70,24 @@ export function enqueue(channel, chatId, content, options) {
67
70
  * Process all pending entries in the queue.
68
71
  * Respects exponential backoff and max attempts.
69
72
  * Returns counts of delivered, failed, and still-pending entries.
73
+ *
74
+ * Re-entrancy guard: if a prior processQueue() call is still in-flight
75
+ * (e.g. a slow sender blocks beyond the 30s tick), the second invocation
76
+ * returns immediately with zero counts. Mirrors the runningJobs Set
77
+ * pattern used by cron.ts to guard overlapping job executions.
70
78
  */
71
79
  export async function processQueue() {
80
+ if (inFlight)
81
+ return { delivered: 0, failed: 0, pending: 0 };
82
+ inFlight = true;
83
+ try {
84
+ return await _processQueueInner();
85
+ }
86
+ finally {
87
+ inFlight = false;
88
+ }
89
+ }
90
+ async function _processQueueInner() {
72
91
  const queue = readQueue();
73
92
  const now = Date.now();
74
93
  let delivered = 0;
@@ -40,11 +40,8 @@ function loadSqlite() {
40
40
  }
41
41
  catch (err) {
42
42
  sqliteLoadError = err instanceof Error ? err : new Error(String(err));
43
- console.warn("⚠️ better-sqlite3 native binary unavailable — embeddings disabled. " +
44
- "Bot continues without semantic memory search. Fix: rebuild deps with " +
45
- "`cd $(npm root -g)/alvin-bot && npm rebuild better-sqlite3` or reinstall " +
46
- "alvin-bot. Underlying error: " +
47
- sqliteLoadError.message);
43
+ console.log("ℹ️ Semantic memory (better-sqlite3) unavailable — using keyword search (FTS5). " +
44
+ "Reinstall or run `npm rebuild better-sqlite3` to enable.");
48
45
  return null;
49
46
  }
50
47
  }
@@ -26,6 +26,10 @@ export function readEnv() {
26
26
  }
27
27
  /** Upsert a key=value pair in the env file, preserving all other lines. */
28
28
  export function writeEnvVar(key, value) {
29
+ // M6 (centralized): reject values containing newline characters — they allow
30
+ // injecting extra .env lines (e.g. value="good\nEVIL=injected").
31
+ if (/[\n\r]/.test(value))
32
+ throw new Error("env value must not contain newline characters");
29
33
  let content = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "";
30
34
  const regex = new RegExp(`^${key}=.*$`, "m");
31
35
  if (regex.test(content)) {
@@ -35,7 +35,7 @@ try {
35
35
  soulContent = readFileSync(SOUL_FILE, "utf-8");
36
36
  }
37
37
  catch {
38
- console.warn("SOUL.md not found — using default personality");
38
+ console.log("ℹ️ soul.md not found — using built-in default personality. Create ~/.alvin-bot/soul.md to customize.");
39
39
  }
40
40
  loadStandingOrders();
41
41
  /** Base system prompt — adapts to user language */
@@ -49,9 +49,14 @@ const SDK_ADDON = `When you run commands or edit files, briefly explain what you
49
49
  /**
50
50
  * Stage 1 of Fix #17 — async sub-agents.
51
51
  *
52
- * Tells Claude to use the SDK's `run_in_background` flag for long-running
53
- * Agent tool calls so the main Telegram session doesn't stay locked for
54
- * 10+ minutes while sub-agents crawl the web, run audits, or build reports.
52
+ * Tells Claude that `mcp__alvin__dispatch_agent` is the ONLY sanctioned
53
+ * path for long-running work on chat platforms. The built-in Task/Agent
54
+ * tool is explicitly disallowed for anything that takes more than ~30 s
55
+ * because it blocks the session: isProcessing stays true, the typing
56
+ * indicator keeps firing, and the user cannot send a new message until
57
+ * the sub-agent finishes. Path A (mcp__alvin__dispatch_agent) spawns a
58
+ * truly detached subprocess and returns in milliseconds so the session
59
+ * is freed immediately.
55
60
  *
56
61
  * Only injected into the prompt when isSDK === true (non-SDK providers
57
62
  * have no Agent tool). The bot's async-agent-watcher (Stage 2) picks up
@@ -63,34 +68,29 @@ const SDK_ADDON = `When you run commands or edit files, briefly explain what you
63
68
  */
64
69
  const BACKGROUND_SUBAGENT_HINT = `## ⚠️ CRITICAL: Background Sub-Agents on Telegram/WhatsApp/Slack
65
70
 
66
- **THE RULE (v4.13 update)**: For ANY long-running work (research,
67
- audits, multi-step analysis, >30 seconds), you MUST prefer the
68
- \`mcp__alvin__dispatch_agent\` tool over the built-in \`Task\` tool.
71
+ **THE RULE**: For ANY long-running work (research, audits, multi-step
72
+ analysis, >30 seconds), you MUST use \`mcp__alvin__dispatch_agent\`.
73
+ The built-in \`Task\`/\`Agent\` tool is BANNED for long work on chat
74
+ platforms (Telegram/WhatsApp/Slack/Discord). Do not use it for anything
75
+ that takes more than ~30 seconds.
69
76
 
70
- **Why alvin_dispatch over Task(run_in_background: true)?**
77
+ **Why mcp__alvin__dispatch_agent is the ONLY background path:**
71
78
 
72
- The built-in Task tool (even with \`run_in_background: true\`) keeps
73
- the sub-agent tied to THIS session's CLI subprocess. If the user sends
74
- me a new message mid-work, aborting this session would kill the
75
- sub-agent half-way through and leave garbage partial output. Alvin
76
- learned this the hard way (v4.12.1–v4.12.3 all got this wrong).
79
+ The built-in Task/Agent tool even with \`run_in_background: true\`
80
+ keeps the sub-agent tied to THIS session's CLI subprocess. The session
81
+ stays blocked: \`isProcessing\` remains true, the typing indicator
82
+ keeps firing, and the user's next message is queued until the
83
+ synchronous Task finishes. There is no way to free the session while a
84
+ built-in Task is running — that is a hard platform constraint.
77
85
 
78
86
  \`mcp__alvin__dispatch_agent\` spawns a COMPLETELY INDEPENDENT
79
87
  \`claude -p\` subprocess with its own PID, own process group. It
80
- survives ANY abort of this session. The user can interrupt me freely
81
- and keep chatting while the sub-agent finishes cleanly. The bot
82
- delivers the result as a separate message when ready.
88
+ returns in milliseconds, the session is freed immediately, and the user
89
+ can keep chatting. The bot delivers the result as a separate message
90
+ when ready.
83
91
 
84
92
  **THIS IS THE SINGLE MOST IMPORTANT RULE FOR USER RESPONSIVENESS.**
85
93
 
86
- **Why it matters**: During a synchronous Agent tool call the parent
87
- session has no way to know the sub-agent is still working. It appears
88
- hung. After a long silence the session can be aborted, destroying the
89
- work. Using \`run_in_background: true\` solves this: the tool returns
90
- an \`agentId\` + \`outputFile\` path IMMEDIATELY, your turn ends in
91
- seconds, the user can keep chatting with me, and the bot automatically
92
- delivers the sub-agent's final result as a separate message when ready.
93
-
94
94
  **Decision tree** (apply every time you consider any sub-agent tool):
95
95
 
96
96
  Does the task involve ANY of the following?
@@ -103,16 +103,11 @@ delivers the sub-agent's final result as a separate message when ready.
103
103
  • Crawling, scraping, or fetching multiple resources
104
104
  • Research across multiple sources or domains
105
105
 
106
- YES → use \`mcp__alvin__dispatch_agent\` (truly detached, preferred)
106
+ YES → use \`mcp__alvin__dispatch_agent\` (the ONLY sanctioned path)
107
107
  NO → foreground is fine (single quick sub-query under 30s, answer
108
108
  yourself if possible)
109
109
 
110
- NOTE: The built-in Task tool with run_in_background: true still works
111
- but is now deprecated on Telegram/Slack/Discord/WhatsApp because it
112
- ties sub-agent lifetime to this session. Only use Task directly when
113
- you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
114
-
115
- **Examples where you MUST use \`run_in_background: true\`:**
110
+ **Examples where you MUST use \`mcp__alvin__dispatch_agent\`:**
116
111
  - ANY audit (SEO, security, code quality, performance, accessibility, GEO)
117
112
  - Research visiting more than 1-2 web pages
118
113
  - Code reviews on more than a single file
@@ -122,12 +117,12 @@ you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
122
117
  - Long data-processing jobs
123
118
  - Anything involving the word "analyze", "audit", "review", "scan", "research"
124
119
 
125
- **Examples where foreground is fine:**
120
+ **Examples where foreground is fine (no sub-agent needed):**
126
121
  - "Read this file and summarize it" (single file, <10s)
127
122
  - "What's 2+2?" (no sub-agent needed — answer yourself)
128
123
  - "Check if package.json has foo" (one quick tool call)
129
124
 
130
- **After launching a background agent (either tool), you MUST:**
125
+ **After calling \`mcp__alvin__dispatch_agent\` you MUST:**
131
126
  1. Tell the user in ONE short sentence what you kicked off.
132
127
  Example: "Starting SEO audit for example.com in the background —
133
128
  I'll send the report when it's done."
@@ -135,6 +130,14 @@ you explicitly need the sub-agent's result IN THIS SAME TURN (rare).
135
130
  3. The bot will deliver the result as a separate message when ready.
136
131
  You don't need to poll the outputFile proactively.
137
132
 
133
+ Only say "running in the background — you can keep chatting" if you
134
+ actually called \`mcp__alvin__dispatch_agent\` in this turn. If the
135
+ task ran inline (foreground tool calls, no dispatch), it blocked the
136
+ session and the user could NOT chat during it — do NOT claim otherwise.
137
+ If a task was too long for foreground but you didn't dispatch it, tell
138
+ the user truthfully: "That ran inline and took a while. Your messages
139
+ were queued until it finished."
140
+
138
141
  **For PARALLEL dispatch** (e.g. user says "research X and Y in parallel"):
139
142
  Call \`mcp__alvin__dispatch_agent\` multiple times in the SAME assistant
140
143
  turn, once per sub-task. Each returns its own agentId immediately. Your
@@ -145,10 +148,10 @@ If the user asks "is it done yet?" before the bot delivers the result,
145
148
  you MAY read the agent's \`outputFile\` (from the original tool result)
146
149
  using the Read tool to peek at progress — but don't block on it.
147
150
 
148
- **Never** call the Agent/Task tool without \`run_in_background: true\`
149
- for anything you're not 100% sure completes in under 30 seconds. The
150
- cost of unnecessary background mode is zero. The cost of blocking the
151
- Telegram user for 20 minutes on a synchronous call is very high.`;
151
+ **Never** call the built-in Agent/Task tool for anything you're not
152
+ 100% sure completes in under 30 seconds. The cost of dispatch is zero.
153
+ The cost of blocking the chat user for 20 minutes on a synchronous
154
+ inline Task is very high.`;
152
155
  /**
153
156
  * Self-Awareness Core — Dynamic introspection block.
154
157
  *
@@ -142,6 +142,12 @@ export function loadPersistedSessions() {
142
142
  if (!raw_parsed || typeof raw_parsed !== "object")
143
143
  return 0;
144
144
  // v4.12.0 — Detect envelope format vs legacy v4.11.0 flat format
145
+ // M4: Validate the top-level shape before trusting any field.
146
+ if (Array.isArray(raw_parsed)) {
147
+ // An array at root is not a valid sessions file
148
+ console.warn("⚠️ session-persistence: sessions file contains an array at root, starting fresh");
149
+ return 0;
150
+ }
145
151
  let parsed;
146
152
  let tgWorkspaces = {};
147
153
  if (raw_parsed &&
@@ -149,11 +155,22 @@ export function loadPersistedSessions() {
149
155
  "version" in raw_parsed &&
150
156
  "sessions" in raw_parsed) {
151
157
  const env = raw_parsed;
152
- parsed = env.sessions ?? {};
153
- tgWorkspaces = env.telegramWorkspaces ?? {};
158
+ // M4: sessions field must be a non-null object; degrade gracefully if tampered
159
+ if (!env.sessions || typeof env.sessions !== "object" || Array.isArray(env.sessions)) {
160
+ console.warn("⚠️ session-persistence: 'sessions' field is not an object, starting fresh");
161
+ return 0;
162
+ }
163
+ parsed = env.sessions;
164
+ tgWorkspaces = (env.telegramWorkspaces && typeof env.telegramWorkspaces === "object" && !Array.isArray(env.telegramWorkspaces))
165
+ ? env.telegramWorkspaces
166
+ : {};
154
167
  }
155
168
  else {
156
- // Legacy flat format (v4.11.0)
169
+ // Legacy flat format (v4.11.0) — must also be an object
170
+ if (!raw_parsed || typeof raw_parsed !== "object") {
171
+ console.warn("⚠️ session-persistence: sessions file is not a valid object, starting fresh");
172
+ return 0;
173
+ }
157
174
  parsed = raw_parsed;
158
175
  }
159
176
  // Rehydrate Telegram workspace map
@@ -184,6 +201,7 @@ export function loadPersistedSessions() {
184
201
  _qHandle: null,
185
202
  _steerChannel: null,
186
203
  _steerAckSentThisTurn: false,
204
+ _turnId: null,
187
205
  lastActivity: persisted.lastActivity ?? Date.now(),
188
206
  startedAt: persisted.startedAt ?? Date.now(),
189
207
  totalCost: persisted.totalCost ?? 0,
@@ -84,6 +84,7 @@ export function getSession(key) {
84
84
  _qHandle: null,
85
85
  _steerChannel: null,
86
86
  _steerAckSentThisTurn: false,
87
+ _turnId: null,
87
88
  lastActivity: Date.now(),
88
89
  startedAt: Date.now(),
89
90
  totalCost: 0,
@@ -120,6 +121,8 @@ export function getSession(key) {
120
121
  session._steerChannel = null;
121
122
  if (session._steerAckSentThisTurn === undefined)
122
123
  session._steerAckSentThisTurn = false;
124
+ if (session._turnId === undefined)
125
+ session._turnId = null;
123
126
  }
124
127
  return session;
125
128
  }