clawgram 2.21.1 → 2.22.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.
package/dist/reactions.js CHANGED
@@ -14,6 +14,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.resolveAgentReactionGuidance = resolveAgentReactionGuidance;
15
15
  exports.parseReactionParams = parseReactionParams;
16
16
  const helpers_1 = require("./helpers");
17
+ const history_1 = require("./history");
17
18
  /**
18
19
  * How freely the agent may react, from the account's `reactionLevel`.
19
20
  *
@@ -59,24 +60,14 @@ function readBooleanFlag(value) {
59
60
  * some unrelated message, so anything else is refused rather than coerced —
60
61
  * the same reasoning as the history parser's id/date guard.
61
62
  */
62
- function parseMessageId(value) {
63
- if (value === undefined || value === null || value === "") {
64
- return undefined;
65
- }
66
- const parsed = Number(value);
67
- if (!Number.isInteger(parsed) || parsed <= 0) {
68
- throw new Error(`clawgram: react messageId must be a positive integer, got ${JSON.stringify(value)}`);
69
- }
70
- return parsed;
71
- }
72
63
  function parseReactionParams(params, toolContext) {
73
64
  const rawTarget = (0, helpers_1.readChatTargetParam)(params, toolContext);
74
65
  const target = typeof rawTarget === "string" ? rawTarget.trim() : "";
75
66
  if (!target) {
76
67
  throw new Error("clawgram: react requires a chatId");
77
68
  }
78
- const messageId = parseMessageId(params.messageId ?? params.msgId ?? params.message_id)
79
- ?? parseMessageId(toolContext?.currentMessageId);
69
+ const messageId = (0, history_1.parseMessageId)(params.messageId ?? params.msgId ?? params.message_id, "react messageId")
70
+ ?? (0, history_1.parseMessageId)(toolContext?.currentMessageId, "react messageId");
80
71
  if (messageId === undefined) {
81
72
  throw new Error("clawgram: react requires a messageId");
82
73
  }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.secretRefKey = secretRefKey;
22
+ exports.asSecretRef = asSecretRef;
22
23
  exports.collectAccountSecretRefs = collectAccountSecretRefs;
23
24
  exports.applyAccountSecrets = applyAccountSecrets;
24
25
  exports.hasUnresolvedSecretRef = hasUnresolvedSecretRef;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isPhoneNumberTarget = isPhoneNumberTarget;
4
+ exports.isSendScopeConfigured = isSendScopeConfigured;
5
+ exports.isChatSendable = isChatSendable;
6
+ exports.rememberSendScope = rememberSendScope;
7
+ exports.sendScopeFor = sendScopeFor;
8
+ exports.forgetSendScope = forgetSendScope;
9
+ const history_1 = require("./history");
10
+ /**
11
+ * Outbound scope for the account: who this account may write to.
12
+ *
13
+ * Reading has had a declared scope since 2.x (`readChats`), management has
14
+ * one (`manageChats`), and sending had none: `send`, `upload-file` and
15
+ * `react` resolved whatever target the caller named and delivered it. The
16
+ * account is a person's own Telegram account, so an injected turn could
17
+ * message strangers under the owner's name, or carry a work chat's content
18
+ * into an attacker's DM one `send` at a time (finding A5-12).
19
+ *
20
+ * Two decisions worth stating, because both could reasonably have gone the
21
+ * other way:
22
+ *
23
+ * 1. **An absent `sendChats` still allows sending.** `manageChats` denies by
24
+ * default because management arrived as a new capability; sending is what
25
+ * this plugin has always done, and flipping it to "current conversation
26
+ * only" would silence every existing deployment on upgrade — including
27
+ * scheduled digests that legitimately write to an id nobody is talking to
28
+ * right now. A deployment that wants the boundary writes `sendChats`, and
29
+ * then it is a boundary in code rather than a sentence in a prompt.
30
+ *
31
+ * 2. **A phone number is refused in every configuration**, wildcard included.
32
+ * Messaging a raw number starts a conversation with someone who never
33
+ * interacted with the account and hands them the account's identity;
34
+ * no deployment has a reason to do that from an assistant, and the
35
+ * address book is not the model's to walk.
36
+ */
37
+ /** Anything that looks like a dialable number rather than a chat we know. */
38
+ function isPhoneNumberTarget(target) {
39
+ const raw = String(target ?? "").trim();
40
+ if (!raw)
41
+ return false;
42
+ // A Telegram chat id is digits (a user) or `-100…` (a group); a phone
43
+ // number is what a person writes with a plus, spaces, dashes or brackets.
44
+ // The `+` is the giveaway that survives normalisation, and a long digit
45
+ // string with separators is the other spelling of the same thing.
46
+ const compact = raw.replace(/[\s()\-.]/g, "");
47
+ if (/^\+\d{6,15}$/.test(compact))
48
+ return true;
49
+ return /[\s()\-.]/.test(raw) && /^\+?\d[\d\s()\-.]{5,}$/.test(raw);
50
+ }
51
+ function normalizeScope(sendChats) {
52
+ if (sendChats === undefined || sendChats === null)
53
+ return [];
54
+ return (Array.isArray(sendChats) ? sendChats : [sendChats])
55
+ .map(history_1.normalizeChatKey)
56
+ .filter(Boolean);
57
+ }
58
+ /** True while the account has a declared outbound scope at all. */
59
+ function isSendScopeConfigured(sendChats) {
60
+ return sendChats !== undefined && sendChats !== null;
61
+ }
62
+ function isChatSendable(target, sendChats) {
63
+ if (isPhoneNumberTarget(target))
64
+ return false;
65
+ if (!isSendScopeConfigured(sendChats))
66
+ return true;
67
+ const entries = normalizeScope(sendChats);
68
+ // A configured empty list is a decision, not an oversight: deny, the same
69
+ // way `readChats: []` denies rather than reading everything.
70
+ if (entries.length === 0)
71
+ return false;
72
+ if (entries.includes("*"))
73
+ return true;
74
+ return (0, history_1.chatKeyCandidates)(target).some((candidate) => entries.includes(candidate));
75
+ }
76
+ /**
77
+ * Область отправки каждого аккаунта, запомненная при его старте.
78
+ *
79
+ * В `outbound.resolveTarget` и `sendText` конфига нет — ядро зовёт их с
80
+ * `{ accountId, to }`, — а тащить её туда параметром значило бы менять
81
+ * контракт ядра ради одной проверки. Тот же приём уже применён к списку
82
+ * операторов (`system-notice.ts`), и по той же причине.
83
+ *
84
+ * Перезапуск канала при правке конфига обновляет запись; аккаунт, о котором
85
+ * ничего не помним, ведёт себя как аккаунт без области — то есть отправка
86
+ * разрешена, но телефонный адресат всё равно отвергнут.
87
+ */
88
+ const sendScopeByAccount = new Map();
89
+ function rememberSendScope(accountId, sendChats) {
90
+ sendScopeByAccount.set(accountId, sendChats);
91
+ }
92
+ function sendScopeFor(accountId) {
93
+ return sendScopeByAccount.get(accountId);
94
+ }
95
+ function forgetSendScope(accountId) {
96
+ sendScopeByAccount.delete(accountId);
97
+ }
@@ -20,14 +20,13 @@
20
20
  * tested without Telegram or a model.
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.TELEGRAM_REACTIONS = void 0;
24
23
  exports.buildEmojiSystemPrompt = buildEmojiSystemPrompt;
25
24
  exports.canonicalizeReactionEmoji = canonicalizeReactionEmoji;
26
25
  exports.parseEmojiChoice = parseEmojiChoice;
27
26
  exports.shouldReactToSilentTurn = shouldReactToSilentTurn;
28
27
  exports.reactToSilentMention = reactToSilentMention;
29
28
  function buildEmojiSystemPrompt(appetite, allowed) {
30
- const choices = allowed === undefined || allowed.length === 0 ? exports.TELEGRAM_REACTIONS : allowed;
29
+ const choices = allowed === undefined || allowed.length === 0 ? TELEGRAM_REACTIONS : allowed;
31
30
  const shared = [
32
31
  "You pick a single emoji reaction for a chat message.",
33
32
  "The assistant was mentioned in this message but decided it needs no written reply.",
@@ -84,7 +83,9 @@ function buildEmojiSystemPrompt(appetite, allowed) {
84
83
  * difference is invisible in an editor and a stray U+FE0F would break them
85
84
  * again silently.
86
85
  */
87
- exports.TELEGRAM_REACTIONS = [
86
+ // Модульная константа: снаружи её никто не читает, а `export` обещает
87
+ // публичную поверхность, которой нет (A6-21).
88
+ const TELEGRAM_REACTIONS = [
88
89
  "👍", "👎", "❤", "🔥", "🥰", "👏", "😁", "🤔", "🤯", "😱",
89
90
  "🤬", "😢", "🎉", "🤩", "🤮", "💩", "🙏", "👌", "\u{1F54A}", "🤡",
90
91
  "🥱", "🥴", "😍", "🐳", "🌚", "🌭", "💯", "🤣", "⚡", "🍌",
@@ -101,7 +102,10 @@ exports.TELEGRAM_REACTIONS = [
101
102
  * `👍🏽` is not a member of the set, `👍` is.
102
103
  */
103
104
  function canonicalizeReactionEmoji(value) {
104
- return value.replace(/️/g, "").replace(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
105
+ // U+FE0F записан кодом, а не символом: в исходнике он невидим, и
106
+ // регекс выглядел как `/ /g` — пустая на вид группа, которую при
107
+ // следующей правке легко «почистить» вместе со смыслом (S1-09).
108
+ return value.replace(/\u{FE0F}/gu, "").replace(/[\u{1F3FB}-\u{1F3FF}]/gu, "");
105
109
  }
106
110
  /**
107
111
  * Turns a model answer into an emoji Telegram will actually take, or nothing.
@@ -132,7 +136,7 @@ function parseEmojiChoice(raw, allowed) {
132
136
  // `ChatReactionsNone` — reactions switched off — and must permit nothing.
133
137
  // Collapsing the two would react in a chat that forbids reacting.
134
138
  const permitted = allowed === undefined
135
- ? exports.TELEGRAM_REACTIONS
139
+ ? TELEGRAM_REACTIONS
136
140
  : allowed.map(canonicalizeReactionEmoji);
137
141
  return permitted.includes(candidate) ? candidate : undefined;
138
142
  }
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveStateDir = resolveStateDir;
4
+ /**
5
+ * Где живёт состояние OpenClaw — одним ответом для всего плагина.
6
+ *
7
+ * `OPENCLAW_STATE_DIR` первичен, дом — запасной вариант. Правило не
8
+ * косметическое: изолированный Gateway, который AGENTS.md предписывает для
9
+ * любой локальной проверки («никогда не трогай `~/.openclaw` — всегда задавай
10
+ * `OPENCLAW_STATE_DIR`»), иначе продолжает писать в боевое состояние.
11
+ *
12
+ * Помощник по медиа переменную уже уважал, журнал присоединений — нет, и
13
+ * тестовый экземпляр дописывал записи в живой журнал, а на двух тысячах
14
+ * записей переписывал его (находка A6-07). Резолвер один, чтобы такие
15
+ * расхождения не заводились по одному на файл.
16
+ */
17
+ const node_os_1 = require("node:os");
18
+ const node_path_1 = require("node:path");
19
+ function resolveStateDir(env = process.env) {
20
+ const configured = env.OPENCLAW_STATE_DIR;
21
+ if (typeof configured === "string" && configured.trim())
22
+ return configured.trim();
23
+ return (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw");
24
+ }
@@ -27,6 +27,10 @@
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.classifySystemNotice = classifySystemNotice;
29
29
  exports.shouldSuppressGroupSystemNotice = shouldSuppressGroupSystemNotice;
30
+ exports.isOperatorRecipient = isOperatorRecipient;
31
+ exports.rememberOperatorIds = rememberOperatorIds;
32
+ exports.operatorIdsFor = operatorIdsFor;
33
+ exports.forgetOperatorIds = forgetOperatorIds;
30
34
  const TOOL_WARNING_PREFIX = "⚠️ 🛠️ ";
31
35
  const MESSAGE_FAILED_PREFIX = "⚠️ ✉️ message failed";
32
36
  const FALLBACK_NOTICE_PREFIX = "↪️ model fallback";
@@ -62,9 +66,50 @@ function classifySystemNotice(text) {
62
66
  * so the text seen here is core's payload verbatim.
63
67
  */
64
68
  function shouldSuppressGroupSystemNotice(params) {
65
- // DMs keep the telemetry: there the reader is the person running the agent.
66
- if (params.targetKind !== "group" && params.targetKind !== "channel") {
67
- return undefined;
69
+ if (params.targetKind === "group" || params.targetKind === "channel") {
70
+ return classifySystemNotice(params.text);
71
+ }
72
+ // Личка держала телеметрию на допущении «здесь читает тот, кто запустил
73
+ // агента». Допущение неверно: в личку пишет всякий, кто попал в `allowFrom`,
74
+ // и посторонний, чей ход уронил инструмент, получал `⚠️ 🛠️ Bash failed:`
75
+ // с полной командой и путями вроде /opt/openclaw-secrets/… (A5-11).
76
+ //
77
+ // Поэтому телеметрия уходит только названному оператору. Список пуст или
78
+ // содержит `*` — значит «оператор» не определён, и уведомление подавляется:
79
+ // потерять диагностику дешевле, чем отдать раскладку инфраструктуры
80
+ // незнакомцу, тем более что те же сбои лежат в диагностике прогона,
81
+ // в `lastError` джоба и в логе gateway.
82
+ if (!isOperatorRecipient(params.to, params.operatorIds)) {
83
+ return classifySystemNotice(params.text);
68
84
  }
69
- return classifySystemNotice(params.text);
85
+ return undefined;
86
+ }
87
+ function isOperatorRecipient(to, operatorIds) {
88
+ if (to === undefined || to === null || String(to).trim() === "")
89
+ return false;
90
+ if (!operatorIds || operatorIds.length === 0)
91
+ return false;
92
+ // `*` здесь не «все операторы», а «оператор не назван»: в списке отправителей
93
+ // звёздочка означает «кто угодно», и телеметрию кому угодно слать нельзя.
94
+ if (operatorIds.some((id) => String(id).trim() === "*"))
95
+ return false;
96
+ const target = String(to).trim().replace(/^@/, "").toLowerCase();
97
+ return operatorIds.some((id) => String(id).trim().replace(/^@/, "").toLowerCase() === target);
98
+ }
99
+ /**
100
+ * Кто оператор у каждого аккаунта.
101
+ *
102
+ * Список запоминается при старте аккаунта: в `outbound.sendText` конфига нет,
103
+ * а тащить её туда параметром значило бы менять контракт ради одной проверки.
104
+ * Перезапуск канала при правке конфига обновляет запись.
105
+ */
106
+ const operatorIdsByAccount = new Map();
107
+ function rememberOperatorIds(accountId, ids) {
108
+ operatorIdsByAccount.set(accountId, [...ids]);
109
+ }
110
+ function operatorIdsFor(accountId) {
111
+ return operatorIdsByAccount.get(accountId) ?? [];
112
+ }
113
+ function forgetOperatorIds(accountId) {
114
+ operatorIdsByAccount.delete(accountId);
70
115
  }
@@ -3,12 +3,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.keepSecretRefs = keepSecretRefs;
6
7
  exports.createConfigBackup = createConfigBackup;
8
+ exports.secretRefFieldsFor = secretRefFieldsFor;
7
9
  exports.updateConfigFileDirectly = updateConfigFileDirectly;
8
10
  const node_fs_1 = require("node:fs");
9
11
  const node_path_1 = __importDefault(require("node:path"));
10
12
  const json5_1 = __importDefault(require("json5"));
11
13
  const constants_1 = require("./constants");
14
+ const secret_refs_1 = require("./secret-refs");
15
+ const util_1 = require("./util");
12
16
  function formatBackupTimestamp(date) {
13
17
  const year = String(date.getUTCFullYear());
14
18
  const month = String(date.getUTCMonth() + 1).padStart(2, "0");
@@ -24,9 +28,6 @@ function buildConfigBackupPath(configPath) {
24
28
  const suffix = `${formatBackupTimestamp(new Date())}-clawgram-auth`;
25
29
  return node_path_1.default.join(dir, `${fileName}.bak-${suffix}`);
26
30
  }
27
- function isPlainObject(value) {
28
- return typeof value === "object" && value !== null && !Array.isArray(value);
29
- }
30
31
  function buildAccountPayload(auth) {
31
32
  return {
32
33
  enabled: true,
@@ -35,19 +36,39 @@ function buildAccountPayload(auth) {
35
36
  sessionString: auth.sessionString,
36
37
  };
37
38
  }
39
+ /** Первый конфиг закрыт: см. одноимённую функцию в cli-core.ts. */
38
40
  function buildAccountConfigFragment(auth) {
39
41
  return {
40
42
  ...buildAccountPayload(auth),
41
- allowFrom: ["*"],
42
- groups: {
43
- "*": {
44
- enabled: true,
45
- groupPolicy: "mention",
46
- allowFrom: ["*"],
47
- },
48
- },
43
+ allowFrom: auth.selfId ? [auth.selfId] : [],
44
+ readChats: [],
49
45
  };
50
46
  }
47
+ /**
48
+ * Credentials already moved into the secret store must survive re-auth.
49
+ *
50
+ * An account whose apiHash/sessionString were migrated to `{source, provider,
51
+ * id}` had them overwritten with the literal strings typed during the
52
+ * interactive flow: the spread put the fresh payload on top of the reference.
53
+ * So an operator re-authorising after a session expiry silently undid the
54
+ * migration and left plaintext credentials in a file that gets backed up and
55
+ * synced — the exact thing secret-refs.ts was written to prevent.
56
+ *
57
+ * The reference wins. The new value is handed back so the caller can tell the
58
+ * operator to store it where the reference points; it is never written to the
59
+ * config, and never printed here.
60
+ */
61
+ function keepSecretRefs(existingAccount, payload) {
62
+ const next = { ...payload };
63
+ const kept = [];
64
+ for (const field of ["apiHash", "sessionString"]) {
65
+ if ((0, secret_refs_1.asSecretRef)(existingAccount?.[field])) {
66
+ next[field] = existingAccount[field];
67
+ kept.push(field);
68
+ }
69
+ }
70
+ return { payload: next, kept };
71
+ }
51
72
  function applyAuthToConfig(config, accountId, auth) {
52
73
  const channels = config.channels && typeof config.channels === "object" ? config.channels : {};
53
74
  const channelConfig = channels[constants_1.CHANNEL_ID] && typeof channels[constants_1.CHANNEL_ID] === "object" ? channels[constants_1.CHANNEL_ID] : {};
@@ -63,16 +84,12 @@ function applyAuthToConfig(config, accountId, auth) {
63
84
  ...accounts,
64
85
  [accountId]: {
65
86
  ...existingAccount,
66
- ...buildAccountPayload(auth),
87
+ ...keepSecretRefs(existingAccount, buildAccountPayload(auth)).payload,
67
88
  enabled: existingAccount.enabled ?? true,
68
- allowFrom: existingAccount.allowFrom ?? ["*"],
69
- groups: existingAccount.groups ?? {
70
- "*": {
71
- enabled: true,
72
- groupPolicy: "mention",
73
- allowFrom: ["*"],
74
- },
75
- },
89
+ // Существующие настройки не трогаем — повторная авторизация не
90
+ // повод переписать чужие решения. Отсутствующие садятся закрытыми.
91
+ allowFrom: existingAccount.allowFrom ?? (auth.selfId ? [auth.selfId] : []),
92
+ readChats: existingAccount.readChats ?? [],
76
93
  },
77
94
  },
78
95
  },
@@ -244,12 +261,27 @@ function scanValue(raw, start) {
244
261
  kind: "scalar",
245
262
  };
246
263
  }
247
- function findObjectEnd(raw, objectStart) {
264
+ /**
265
+ * Позиция СРАЗУ ЗА закрывающей скобкой объекта — как `end` у среза.
266
+ *
267
+ * Имя обманывало: `scanEnclosedValue` возвращает индекс после `}`, а вставка
268
+ * свойства обращалась с этим числом как с позицией самой скобки и клала
269
+ * свойство ЗА объектом. Итог — испорченный конфиг: аккаунт, добавленный к
270
+ * существующему `accounts`, оказывался соседом `accounts` внутри `clawgram`,
271
+ * а при отсутствующем `channels` вставка уезжала за корневую `}` и файл
272
+ * переставал быть JSON вовсе. Ловилось только на путях вставки, а обычная
273
+ * переавторизация идёт путём замены — поэтому и жило (находка A6-17).
274
+ */
275
+ function findObjectEndExclusive(raw, objectStart) {
248
276
  return scanEnclosedValue(raw, objectStart, "{", "}");
249
277
  }
278
+ /** Позиция самой закрывающей скобки — точка, ПЕРЕД которой вставляют. */
279
+ function findObjectCloseBrace(raw, objectStart) {
280
+ return findObjectEndExclusive(raw, objectStart) - 1;
281
+ }
250
282
  function listObjectProperties(raw, objectStart) {
251
283
  const properties = [];
252
- const objectEnd = findObjectEnd(raw, objectStart);
284
+ const objectEnd = findObjectEndExclusive(raw, objectStart);
253
285
  let cursor = skipTrivia(raw, objectStart + 1);
254
286
  while (cursor < objectEnd) {
255
287
  if (raw[cursor] === "}") {
@@ -311,16 +343,16 @@ function replaceRange(raw, start, end, value) {
311
343
  return `${raw.slice(0, start)}${value}${raw.slice(end)}`;
312
344
  }
313
345
  function insertObjectProperty(raw, objectStart, key, value, format) {
314
- const objectEnd = findObjectEnd(raw, objectStart);
346
+ const closeBrace = findObjectCloseBrace(raw, objectStart);
315
347
  const parentIndent = getLineIndent(raw, objectStart);
316
348
  const propertyIndent = `${parentIndent}${format.indentUnit}`;
317
349
  const propertyText = `${JSON.stringify(key)}: ${formatConfigValue(value, propertyIndent, format)}`;
318
350
  const properties = listObjectProperties(raw, objectStart);
319
351
  if (properties.length === 0) {
320
352
  const insertion = `${format.eol}${propertyIndent}${propertyText}${format.eol}${parentIndent}`;
321
- return replaceRange(raw, objectEnd, objectEnd, insertion);
353
+ return replaceRange(raw, closeBrace, closeBrace, insertion);
322
354
  }
323
- let insertAt = objectEnd;
355
+ let insertAt = closeBrace;
324
356
  while (insertAt > objectStart + 1 && /[ \t\r\n]/.test(raw[insertAt - 1])) {
325
357
  insertAt -= 1;
326
358
  }
@@ -333,23 +365,74 @@ function replaceObjectPropertyValue(raw, property, value, format) {
333
365
  const formattedValue = formatConfigValue(value, propertyIndent, format);
334
366
  return replaceRange(raw, property.valueStart, property.valueEnd, formattedValue);
335
367
  }
368
+ /**
369
+ * Спуск по пути объектов. Недостающие звенья создаются пустыми объектами.
370
+ *
371
+ * Возвращает и текст, и позицию начала найденного объекта: после вставки
372
+ * прежние смещения указывают не туда.
373
+ */
374
+ function descend(raw, objectStart, path) {
375
+ let start = objectStart;
376
+ for (let i = 0; i < path.length; i += 1) {
377
+ const property = findObjectProperty(raw, start, path[i]);
378
+ // Недостающее звено не достраивается по одному: вставить пустой объект и
379
+ // тут же найти его снова — значит положиться на разбор свойств сразу
380
+ // после правки текста, а это самое хрупкое место здесь. Вместо этого
381
+ // вызывающий вставляет весь остаток пути одним литералом.
382
+ if (!property)
383
+ return { objectStart: start, missing: path.slice(i) };
384
+ const valueStart = skipTrivia(raw, property.valueStart);
385
+ if (raw[valueStart] !== "{") {
386
+ throw new Error(`clawgram: ${path[i]} в конфиге не объект — правка вручную безопаснее`);
387
+ }
388
+ start = valueStart;
389
+ }
390
+ return { objectStart: start, missing: [] };
391
+ }
392
+ /** Вложенный литерал `{a: {b: {c: value}}}` для недостающего остатка пути. */
393
+ function nest(path, value) {
394
+ return path.reduceRight((inner, key) => ({ [key]: inner }), value);
395
+ }
396
+ /**
397
+ * Правится один аккаунт, а не весь блок `channels`.
398
+ *
399
+ * Раньше блок находился хирургически, а его значение целиком пересобиралось
400
+ * через `JSON.stringify`: пропадали все комментарии JSON5, висячие запятые и
401
+ * ручное форматирование — включая блоки ДРУГИХ каналов, к авторизации
402
+ * отношения не имеющих. Файл потому и JSON5, что в нём пишут пояснения; после
403
+ * каждой переавторизации они исчезали, а diff тонул в переформатировании,
404
+ * пряча ровно то изменение, ради которого всё делалось (находка A6-06).
405
+ *
406
+ * Чего это НЕ спасает: комментарии внутри самого правимого аккаунта. Его
407
+ * значение пересобирается — там меняются учётные данные, и разбирать его
408
+ * посвойственно значит полагаться на разбор комментариев в позициях, который
409
+ * здесь и так самое хрупкое место. Всё за пределами этого аккаунта остаётся
410
+ * как было.
411
+ */
336
412
  function buildUpdatedConfigText(raw, accountId, auth) {
337
413
  const parsed = json5_1.default.parse(raw);
338
- if (!isPlainObject(parsed)) {
414
+ if (!(0, util_1.isPlainObject)(parsed)) {
339
415
  throw new Error("OpenClaw config root must be an object.");
340
416
  }
341
- const updatedConfig = applyAuthToConfig(parsed, accountId, auth);
342
- const updatedChannels = isPlainObject(updatedConfig.channels) ? updatedConfig.channels : {};
343
417
  const format = detectTextFormat(raw);
344
418
  const rootStart = skipTrivia(raw, 0);
345
419
  if (raw[rootStart] !== "{") {
346
420
  throw new Error("OpenClaw config file is not a JSON object.");
347
421
  }
348
- const channelsProperty = findObjectProperty(raw, rootStart, "channels");
349
- if (!channelsProperty) {
350
- return insertObjectProperty(raw, rootStart, "channels", updatedChannels, format);
422
+ // Что должно оказаться в аккаунте — считает та же функция, что и раньше:
423
+ // правила про SecretRef и закрытый посев живут в одном месте.
424
+ const updatedConfig = applyAuthToConfig(parsed, accountId, auth);
425
+ const account = updatedConfig?.channels?.[constants_1.CHANNEL_ID]?.accounts?.[accountId] ?? {};
426
+ const path = ["channels", constants_1.CHANNEL_ID, "accounts"];
427
+ const { objectStart, missing } = descend(raw, rootStart, path);
428
+ if (missing.length) {
429
+ // Раздела нет — вставляем недостающий остаток вместе с аккаунтом.
430
+ return insertObjectProperty(raw, objectStart, missing[0], nest(missing.slice(1), { [accountId]: account }), format);
351
431
  }
352
- return replaceObjectPropertyValue(raw, channelsProperty, updatedChannels, format);
432
+ const existing = findObjectProperty(raw, objectStart, accountId);
433
+ return existing
434
+ ? replaceObjectPropertyValue(raw, existing, account, format)
435
+ : insertObjectProperty(raw, objectStart, accountId, account, format);
353
436
  }
354
437
  /**
355
438
  * The config this writes holds `apiHash` and `sessionString` in plaintext, so
@@ -391,11 +474,34 @@ async function createConfigBackup(configPath) {
391
474
  await node_fs_1.promises.chmod(backupPath, mode);
392
475
  return backupPath;
393
476
  }
477
+ /**
478
+ * Which credentials stayed as secret-store references, so the caller can say
479
+ * so. Silence here would be the worst outcome: the operator would believe the
480
+ * freshly issued credential is in the config and find out at the next start.
481
+ */
482
+ function secretRefFieldsFor(raw, accountId) {
483
+ let parsed;
484
+ try {
485
+ parsed = json5_1.default.parse(raw);
486
+ }
487
+ catch {
488
+ return [];
489
+ }
490
+ if (!(0, util_1.isPlainObject)(parsed))
491
+ return [];
492
+ const channels = (0, util_1.isPlainObject)(parsed.channels) ? parsed.channels : {};
493
+ const channel = (0, util_1.isPlainObject)(channels[constants_1.CHANNEL_ID]) ? channels[constants_1.CHANNEL_ID] : {};
494
+ const accounts = (0, util_1.isPlainObject)(channel.accounts) ? channel.accounts : {};
495
+ const account = (0, util_1.isPlainObject)(accounts[accountId]) ? accounts[accountId] : {};
496
+ return ["apiHash", "sessionString"].filter((field) => (0, secret_refs_1.asSecretRef)(account[field]));
497
+ }
394
498
  async function updateConfigFileDirectly(configPath, accountId, auth) {
395
499
  const raw = await node_fs_1.promises.readFile(configPath, "utf8");
500
+ const keptRefs = secretRefFieldsFor(raw, accountId);
396
501
  const nextRaw = buildUpdatedConfigText(raw, accountId, auth);
397
502
  if (nextRaw === raw) {
398
- return;
503
+ return keptRefs;
399
504
  }
400
505
  await writeConfigAtomically(configPath, nextRaw);
506
+ return keptRefs;
401
507
  }
package/dist/util.js ADDED
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ /**
3
+ * Small readers reimplemented once per module.
4
+ *
5
+ * `readString`, `readNumber` and `isPlainObject` each existed in two or three
6
+ * files with identical bodies. Identical is the good case: `toStringId` had
7
+ * drifted, and one path accepted an id the other rejected (finding A12-05).
8
+ * Nothing here is clever; the point is that there is one of each.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.readString = readString;
12
+ exports.readNumber = readNumber;
13
+ exports.isPlainObject = isPlainObject;
14
+ /** A non-empty trimmed string, or nothing. */
15
+ function readString(value) {
16
+ if (typeof value !== "string") {
17
+ return undefined;
18
+ }
19
+ const trimmed = value.trim();
20
+ return trimmed === "" ? undefined : trimmed;
21
+ }
22
+ /** A finite number, from a number or from its decimal spelling. */
23
+ function readNumber(value) {
24
+ if (typeof value === "number") {
25
+ return Number.isFinite(value) ? value : undefined;
26
+ }
27
+ if (value === undefined || value === null) {
28
+ return undefined;
29
+ }
30
+ const parsed = Number(String(value));
31
+ return Number.isFinite(parsed) ? parsed : undefined;
32
+ }
33
+ /** An object that is not an array and not null — a config or params bag. */
34
+ function isPlainObject(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }