hovclaw 0.1.0 → 0.1.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.
@@ -1,10 +1,11 @@
1
- import { D as getDefaultFileConfig, F as saveConfigFile, L as writeOpenClawMirror, N as loadFileConfig, P as resolveTelegramAccountConfig, S as config, _ as resolveModelAlias, a as TelegramChannel, b as parseConnectParams, c as composeSessionKey, d as extractAssistantText, f as toUserFacingAssistantError, g as parseModelRef, h as listConfiguredModelRefs, i as HovClawDb, j as loadConfig, k as hasConfigFile, l as ensureWorkspaceBootstrapForConfig, m as loadSkill, n as LocalHostRuntime, o as DiscordChannel, p as listAvailableSkills, r as ContainerRuntime, s as PiAgentManager, t as createTools, u as extractAssistantError, v as logger, w as ensureConfigFromLegacyEnv, x as parseGatewayFrame, y as PROTOCOL_VERSION } from "./hovclaw.js";
1
+ import { A as ensureConfigFromLegacyEnv, B as resolveTelegramAccountConfig, C as resolveModelAlias, D as parseGatewayFrame, E as parseConnectParams, F as hasConfigFile, L as loadConfig, N as getDefaultFileConfig, O as config, S as parseModelRef, T as PROTOCOL_VERSION, U as writeOpenClawMirror, V as saveConfigFile, _ as extractAssistantText, a as LocalHostRuntime, b as loadSkill, c as redactSensitiveData, d as PiAgentManager, f as composeSessionKey, g as extractAssistantError, h as resolveAgentWorkspaceDir, i as createTools, l as TelegramChannel, m as ensureWorkspaceBootstrapForConfig, o as ContainerRuntime, p as WORKSPACE_CONTEXT_FILE_ORDER, r as TelegramPairingStore, s as HovClawDb, t as requestDaemonRestartFromCurrentProcess, u as DiscordChannel, v as toUserFacingAssistantError, w as logger, x as listConfiguredModelRefs, y as listAvailableSkills, z as loadFileConfig } from "./hovclaw.js";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ZodError, z } from "zod";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import WebSocket, { WebSocketServer } from "ws";
8
+ import fs$1 from "node:fs/promises";
8
9
  import { createServer } from "node:http";
9
10
  import { spawnSync } from "node:child_process";
10
11
  import { Cron } from "croner";
@@ -157,98 +158,240 @@ function buildModelCatalog(aliases) {
157
158
  }
158
159
 
159
160
  //#endregion
160
- //#region src/channels/telegram-policy.ts
161
- function toIdSet(values) {
162
- return new Set((values ?? []).map((value) => String(value).trim().toLowerCase()).filter(Boolean));
161
+ //#region src/channels/command-auth.ts
162
+ function normalizeAllowFromEntry(value) {
163
+ return String(value).trim().toLowerCase();
163
164
  }
164
- function splitThread(chatId) {
165
- const [baseChatIdRaw, threadIdRaw] = chatId.split("#");
166
- return {
167
- baseChatId: baseChatIdRaw ?? chatId,
168
- ...threadIdRaw ? { threadId: threadIdRaw } : {}
169
- };
165
+ function senderCandidates(msg) {
166
+ const candidates = /* @__PURE__ */ new Set();
167
+ const userId = msg.userId.trim();
168
+ if (userId) candidates.add(userId.toLowerCase());
169
+ const displayName = msg.displayName.trim();
170
+ if (displayName.startsWith("@")) candidates.add(displayName.toLowerCase());
171
+ return Array.from(candidates);
170
172
  }
171
- function isDirectMessage(msg) {
172
- return msg.peer?.kind === "direct" || !msg.chatId.startsWith("-") && !msg.chatId.includes("#");
173
+ function resolveCommandsAllowFrom(config, channel) {
174
+ const entries = config.commands.allowFrom;
175
+ if (Object.keys(entries).length === 0) return null;
176
+ const providerSpecific = entries[channel];
177
+ if (Array.isArray(providerSpecific)) return providerSpecific;
178
+ const global = entries["*"];
179
+ if (Array.isArray(global)) return global;
180
+ return [];
173
181
  }
174
- function containsMention(text, assistantName) {
175
- const normalized = assistantName.trim().toLowerCase();
176
- if (!normalized) return false;
177
- return text.toLowerCase().includes(`@${normalized}`);
178
- }
179
- function isAllowListed(msg, allowSet) {
180
- if (allowSet.has("*")) return true;
181
- const userId = msg.userId.trim().toLowerCase();
182
- if (userId && allowSet.has(userId)) return true;
183
- const displayName = msg.displayName.trim().replace(/^@/, "").toLowerCase();
184
- if (displayName && allowSet.has(displayName)) return true;
182
+ function isCommandAuthorized(config, msg) {
183
+ const allowFrom = resolveCommandsAllowFrom(config, msg.channel);
184
+ if (allowFrom === null) return true;
185
+ const normalizedAllow = new Set(allowFrom.map((entry) => normalizeAllowFromEntry(entry)));
186
+ if (normalizedAllow.has("*")) return true;
187
+ for (const candidate of senderCandidates(msg)) if (normalizedAllow.has(candidate)) return true;
185
188
  return false;
186
189
  }
187
- function evaluateTelegramPolicy(params) {
188
- const { config, msg, pairingStore } = params;
189
- const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
190
- const account = config.channels.telegram.accounts[accountId] ?? config.channels.telegram.accounts[config.channels.telegram.defaultAccountId] ?? config.channels.telegram.accounts.default;
191
- if (!account || account.enabled === false) return {
192
- allowed: false,
193
- reason: "account-disabled"
194
- };
195
- const direct = isDirectMessage(msg);
196
- const allowFromSet = toIdSet(account.allowFrom);
197
- if (direct) {
198
- const dmPolicy = account.dmPolicy ?? "pairing";
199
- if (dmPolicy === "disabled") return {
200
- allowed: false,
201
- reason: "dm-disabled"
202
- };
203
- if (dmPolicy === "open") return { allowed: true };
204
- if (dmPolicy === "allowlist") {
205
- if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
206
- return {
207
- allowed: false,
208
- reason: "dm-not-allowlisted"
209
- };
210
- }
211
- if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
212
- return {
213
- allowed: false,
214
- reason: "pairing-required",
215
- pairingCode: pairingStore.ensurePendingCode(accountId, msg.userId)
216
- };
190
+
191
+ //#endregion
192
+ //#region src/channels/commands-registry.ts
193
+ const TELEGRAM_COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/;
194
+ const TELEGRAM_COMMAND_LIMIT = 100;
195
+ const BASE_COMMANDS = [
196
+ {
197
+ key: "help",
198
+ nativeName: "help",
199
+ description: "Show available commands"
200
+ },
201
+ {
202
+ key: "commands",
203
+ nativeName: "commands",
204
+ description: "List all slash commands"
205
+ },
206
+ {
207
+ key: "status",
208
+ nativeName: "status",
209
+ description: "Show runtime status"
210
+ },
211
+ {
212
+ key: "whoami",
213
+ nativeName: "whoami",
214
+ description: "Show your sender id"
215
+ },
216
+ {
217
+ key: "context",
218
+ nativeName: "context",
219
+ description: "Show prompt/workspace context info"
220
+ },
221
+ {
222
+ key: "model",
223
+ nativeName: "model",
224
+ description: "Show or set interactive model"
225
+ },
226
+ {
227
+ key: "models",
228
+ nativeName: "models",
229
+ description: "List available models"
230
+ },
231
+ {
232
+ key: "skill",
233
+ nativeName: "skill",
234
+ description: "Run a skill by name"
235
+ },
236
+ {
237
+ key: "skills",
238
+ nativeName: "skills",
239
+ description: "List installed skills"
240
+ },
241
+ {
242
+ key: "identity",
243
+ nativeName: "identity",
244
+ description: "View or update IDENTITY.md"
245
+ },
246
+ {
247
+ key: "soul",
248
+ nativeName: "soul",
249
+ description: "View or update SOUL.md"
250
+ },
251
+ {
252
+ key: "pair",
253
+ nativeName: "pair",
254
+ description: "Show pairing approval hint"
255
+ },
256
+ {
257
+ key: "new",
258
+ nativeName: "new",
259
+ description: "Start a fresh conversation session"
260
+ },
261
+ {
262
+ key: "reset",
263
+ nativeName: "reset",
264
+ description: "Reset current conversation session"
265
+ },
266
+ {
267
+ key: "think",
268
+ nativeName: "think",
269
+ description: "Set per-task or default thinking level"
270
+ },
271
+ {
272
+ key: "verbose",
273
+ nativeName: "verbose",
274
+ description: "Toggle verbose mode (hint)"
275
+ },
276
+ {
277
+ key: "reasoning",
278
+ nativeName: "reasoning",
279
+ description: "Toggle reasoning visibility (hint)"
280
+ },
281
+ {
282
+ key: "stop",
283
+ nativeName: "stop",
284
+ description: "Stop current run (not yet supported)"
285
+ },
286
+ {
287
+ key: "config",
288
+ nativeName: "config",
289
+ description: "View or edit runtime config (if enabled)"
290
+ },
291
+ {
292
+ key: "debug",
293
+ nativeName: "debug",
294
+ description: "Inspect or change debug state (if enabled)"
295
+ },
296
+ {
297
+ key: "bash",
298
+ nativeName: "bash",
299
+ description: "Run a shell command through the assistant (if enabled)"
300
+ },
301
+ {
302
+ key: "restart",
303
+ nativeName: "restart",
304
+ description: "Restart daemon (if enabled)"
217
305
  }
218
- const { baseChatId, threadId } = splitThread(msg.chatId);
219
- const groupConfig = account.groups?.[baseChatId];
220
- const topicConfig = threadId ? groupConfig?.topics?.[threadId] : void 0;
221
- if (groupConfig?.enabled === false) return {
222
- allowed: false,
223
- reason: "group-disabled"
224
- };
225
- if (topicConfig?.enabled === false) return {
226
- allowed: false,
227
- reason: "topic-disabled"
228
- };
229
- const groupPolicy = topicConfig?.groupPolicy ?? groupConfig?.groupPolicy ?? account.groupPolicy ?? "open";
230
- if (groupPolicy === "disabled") return {
231
- allowed: false,
232
- reason: "group-disabled"
233
- };
234
- if (groupPolicy === "allowlist") {
235
- if (!isAllowListed(msg, toIdSet(topicConfig?.allowFrom ?? groupConfig?.allowFrom ?? account.groupAllowFrom))) return {
236
- allowed: false,
237
- reason: "group-not-allowlisted"
238
- };
306
+ ];
307
+ function sanitizeCommandName(raw) {
308
+ return raw.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/_+/g, "_").replace(/^_+|_+$/g, "").slice(0, 32);
309
+ }
310
+ function coerceDescription(input, fallback) {
311
+ const trimmed = input.trim();
312
+ if (!trimmed) return fallback;
313
+ if (trimmed.length <= 256) return trimmed;
314
+ return `${trimmed.slice(0, 255)}…`;
315
+ }
316
+ function resolveSkillCommandName(skillName, used) {
317
+ const base = sanitizeCommandName(skillName) || "skill";
318
+ if (!used.has(base)) return base;
319
+ for (let i = 2; i < 500; i += 1) {
320
+ const suffix = `_${i}`;
321
+ const maxBaseLen = Math.max(1, 32 - suffix.length);
322
+ const candidate = `${base.slice(0, maxBaseLen)}${suffix}`;
323
+ if (!used.has(candidate)) return candidate;
239
324
  }
240
- if ((topicConfig?.requireMention ?? groupConfig?.requireMention ?? false) && !containsMention(msg.text, config.assistantName)) return {
241
- allowed: false,
242
- reason: "mention-required"
243
- };
244
- return { allowed: true };
325
+ return `skill_${used.size}`;
245
326
  }
246
- function canApproveTelegramPairing(params) {
247
- const { config, msg } = params;
248
- const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
249
- const account = config.channels.telegram.accounts[accountId] ?? config.channels.telegram.accounts[config.channels.telegram.defaultAccountId] ?? config.channels.telegram.accounts.default;
250
- if (!account) return false;
251
- return isAllowListed(msg, toIdSet(account.allowFrom));
327
+ function resolveNativeEnabled(config, accountId) {
328
+ const accountMode = config.channels.telegram.accounts[accountId]?.commands;
329
+ if (typeof accountMode === "boolean") return accountMode;
330
+ if (typeof config.commands.native === "boolean") return config.commands.native;
331
+ return true;
332
+ }
333
+ function resolveNativeSkillsEnabled(config) {
334
+ if (typeof config.commands.nativeSkills === "boolean") return config.commands.nativeSkills;
335
+ return true;
336
+ }
337
+ function listSkillCommandSpecs(config) {
338
+ const installed = listAvailableSkills();
339
+ const used = /* @__PURE__ */ new Set();
340
+ for (const command of BASE_COMMANDS) used.add(command.nativeName);
341
+ const specs = [];
342
+ for (const skillName of installed) {
343
+ const loaded = loadSkill(skillName);
344
+ if (!loaded) continue;
345
+ const commandName = resolveSkillCommandName(skillName, used);
346
+ if (!TELEGRAM_COMMAND_NAME_RE.test(commandName)) continue;
347
+ used.add(commandName);
348
+ specs.push({
349
+ name: commandName,
350
+ skillName,
351
+ description: coerceDescription(loaded.frontmatter.description ?? `Run ${skillName} skill`, `Run ${skillName} skill`)
352
+ });
353
+ }
354
+ return specs;
355
+ }
356
+ function listCustomCommandSpecs(config, accountId) {
357
+ const account = config.channels.telegram.accounts[accountId];
358
+ if (!account) return [];
359
+ const commands = Array.isArray(account.customCommands) ? account.customCommands : [];
360
+ const out = [];
361
+ for (const entry of commands) {
362
+ const normalized = sanitizeCommandName(entry.command);
363
+ if (!TELEGRAM_COMMAND_NAME_RE.test(normalized)) continue;
364
+ out.push({
365
+ command: normalized,
366
+ description: coerceDescription(entry.description, `Run /${normalized}`)
367
+ });
368
+ }
369
+ return out;
370
+ }
371
+ function listTelegramNativeCommandSpecs(config, accountId) {
372
+ if (!resolveNativeEnabled(config, accountId)) return [];
373
+ const out = BASE_COMMANDS.map((command) => ({
374
+ command: command.nativeName,
375
+ description: command.description
376
+ }));
377
+ if (resolveNativeSkillsEnabled(config)) out.push(...listSkillCommandSpecs(config).map((skill) => ({
378
+ command: skill.name,
379
+ description: skill.description
380
+ })));
381
+ out.push(...listCustomCommandSpecs(config, accountId));
382
+ const deduped = [];
383
+ const seen = /* @__PURE__ */ new Set();
384
+ for (const entry of out) {
385
+ if (!TELEGRAM_COMMAND_NAME_RE.test(entry.command)) continue;
386
+ if (seen.has(entry.command)) continue;
387
+ seen.add(entry.command);
388
+ deduped.push(entry);
389
+ if (deduped.length >= TELEGRAM_COMMAND_LIMIT) break;
390
+ }
391
+ return deduped;
392
+ }
393
+ function listBaseCommands() {
394
+ return [...BASE_COMMANDS];
252
395
  }
253
396
 
254
397
  //#endregion
@@ -339,11 +482,28 @@ function buildModelsKeyboard(params) {
339
482
 
340
483
  //#endregion
341
484
  //#region src/channels/telegram-commands.ts
485
+ const SKILL_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
486
+ const DEBUG_LOG_LEVELS = new Set([
487
+ "trace",
488
+ "debug",
489
+ "info",
490
+ "warn",
491
+ "error",
492
+ "fatal",
493
+ "silent"
494
+ ]);
495
+ const BLOCKED_CONFIG_PATH_SEGMENTS = new Set([
496
+ "__proto__",
497
+ "prototype",
498
+ "constructor"
499
+ ]);
342
500
  function extractCommand(text) {
343
501
  const trimmed = text.trim();
344
502
  if (!trimmed.startsWith("/")) return null;
345
503
  const segments = trimmed.split(/\s+/);
346
- const command = segments[0]?.slice(1).toLowerCase();
504
+ const raw = segments[0]?.slice(1).toLowerCase();
505
+ if (!raw) return null;
506
+ const command = raw.split("@")[0]?.trim();
347
507
  if (!command) return null;
348
508
  return {
349
509
  command,
@@ -358,8 +518,125 @@ function callbackDataFromMessage(msg) {
358
518
  function setInteractiveModel(modelRef) {
359
519
  const next = loadFileConfig();
360
520
  next.models.interactive = modelRef;
521
+ persistFileConfig(next);
522
+ }
523
+ function isRecord$1(value) {
524
+ return typeof value === "object" && value !== null && !Array.isArray(value);
525
+ }
526
+ function deepMerge$1(base, patch) {
527
+ const result = { ...base };
528
+ for (const [key, value] of Object.entries(patch)) {
529
+ const existing = result[key];
530
+ if (isRecord$1(existing) && isRecord$1(value)) {
531
+ result[key] = deepMerge$1(existing, value);
532
+ continue;
533
+ }
534
+ result[key] = value;
535
+ }
536
+ return result;
537
+ }
538
+ function reloadRuntimeConfig() {
539
+ const reloaded = loadConfig();
540
+ Object.assign(config, reloaded);
541
+ logger.level = reloaded.logLevel;
542
+ }
543
+ function persistFileConfig(next) {
361
544
  saveConfigFile(next);
362
- config.models.interactive = modelRef;
545
+ reloadRuntimeConfig();
546
+ writeOpenClawMirror(config);
547
+ }
548
+ function parseConfigPath(raw) {
549
+ const pathValue = raw?.trim();
550
+ if (!pathValue) return null;
551
+ const segments = pathValue.split(".").map((segment) => segment.trim()).filter(Boolean);
552
+ if (segments.length === 0) return null;
553
+ if (segments.some((segment) => BLOCKED_CONFIG_PATH_SEGMENTS.has(segment))) return null;
554
+ return segments;
555
+ }
556
+ function readPathValue(root, segments) {
557
+ let current = root;
558
+ for (const segment of segments) {
559
+ if (Array.isArray(current)) {
560
+ const index = Number.parseInt(segment, 10);
561
+ if (!Number.isInteger(index) || index < 0 || index >= current.length) return { found: false };
562
+ current = current[index];
563
+ continue;
564
+ }
565
+ if (!isRecord$1(current) || !(segment in current)) return { found: false };
566
+ current = current[segment];
567
+ }
568
+ return {
569
+ found: true,
570
+ value: current
571
+ };
572
+ }
573
+ function writePathValue(root, segments, value) {
574
+ if (segments.length === 0) return false;
575
+ let current = root;
576
+ for (let i = 0; i < segments.length - 1; i += 1) {
577
+ const segment = segments[i];
578
+ const nextSegment = segments[i + 1];
579
+ if (!segment || !nextSegment) return false;
580
+ if (Array.isArray(current)) {
581
+ const index = Number.parseInt(segment, 10);
582
+ if (!Number.isInteger(index) || index < 0) return false;
583
+ if (current[index] === void 0) current[index] = /^[0-9]+$/.test(nextSegment) ? [] : {};
584
+ current = current[index];
585
+ continue;
586
+ }
587
+ if (!isRecord$1(current)) return false;
588
+ const existing = current[segment];
589
+ if (existing === void 0 || !isRecord$1(existing) && !Array.isArray(existing)) current[segment] = /^[0-9]+$/.test(nextSegment) ? [] : {};
590
+ current = current[segment];
591
+ }
592
+ const lastSegment = segments[segments.length - 1];
593
+ if (!lastSegment) return false;
594
+ if (Array.isArray(current)) {
595
+ const index = Number.parseInt(lastSegment, 10);
596
+ if (!Number.isInteger(index) || index < 0) return false;
597
+ current[index] = value;
598
+ return true;
599
+ }
600
+ if (!isRecord$1(current)) return false;
601
+ current[lastSegment] = value;
602
+ return true;
603
+ }
604
+ function parseConfigValue(raw) {
605
+ const trimmed = raw.trim();
606
+ if (!trimmed) return "";
607
+ try {
608
+ return JSON.parse(trimmed);
609
+ } catch {
610
+ return raw;
611
+ }
612
+ }
613
+ function formatValue(value) {
614
+ if (typeof value === "string") return value;
615
+ return JSON.stringify(value, null, 2);
616
+ }
617
+ function configSummaryText(current) {
618
+ return [
619
+ "Config summary",
620
+ `assistant.name=${current.assistant.name}`,
621
+ `models.interactive=${current.models.interactive}`,
622
+ `models.discord=${current.models.discord}`,
623
+ `models.cron=${current.models.cron}`,
624
+ `runtime.mode=${current.runtime.mode}`,
625
+ `commands.text=${current.commands.text}`,
626
+ `commands.defaultThinkingLevel=${current.commands.defaultThinkingLevel}`,
627
+ `commands.config=${current.commands.config}`,
628
+ `commands.debug=${current.commands.debug}`,
629
+ `commands.bash=${current.commands.bash}`,
630
+ `commands.restart=${current.commands.restart}`,
631
+ `channels.telegram.enabled=${current.channels.telegram.enabled}`,
632
+ `channels.discord.enabled=${current.channels.discord.enabled}`,
633
+ "",
634
+ "Usage:",
635
+ "/config get <path>",
636
+ "/config set <path> <json-or-text>",
637
+ "/config patch <json-object>",
638
+ "/config reload"
639
+ ].join("\n");
363
640
  }
364
641
  function groupedModels() {
365
642
  return buildModelCatalog(config.models.aliases).reduce((acc, entry) => {
@@ -369,9 +646,57 @@ function groupedModels() {
369
646
  return acc;
370
647
  }, {});
371
648
  }
649
+ function workspaceFilePath(agentId, fileName) {
650
+ return path.join(resolveAgentWorkspaceDir(config, agentId), fileName);
651
+ }
652
+ async function readWorkspaceFileOrEmpty(agentId, fileName) {
653
+ const filePath = workspaceFilePath(agentId, fileName);
654
+ try {
655
+ return await fs$1.readFile(filePath, "utf8");
656
+ } catch {
657
+ return "";
658
+ }
659
+ }
660
+ async function writeWorkspaceFile(agentId, fileName, content) {
661
+ const filePath = workspaceFilePath(agentId, fileName);
662
+ await fs$1.mkdir(path.dirname(filePath), { recursive: true });
663
+ await fs$1.writeFile(filePath, `${content.trimEnd()}\n`, "utf8");
664
+ }
665
+ function trimForTelegram(text, limit = 3600) {
666
+ if (text.length <= limit) return text;
667
+ return `${text.slice(0, limit)}\n\n[...truncated]`;
668
+ }
669
+ async function addSkillToAgent(agentId, skillName) {
670
+ const agentDir = path.join(config.agentsDir, agentId);
671
+ const agentPath = path.join(agentDir, "agent.json");
672
+ let parsed = {};
673
+ try {
674
+ const raw = await fs$1.readFile(agentPath, "utf8");
675
+ parsed = JSON.parse(raw);
676
+ } catch {
677
+ parsed = {};
678
+ }
679
+ const existingSkills = Array.isArray(parsed.skills) ? parsed.skills.filter((value) => typeof value === "string") : [];
680
+ if (existingSkills.includes(skillName)) return {
681
+ added: false,
682
+ path: agentPath
683
+ };
684
+ const nextSkills = [...existingSkills, skillName];
685
+ const next = {
686
+ ...parsed,
687
+ skills: nextSkills
688
+ };
689
+ await fs$1.mkdir(agentDir, { recursive: true });
690
+ await fs$1.writeFile(agentPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
691
+ return {
692
+ added: true,
693
+ path: agentPath
694
+ };
695
+ }
372
696
  async function handleModelCallback(context, callbackData) {
373
697
  const parsed = parseModelCallbackData(callbackData);
374
698
  if (!parsed) return false;
699
+ const authorized = isCommandAuthorized(config, context.msg);
375
700
  const grouped = groupedModels();
376
701
  const target = {
377
702
  channel: "telegram",
@@ -403,6 +728,12 @@ async function handleModelCallback(context, callbackData) {
403
728
  if (callbackId) await context.channel.answerCallbackQuery(callbackId);
404
729
  return true;
405
730
  }
731
+ if (!authorized) {
732
+ await context.channel.sendMessage(target, "You are not authorized to use this command.");
733
+ const callbackId = context.msg.raw?.callback_query?.id;
734
+ if (callbackId) await context.channel.answerCallbackQuery(callbackId, "Not authorized");
735
+ return true;
736
+ }
406
737
  const selectedRef = `${parsed.provider}:${parsed.model}`;
407
738
  setInteractiveModel(selectedRef);
408
739
  await context.channel.sendMessage(target, `Interactive model set to ${selectedRef}.`);
@@ -410,201 +741,575 @@ async function handleModelCallback(context, callbackData) {
410
741
  if (callbackId) await context.channel.answerCallbackQuery(callbackId, "Model updated");
411
742
  return true;
412
743
  }
744
+ function buildCommandsHelpText(accountId) {
745
+ const nativeCommands = listTelegramNativeCommandSpecs(config, accountId);
746
+ const fallbackCommands = [...listBaseCommands().map((command) => ({
747
+ command: command.nativeName,
748
+ description: command.description
749
+ })), ...listSkillCommandSpecs(config).map((command) => ({
750
+ command: command.name,
751
+ description: command.description
752
+ }))];
753
+ return ["Commands:", ...(nativeCommands.length > 0 ? nativeCommands : fallbackCommands).map((command) => `/${command.command} - ${command.description}`)].join("\n");
754
+ }
755
+ function resolveSkillPrompt(skillName, args) {
756
+ const input = args.join(" ").trim();
757
+ if (!input) return `Use the ${skillName} skill to help with the user's request.`;
758
+ return `Use the ${skillName} skill for this request: ${input}`;
759
+ }
760
+ function normalizeSkillName(raw) {
761
+ const name = raw?.trim();
762
+ if (!name) return null;
763
+ if (!SKILL_NAME_RE.test(name)) return null;
764
+ return name;
765
+ }
766
+ function parseThinkingLevel(raw) {
767
+ const value = raw?.trim().toLowerCase();
768
+ if (value === "low" || value === "medium" || value === "high") return value;
769
+ return null;
770
+ }
413
771
  async function handleTelegramCommand(context) {
414
772
  const callbackData = callbackDataFromMessage(context.msg);
415
- if (callbackData) return await handleModelCallback(context, callbackData);
773
+ if (callbackData) return { handled: await handleModelCallback(context, callbackData) };
416
774
  const parsed = extractCommand(context.msg.text);
417
- if (!parsed) return false;
775
+ if (!parsed) return { handled: false };
776
+ if (!config.commands.text) return { handled: false };
418
777
  const target = {
419
778
  channel: "telegram",
420
779
  chatId: context.msg.chatId,
421
780
  accountId: context.msg.accountId
422
781
  };
423
- if (parsed.command === "help") {
424
- await context.channel.sendMessage(target, [
425
- "Commands:",
426
- "/help",
427
- "/status",
428
- "/models",
429
- "/model [provider:model]",
430
- "/pair approve <code>",
431
- "/pair reject <code>"
432
- ].join("\n"));
433
- return true;
782
+ const authorized = isCommandAuthorized(config, context.msg);
783
+ const allowWithoutAuth = parsed.command === "help" || parsed.command === "commands";
784
+ if (!authorized && !allowWithoutAuth) {
785
+ await context.channel.sendMessage(target, "You are not authorized to use this command.");
786
+ return { handled: true };
787
+ }
788
+ if (parsed.command === "help" || parsed.command === "commands") {
789
+ const accountId = (context.msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
790
+ await context.channel.sendMessage(target, buildCommandsHelpText(accountId));
791
+ return { handled: true };
434
792
  }
435
793
  if (parsed.command === "status") {
436
794
  const diagnostics = context.channel.getDiagnostics?.() ?? {};
437
795
  await context.channel.sendMessage(target, `Status\nactiveSessions=${context.db.listSessions().length}\n${JSON.stringify(diagnostics)}`);
438
- return true;
796
+ return { handled: true };
797
+ }
798
+ if (parsed.command === "whoami") {
799
+ const lines = [
800
+ "Identity",
801
+ `channel=${context.msg.channel}`,
802
+ `chatId=${context.msg.chatId}`,
803
+ `userId=${context.msg.userId}`,
804
+ `displayName=${context.msg.displayName}`,
805
+ `agent=${context.agentId}`
806
+ ];
807
+ await context.channel.sendMessage(target, lines.join("\n"));
808
+ return { handled: true };
809
+ }
810
+ if (parsed.command === "context") {
811
+ const workspaceDir = resolveAgentWorkspaceDir(config, context.agentId);
812
+ const lines = [
813
+ `Agent: ${context.agentId}`,
814
+ `Workspace: ${workspaceDir}`,
815
+ "Context files:",
816
+ ...WORKSPACE_CONTEXT_FILE_ORDER.map((file) => `- ${file}`)
817
+ ];
818
+ await context.channel.sendMessage(target, lines.join("\n"));
819
+ return { handled: true };
439
820
  }
440
821
  if (parsed.command === "models") {
441
- const lines = buildModelCatalog(config.models.aliases).slice(0, 80).map((entry) => `- ${entry.ref}${entry.alias ? ` (alias: ${entry.alias})` : ""}`);
822
+ const lines = buildModelCatalog(config.models.aliases).slice(0, 100).map((entry) => `- ${entry.ref}${entry.alias ? ` (alias: ${entry.alias})` : ""}`);
442
823
  await context.channel.sendMessage(target, lines.length > 0 ? lines.join("\n") : "No models found.");
443
- return true;
824
+ return { handled: true };
444
825
  }
445
826
  if (parsed.command === "model") {
446
- const modelArg = parsed.args[0]?.trim();
827
+ const modelArg = parsed.args.join(" ").trim();
447
828
  if (modelArg) {
448
829
  setInteractiveModel(modelArg);
449
830
  await context.channel.sendMessage(target, `Interactive model set to ${modelArg}.`);
450
- return true;
831
+ return { handled: true };
451
832
  }
452
833
  const providerEntries = Object.entries(groupedModels()).map(([id, models]) => ({
453
834
  id,
454
835
  count: models.length
455
836
  }));
456
837
  await context.channel.sendRichMessage(target, `Current interactive model: ${config.models.interactive}`, { inlineKeyboard: providerEntries.length > 0 ? buildProviderKeyboard(providerEntries) : buildBrowseProvidersButton() });
457
- return true;
838
+ return { handled: true };
458
839
  }
459
840
  if (parsed.command === "pair") {
460
- if (!canApproveTelegramPairing({
461
- config,
462
- msg: context.msg
463
- })) {
464
- await context.channel.sendMessage(target, "Not authorized to approve pairing requests.");
465
- return true;
466
- }
467
- const action = parsed.args[0]?.toLowerCase();
468
- const code = parsed.args[1]?.trim().toUpperCase();
469
841
  const accountId = (context.msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
470
- if (!action || !code) {
471
- await context.channel.sendMessage(target, "Usage: /pair approve <code> or /pair reject <code>");
472
- return true;
473
- }
474
- if (action === "approve") {
475
- const result = context.pairingStore.approveByCode(accountId, code);
476
- if (!result.ok) await context.channel.sendMessage(target, `Pairing code not found: ${code}`);
477
- else {
478
- context.db.appendAuditEvent({
479
- actor: "channel",
480
- eventType: "telegram.pair.approve",
481
- payload: {
482
- accountId,
483
- code,
484
- userId: result.userId,
485
- approver: context.msg.userId
486
- }
487
- });
488
- await context.channel.sendMessage(target, `Approved pairing for user ${result.userId}.`);
842
+ const code = (parsed.args[1] ?? parsed.args[0])?.trim().toUpperCase() || "<code>";
843
+ await context.channel.sendMessage(target, `Pair approvals are CLI-only. Run: hovclaw pairing approve --channel telegram --account ${accountId} ${code}`);
844
+ return { handled: true };
845
+ }
846
+ if (parsed.command === "skills") {
847
+ const skills = listAvailableSkills();
848
+ if (skills.length === 0) {
849
+ await context.channel.sendMessage(target, "No installed skills found.");
850
+ return { handled: true };
851
+ }
852
+ const lines = skills.slice(0, 40).map((name) => {
853
+ return `- ${name}: ${loadSkill(name)?.frontmatter.description?.trim() || "No description"}`;
854
+ });
855
+ await context.channel.sendMessage(target, trimForTelegram(lines.join("\n")));
856
+ return { handled: true };
857
+ }
858
+ if (parsed.command === "identity" || parsed.command === "soul") {
859
+ const fileName = parsed.command === "identity" ? "IDENTITY.md" : "SOUL.md";
860
+ const subcommand = parsed.args[0]?.toLowerCase();
861
+ const body = parsed.args.slice(1).join(" ").trim();
862
+ if (!subcommand || subcommand === "view") {
863
+ const current = await readWorkspaceFileOrEmpty(context.agentId, fileName);
864
+ if (!current.trim()) {
865
+ await context.channel.sendMessage(target, `${fileName} is empty.`);
866
+ return { handled: true };
489
867
  }
490
- return true;
491
- }
492
- if (action === "reject") {
493
- const result = context.pairingStore.rejectByCode(accountId, code);
494
- if (!result.ok) await context.channel.sendMessage(target, `Pairing code not found: ${code}`);
495
- else {
496
- context.db.appendAuditEvent({
497
- actor: "channel",
498
- eventType: "telegram.pair.reject",
499
- payload: {
500
- accountId,
501
- code,
502
- userId: result.userId,
503
- approver: context.msg.userId
504
- }
505
- });
506
- await context.channel.sendMessage(target, `Rejected pairing for user ${result.userId}.`);
868
+ await context.channel.sendMessage(target, trimForTelegram(current));
869
+ return { handled: true };
870
+ }
871
+ if (subcommand === "set") {
872
+ if (!body) {
873
+ await context.channel.sendMessage(target, `Usage: /${parsed.command} set <content>`);
874
+ return { handled: true };
507
875
  }
508
- return true;
876
+ await writeWorkspaceFile(context.agentId, fileName, body);
877
+ await context.channel.sendMessage(target, `${fileName} updated.`);
878
+ return { handled: true };
509
879
  }
510
- await context.channel.sendMessage(target, "Unknown /pair action. Use approve or reject.");
511
- return true;
880
+ if (subcommand === "append") {
881
+ if (!body) {
882
+ await context.channel.sendMessage(target, `Usage: /${parsed.command} append <content>`);
883
+ return { handled: true };
884
+ }
885
+ const current = await readWorkspaceFileOrEmpty(context.agentId, fileName);
886
+ const next = current.trim().length > 0 ? `${current.trimEnd()}\n${body}` : body;
887
+ await writeWorkspaceFile(context.agentId, fileName, next);
888
+ await context.channel.sendMessage(target, `${fileName} appended.`);
889
+ return { handled: true };
890
+ }
891
+ await context.channel.sendMessage(target, `Usage: /${parsed.command} [view]\n/${parsed.command} set <content>\n/${parsed.command} append <content>`);
892
+ return { handled: true };
512
893
  }
513
- return false;
894
+ if (parsed.command === "reset" || parsed.command === "new") {
895
+ const remainder = parsed.args.join(" ").trim();
896
+ if (!remainder) return {
897
+ handled: true,
898
+ resetSession: true
899
+ };
900
+ return {
901
+ handled: false,
902
+ resetSession: true,
903
+ promptOverride: remainder
904
+ };
905
+ }
906
+ if (parsed.command === "skill") {
907
+ const sub = parsed.args[0]?.toLowerCase();
908
+ if (!sub) {
909
+ await context.channel.sendMessage(target, "Usage: /skill <name> [input] | /skill create <name> [description] | /skill add <name>");
910
+ return { handled: true };
911
+ }
912
+ if (sub === "create") {
913
+ const name = normalizeSkillName(parsed.args[1]);
914
+ if (!name) {
915
+ await context.channel.sendMessage(target, "Usage: /skill create <name> [description]");
916
+ return { handled: true };
917
+ }
918
+ const description = parsed.args.slice(2).join(" ").trim() || `Skill ${name}`;
919
+ const skillDir = path.join(config.skillsDir, name);
920
+ const skillPath = path.join(skillDir, "SKILL.md");
921
+ const scaffold = [
922
+ "---",
923
+ `name: ${name}`,
924
+ `description: ${description}`,
925
+ "---",
926
+ "",
927
+ `# ${name}`,
928
+ "",
929
+ "Describe how this skill should solve the task."
930
+ ].join("\n");
931
+ try {
932
+ await fs$1.mkdir(skillDir, { recursive: false });
933
+ } catch {
934
+ await context.channel.sendMessage(target, `Skill already exists: ${name}`);
935
+ return { handled: true };
936
+ }
937
+ await fs$1.writeFile(skillPath, `${scaffold}\n`, "utf8");
938
+ await context.channel.sendMessage(target, `Created ${skillPath}`);
939
+ return { handled: true };
940
+ }
941
+ if (sub === "add") {
942
+ const skillName = normalizeSkillName(parsed.args[1]);
943
+ if (!skillName) {
944
+ await context.channel.sendMessage(target, "Usage: /skill add <name>");
945
+ return { handled: true };
946
+ }
947
+ const { added, path: agentPath } = await addSkillToAgent(context.agentId, skillName);
948
+ await context.channel.sendMessage(target, added ? `Added ${skillName} to ${agentPath}` : `${skillName} is already enabled for ${context.agentId}.`);
949
+ return { handled: true };
950
+ }
951
+ const targetSkill = normalizeSkillName(sub);
952
+ if (!targetSkill) {
953
+ await context.channel.sendMessage(target, "Invalid skill name.");
954
+ return { handled: true };
955
+ }
956
+ if (!new Set(listAvailableSkills()).has(targetSkill)) {
957
+ await context.channel.sendMessage(target, `Skill not found: ${targetSkill}. Run /skills to list installed skills or /skill create ${targetSkill}.`);
958
+ return { handled: true };
959
+ }
960
+ return {
961
+ handled: false,
962
+ promptOverride: resolveSkillPrompt(targetSkill, parsed.args.slice(1))
963
+ };
964
+ }
965
+ const skillCommand = listSkillCommandSpecs(config).find((entry) => entry.name === parsed.command);
966
+ if (skillCommand) return {
967
+ handled: false,
968
+ promptOverride: resolveSkillPrompt(skillCommand.skillName, parsed.args)
969
+ };
970
+ if (parsed.command === "think") {
971
+ if (parsed.args.length === 0) {
972
+ await context.channel.sendMessage(target, [
973
+ `Default thinking level: ${config.commands.defaultThinkingLevel}`,
974
+ "Usage:",
975
+ "/think <low|medium|high> <task>",
976
+ "/think default <low|medium|high>"
977
+ ].join("\n"));
978
+ return { handled: true };
979
+ }
980
+ if (parsed.args[0]?.trim().toLowerCase() === "default") {
981
+ const requested = parseThinkingLevel(parsed.args[1]);
982
+ if (!requested) {
983
+ await context.channel.sendMessage(target, `Current default thinking level: ${config.commands.defaultThinkingLevel}\nUsage: /think default <low|medium|high>`);
984
+ return { handled: true };
985
+ }
986
+ const next = loadFileConfig();
987
+ next.commands.defaultThinkingLevel = requested;
988
+ try {
989
+ persistFileConfig(next);
990
+ } catch (error) {
991
+ const message = error instanceof Error ? error.message : String(error);
992
+ await context.channel.sendMessage(target, `Failed to update default thinking level: ${message}`);
993
+ return { handled: true };
994
+ }
995
+ await context.channel.sendMessage(target, `Default thinking level set to ${requested}.`);
996
+ return { handled: true };
997
+ }
998
+ const level = parseThinkingLevel(parsed.args[0]);
999
+ if (!level || parsed.args.length < 2) {
1000
+ await context.channel.sendMessage(target, "Usage: /think <low|medium|high> <task>\nOr set default: /think default <low|medium|high>");
1001
+ return { handled: true };
1002
+ }
1003
+ return {
1004
+ handled: false,
1005
+ promptOverride: parsed.args.slice(1).join(" ").trim(),
1006
+ thinkingLevelOverride: level
1007
+ };
1008
+ }
1009
+ if (parsed.command === "reasoning") {
1010
+ if (parsed.args.length < 2) {
1011
+ await context.channel.sendMessage(target, "Usage: /reasoning <on|off> <task>\nThis command is per-message and not persisted.");
1012
+ return { handled: true };
1013
+ }
1014
+ const mode = (parsed.args[0] ?? "on").trim().toLowerCase() === "off" ? "off" : "on";
1015
+ const task = parsed.args.slice(1).join(" ").trim();
1016
+ return {
1017
+ handled: false,
1018
+ promptOverride: mode === "on" ? `Solve this task and include concise reasoning in your response: ${task}` : `Solve this task and return only final output without exposing internal reasoning: ${task}`
1019
+ };
1020
+ }
1021
+ if (parsed.command === "verbose") {
1022
+ if (parsed.args.length < 2) {
1023
+ await context.channel.sendMessage(target, "Usage: /verbose <on|off> <task>\nThis command is per-message and not persisted.");
1024
+ return { handled: true };
1025
+ }
1026
+ const mode = (parsed.args[0] ?? "on").trim().toLowerCase() === "off" ? "off" : "on";
1027
+ const task = parsed.args.slice(1).join(" ").trim();
1028
+ return {
1029
+ handled: false,
1030
+ promptOverride: mode === "on" ? `Answer in a detailed, explicit style for this request: ${task}` : `Answer briefly and directly for this request: ${task}`
1031
+ };
1032
+ }
1033
+ if (parsed.command === "stop") {
1034
+ await context.channel.sendMessage(target, "Stopping an in-flight run is not supported yet. Use /new to start a fresh session.");
1035
+ return { handled: true };
1036
+ }
1037
+ if (parsed.command === "restart") {
1038
+ if (!config.commands.restart) {
1039
+ await context.channel.sendMessage(target, "Restart command is disabled. Set commands.restart=true to enable.");
1040
+ return { handled: true };
1041
+ }
1042
+ await context.channel.sendMessage(target, "Restart requested. Applying restart now.");
1043
+ try {
1044
+ if ((await requestDaemonRestartFromCurrentProcess()).mode === "spawn-reexec") setTimeout(() => {
1045
+ try {
1046
+ process.kill(process.pid, "SIGTERM");
1047
+ } catch {}
1048
+ }, 250);
1049
+ } catch (error) {
1050
+ const message = error instanceof Error ? error.message : String(error);
1051
+ await context.channel.sendMessage(target, `Restart failed: ${message}`);
1052
+ }
1053
+ return { handled: true };
1054
+ }
1055
+ if (parsed.command === "config") {
1056
+ if (!config.commands.config) {
1057
+ await context.channel.sendMessage(target, "/config is disabled. Set commands.config=true to enable.");
1058
+ return { handled: true };
1059
+ }
1060
+ const sub = parsed.args[0]?.toLowerCase() ?? "show";
1061
+ if (sub === "show" || sub === "status") {
1062
+ const current = loadFileConfig();
1063
+ await context.channel.sendMessage(target, trimForTelegram(configSummaryText(current)));
1064
+ return { handled: true };
1065
+ }
1066
+ if (sub === "reload") {
1067
+ try {
1068
+ reloadRuntimeConfig();
1069
+ } catch (error) {
1070
+ const message = error instanceof Error ? error.message : String(error);
1071
+ await context.channel.sendMessage(target, `Failed to reload config: ${message}`);
1072
+ return { handled: true };
1073
+ }
1074
+ await context.channel.sendMessage(target, "Reloaded runtime config from disk.");
1075
+ return { handled: true };
1076
+ }
1077
+ if (sub === "get") {
1078
+ const pathSegments = parseConfigPath(parsed.args[1]);
1079
+ if (!pathSegments) {
1080
+ await context.channel.sendMessage(target, "Usage: /config get <path>");
1081
+ return { handled: true };
1082
+ }
1083
+ const found = readPathValue(loadFileConfig(), pathSegments);
1084
+ if (!found.found) {
1085
+ await context.channel.sendMessage(target, `Path not found: ${parsed.args[1]}`);
1086
+ return { handled: true };
1087
+ }
1088
+ const pretty = formatValue(found.value);
1089
+ await context.channel.sendMessage(target, trimForTelegram(`${parsed.args[1]} = ${pretty}`));
1090
+ return { handled: true };
1091
+ }
1092
+ if (sub === "set") {
1093
+ const pathSegments = parseConfigPath(parsed.args[1]);
1094
+ const valueRaw = parsed.args.slice(2).join(" ");
1095
+ if (!pathSegments || !valueRaw.trim()) {
1096
+ await context.channel.sendMessage(target, "Usage: /config set <path> <json-or-text>");
1097
+ return { handled: true };
1098
+ }
1099
+ const next = loadFileConfig();
1100
+ if (!writePathValue(next, pathSegments, parseConfigValue(valueRaw))) {
1101
+ await context.channel.sendMessage(target, `Could not update path: ${parsed.args[1]}`);
1102
+ return { handled: true };
1103
+ }
1104
+ try {
1105
+ persistFileConfig(next);
1106
+ } catch (error) {
1107
+ const message = error instanceof Error ? error.message : String(error);
1108
+ await context.channel.sendMessage(target, `Config update failed: ${message}`);
1109
+ return { handled: true };
1110
+ }
1111
+ await context.channel.sendMessage(target, `Updated ${parsed.args[1]}.`);
1112
+ return { handled: true };
1113
+ }
1114
+ if (sub === "patch") {
1115
+ const patchRaw = parsed.args.slice(1).join(" ").trim();
1116
+ if (!patchRaw) {
1117
+ await context.channel.sendMessage(target, "Usage: /config patch <json-object>");
1118
+ return { handled: true };
1119
+ }
1120
+ let patch;
1121
+ try {
1122
+ patch = JSON.parse(patchRaw);
1123
+ } catch (error) {
1124
+ const message = error instanceof Error ? error.message : String(error);
1125
+ await context.channel.sendMessage(target, `Invalid JSON patch: ${message}`);
1126
+ return { handled: true };
1127
+ }
1128
+ if (!isRecord$1(patch)) {
1129
+ await context.channel.sendMessage(target, "Patch must be a JSON object.");
1130
+ return { handled: true };
1131
+ }
1132
+ const merged = deepMerge$1(loadFileConfig(), patch);
1133
+ try {
1134
+ persistFileConfig(merged);
1135
+ } catch (error) {
1136
+ const message = error instanceof Error ? error.message : String(error);
1137
+ await context.channel.sendMessage(target, `Config patch failed: ${message}`);
1138
+ return { handled: true };
1139
+ }
1140
+ await context.channel.sendMessage(target, "Config patch applied.");
1141
+ return { handled: true };
1142
+ }
1143
+ await context.channel.sendMessage(target, "Usage: /config show | get <path> | set <path> <value> | patch <json-object> | reload");
1144
+ return { handled: true };
1145
+ }
1146
+ if (parsed.command === "debug") {
1147
+ const sub = parsed.args[0]?.toLowerCase() ?? "status";
1148
+ const canSelfEnable = sub === "on";
1149
+ if (!config.commands.debug && !canSelfEnable) {
1150
+ await context.channel.sendMessage(target, "/debug is disabled. Set commands.debug=true to enable.");
1151
+ return { handled: true };
1152
+ }
1153
+ if (sub === "on" || sub === "off") {
1154
+ const next = loadFileConfig();
1155
+ next.commands.debug = sub === "on";
1156
+ try {
1157
+ persistFileConfig(next);
1158
+ } catch (error) {
1159
+ const message = error instanceof Error ? error.message : String(error);
1160
+ await context.channel.sendMessage(target, `Failed to update debug mode: ${message}`);
1161
+ return { handled: true };
1162
+ }
1163
+ await context.channel.sendMessage(target, `commands.debug=${next.commands.debug}`);
1164
+ return { handled: true };
1165
+ }
1166
+ if (sub === "level") {
1167
+ const requested = parsed.args[1]?.trim().toLowerCase();
1168
+ if (!requested || !DEBUG_LOG_LEVELS.has(requested)) {
1169
+ await context.channel.sendMessage(target, "Usage: /debug level <trace|debug|info|warn|error|fatal|silent>");
1170
+ return { handled: true };
1171
+ }
1172
+ logger.level = requested;
1173
+ process.env.LOG_LEVEL = requested;
1174
+ await context.channel.sendMessage(target, `Logger level set to ${requested} (runtime only).`);
1175
+ return { handled: true };
1176
+ }
1177
+ if (sub === "dump") {
1178
+ const payload = {
1179
+ pid: process.pid,
1180
+ uptimeSec: Math.floor(process.uptime()),
1181
+ loggerLevel: logger.level,
1182
+ commands: config.commands,
1183
+ activeSessions: context.db.listSessions().length,
1184
+ channelDiagnostics: context.channel.getDiagnostics?.() ?? {}
1185
+ };
1186
+ await context.channel.sendMessage(target, trimForTelegram(JSON.stringify(payload, null, 2)));
1187
+ return { handled: true };
1188
+ }
1189
+ if (sub === "status") {
1190
+ const lines = [
1191
+ "Debug status",
1192
+ `pid=${process.pid}`,
1193
+ `uptimeSec=${Math.floor(process.uptime())}`,
1194
+ `logger.level=${logger.level}`,
1195
+ `commands.debug=${config.commands.debug}`,
1196
+ `commands.config=${config.commands.config}`,
1197
+ `commands.bash=${config.commands.bash}`,
1198
+ `commands.restart=${config.commands.restart}`,
1199
+ `activeSessions=${context.db.listSessions().length}`,
1200
+ "",
1201
+ "Usage:",
1202
+ "/debug status",
1203
+ "/debug level <trace|debug|info|warn|error|fatal|silent>",
1204
+ "/debug dump",
1205
+ "/debug on|off"
1206
+ ];
1207
+ await context.channel.sendMessage(target, lines.join("\n"));
1208
+ return { handled: true };
1209
+ }
1210
+ await context.channel.sendMessage(target, "Usage: /debug status | level <...> | dump | on | off");
1211
+ return { handled: true };
1212
+ }
1213
+ if (parsed.command === "bash") {
1214
+ if (!config.commands.bash) {
1215
+ await context.channel.sendMessage(target, "/bash is disabled. Set commands.bash=true to enable.");
1216
+ return { handled: true };
1217
+ }
1218
+ return {
1219
+ handled: false,
1220
+ promptOverride: `Run this shell command safely: ${parsed.args.join(" ").trim()}`
1221
+ };
1222
+ }
1223
+ return { handled: false };
514
1224
  }
515
1225
 
516
1226
  //#endregion
517
- //#region src/channels/telegram-pairing-store.ts
518
- function nowIso() {
519
- return (/* @__PURE__ */ new Date()).toISOString();
520
- }
521
- function randomCode() {
522
- return Math.random().toString(36).slice(2, 8).toUpperCase();
523
- }
524
- var TelegramPairingStore = class {
525
- filePath;
526
- state = null;
527
- constructor(storeDir) {
528
- this.filePath = path.join(storeDir, "telegram-pairing.json");
529
- }
530
- load() {
531
- if (this.state) return this.state;
532
- if (!fs.existsSync(this.filePath)) {
533
- this.state = {
534
- approved: {},
535
- pending: {}
1227
+ //#region src/channels/telegram-policy.ts
1228
+ function toIdSet(values) {
1229
+ return new Set((values ?? []).map((value) => String(value).trim().toLowerCase()).filter(Boolean));
1230
+ }
1231
+ function splitThread(chatId) {
1232
+ const [baseChatIdRaw, threadIdRaw] = chatId.split("#");
1233
+ return {
1234
+ baseChatId: baseChatIdRaw ?? chatId,
1235
+ ...threadIdRaw ? { threadId: threadIdRaw } : {}
1236
+ };
1237
+ }
1238
+ function isDirectMessage(msg) {
1239
+ return msg.peer?.kind === "direct" || !msg.chatId.startsWith("-") && !msg.chatId.includes("#");
1240
+ }
1241
+ function containsMention(text, assistantName) {
1242
+ const normalized = assistantName.trim().toLowerCase();
1243
+ if (!normalized) return false;
1244
+ return text.toLowerCase().includes(`@${normalized}`);
1245
+ }
1246
+ function isAllowListed(msg, allowSet) {
1247
+ if (allowSet.has("*")) return true;
1248
+ const userId = msg.userId.trim().toLowerCase();
1249
+ if (userId && allowSet.has(userId)) return true;
1250
+ const displayName = msg.displayName.trim().replace(/^@/, "").toLowerCase();
1251
+ if (displayName && allowSet.has(displayName)) return true;
1252
+ return false;
1253
+ }
1254
+ function evaluateTelegramPolicy(params) {
1255
+ const { config, msg, pairingStore } = params;
1256
+ const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
1257
+ const account = config.channels.telegram.accounts[accountId] ?? config.channels.telegram.accounts[config.channels.telegram.defaultAccountId] ?? config.channels.telegram.accounts.default;
1258
+ if (!account || account.enabled === false) return {
1259
+ allowed: false,
1260
+ reason: "account-disabled"
1261
+ };
1262
+ const direct = isDirectMessage(msg);
1263
+ const allowFromSet = toIdSet(account.allowFrom);
1264
+ if (direct) {
1265
+ const dmPolicy = account.dmPolicy ?? "pairing";
1266
+ if (dmPolicy === "disabled") return {
1267
+ allowed: false,
1268
+ reason: "dm-disabled"
1269
+ };
1270
+ if (dmPolicy === "open") return { allowed: true };
1271
+ if (dmPolicy === "allowlist") {
1272
+ if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
1273
+ return {
1274
+ allowed: false,
1275
+ reason: "dm-not-allowlisted"
536
1276
  };
537
- return this.state;
538
1277
  }
539
- try {
540
- const parsed = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
541
- this.state = {
542
- approved: parsed.approved ?? {},
543
- pending: parsed.pending ?? {}
544
- };
545
- return this.state;
546
- } catch {
547
- this.state = {
548
- approved: {},
549
- pending: {}
550
- };
551
- return this.state;
552
- }
553
- }
554
- save() {
555
- if (!this.state) return;
556
- fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
557
- fs.writeFileSync(this.filePath, `${JSON.stringify(this.state, null, 2)}\n`, "utf8");
558
- }
559
- isApproved(accountId, userId) {
560
- return (this.load().approved[accountId] ?? []).includes(userId);
561
- }
562
- ensurePendingCode(accountId, userId) {
563
- const state = this.load();
564
- const pendingByAccount = state.pending[accountId] ?? {};
565
- const existing = Object.entries(pendingByAccount).find(([, entry]) => entry.userId === userId);
566
- if (existing) return existing[0];
567
- const code = randomCode();
568
- state.pending[accountId] = {
569
- ...pendingByAccount,
570
- [code]: {
571
- userId,
572
- createdAt: nowIso()
573
- }
574
- };
575
- this.save();
576
- return code;
577
- }
578
- approveByCode(accountId, code) {
579
- const state = this.load();
580
- const pendingByAccount = state.pending[accountId] ?? {};
581
- const entry = pendingByAccount[code];
582
- if (!entry) return { ok: false };
583
- const approved = new Set(state.approved[accountId] ?? []);
584
- approved.add(entry.userId);
585
- state.approved[accountId] = Array.from(approved).sort((a, b) => a.localeCompare(b));
586
- delete pendingByAccount[code];
587
- state.pending[accountId] = pendingByAccount;
588
- this.save();
1278
+ if (isAllowListed(msg, allowFromSet) || pairingStore.isApproved(accountId, msg.userId)) return { allowed: true };
589
1279
  return {
590
- ok: true,
591
- userId: entry.userId
1280
+ allowed: false,
1281
+ reason: "pairing-required",
1282
+ pairingCode: pairingStore.ensurePendingCode(accountId, msg.userId)
592
1283
  };
593
1284
  }
594
- rejectByCode(accountId, code) {
595
- const state = this.load();
596
- const pendingByAccount = state.pending[accountId] ?? {};
597
- const entry = pendingByAccount[code];
598
- if (!entry) return { ok: false };
599
- delete pendingByAccount[code];
600
- state.pending[accountId] = pendingByAccount;
601
- this.save();
602
- return {
603
- ok: true,
604
- userId: entry.userId
1285
+ const { baseChatId, threadId } = splitThread(msg.chatId);
1286
+ const groupConfig = account.groups?.[baseChatId];
1287
+ const topicConfig = threadId ? groupConfig?.topics?.[threadId] : void 0;
1288
+ if (groupConfig?.enabled === false) return {
1289
+ allowed: false,
1290
+ reason: "group-disabled"
1291
+ };
1292
+ if (topicConfig?.enabled === false) return {
1293
+ allowed: false,
1294
+ reason: "topic-disabled"
1295
+ };
1296
+ const groupPolicy = topicConfig?.groupPolicy ?? groupConfig?.groupPolicy ?? account.groupPolicy ?? "open";
1297
+ if (groupPolicy === "disabled") return {
1298
+ allowed: false,
1299
+ reason: "group-disabled"
1300
+ };
1301
+ if (groupPolicy === "allowlist") {
1302
+ if (!isAllowListed(msg, toIdSet(topicConfig?.allowFrom ?? groupConfig?.allowFrom ?? account.groupAllowFrom))) return {
1303
+ allowed: false,
1304
+ reason: "group-not-allowlisted"
605
1305
  };
606
1306
  }
607
- };
1307
+ if ((topicConfig?.requireMention ?? groupConfig?.requireMention ?? false) && !containsMention(msg.text, config.assistantName)) return {
1308
+ allowed: false,
1309
+ reason: "mention-required"
1310
+ };
1311
+ return { allowed: true };
1312
+ }
608
1313
 
609
1314
  //#endregion
610
1315
  //#region src/gateway/methods/agent.ts
@@ -773,7 +1478,7 @@ function deepMerge(base, patch) {
773
1478
  return result;
774
1479
  }
775
1480
  const configGetMethod = async (_params, context) => {
776
- return context.readFileConfig();
1481
+ return redactSensitiveData(context.readFileConfig());
777
1482
  };
778
1483
  const configSetMethod = async (params, context) => {
779
1484
  const parsed = configSetParamsSchema.parse(params);
@@ -855,7 +1560,7 @@ const logsTailMethod = async (params, context) => {
855
1560
  message: event.eventType,
856
1561
  sessionKey: event.sessionKey,
857
1562
  actor: event.actor,
858
- payload: event.payload
1563
+ payload: redactSensitiveData(event.payload)
859
1564
  })) };
860
1565
  };
861
1566
 
@@ -1102,7 +1807,46 @@ function sendResponse(socket, id, ok, payload, error) {
1102
1807
  function setUiSecurityHeaders(res) {
1103
1808
  res.setHeader("X-Frame-Options", "DENY");
1104
1809
  res.setHeader("X-Content-Type-Options", "nosniff");
1105
- res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
1810
+ res.setHeader("Content-Security-Policy", [
1811
+ "default-src 'self'",
1812
+ "script-src 'self'",
1813
+ "style-src 'self'",
1814
+ "img-src 'self' data:",
1815
+ "font-src 'self'",
1816
+ "connect-src 'self'",
1817
+ "object-src 'none'",
1818
+ "base-uri 'none'",
1819
+ "frame-ancestors 'none'",
1820
+ "form-action 'none'"
1821
+ ].join("; "));
1822
+ }
1823
+ function normalizeOrigin(value) {
1824
+ try {
1825
+ const parsed = new URL(value);
1826
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1827
+ return `${parsed.protocol}//${parsed.host}`.toLowerCase();
1828
+ } catch {
1829
+ return null;
1830
+ }
1831
+ }
1832
+ function isOriginAllowed(request, config) {
1833
+ const rawOrigin = request.headers.origin;
1834
+ if (typeof rawOrigin !== "string" || rawOrigin.trim().length === 0) return true;
1835
+ const normalizedOrigin = normalizeOrigin(rawOrigin.trim());
1836
+ if (!normalizedOrigin) return false;
1837
+ const rawHost = request.headers.host;
1838
+ if (typeof rawHost === "string" && rawHost.trim().length > 0) {
1839
+ const normalizedHost = rawHost.trim().toLowerCase();
1840
+ if (normalizedOrigin === `http://${normalizedHost}` || normalizedOrigin === `https://${normalizedHost}`) return true;
1841
+ }
1842
+ const allowedOrigins = config.gateway.auth.allowedOrigins;
1843
+ if (allowedOrigins.includes("*")) return true;
1844
+ for (const allowed of allowedOrigins) {
1845
+ const normalizedAllowed = normalizeOrigin(allowed);
1846
+ if (!normalizedAllowed) continue;
1847
+ if (normalizedAllowed === normalizedOrigin) return true;
1848
+ }
1849
+ return false;
1106
1850
  }
1107
1851
  function contentTypeForFile(filePath) {
1108
1852
  const ext = path.extname(filePath).toLowerCase();
@@ -1203,6 +1947,15 @@ var HovClawGatewayServer = class {
1203
1947
  socket.destroy();
1204
1948
  return;
1205
1949
  }
1950
+ if (!isOriginAllowed(request, this.runtimeConfig)) {
1951
+ socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
1952
+ socket.destroy();
1953
+ logger.warn({
1954
+ origin: request.headers.origin,
1955
+ host: request.headers.host
1956
+ }, "Rejected websocket upgrade due to origin policy");
1957
+ return;
1958
+ }
1206
1959
  this.wss.handleUpgrade(request, socket, head, (wsSocket) => {
1207
1960
  this.wss?.emit("connection", wsSocket, request);
1208
1961
  });
@@ -1317,7 +2070,14 @@ var HovClawGatewayServer = class {
1317
2070
  const connectParams = parseConnectParams(request.params);
1318
2071
  const expectedToken = this.runtimeConfig.gateway.auth.token.trim();
1319
2072
  const expectedPassword = this.runtimeConfig.gateway.auth.password.trim();
1320
- if (!expectedToken && !expectedPassword) return { ok: true };
2073
+ const allowUnauthenticated = this.runtimeConfig.gateway.auth.allowUnauthenticated;
2074
+ if (!expectedToken && !expectedPassword) {
2075
+ if (allowUnauthenticated) return { ok: true };
2076
+ return {
2077
+ ok: false,
2078
+ reason: "gateway auth required"
2079
+ };
2080
+ }
1321
2081
  if (expectedToken && connectParams.auth?.token === expectedToken) return { ok: true };
1322
2082
  if (expectedPassword && connectParams.auth?.password === expectedPassword) return { ok: true };
1323
2083
  return {
@@ -1765,6 +2525,19 @@ var HovClawScheduler = class {
1765
2525
  function normalizePrompt(msg) {
1766
2526
  return msg.text.trim();
1767
2527
  }
2528
+ function formatModelForSessionNotice(modelRef) {
2529
+ const match = modelRef.match(/^([^:\/]+)[:\/](.+)$/);
2530
+ if (!match) return modelRef;
2531
+ const provider = match[1];
2532
+ const model = match[2];
2533
+ if (!provider || !model) return modelRef;
2534
+ return `${provider}/${model}`;
2535
+ }
2536
+ function applyThinkingLevel(prompt, level, force = false) {
2537
+ if (/^Use (low|medium|high) reasoning effort for this task:/i.test(prompt)) return prompt;
2538
+ if (level === "medium" && !force) return prompt;
2539
+ return `Use ${level} reasoning effort for this task: ${prompt}`;
2540
+ }
1768
2541
  function buildChannelTarget(msg) {
1769
2542
  return {
1770
2543
  channel: msg.channel,
@@ -1800,6 +2573,17 @@ var MultiNotifier = class {
1800
2573
  }, text);
1801
2574
  }
1802
2575
  };
2576
+ function validateGatewaySecurityConfig() {
2577
+ if (!config.gateway.enabled) return;
2578
+ const auth = config.gateway.auth;
2579
+ if (auth.allowUnauthenticated) return;
2580
+ if (auth.token.trim() || auth.password.trim()) return;
2581
+ throw new Error([
2582
+ "Gateway auth is required when gateway is enabled.",
2583
+ "Set gateway.auth.token or gateway.auth.password in ~/.hovclaw/config.json,",
2584
+ "or explicitly set gateway.auth.allowUnauthenticated=true for insecure compatibility mode."
2585
+ ].join(" "));
2586
+ }
1803
2587
  async function main() {
1804
2588
  const importedLegacyEnv = ensureConfigFromLegacyEnv();
1805
2589
  if (!hasConfigFile()) {
@@ -1810,6 +2594,7 @@ async function main() {
1810
2594
  configPath: config.configPath,
1811
2595
  credentialsPath: config.credentialsPath
1812
2596
  }, "Imported legacy env configuration into ~/.hovclaw");
2597
+ validateGatewaySecurityConfig();
1813
2598
  try {
1814
2599
  const bootstrap = await ensureWorkspaceBootstrapForConfig(config);
1815
2600
  if (bootstrap.createdFileCount > 0) logger.info({
@@ -1840,7 +2625,8 @@ async function main() {
1840
2625
  });
1841
2626
  const agentManager = new PiAgentManager(db, createTools({
1842
2627
  runtime,
1843
- audit: (record) => db.appendAuditEvent(record)
2628
+ audit: (record) => db.appendAuditEvent(record),
2629
+ bashEnabled: config.runtime.tools.bashEnabled
1844
2630
  }));
1845
2631
  let lastMessageAt = null;
1846
2632
  const telegramPairingStore = new TelegramPairingStore(config.storeDir);
@@ -1851,10 +2637,19 @@ async function main() {
1851
2637
  const channels = channelPluginManager.initializeAdapters();
1852
2638
  const handleMessage = async (msg) => {
1853
2639
  lastMessageAt = (/* @__PURE__ */ new Date()).toISOString();
1854
- const normalizedPrompt = normalizePrompt(msg);
1855
- if (!normalizedPrompt) return;
1856
2640
  const channel = channels.get(msg.channel);
1857
2641
  if (!channel) return;
2642
+ const agentId = resolveAgentIdForInbound(config, msg);
2643
+ const sessionKey = composeSessionKey({
2644
+ agent: agentId,
2645
+ channel: msg.channel,
2646
+ identity: canonicalizeSessionIdentity(msg)
2647
+ });
2648
+ const target = buildChannelTarget(msg);
2649
+ let normalizedPrompt = normalizePrompt(msg);
2650
+ if (!normalizedPrompt) return;
2651
+ let thinkingLevel = config.commands.defaultThinkingLevel;
2652
+ let thinkingLevelForced = false;
1858
2653
  if (msg.channel === "telegram") {
1859
2654
  const policy = evaluateTelegramPolicy({
1860
2655
  config,
@@ -1863,12 +2658,13 @@ async function main() {
1863
2658
  });
1864
2659
  if (!policy.allowed) {
1865
2660
  if (policy.pairingCode && channel instanceof TelegramChannel) {
1866
- await channel.sendMessage(buildChannelTarget(msg), `Pairing required. Ask an approver to run: /pair approve ${policy.pairingCode}`);
2661
+ const accountId = (msg.accountId ?? config.channels.telegram.defaultAccountId).trim() || "default";
2662
+ await channel.sendMessage(target, `Pairing required. Ask an approver to run: hovclaw pairing approve --channel telegram --account ${accountId} ${policy.pairingCode}`);
1867
2663
  db.appendAuditEvent({
1868
2664
  actor: "channel",
1869
2665
  eventType: "telegram.pair.pending",
1870
2666
  payload: {
1871
- accountId: msg.accountId ?? config.channels.telegram.defaultAccountId,
2667
+ accountId,
1872
2668
  userId: msg.userId,
1873
2669
  code: policy.pairingCode
1874
2670
  }
@@ -1877,20 +2673,36 @@ async function main() {
1877
2673
  return;
1878
2674
  }
1879
2675
  if (channel instanceof TelegramChannel) {
1880
- if (await handleTelegramCommand({
2676
+ const result = await handleTelegramCommand({
1881
2677
  msg,
1882
2678
  channel,
1883
2679
  db,
1884
- pairingStore: telegramPairingStore
1885
- })) return;
2680
+ agentId,
2681
+ sessionKey
2682
+ });
2683
+ if (result.resetSession) {
2684
+ try {
2685
+ await agentManager.resetSession(sessionKey);
2686
+ } catch (error) {
2687
+ logger.error({
2688
+ error,
2689
+ sessionKey
2690
+ }, "Failed to reset session");
2691
+ await channel.sendMessage(target, "Failed to reset session. Check logs and try again.");
2692
+ return;
2693
+ }
2694
+ const modelLabel = formatModelForSessionNotice(config.models.interactive);
2695
+ await channel.sendMessage(target, `New session started. Model: ${modelLabel}`);
2696
+ }
2697
+ if (result.promptOverride?.trim()) normalizedPrompt = result.promptOverride.trim();
2698
+ if (result.thinkingLevelOverride) {
2699
+ thinkingLevel = result.thinkingLevelOverride;
2700
+ thinkingLevelForced = true;
2701
+ }
2702
+ if (result.handled) return;
1886
2703
  }
1887
2704
  }
1888
- const sessionKey = composeSessionKey({
1889
- agent: resolveAgentIdForInbound(config, msg),
1890
- channel: msg.channel,
1891
- identity: canonicalizeSessionIdentity(msg)
1892
- });
1893
- const target = buildChannelTarget(msg);
2705
+ normalizedPrompt = applyThinkingLevel(normalizedPrompt, thinkingLevel, thinkingLevelForced);
1894
2706
  try {
1895
2707
  await channel.setTyping?.(target, true);
1896
2708
  let finalText = null;
@@ -1940,6 +2752,18 @@ async function main() {
1940
2752
  channel.onMessage(handleMessage);
1941
2753
  try {
1942
2754
  await channel.start();
2755
+ if (channel instanceof TelegramChannel) {
2756
+ const commands = listTelegramNativeCommandSpecs(config, channel.getAccountId());
2757
+ try {
2758
+ await channel.setMyCommands(commands);
2759
+ } catch (commandError) {
2760
+ logger.warn({
2761
+ error: commandError,
2762
+ channel: channelName,
2763
+ commandCount: commands.length
2764
+ }, "Telegram command registration failed; continuing without native command menu sync");
2765
+ }
2766
+ }
1943
2767
  } catch (error) {
1944
2768
  logger.error({
1945
2769
  error,
@@ -1979,6 +2803,7 @@ async function main() {
1979
2803
  gatewayServer?.start();
1980
2804
  const healthPortRaw = process.env.HEALTH_PORT || "8787";
1981
2805
  const healthPort = Number(healthPortRaw);
2806
+ const healthHost = (process.env.HEALTH_HOST || "127.0.0.1").trim() || "127.0.0.1";
1982
2807
  const healthServer = Number.isFinite(healthPort) && healthPort > 0 ? createServer((req, res) => {
1983
2808
  if (req.url !== "/health") {
1984
2809
  res.statusCode = 404;
@@ -1999,8 +2824,11 @@ async function main() {
1999
2824
  port: config.gateway.port
2000
2825
  }
2001
2826
  }));
2002
- }).listen(healthPort, () => {
2003
- logger.info({ healthPort }, "Health endpoint started");
2827
+ }).listen(healthPort, healthHost, () => {
2828
+ logger.info({
2829
+ healthHost,
2830
+ healthPort
2831
+ }, "Health endpoint started");
2004
2832
  }) : null;
2005
2833
  const shutdown = async () => {
2006
2834
  logger.info("Shutting down...");