@link-assistant/hive-mind 2.12.2 → 2.12.4

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.
@@ -654,6 +654,7 @@ hi
654
654
  runner_also_failed "रनर भी विफल हुआ; जाँच के लिए उसका exit code सुरक्षित रखा गया है।"
655
655
  killed "कार्य सत्र रोका गया: {{reason}}{{exitSuffix}}"
656
656
  stopped "कार्य सत्र उपयोगकर्ता द्वारा रोका गया{{requestedBy}}{{exitSuffix}}"
657
+ not_launched "कार्य सत्र शुरू ही नहीं हुआ, इसलिए उसका कोई log नहीं है और वह `--list` में नहीं दिखता।"
657
658
  duration
658
659
  label "अवधि"
659
660
  session
@@ -670,6 +671,8 @@ hi
670
671
  resumed_attempt "🔄 इस समाप्ति से पुनर्प्राप्ति हेतु नया कार्य सत्र शुरू किया गया (प्रयास {{attempt}}): {{sessionId}}"
671
672
  isolation
672
673
  label "Isolation"
674
+ execution
675
+ label "निष्पादन"
673
676
  error
674
677
  executing
675
678
  command "❌ {{commandName}} command चलाने में त्रुटि"
@@ -654,6 +654,7 @@ ru
654
654
  runner_also_failed "Средство запуска также завершилось с ошибкой; код выхода сохранён для расследования."
655
655
  killed "Рабочий сеанс остановлен: {{reason}}{{exitSuffix}}"
656
656
  stopped "Рабочий сеанс остановлен пользователем{{requestedBy}}{{exitSuffix}}"
657
+ not_launched "Рабочий сеанс не был запущен, поэтому у него нет журнала и он не отображается в `--list`."
657
658
  duration
658
659
  label "Длительность"
659
660
  session
@@ -670,6 +671,8 @@ ru
670
671
  resumed_attempt "🔄 Запущена новая рабочая сессия для восстановления после этого завершения (попытка {{attempt}}): {{sessionId}}"
671
672
  isolation
672
673
  label "Изоляция"
674
+ execution
675
+ label "Запуск"
673
676
  error
674
677
  executing
675
678
  command "❌ Ошибка выполнения команды {{commandName}}"
@@ -654,6 +654,7 @@ zh
654
654
  runner_also_failed "运行器也失败了;其退出代码已保留以供调查。"
655
655
  killed "工作会话已终止:{{reason}}{{exitSuffix}}"
656
656
  stopped "工作会话已由用户停止{{requestedBy}}{{exitSuffix}}"
657
+ not_launched "工作会话未启动,因此没有日志,也不会出现在 `--list` 中。"
657
658
  duration
658
659
  label "耗时"
659
660
  session
@@ -670,6 +671,8 @@ zh
670
671
  resumed_attempt "🔄 已启动新的工作会话以从此次终止中恢复(第 {{attempt}} 次尝试):{{sessionId}}"
671
672
  isolation
672
673
  label "隔离"
674
+ execution
675
+ label "执行"
673
676
  error
674
677
  executing
675
678
  command "❌ 执行 {{commandName}} 命令时出错"
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { spawn } from 'child_process';
26
26
  import { describeChildExit } from './child-exit.lib.mjs';
27
+ import { sanitizeForPublication } from './token-sanitization.lib.mjs'; // issue #2156: this body is published to a pull request
27
28
  import { KILL_CAUSE_DISK_FULL, KILL_CAUSE_FORCED_KILL, KILL_CAUSE_OUT_OF_MEMORY } from './session-kill-diagnostics.lib.mjs';
28
29
  import { ON_SESSION_KILL_RESUME } from './session-kill-policy.lib.mjs';
29
30
 
@@ -161,6 +162,13 @@ const defaultUnlink = async filePath => {
161
162
  * `--body-file` (not `--body`) is used deliberately: the notice contains
162
163
  * backticks and newlines that would otherwise have to survive shell quoting.
163
164
  *
165
+ * Issue #2156: the body is sanitized here rather than by the caller. It carries
166
+ * kill diagnostics and a resume command, both assembled from process and log
167
+ * data, so it is a publication boundary like any other and must fail closed.
168
+ * The array-argument `gh` invocation below is invisible to the
169
+ * `require-sanitized-output` ESLint rule, which is exactly how this path stayed
170
+ * unsanitized; the rule now understands this shape too.
171
+ *
164
172
  * @param {Object} options
165
173
  * @param {string} options.pullRequestUrl
166
174
  * @param {string} options.body
@@ -178,7 +186,7 @@ export async function postKillRecoveryNotice({ pullRequestUrl, body, runCommand
178
186
 
179
187
  const bodyFile = `${tempDir.replace(/\/$/, '')}/hive-mind-kill-notice-${fileSuffix}.md`;
180
188
  try {
181
- await writeFile(bodyFile, body);
189
+ await writeFile(bodyFile, await sanitizeForPublication(body));
182
190
  const result = await runCommand('gh', ['pr', 'comment', pullRequestUrl, '--body-file', bodyFile]);
183
191
  if (result?.code === 0) {
184
192
  const url = String(result.stdout || '').trim() || null;
@@ -164,6 +164,9 @@ export function trackSession(sessionName, sessionInfo, verbose = false) {
164
164
  url: sessionInfo.url || null,
165
165
  command: sessionInfo.command || null,
166
166
  sessionId: sessionInfo.sessionId || null,
167
+ // Issue #2154: `$ --list` prints start-command's execution UUID, not the
168
+ // session name. Logging both is what makes the two views joinable.
169
+ executionUuid: sessionInfo.executionUuid || null,
167
170
  startTime: sessionInfo.startTime instanceof Date ? sessionInfo.startTime.toISOString() : sessionInfo.startTime || null,
168
171
  });
169
172
  }
@@ -230,24 +233,40 @@ export function markSessionStopRequested(sessionId, { requestedBy = null, verbos
230
233
  * map and the durable store without emitting a `session_completed` audit event —
231
234
  * the session never ran, so it has no exit code to record (issue #1946).
232
235
  *
236
+ * Issue #2154: the durable structured log recorded only
237
+ * `session_untracked {"sessionName":…}`. Why the session disappeared a second
238
+ * after it was announced lived on untimestamped console lines, so an incident
239
+ * could not be reconstructed from the timestamped log alone. Callers now pass
240
+ * the reason and it is recorded with the event.
241
+ *
233
242
  * @param {string} sessionName - Name/UUID of the session to drop
234
243
  * @param {boolean} verbose - Whether to log verbose output
244
+ * @param {object} [details] - Extra context recorded with the `session_untracked` event
245
+ * @param {string} [details.reason] - Why the session was dropped (e.g. the launch error)
235
246
  */
236
- export function untrackSession(sessionName, verbose = false) {
247
+ export function untrackSession(sessionName, verbose = false, details = {}) {
237
248
  if (!sessionName) return;
238
249
  const sessionInfo = activeSessions.get(sessionName) || null;
239
250
  const existed = activeSessions.delete(sessionName);
251
+ const reason = typeof details?.reason === 'string' && details.reason.trim() ? details.reason.trim() : null;
240
252
  if (verbose && existed) {
241
- console.log(`[VERBOSE] Session ${sessionName} untracked (launch failed before it started)`);
253
+ console.log(`[VERBOSE] Session ${sessionName} untracked (launch failed before it started)${reason ? `: ${reason}` : ''}`);
242
254
  }
243
255
  if (sessionStore && isPersistableSession(sessionInfo)) {
244
256
  try {
245
- sessionStore.remove(sessionName, { status: 'launch-failed', exitCode: null });
257
+ sessionStore.remove(sessionName, { status: 'launch-failed', exitCode: null, reason });
246
258
  } catch (error) {
247
259
  console.error(`[session-monitor] Could not remove untracked session ${sessionName}: ${error.message}`);
248
260
  }
249
261
  }
250
- logEvent('session_untracked', { sessionName });
262
+ logEvent('session_untracked', {
263
+ sessionName,
264
+ reason,
265
+ url: sessionInfo?.url || null,
266
+ command: sessionInfo?.command || null,
267
+ tool: sessionInfo?.tool || null,
268
+ isolationBackend: sessionInfo?.isolationBackend || null,
269
+ });
251
270
  }
252
271
  /**
253
272
  * Get the number of active sessions being tracked
@@ -646,6 +665,18 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
646
665
  sessionInfo.logPath = statusResult.logPath;
647
666
  persistSessionSnapshot(sessionName, sessionInfo);
648
667
  }
668
+ // Issue #2154: the same status record carries start-command's *execution*
669
+ // UUID — the only identifier `$ --list` prints. A session launched before
670
+ // this fix (or by a start-command whose banner we could not parse) has
671
+ // none, so backfill it here; otherwise that session stays impossible to
672
+ // find in the session list for its whole lifetime.
673
+ if (statusResult?.uuid && sessionInfo.executionUuid !== statusResult.uuid) {
674
+ if (verbose) {
675
+ console.log(`[VERBOSE] Session ${sessionName}: recorded start-command execution UUID ${statusResult.uuid} (this is what '$ --list' shows)`);
676
+ }
677
+ sessionInfo.executionUuid = statusResult.uuid;
678
+ persistSessionSnapshot(sessionName, sessionInfo);
679
+ }
649
680
  } else {
650
681
  // Issue #1586: Non-isolation screen sessions cannot reliably detect
651
682
  // completion because start-screen keeps the screen alive via `exec bash`.
@@ -35,7 +35,10 @@ import path from 'node:path';
35
35
  // with its exact original invocation plus `--resume <lastSessionId>`.
36
36
  // `commandAlias` (#2109) preserves the Telegram spelling (`solve`, `codex`,
37
37
  // `claude`, etc.) so a bot notification never suggests a terminal-only command.
38
- const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
38
+ // `executionUuid` (#2154) is start-command's own identifier for the execution
39
+ // the one `$ --list` prints. It differs from `sessionId`, so persisting it is
40
+ // what lets a restarted bot still correlate its sessions with the session list.
41
+ const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'executionUuid', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
39
42
 
40
43
  /**
41
44
  * Resolve the directory durable bot state is written to. Honors
@@ -213,7 +216,9 @@ export function createSessionStore(options = {}) {
213
216
  delete sessions[sessionName];
214
217
  writeSnapshotMap(sessions);
215
218
  }
216
- appendEvent('complete', sessionName, { status: meta.status ?? null, exitCode: meta.exitCode ?? null });
219
+ // Issue #2154: carry the reason (when the caller knows it) so the durable
220
+ // event log says why a session ended, not just that it did.
221
+ appendEvent('complete', sessionName, { status: meta.status ?? null, exitCode: meta.exitCode ?? null, reason: meta.reason ?? null });
217
222
  log('debug', `Removed session ${sessionName} from snapshot`, meta);
218
223
  },
219
224
 
@@ -2,7 +2,7 @@ import { spawn } from 'child_process';
2
2
  import { describeChildExit } from './child-exit.lib.mjs';
3
3
  import { promisify } from 'util';
4
4
  import { exec as execCallback } from 'child_process';
5
- import { t } from './i18n.lib.mjs';
5
+ import { formatFailedLaunchMessage as defaultFormatFailedLaunchMessage } from './work-session-formatting.lib.mjs';
6
6
 
7
7
  const exec = promisify(execCallback);
8
8
 
@@ -101,7 +101,7 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
101
101
  * @returns {Function} executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation, tool, urlContext, sessionExtras)
102
102
  */
103
103
  export function buildExecuteAndUpdateMessage(deps) {
104
- const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = deps;
104
+ const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage, formatFailedLaunchMessage = defaultFormatFailedLaunchMessage } = deps;
105
105
  return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null, commandAlias = null } = {}) {
106
106
  const { chat, message_id: msgId } = startingMessage;
107
107
  const safeEdit = async text => {
@@ -130,14 +130,27 @@ export function buildExecuteAndUpdateMessage(deps) {
130
130
  trackSession(session, sessionInfo, VERBOSE);
131
131
  await safeEdit(formatStartingWorkSessionMessage({ sessionName: session, isolationBackend: iso.backend, infoBlock, locale }));
132
132
  result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, tool, verbose: VERBOSE });
133
- if (result.success && sessionInfo && Number.isFinite(result.containerFilesystemStartBytes)) {
134
- sessionInfo.containerFilesystemStartBytes = result.containerFilesystemStartBytes;
133
+ if (result.success && sessionInfo && (Number.isFinite(result.containerFilesystemStartBytes) || result.executionUuid)) {
134
+ if (Number.isFinite(result.containerFilesystemStartBytes)) sessionInfo.containerFilesystemStartBytes = result.containerFilesystemStartBytes;
135
+ // Issue #2154: `$ --list` identifies executions by start-command's own
136
+ // UUID, not by the session name the bot shows. Keep both on the session
137
+ // so the two views can be joined — in the reply, in the structured log
138
+ // and in the durable snapshot after a restart.
139
+ if (result.executionUuid) sessionInfo.executionUuid = result.executionUuid;
135
140
  trackSession(session, sessionInfo, VERBOSE);
136
141
  }
137
142
  if (!result.success) {
138
143
  // The launch never produced a live container — drop the optimistic
139
144
  // tracking so a phantom session is not monitored or resumed.
140
- if (typeof untrackSession === 'function') untrackSession(session, VERBOSE);
145
+ // Issue #2154: the untracking used to be the *only* trace of the
146
+ // failure anywhere outside Telegram. Record the UUID, the backend and
147
+ // the reason first, so the bot log explains why a session that was
148
+ // announced a second ago is gone and absent from `--list`.
149
+ const launchError = result.error || result.output || 'unknown error';
150
+ console.error(`[telegram-bot] ${commandName} session ${session} was not launched (isolation=${iso.backend}, tool=${tool}): ${launchError}`);
151
+ // The reason also goes into the structured `session_untracked` event, so
152
+ // the timestamped log explains the disappearance on its own.
153
+ if (typeof untrackSession === 'function') untrackSession(session, VERBOSE, { reason: launchError });
141
154
  sessionInfo = undefined;
142
155
  }
143
156
  } else {
@@ -152,9 +165,15 @@ export function buildExecuteAndUpdateMessage(deps) {
152
165
  }
153
166
  if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
154
167
  if (result.success) {
155
- await safeEdit(formatExecutingWorkSessionMessage({ sessionName: session, isolationBackend: iso?.backend || null, infoBlock, locale }));
168
+ await safeEdit(formatExecutingWorkSessionMessage({ sessionName: session, executionUuid: result.executionUuid || null, isolationBackend: iso?.backend || null, infoBlock, locale }));
156
169
  if (AUTO_WATCH_MESSAGE && commandName === 'solve' && sessionInfo?.isolationBackend) await startAutoTerminalWatchForSession({ bot, ctx, sessionId: session, sessionInfo, verbose: VERBOSE });
157
- } else await safeEdit(`${t('telegram.error_executing_command', { commandName }, { locale })}:\n\n\`\`\`\n${result.error || result.output}\n\`\`\`\n\n${infoBlock}`);
170
+ } else {
171
+ // Issue #2154: keep the session UUID in the failure reply. It is the only
172
+ // handle the operator has on the attempt, and this edit replaces the
173
+ // "🔄 Starting..." message that used to carry it.
174
+ if (!iso) console.error(`[telegram-bot] ${commandName} command failed to start (no isolation, tool=${tool}): ${result.error || result.output || 'unknown error'}`);
175
+ await safeEdit(formatFailedLaunchMessage({ commandName, sessionName: iso ? session : null, isolationBackend: iso?.backend || null, infoBlock, error: result.error || result.output, locale }));
176
+ }
158
177
  };
159
178
  }
160
179
 
@@ -15,7 +15,7 @@ import { collectExecutingItems, formatDuration, formatQueueToolSection, formatWa
15
15
  export { QUEUE_CONFIG, THRESHOLD_STRATEGIES } from './queue-config.lib.mjs';
16
16
  import { QUEUE_CONFIG } from './queue-config.lib.mjs';
17
17
  import { reserveStartSlotForQueue } from './queue-start-reservation.lib.mjs';
18
- import { formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
18
+ import { formatExecutingWorkSessionMessage, formatFailedLaunchMessage, formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
19
19
  import { t } from './i18n.lib.mjs';
20
20
  import { lt } from './limits-i18n.lib.mjs';
21
21
  export const QueueItemStatus = {
@@ -1070,9 +1070,24 @@ export class SolveQueue {
1070
1070
  // This was a bug where the final message update never happened because messageInfo was null
1071
1071
  // See: https://github.com/link-assistant/hive-mind/issues/1062
1072
1072
  const savedMessageInfo = item.messageInfo;
1073
- // Update to Started status (terminal - forgets message tracking)
1074
- item.setStarted(sessionName);
1075
- this.stats.totalCompleted++;
1073
+ // Issue #2154: a launch that never produced a container was still marked
1074
+ // STARTED, counted in `totalCompleted`, pushed onto `completed` and
1075
+ // logged as `Finished: […] (started)`. Three Formal AI tasks that never
1076
+ // ran therefore appear in the bot log as successful starts — a false
1077
+ // positive that hid the incident and contradicted `$ --list`, which had
1078
+ // no such sessions. A refused launch is a failure of the queue item.
1079
+ const launchFailed = Boolean(result) && result.success === false;
1080
+ if (launchFailed) {
1081
+ item.setFailed(result.error || result.output || 'the launch was refused before the container started');
1082
+ item.sessionName = sessionName;
1083
+ item.messageInfo = null; // terminal status — stop tracking the message
1084
+ this.stats.totalFailed++;
1085
+ console.error(`[solve_queue] Item ${item.id} was not launched (session ${sessionName}): ${item.error}`);
1086
+ } else {
1087
+ // Update to Started status (terminal - forgets message tracking)
1088
+ item.setStarted(sessionName);
1089
+ this.stats.totalCompleted++;
1090
+ }
1076
1091
  // Final message update using saved messageInfo
1077
1092
  if (item.ctx && result && savedMessageInfo) {
1078
1093
  const { chatId, messageId } = savedMessageInfo;
@@ -1089,7 +1104,17 @@ export class SolveQueue {
1089
1104
  });
1090
1105
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
1091
1106
  } else {
1092
- const response = `${t('telegram.error_executing_command', { commandName: 'solve' }, { locale: item.locale })}:\n\n\`\`\`\n${result.error || result.output}\n\`\`\`\n\n${item.infoBlock}`;
1107
+ // Issue #2154: a queued /solve that fails to launch reports the
1108
+ // same way as a direct one — with its session UUID and a note
1109
+ // that nothing was started, instead of a bare error dump.
1110
+ const response = formatFailedLaunchMessage({
1111
+ commandName: 'solve',
1112
+ sessionName: sessionName === 'unknown' ? null : sessionName,
1113
+ isolationBackend: result.isolationBackend || null,
1114
+ infoBlock: item.infoBlock,
1115
+ error: result.error || result.output,
1116
+ locale: item.locale,
1117
+ });
1093
1118
  await item.ctx.telegram.editMessageText(chatId, messageId, undefined, response, { parse_mode: 'Markdown' });
1094
1119
  }
1095
1120
  } catch (error) {
@@ -19,6 +19,7 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
19
19
  // lib.mjs, so it must not depend on this asynchronous Secretlint layer.
20
20
  import { log, isENOSPC } from './lib.mjs';
21
21
  import { CREDENTIAL_SANITIZATION_ERROR_CODE, CREDENTIAL_SANITIZATION_FAILURE_MESSAGE, createCredentialStreamSanitizer, findCredentialResiduals, maskToken, sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
22
+ import { findDecodableRuns, findEncodedKnownTokenRuns, sanitizeEncodedCredentials } from './encoded-credential-detection.lib.mjs'; // issue #2156: credentials that only appear re-encoded
22
23
  import { reportError } from './sentry.lib.mjs';
23
24
 
24
25
  export { createCredentialStreamSanitizer };
@@ -518,6 +519,130 @@ const sanitizeCredentialTextPreservingExclusions = (input, excludedSet) => {
518
519
  return output;
519
520
  };
520
521
 
522
+ // ---------------------------------------------------------------------------
523
+ // Issue #2156 — known-local tokens that only appear in an encoded form
524
+ // ---------------------------------------------------------------------------
525
+ // The leak in this issue was a `gho_` token that the GHCR token endpoint echoed
526
+ // back base64-encoded inside a JSON body. Every masking layer we had compared
527
+ // bytes literally, so the encoded copy walked straight through. These helpers
528
+ // mask the *encoded* occurrences of tokens we already hold locally.
529
+ // ---------------------------------------------------------------------------
530
+
531
+ /** Encoded-scan recursion limit: base64-of-base64-of-base64 and no deeper. */
532
+ const MAX_ENCODED_KNOWN_TOKEN_DEPTH = 2;
533
+
534
+ /**
535
+ * Replace every verbatim occurrence of the supplied token values.
536
+ *
537
+ * @param {string} text
538
+ * @param {Array<string>} values already filtered and de-duplicated
539
+ * @returns {string}
540
+ */
541
+ const maskKnownTokenValues = (text, values) => {
542
+ let output = text;
543
+ for (const value of values) {
544
+ if (output.includes(value)) output = output.split(value).join(maskToken(value));
545
+ }
546
+ return output;
547
+ };
548
+
549
+ /**
550
+ * Narrow a raw token list to the values worth searching for.
551
+ *
552
+ * @param {Array<string|{value: string}>} tokens
553
+ * @param {Set<string>} [excludedSet] issue #1745 user-content carve-out
554
+ * @returns {Array<string>}
555
+ */
556
+ const usableTokenValues = (tokens, excludedSet) => [...new Set((tokens || []).map(t => (typeof t === 'string' ? t : t?.value)).filter(value => typeof value === 'string' && value.length >= 12))].filter(value => !excludedSet?.has(value));
557
+
558
+ /**
559
+ * Mask encoded occurrences of known-local tokens.
560
+ *
561
+ * Decoded payloads are rebuilt rather than dropped: a base64 blob that merely
562
+ * *contains* the token keeps its other fields and stays parseable, and the
563
+ * masked token retains its first/last characters for debugging — the same
564
+ * contract plaintext masking has always offered.
565
+ *
566
+ * @param {string} text
567
+ * @param {Array<string>} values from {@link usableTokenValues}
568
+ * @param {number} [depth] internal recursion counter
569
+ * @returns {string}
570
+ */
571
+ const maskEncodedKnownTokens = (text, values, depth = 0) => {
572
+ if (values.length === 0) return text;
573
+ return sanitizeEncodedCredentials(text, {
574
+ knownTokens: values,
575
+ sanitizePlaintext: decoded => {
576
+ const masked = maskKnownTokenValues(decoded, values);
577
+ // Peel nested encodings so base64-of-base64 is covered too.
578
+ return depth >= MAX_ENCODED_KNOWN_TOKEN_DEPTH ? masked : maskEncodedKnownTokens(masked, values, depth + 1);
579
+ },
580
+ });
581
+ };
582
+
583
+ /**
584
+ * Mask encoded runs whose *decoded* payload Secretlint recognises.
585
+ *
586
+ * This is the redundancy the issue asks for, aimed at where it actually helps.
587
+ * Secretlint is blind to encoding: its GitHub rule flags a bare `gho_…` but
588
+ * reports nothing for the same token base64-encoded, and neither does any other
589
+ * pattern scanner, because a pattern scanner matches the bytes it is given.
590
+ * Adding a third scanner alongside the first two would therefore have changed
591
+ * nothing about this incident. Decoding first and *then* asking both detectors
592
+ * is what closes the gap, so the external rule set is applied to the decoded
593
+ * payload exactly as the maintained core already is.
594
+ *
595
+ * The two detectors stay independent: this runs whether or not the core found
596
+ * anything, so a credential format Secretlint knows and we do not is still
597
+ * caught once it is decoded.
598
+ *
599
+ * @param {string} text
600
+ * @param {Set<string>} [excludedSet] issue #1745 user-content carve-out
601
+ * @returns {Promise<{text: string, masked: number, ruleIds: Array<string>}>}
602
+ */
603
+ const maskEncodedSecretsWithSecretlint = async (text, excludedSet) => {
604
+ const runs = findDecodableRuns(text);
605
+ if (runs.length === 0) return { text, masked: 0, ruleIds: [] };
606
+
607
+ // Each payload is scanned on its own rather than as one joined document: a
608
+ // rule that matched across a join boundary would blame a run that is
609
+ // innocent, and masking an innocent run destroys log content.
610
+ const verdicts = await Promise.all(runs.map(run => detectSecretsWithSecretlint(run.decoded)));
611
+
612
+ // Keyed by decoded content, because that is what the sync layer hands back
613
+ // when it re-walks the same runs below. Two runs that decode identically are
614
+ // masked identically, which is what we want.
615
+ const maskedPayloads = new Map();
616
+ const ruleIds = new Set();
617
+ for (const [index, findings] of verdicts.entries()) {
618
+ const usable = findings.filter(finding => !excludedSet?.has(finding.token));
619
+ if (usable.length === 0) continue;
620
+ const { decoded } = runs[index];
621
+
622
+ // Mask inside the decoded payload so the surrounding structure survives.
623
+ // Ranges are spliced from the end so earlier offsets stay valid.
624
+ let payload = decoded;
625
+ for (const finding of [...usable].sort((a, b) => b.start - a.start)) {
626
+ if (payload.substring(finding.start, finding.end) !== finding.token) continue;
627
+ payload = payload.substring(0, finding.start) + maskToken(finding.token) + payload.substring(finding.end);
628
+ ruleIds.add(finding.ruleId);
629
+ }
630
+ if (payload === decoded) continue;
631
+ maskedPayloads.set(decoded, payload);
632
+ }
633
+
634
+ if (maskedPayloads.size === 0) return { text, masked: 0, ruleIds: [] };
635
+
636
+ // Re-encoding, round-trip verification and overlap merging are the sync
637
+ // layer's job. Driving it with a lookup of payloads we have already masked
638
+ // means the two paths cannot disagree about what a masked run looks like.
639
+ const output = sanitizeEncodedCredentials(text, {
640
+ sanitizePlaintext: decoded => maskedPayloads.get(decoded) ?? decoded,
641
+ });
642
+
643
+ return { text: output, masked: maskedPayloads.size, ruleIds: [...ruleIds] };
644
+ };
645
+
521
646
  /**
522
647
  * Sanitize arbitrary outbound output by masking sensitive tokens while avoiding false positives
523
648
  * Uses DUAL APPROACH: Both secretlint AND custom patterns run independently
@@ -543,6 +668,8 @@ export const sanitizeOutput = async (output, options = {}) => {
543
668
  const stats = {
544
669
  knownTokens: 0,
545
670
  secretlintDetections: 0,
671
+ encodedSecretlintDetections: 0,
672
+ encodedSecretlintRuleIds: [],
546
673
  customDetections: 0,
547
674
  secretlintOnlyWarnings: [],
548
675
  customOnlyDetections: [],
@@ -571,6 +698,17 @@ export const sanitizeOutput = async (output, options = {}) => {
571
698
  }
572
699
  }
573
700
  }
701
+
702
+ // Issue #2156: the same tokens, base64/hex/percent-encoded. Byte-for-byte
703
+ // comparison above cannot see those copies.
704
+ const encodableTokens = usableTokenValues(allKnownTokens, excludedSet);
705
+ const beforeEncoded = sanitized;
706
+ sanitized = maskEncodedKnownTokens(sanitized, encodableTokens);
707
+ if (sanitized !== beforeEncoded) {
708
+ stats.knownTokens++;
709
+ sanitizationStats.knownTokenMasks++;
710
+ sanitizationStats.totalMasked++;
711
+ }
574
712
  }
575
713
 
576
714
  if (skipOutputSanitization) {
@@ -663,6 +801,21 @@ export const sanitizeOutput = async (output, options = {}) => {
663
801
  }
664
802
  }
665
803
 
804
+ // Step 3b (issue #2156): everything above compares against the *surface*
805
+ // text, so a credential that only ever appears encoded is invisible to it —
806
+ // that is exactly how the leaked token survived. The maintained core
807
+ // already reads decoded payloads; run the external rule set over them too,
808
+ // so the two layers cover the same ground and either one can be the catch.
809
+ const beforeEncodedScan = sanitized;
810
+ const encodedScan = await maskEncodedSecretsWithSecretlint(sanitized, excludedSet);
811
+ if (encodedScan.text !== beforeEncodedScan) {
812
+ sanitized = encodedScan.text;
813
+ stats.encodedSecretlintDetections += encodedScan.masked;
814
+ stats.encodedSecretlintRuleIds = encodedScan.ruleIds;
815
+ sanitizationStats.patternMasks += encodedScan.masked;
816
+ sanitizationStats.totalMasked += encodedScan.masked;
817
+ }
818
+
666
819
  // Step 4: Handle 40-char hex tokens specially - only mask if NOT in safe context
667
820
  // These could be GitHub tokens OR git commit hashes/gist IDs
668
821
  const hexPattern = /(?:^|[\s:=])([a-f0-9]{40})(?=[\s\n]|$)/gm;
@@ -701,11 +854,14 @@ export const sanitizeOutput = async (output, options = {}) => {
701
854
  }
702
855
 
703
856
  // Summary logging
704
- const totalMasked = allSecrets.size + hexReplacements.length + stats.knownTokens;
857
+ const totalMasked = allSecrets.size + hexReplacements.length + stats.knownTokens + stats.encodedSecretlintDetections;
705
858
  if (global.verboseMode && totalMasked > 0) {
706
859
  await log(` 🔒 Sanitized ${totalMasked} secrets using dual approach:`, { verbose: true });
707
860
  await log(` • Known tokens: ${stats.knownTokens}`, { verbose: true });
708
861
  await log(` • Secretlint: ${stats.secretlintDetections} detections`, { verbose: true });
862
+ if (stats.encodedSecretlintDetections > 0) {
863
+ await log(` • Secretlint (encoded payloads): ${stats.encodedSecretlintDetections} run(s) [${stats.encodedSecretlintRuleIds.join(', ')}]`, { verbose: true });
864
+ }
709
865
  await log(` • Custom patterns: ${stats.customDetections} detections`, { verbose: true });
710
866
  await log(` • Hex tokens: ${hexReplacements.length}`, { verbose: true });
711
867
  if (stats.secretlintOnlyWarnings.length > 0) {
@@ -929,16 +1085,29 @@ export const getAllKnownLocalTokens = async () => {
929
1085
  * @param {Array<{value: string, name?: string, source?: string}>} [tokens]
930
1086
  * Pre-fetched token list (if you already called getAllKnownLocalTokens).
931
1087
  * Pass an explicit list to avoid re-running `gh auth status` per check.
932
- * @returns {Promise<Array<{name: string, source: string}>>} list of token
933
- * identifiers that were found in the text (NOT the values themselves).
1088
+ * Issue #2156: a token that appears only base64/hex/percent-encoded is a leak
1089
+ * just the same GitHub's own secret scanning decodes before matching, which
1090
+ * is exactly how the revocation in that issue was triggered. Encoded hits are
1091
+ * reported with the encoding that matched so operators can tell the two cases
1092
+ * apart in the fail-closed publication error path.
1093
+ *
1094
+ * @returns {Promise<Array<{name: string, source: string, encoding: string}>>}
1095
+ * list of token identifiers that were found in the text (NOT the values
1096
+ * themselves).
934
1097
  */
935
1098
  export const containsKnownToken = async (text, tokens) => {
936
1099
  if (typeof text !== 'string' || text.length === 0) return [];
937
1100
  const list = tokens || (await getAllKnownLocalTokens());
938
1101
  const hits = [];
939
1102
  for (const t of list) {
940
- if (t.value && text.includes(t.value)) {
941
- hits.push({ name: t.name, source: t.source });
1103
+ if (!t.value) continue;
1104
+ if (text.includes(t.value)) {
1105
+ hits.push({ name: t.name, source: t.source, encoding: 'plaintext' });
1106
+ continue;
1107
+ }
1108
+ const encodedRuns = findEncodedKnownTokenRuns(text, [t.value]);
1109
+ if (encodedRuns.length > 0) {
1110
+ hits.push({ name: t.name, source: t.source, encoding: encodedRuns[0].encoding });
942
1111
  }
943
1112
  }
944
1113
  return hits;
@@ -979,6 +1148,14 @@ export const sanitizeCommentBody = async (body, options = {}) => {
979
1148
  sanitizationStats.totalMasked++;
980
1149
  }
981
1150
  }
1151
+
1152
+ // Issue #2156: the same tokens, re-encoded (base64/hex/percent/escapes).
1153
+ const beforeEncoded = sanitized;
1154
+ sanitized = maskEncodedKnownTokens(sanitized, usableTokenValues(knownTokens, excludedSet));
1155
+ if (sanitized !== beforeEncoded) {
1156
+ sanitizationStats.knownTokenMasks++;
1157
+ sanitizationStats.totalMasked++;
1158
+ }
982
1159
  }
983
1160
 
984
1161
  // Pass 2: regex + secretlint sweep for anything else.
@@ -80,12 +80,58 @@ export function formatStartingWorkSessionMessage({ sessionName = null, isolation
80
80
  return `${header}\n\n📊 ${sessionLabel}: \`${sessionName}\`${isolationInfo}${details}`;
81
81
  }
82
82
 
83
- export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', isolationBackend = null, infoBlock = '', locale = null } = {}) {
83
+ export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', executionUuid = null, isolationBackend = null, infoBlock = '', locale = null } = {}) {
84
84
  const sessionLabel = text(locale, 'telegram.session_label', 'Session');
85
85
  const isolationLabel = text(locale, 'telegram.isolation_label', 'Isolation');
86
86
  const isolationInfo = isolationBackend ? `\n🔒 ${isolationLabel}: \`${isolationBackend}\`` : '';
87
87
  const details = infoBlock ? `\n\n${infoBlock}` : '';
88
- return `${text(locale, 'telegram.work_session_executing', '⏳ Executing...')}\n\n📊 ${sessionLabel}: \`${sessionName}\`${isolationInfo}${details}`;
88
+ // Issue #2154: `$ --list` identifies an execution by start-command's own UUID,
89
+ // which is *not* the session name shown above it. Printing only one of the two
90
+ // left the operator unable to match a running task to the session list. The
91
+ // line is omitted entirely when start-command reported no UUID, so a session
92
+ // never carries an empty label.
93
+ const executionLabel = text(locale, 'telegram.execution_label', 'Execution');
94
+ const executionInfo = executionUuid ? `\n🆔 ${executionLabel}: \`${executionUuid}\`` : '';
95
+ return `${text(locale, 'telegram.work_session_executing', '⏳ Executing...')}\n\n📊 ${sessionLabel}: \`${sessionName}\`${executionInfo}${isolationInfo}${details}`;
96
+ }
97
+
98
+ /**
99
+ * Render the reply for a work session that never started (issue #2154).
100
+ *
101
+ * The previous reply was the raw runner error in a code fence and nothing else.
102
+ * Two things were missing, and both were reported as separate symptoms of the
103
+ * same incident:
104
+ *
105
+ * - **The session UUID.** It was generated before the launch and shown in the
106
+ * "🔄 Starting..." message, but the failure reply *overwrote* that message,
107
+ * so the only identifier the task ever had was destroyed by its own error
108
+ * report. Nothing then connected the Telegram thread to the bot log lines,
109
+ * to the session store, or to a `--log <uuid>` lookup.
110
+ * - **Why the task is missing from `--list`.** A failed launch produces no
111
+ * container, so the session is untracked and never appears in the listing.
112
+ * Without saying so, the reply reads as if the task were running somewhere.
113
+ *
114
+ * @param {Object} params
115
+ * @param {string} [params.commandName] - Command the user invoked (`solve`, `hive`, …)
116
+ * @param {string|null} [params.sessionName] - Session UUID, when one was generated
117
+ * @param {string|null} [params.isolationBackend]
118
+ * @param {string} [params.infoBlock]
119
+ * @param {string} [params.error] - Runner error text
120
+ * @param {string|null} [params.locale]
121
+ * @returns {string} Markdown reply
122
+ *
123
+ * @see https://github.com/link-assistant/hive-mind/issues/2154
124
+ */
125
+ export function formatFailedLaunchMessage({ commandName = 'command', sessionName = null, isolationBackend = null, infoBlock = '', error = '', locale = null } = {}) {
126
+ const header = text(locale, 'telegram.error_executing_command', `❌ Error executing ${commandName} command`, { commandName });
127
+ const sessionLabel = text(locale, 'telegram.session_label', 'Session');
128
+ const isolationLabel = text(locale, 'telegram.isolation_label', 'Isolation');
129
+ const sessionLine = sessionName ? `\n📊 ${sessionLabel}: \`${sessionName}\`` : '';
130
+ const isolationLine = isolationBackend ? `\n🔒 ${isolationLabel}: \`${isolationBackend}\`` : '';
131
+ const body = String(error ?? '').trim() || 'unknown error';
132
+ const notLaunched = sessionName ? `\n\n${text(locale, 'telegram.work_session_not_launched', 'The work session was not launched, so it has no log and is not listed by `--list`.')}` : '';
133
+ const details = infoBlock ? `\n\n${infoBlock}` : '';
134
+ return `${header}:${sessionLine}${isolationLine}\n\n\`\`\`\n${body}\n\`\`\`${notLaunched}${details}`;
89
135
  }
90
136
 
91
137
  /**
@@ -176,6 +222,13 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
176
222
  const sessionLabel = text(messageLocale, 'telegram.session_label', 'Session');
177
223
  const isolationLabel = text(messageLocale, 'telegram.isolation_label', 'Isolation');
178
224
  const isolationInfo = sessionInfo?.isolationBackend ? `\n🔒 ${isolationLabel}: \`${sessionInfo.isolationBackend}\`` : '';
225
+ // Issue #2154: the completion reply is the last word the Telegram thread has
226
+ // on a task, and the handle an operator uses afterwards to fetch its log. Keep
227
+ // start-command's execution UUID (the one `$ --list` prints) next to the
228
+ // session UUID, so the finished task can still be found in the session list.
229
+ const executionLabel = text(messageLocale, 'telegram.execution_label', 'Execution');
230
+ const executionUuid = sessionInfo?.executionUuid || statusResult?.uuid || null;
231
+ const executionInfo = executionUuid ? `\n🆔 ${executionLabel}: \`${executionUuid}\`` : '';
179
232
  const startTime = parseDateValue(statusResult?.startTime) || parseDateValue(sessionInfo?.startTime) || observedEndTime;
180
233
  const endTime = parseDateValue(statusResult?.endTime) || observedEndTime;
181
234
  const durationSeconds = Math.max(0, (endTime.getTime() - startTime.getTime()) / 1000);
@@ -188,7 +241,7 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
188
241
  const statusEmoji = statusEmojiOverride || (failed ? '❌' : '✅');
189
242
  let message = `${statusEmoji} *${statusText}*\n\n`;
190
243
  message += `⏱️ ${durationLabel}: ${formatSessionDurationSeconds(durationSeconds)}\n`;
191
- message += `📊 ${sessionLabel}: \`${sessionName || 'unknown'}\`${isolationInfo}${details}`;
244
+ message += `📊 ${sessionLabel}: \`${sessionName || 'unknown'}\`${executionInfo}${isolationInfo}${details}`;
192
245
 
193
246
  // Issue #594: --show-limits virtual option appends snapshot/delta sections
194
247
  // (Markdown code blocks) below the standard completion details.