@link-assistant/hive-mind 1.64.1 → 1.64.3

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.
@@ -38,6 +38,13 @@ import { CONFIG, createCollapsible, createRawJsonSection, escapeMarkdown, execFi
38
38
  // Use the session-started marker as the single source of truth for the
39
39
  // header string, keeping posting and filtering in lock-step.
40
40
  import { INTERACTIVE_SESSION_STARTED_MARKER, trackToolCommentId } from './tool-comments.lib.mjs';
41
+ // Issue #1745: every comment body posted by the AI bridge MUST flow through
42
+ // sanitizeCommentBody() before leaving the process. The leak in
43
+ // xlab2016/space_db_private#20 happened because raw bash-tool stdout
44
+ // (including TELEGRAM_BOT_TOKEN=...) was published verbatim. See
45
+ // docs/case-studies/issue-1745/analysis.md for the full timeline.
46
+ import { containsKnownToken, getAllKnownLocalTokens, sanitizeCommentBody } from './token-sanitization.lib.mjs';
47
+ import { reportInteractiveLeak } from './telegram-leak-notifier.lib.mjs';
41
48
 
42
49
  /**
43
50
  * Creates an interactive mode handler for processing Claude/Codex CLI events
@@ -52,7 +59,23 @@ import { INTERACTIVE_SESSION_STARTED_MARKER, trackToolCommentId } from './tool-c
52
59
  * @returns {Object} Handler object with event processing methods
53
60
  */
54
61
  export const createInteractiveHandler = options => {
55
- const { owner, repo, prNumber, log, verbose = false, execFile: execFileFn } = options;
62
+ const {
63
+ owner,
64
+ repo,
65
+ prNumber,
66
+ log,
67
+ verbose = false,
68
+ execFile: execFileFn,
69
+ // Issue #1745: dangerous-skip flags. All default to false; passing them
70
+ // through lets the operator opt out of pattern-based sanitization (for
71
+ // controlled debugging in private repos) while keeping active-token
72
+ // masking on by default.
73
+ skipOutputSanitization = false,
74
+ skipActiveTokensOutputSanitization = false,
75
+ // Pre-existing user content carve-out (issue body / non-bot comments /
76
+ // pre-existing code). When provided, sanitizer leaves these tokens untouched.
77
+ excludeTokens = [],
78
+ } = options;
56
79
  // Use injected execFile for testability, or the real one by default
57
80
  const runGhApi = execFileFn || execFileAsync;
58
81
 
@@ -88,6 +111,71 @@ export const createInteractiveHandler = options => {
88
111
  editsFailed: 0,
89
112
  };
90
113
 
114
+ /**
115
+ * Sanitize a comment body and warn the chat owner when a known-local token
116
+ * was about to be published. Issue #1745. The returned string is what we
117
+ * actually send to GitHub.
118
+ *
119
+ * @param {string} body
120
+ * @returns {Promise<string>} sanitized body
121
+ * @private
122
+ */
123
+ const sanitizeAndWarn = async body => {
124
+ if (typeof body !== 'string' || body.length === 0) return body;
125
+
126
+ let knownTokens;
127
+ try {
128
+ knownTokens = await getAllKnownLocalTokens();
129
+ } catch (err) {
130
+ // Best-effort: if token lookup fails, fall back to regex/secretlint only.
131
+ knownTokens = [];
132
+ if (verbose) {
133
+ await log(`⚠️ Interactive mode: getAllKnownLocalTokens failed: ${err.message}`, { verbose: true });
134
+ }
135
+ }
136
+
137
+ let hits = [];
138
+ try {
139
+ hits = await containsKnownToken(body, knownTokens);
140
+ } catch {
141
+ hits = [];
142
+ }
143
+
144
+ let sanitized = body;
145
+ try {
146
+ sanitized = await sanitizeCommentBody(body, {
147
+ knownTokens,
148
+ skipOutputSanitization,
149
+ skipActiveTokensOutputSanitization,
150
+ excludeTokens,
151
+ });
152
+ } catch (err) {
153
+ await log(`⚠️ Interactive mode: sanitizeCommentBody failed: ${err.message} — falling back to raw body MASKED`);
154
+ // Fail closed: if sanitization fails entirely, drop the body to a safe
155
+ // placeholder rather than leaking. Better to lose detail than secrets.
156
+ sanitized = '[redacted: sanitization failed]';
157
+ }
158
+
159
+ if (hits.length > 0) {
160
+ await log(`🚨 Interactive mode: known-local token(s) detected in outbound comment — sanitizer masked them. Sources: ${hits.map(h => h.source).join(', ')}`);
161
+ try {
162
+ await reportInteractiveLeak({
163
+ owner,
164
+ repo,
165
+ prNumber,
166
+ tokenHits: hits,
167
+ log,
168
+ });
169
+ } catch (err) {
170
+ if (verbose) {
171
+ await log(`⚠️ Interactive mode: leak notifier failed: ${err.message}`, { verbose: true });
172
+ }
173
+ }
174
+ }
175
+
176
+ return sanitized;
177
+ };
178
+
91
179
  /**
92
180
  * Post a comment to the PR (with rate limiting)
93
181
  * @param {string} body - Comment body
@@ -104,12 +192,16 @@ export const createInteractiveHandler = options => {
104
192
  return null;
105
193
  }
106
194
 
195
+ // Issue #1745: sanitize BEFORE rate-limit queuing so queued bodies are
196
+ // also safe (the queue persists across reconnects).
197
+ const safeBody = await sanitizeAndWarn(body);
198
+
107
199
  const now = Date.now();
108
200
  const timeSinceLastComment = now - state.lastCommentTime;
109
201
 
110
202
  if (timeSinceLastComment < CONFIG.MIN_COMMENT_INTERVAL) {
111
203
  // Queue the comment for later with toolId/taskId for tracking
112
- state.commentQueue.push({ body, toolId, taskId });
204
+ state.commentQueue.push({ body: safeBody, toolId, taskId });
113
205
  if (verbose) {
114
206
  await log(`📝 Interactive mode: Comment queued (${state.commentQueue.length} in queue)${toolId ? ` [tool: ${toolId}]` : ''}${taskId ? ` [task: ${taskId}]` : ''}`, { verbose: true });
115
207
  }
@@ -122,7 +214,7 @@ export const createInteractiveHandler = options => {
122
214
  // with complex markdown bodies containing backticks, quotes, etc.
123
215
  // See: https://github.com/link-assistant/hive-mind/issues/1458
124
216
  const apiUrl = `repos/${owner}/${repo}/issues/${prNumber}/comments`;
125
- const jsonPayload = JSON.stringify({ body });
217
+ const jsonPayload = JSON.stringify({ body: safeBody });
126
218
  const { stdout } = await runGhApi('gh', ['api', apiUrl, '-X', 'POST', '--input', '-'], {
127
219
  input: jsonPayload,
128
220
  maxBuffer: 10 * 1024 * 1024, // 10MB
@@ -147,13 +239,13 @@ export const createInteractiveHandler = options => {
147
239
  trackToolCommentId(commentId);
148
240
 
149
241
  if (verbose) {
150
- await log(`✅ Interactive mode: Comment posted${commentId ? ` (ID: ${commentId})` : ''} (body: ${body.length} chars)`, { verbose: true });
242
+ await log(`✅ Interactive mode: Comment posted${commentId ? ` (ID: ${commentId})` : ''} (body: ${safeBody.length} chars)`, { verbose: true });
151
243
  }
152
244
  return commentId;
153
245
  } catch (error) {
154
246
  state.commentsFailed++;
155
247
  // Issue #1472: Always log comment failures (not just verbose) — silent failures cause zero-comment bugs
156
- await log(`⚠️ Interactive mode: Failed to post comment: ${error.message} (body: ${body.length} chars)`);
248
+ await log(`⚠️ Interactive mode: Failed to post comment: ${error.message} (body: ${safeBody.length} chars)`);
157
249
  return null;
158
250
  }
159
251
  };
@@ -173,25 +265,29 @@ export const createInteractiveHandler = options => {
173
265
  return false;
174
266
  }
175
267
 
268
+ // Issue #1745: sanitize before sending. editComment is the path that
269
+ // leaked TELEGRAM_BOT_TOKEN in xlab2016/space_db_private#20.
270
+ const safeBody = await sanitizeAndWarn(body);
271
+
176
272
  state.editsAttempted++;
177
273
  try {
178
274
  // Edit comment via gh api with stdin to avoid shell quoting issues
179
275
  // with complex markdown bodies containing backticks, quotes, etc.
180
276
  // See: https://github.com/link-assistant/hive-mind/issues/1458
181
277
  const apiUrl = `repos/${owner}/${repo}/issues/comments/${commentId}`;
182
- const jsonPayload = JSON.stringify({ body });
278
+ const jsonPayload = JSON.stringify({ body: safeBody });
183
279
  await runGhApi('gh', ['api', apiUrl, '-X', 'PATCH', '--input', '-'], {
184
280
  input: jsonPayload,
185
281
  maxBuffer: 10 * 1024 * 1024, // 10MB
186
282
  });
187
283
  state.editsSucceeded++;
188
284
  if (verbose) {
189
- await log(`✅ Interactive mode: Comment ${commentId} updated (body: ${body.length} chars, payload: ${jsonPayload.length} chars)`, { verbose: true });
285
+ await log(`✅ Interactive mode: Comment ${commentId} updated (body: ${safeBody.length} chars, payload: ${jsonPayload.length} chars)`, { verbose: true });
190
286
  }
191
287
  return true;
192
288
  } catch (error) {
193
289
  state.editsFailed++;
194
- await log(`⚠️ Interactive mode: Failed to edit comment ${commentId}: ${error.message} (body: ${body.length} chars)`);
290
+ await log(`⚠️ Interactive mode: Failed to edit comment ${commentId}: ${error.message} (body: ${safeBody.length} chars)`);
195
291
  return false;
196
292
  }
197
293
  };
package/src/lib.mjs CHANGED
@@ -334,12 +334,12 @@ export const setupStdioLogInterceptor = () => {
334
334
  * @param {string} token - Token to mask
335
335
  * @param {Object} options - Masking options
336
336
  * @param {number} [options.minLength=12] - Minimum length to mask
337
- * @param {number} [options.startChars=5] - Number of characters to show at start
338
- * @param {number} [options.endChars=5] - Number of characters to show at end
337
+ * @param {number} [options.startChars=3] - Number of characters to show at start
338
+ * @param {number} [options.endChars=3] - Number of characters to show at end
339
339
  * @returns {string} Masked token
340
340
  */
341
341
  export const maskToken = (token, options = {}) => {
342
- const { minLength = 12, startChars = 5, endChars = 5 } = options;
342
+ const { minLength = 12, startChars = 3, endChars = 3 } = options;
343
343
 
344
344
  if (!token || token.length < minLength) {
345
345
  return token; // Don't mask very short strings
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Issue #1745 — post-finish sanitization sweep.
4
+ *
5
+ * Comment #4364642786 requirement: "after AI finishes whatever the content
6
+ * was ... we should by default go and mask the token by editing comments,
7
+ * pull requests".
8
+ *
9
+ * This module re-reads bot-authored comments and the PR description after the
10
+ * AI session finishes, runs the body through `sanitizeOutput`, and edits the
11
+ * comment / PR in place if a difference is detected. It is intentionally
12
+ * conservative:
13
+ *
14
+ * - We only touch content authored by the running gh user (the bot).
15
+ * - We never touch issue bodies (those belong to the human).
16
+ * - History rewriting (force-pushing to delete commits) is NOT performed
17
+ * here. The risk to a shared branch is too high; that step requires the
18
+ * operator to opt in explicitly via a future flag, and is documented in
19
+ * docs/case-studies/issue-1745/analysis.md.
20
+ *
21
+ * @module post-finish-sanitization-sweep
22
+ */
23
+
24
+ import { sanitizeOutput, getSanitizationStats } from './token-sanitization.lib.mjs';
25
+ import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): caller passes $ already wrapped through wrapDollarWithGhRetry
26
+
27
+ /**
28
+ * Determine the bot's gh login name. The function returns null on any error
29
+ * so the sweep degrades gracefully when offline / unauthenticated.
30
+ *
31
+ * @param {Function} $
32
+ * @returns {Promise<string|null>}
33
+ */
34
+ const detectBotLogin = async $ => {
35
+ try {
36
+ const result = await $`gh api user --jq .login`;
37
+ if (result && result.code === 0 && result.stdout) {
38
+ const login = result.stdout.toString().trim();
39
+ return login || null;
40
+ }
41
+ } catch {
42
+ /* swallow */
43
+ }
44
+ return null;
45
+ };
46
+
47
+ /**
48
+ * Sanitize bot-authored PR conversation comments (the issuecomment endpoint).
49
+ *
50
+ * @param {Object} args
51
+ * @param {Function} args.$ command-stream helper
52
+ * @param {string} args.owner
53
+ * @param {string} args.repo
54
+ * @param {number|string} args.prNumber
55
+ * @param {string} args.botLogin
56
+ * @param {Function} [args.log]
57
+ * @param {Object} [args.sanitizationOptions] forwarded to sanitizeOutput
58
+ * @returns {Promise<{scanned:number, edited:number, errors:number}>}
59
+ */
60
+ export const sweepPrConversationComments = async ({ $, owner, repo, prNumber, botLogin, log = async () => {}, sanitizationOptions = {} }) => {
61
+ const stats = { scanned: 0, edited: 0, errors: 0 };
62
+ let response;
63
+ try {
64
+ response = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
65
+ } catch (err) {
66
+ await log(`⚠️ post-finish sweep: failed to list comments: ${err.message || err}`);
67
+ stats.errors++;
68
+ return stats;
69
+ }
70
+ if (!response || response.code !== 0) {
71
+ stats.errors++;
72
+ return stats;
73
+ }
74
+ let comments;
75
+ try {
76
+ comments = JSON.parse(response.stdout.toString());
77
+ } catch {
78
+ stats.errors++;
79
+ return stats;
80
+ }
81
+ if (!Array.isArray(comments)) return stats;
82
+
83
+ for (const c of comments) {
84
+ if (!c || !c.user || c.user.login !== botLogin) continue;
85
+ if (typeof c.body !== 'string' || c.body.length === 0) continue;
86
+ stats.scanned++;
87
+ let sanitized;
88
+ try {
89
+ sanitized = await sanitizeOutput(c.body, sanitizationOptions);
90
+ } catch (err) {
91
+ await log(`⚠️ post-finish sweep: sanitize comment ${c.id} failed: ${err.message || err}`);
92
+ stats.errors++;
93
+ continue;
94
+ }
95
+ if (sanitized === c.body) continue;
96
+ try {
97
+ const payload = JSON.stringify({ body: sanitized });
98
+ const edit = await $({ stdin: payload })`gh api repos/${owner}/${repo}/issues/comments/${c.id} -X PATCH --input -`;
99
+ if (edit && edit.code === 0) {
100
+ stats.edited++;
101
+ await log(`🔒 post-finish sweep: edited comment ${c.id} to mask leaked token(s)`);
102
+ } else {
103
+ stats.errors++;
104
+ }
105
+ } catch (err) {
106
+ await log(`⚠️ post-finish sweep: edit comment ${c.id} failed: ${err.message || err}`);
107
+ stats.errors++;
108
+ }
109
+ }
110
+ return stats;
111
+ };
112
+
113
+ /**
114
+ * Sanitize the PR description if needed.
115
+ *
116
+ * @param {Object} args
117
+ * @returns {Promise<{scanned:number, edited:number, errors:number}>}
118
+ */
119
+ export const sweepPrDescription = async ({ $, owner, repo, prNumber, log = async () => {}, sanitizationOptions = {} }) => {
120
+ const stats = { scanned: 0, edited: 0, errors: 0 };
121
+ let response;
122
+ try {
123
+ response = await $`gh api repos/${owner}/${repo}/pulls/${prNumber}`;
124
+ } catch (err) {
125
+ await log(`⚠️ post-finish sweep: failed to fetch PR ${prNumber}: ${err.message || err}`);
126
+ stats.errors++;
127
+ return stats;
128
+ }
129
+ if (!response || response.code !== 0) {
130
+ stats.errors++;
131
+ return stats;
132
+ }
133
+ let pr;
134
+ try {
135
+ pr = JSON.parse(response.stdout.toString());
136
+ } catch {
137
+ stats.errors++;
138
+ return stats;
139
+ }
140
+ const body = typeof pr.body === 'string' ? pr.body : '';
141
+ if (body.length === 0) return stats;
142
+ stats.scanned++;
143
+ let sanitized;
144
+ try {
145
+ sanitized = await sanitizeOutput(body, sanitizationOptions);
146
+ } catch (err) {
147
+ await log(`⚠️ post-finish sweep: sanitize PR body failed: ${err.message || err}`);
148
+ stats.errors++;
149
+ return stats;
150
+ }
151
+ if (sanitized === body) return stats;
152
+ try {
153
+ const payload = JSON.stringify({ body: sanitized });
154
+ const edit = await $({ stdin: payload })`gh api repos/${owner}/${repo}/pulls/${prNumber} -X PATCH --input -`;
155
+ if (edit && edit.code === 0) {
156
+ stats.edited++;
157
+ await log('🔒 post-finish sweep: edited PR description to mask leaked token(s)');
158
+ } else {
159
+ stats.errors++;
160
+ }
161
+ } catch (err) {
162
+ await log(`⚠️ post-finish sweep: edit PR body failed: ${err.message || err}`);
163
+ stats.errors++;
164
+ }
165
+ return stats;
166
+ };
167
+
168
+ /**
169
+ * Run the full post-finish sweep: bot-authored PR comments + PR description.
170
+ * Idempotent and safe to call multiple times.
171
+ *
172
+ * @param {Object} args
173
+ * @returns {Promise<{comments:Object, prBody:Object, totalEdited:number, sanitizationStatsBefore:Object, sanitizationStatsAfter:Object}>}
174
+ */
175
+ export const runPostFinishSweep = async ({ $, owner, repo, prNumber, log = async () => {}, sanitizationOptions = {}, botLogin: providedBotLogin }) => {
176
+ const sanitizationStatsBefore = getSanitizationStats();
177
+ const botLogin = providedBotLogin || (await detectBotLogin($));
178
+ const result = {
179
+ comments: { scanned: 0, edited: 0, errors: 0, skipped: !botLogin },
180
+ prBody: { scanned: 0, edited: 0, errors: 0 },
181
+ totalEdited: 0,
182
+ sanitizationStatsBefore,
183
+ sanitizationStatsAfter: sanitizationStatsBefore,
184
+ };
185
+ if (!owner || !repo || !prNumber) return result;
186
+ if (botLogin) {
187
+ result.comments = await sweepPrConversationComments({ $, owner, repo, prNumber, botLogin, log, sanitizationOptions });
188
+ } else {
189
+ await log('⚠️ post-finish sweep: could not determine bot login; skipping comment sweep.');
190
+ }
191
+ result.prBody = await sweepPrDescription({ $, owner, repo, prNumber, log, sanitizationOptions });
192
+ result.totalEdited = result.comments.edited + result.prBody.edited;
193
+ result.sanitizationStatsAfter = getSanitizationStats();
194
+ return result;
195
+ };
196
+
197
+ export default {
198
+ sweepPrConversationComments,
199
+ sweepPrDescription,
200
+ runPostFinishSweep,
201
+ };
package/src/qwen.lib.mjs CHANGED
@@ -20,6 +20,7 @@ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
20
  import { qwenModels, defaultModels } from './models/index.mjs';
21
21
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
22
22
  import { classifyRetryableError, getRetryDelayMs, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
23
+ import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
23
24
 
24
25
  export const mapModelToId = model => qwenModels[model] || model;
25
26
 
@@ -63,6 +64,59 @@ const findFirstValue = (object, paths) => {
63
64
  return null;
64
65
  };
65
66
 
67
+ const createQwenTokenFieldAvailability = () => ({
68
+ inputTokens: false,
69
+ outputTokens: false,
70
+ reasoningTokens: false,
71
+ cacheReadTokens: false,
72
+ cacheWriteTokens: false,
73
+ });
74
+
75
+ const createQwenTokenUsage = modelId => ({
76
+ inputTokens: 0,
77
+ outputTokens: 0,
78
+ reasoningTokens: 0,
79
+ cacheReadTokens: 0,
80
+ cacheWriteTokens: 0,
81
+ totalTokens: 0,
82
+ stepCount: 0,
83
+ requestedModelId: modelId || null,
84
+ respondedModelId: modelId || null,
85
+ contextLimit: null,
86
+ outputLimit: null,
87
+ contextFillInputTokens: 0,
88
+ peakContextUsage: 0,
89
+ tokenFieldAvailability: createQwenTokenFieldAvailability(),
90
+ });
91
+
92
+ const cloneQwenTokenUsage = usage => {
93
+ if (!usage) return createQwenTokenUsage();
94
+ return {
95
+ ...usage,
96
+ tokenFieldAvailability: {
97
+ ...createQwenTokenFieldAvailability(),
98
+ ...(usage.tokenFieldAvailability || {}),
99
+ },
100
+ };
101
+ };
102
+
103
+ const getQwenUsageField = (usage, paths) => {
104
+ const value = findFirstValue(usage, paths);
105
+ if (value === null) return { observed: false, value: 0 };
106
+ return { observed: true, value: toTokenCount(value) };
107
+ };
108
+
109
+ const QWEN_USAGE_PATHS = {
110
+ input: [['inputTokens'], ['input_tokens'], ['input'], ['promptTokens'], ['prompt_tokens'], ['prompt']],
111
+ output: [['outputTokens'], ['output_tokens'], ['output'], ['completionTokens'], ['completion_tokens'], ['completion']],
112
+ reasoning: [['reasoningTokens'], ['reasoning_tokens'], ['thoughtsTokens'], ['thoughts_tokens']],
113
+ cacheRead: [['cacheReadTokens'], ['cache_read_tokens'], ['cache_read_input_tokens'], ['cachedInputTokens'], ['cached_input_tokens'], ['prompt_tokens_details', 'cached_tokens'], ['cache', 'read']],
114
+ cacheWrite: [['cacheWriteTokens'], ['cache_write_tokens'], ['cache_creation_input_tokens'], ['cacheCreationTokens'], ['cacheCreationInputTokens'], ['cache', 'write']],
115
+ contextLimit: [['contextLimit'], ['context_limit'], ['limit', 'context'], ['limits', 'context']],
116
+ outputLimit: [['outputLimit'], ['output_limit'], ['limit', 'output'], ['limits', 'output']],
117
+ model: [['model'], ['model_id'], ['modelId'], ['name']],
118
+ };
119
+
66
120
  const extractTextFragments = value => {
67
121
  if (typeof value === 'string') return [value];
68
122
  if (!value || typeof value !== 'object') return [];
@@ -89,8 +143,139 @@ const createQwenParserState = state => ({
89
143
  errors: Array.isArray(state?.errors) ? [...state.errors] : [],
90
144
  sessionId: state?.sessionId || null,
91
145
  lastTextContent: state?.lastTextContent || '',
146
+ tokenUsage: cloneQwenTokenUsage(state?.tokenUsage),
147
+ resultModelUsage: state?.resultModelUsage ? { ...state.resultModelUsage } : null,
92
148
  });
93
149
 
150
+ const buildQwenResultModelUsage = tokenUsage => {
151
+ if (!tokenUsage || tokenUsage.stepCount === 0) return null;
152
+ const modelId = tokenUsage.respondedModelId || tokenUsage.requestedModelId || 'qwen';
153
+ const modelInfo = tokenUsage.contextLimit || tokenUsage.outputLimit ? { limit: { context: tokenUsage.contextLimit || null, output: tokenUsage.outputLimit || null } } : null;
154
+ return {
155
+ [modelId]: {
156
+ inputTokens: tokenUsage.inputTokens,
157
+ cacheCreationTokens: tokenUsage.cacheWriteTokens,
158
+ cacheReadTokens: tokenUsage.cacheReadTokens,
159
+ outputTokens: tokenUsage.outputTokens,
160
+ modelName: modelId,
161
+ modelInfo,
162
+ contextFillInputTokens: tokenUsage.contextFillInputTokens,
163
+ peakContextUsage: tokenUsage.peakContextUsage,
164
+ costUSD: null,
165
+ },
166
+ };
167
+ };
168
+
169
+ const applyQwenUsageObject = (state, rawUsage, fallbackModelId = null) => {
170
+ if (!rawUsage || typeof rawUsage !== 'object') return;
171
+
172
+ const model = findFirstValue(rawUsage, QWEN_USAGE_PATHS.model) || fallbackModelId;
173
+ if (model) {
174
+ state.tokenUsage.requestedModelId ||= String(model);
175
+ state.tokenUsage.respondedModelId = String(model);
176
+ }
177
+
178
+ const input = getQwenUsageField(rawUsage, QWEN_USAGE_PATHS.input);
179
+ const output = getQwenUsageField(rawUsage, QWEN_USAGE_PATHS.output);
180
+ const reasoning = getQwenUsageField(rawUsage, QWEN_USAGE_PATHS.reasoning);
181
+ const cacheRead = getQwenUsageField(rawUsage, QWEN_USAGE_PATHS.cacheRead);
182
+ const cacheWrite = getQwenUsageField(rawUsage, QWEN_USAGE_PATHS.cacheWrite);
183
+ const contextLimit = getQwenUsageField(rawUsage, QWEN_USAGE_PATHS.contextLimit);
184
+ const outputLimit = getQwenUsageField(rawUsage, QWEN_USAGE_PATHS.outputLimit);
185
+
186
+ const observedTokenField = input.observed || output.observed || reasoning.observed || cacheRead.observed || cacheWrite.observed;
187
+ if (!observedTokenField) return;
188
+
189
+ state.tokenUsage.stepCount += 1;
190
+ if (input.observed) {
191
+ state.tokenUsage.tokenFieldAvailability.inputTokens = true;
192
+ state.tokenUsage.inputTokens += input.value;
193
+ }
194
+ if (output.observed) {
195
+ state.tokenUsage.tokenFieldAvailability.outputTokens = true;
196
+ state.tokenUsage.outputTokens += output.value;
197
+ }
198
+ if (reasoning.observed) {
199
+ state.tokenUsage.tokenFieldAvailability.reasoningTokens = true;
200
+ state.tokenUsage.reasoningTokens += reasoning.value;
201
+ }
202
+ if (cacheRead.observed) {
203
+ state.tokenUsage.tokenFieldAvailability.cacheReadTokens = true;
204
+ state.tokenUsage.cacheReadTokens += cacheRead.value;
205
+ }
206
+ if (cacheWrite.observed) {
207
+ state.tokenUsage.tokenFieldAvailability.cacheWriteTokens = true;
208
+ state.tokenUsage.cacheWriteTokens += cacheWrite.value;
209
+ }
210
+ if (contextLimit.observed) state.tokenUsage.contextLimit = contextLimit.value;
211
+ if (outputLimit.observed) state.tokenUsage.outputLimit = outputLimit.value;
212
+
213
+ const stepContextFill = getCumulativeContextInputTokens({
214
+ inputTokens: input.value,
215
+ cacheWriteTokens: cacheWrite.value,
216
+ });
217
+ if (stepContextFill > (state.tokenUsage.contextFillInputTokens || 0)) {
218
+ state.tokenUsage.contextFillInputTokens = stepContextFill;
219
+ }
220
+
221
+ const stepRestoredContext = getRestoredContextInputTokens({
222
+ inputTokens: input.value,
223
+ cacheWriteTokens: cacheWrite.value,
224
+ cacheReadTokens: cacheRead.value,
225
+ });
226
+ if (stepRestoredContext > (state.tokenUsage.peakContextUsage || 0)) {
227
+ state.tokenUsage.peakContextUsage = stepRestoredContext;
228
+ }
229
+
230
+ state.tokenUsage.totalTokens = state.tokenUsage.inputTokens + state.tokenUsage.cacheReadTokens + state.tokenUsage.cacheWriteTokens + state.tokenUsage.outputTokens;
231
+ state.resultModelUsage = buildQwenResultModelUsage(state.tokenUsage);
232
+ };
233
+
234
+ const applyQwenUsageToState = (state, event) => {
235
+ const rawUsage = event?.usage || event?.stats || event?.tokenUsage || null;
236
+ if (!rawUsage || typeof rawUsage !== 'object') return;
237
+
238
+ const modelStats = rawUsage.models && typeof rawUsage.models === 'object' ? rawUsage.models : null;
239
+ if (modelStats) {
240
+ for (const [modelId, data] of Object.entries(modelStats)) {
241
+ applyQwenUsageObject(state, data?.tokens || data?.usage || data, modelId);
242
+ }
243
+ return;
244
+ }
245
+
246
+ applyQwenUsageObject(state, rawUsage, findFirstValue(event, QWEN_USAGE_PATHS.model));
247
+ };
248
+
249
+ const buildQwenPricingInfo = (state, mappedModel) => {
250
+ const tokenUsage = cloneQwenTokenUsage(state?.tokenUsage);
251
+ if (!tokenUsage || tokenUsage.stepCount === 0) {
252
+ return {
253
+ pricingInfo: null,
254
+ publicPricingEstimate: null,
255
+ tokenUsage: null,
256
+ resultModelUsage: null,
257
+ };
258
+ }
259
+
260
+ tokenUsage.requestedModelId ||= mappedModel || 'qwen';
261
+ tokenUsage.respondedModelId ||= tokenUsage.requestedModelId;
262
+ const modelId = tokenUsage.respondedModelId || tokenUsage.requestedModelId;
263
+
264
+ return {
265
+ pricingInfo: {
266
+ provider: 'Qwen Code',
267
+ modelId,
268
+ modelName: modelId,
269
+ totalCostUSD: null,
270
+ source: 'qwen-stream-json',
271
+ tokenUsage,
272
+ },
273
+ publicPricingEstimate: null,
274
+ tokenUsage,
275
+ resultModelUsage: buildQwenResultModelUsage(tokenUsage),
276
+ };
277
+ };
278
+
94
279
  const addQwenEventToState = (state, rawEvent) => {
95
280
  const event = sanitizeObjectStrings(rawEvent);
96
281
  state.parsedEvents.push(event);
@@ -118,6 +303,8 @@ const addQwenEventToState = (state, rawEvent) => {
118
303
  isAuthError: isQwenAuthError(errorMessage),
119
304
  });
120
305
  }
306
+
307
+ applyQwenUsageToState(state, event);
121
308
  };
122
309
 
123
310
  export const parseQwenStreamJsonOutput = (output, state = {}) => {
@@ -377,6 +564,7 @@ export const executeQwenCommand = async params => {
377
564
  .join('\n');
378
565
  const combinedErrorText = `${allOutput}\n${errorMessage}`.trim();
379
566
  const limitInfo = detectUsageLimit(combinedErrorText);
567
+ const usageResult = buildQwenPricingInfo(qwenState, mappedModel);
380
568
 
381
569
  if (limitInfo.isUsageLimit) {
382
570
  const messageLines = formatUsageLimitMessage({
@@ -394,9 +582,7 @@ export const executeQwenCommand = async params => {
394
582
  sessionId,
395
583
  limitReached: true,
396
584
  limitResetTime: limitInfo.resetTime,
397
- pricingInfo: null,
398
- publicPricingEstimate: null,
399
- tokenUsage: null,
585
+ ...usageResult,
400
586
  resultSummary,
401
587
  };
402
588
  }
@@ -444,9 +630,7 @@ export const executeQwenCommand = async params => {
444
630
  sessionId,
445
631
  limitReached: false,
446
632
  limitResetTime: null,
447
- pricingInfo: null,
448
- publicPricingEstimate: null,
449
- tokenUsage: null,
633
+ ...usageResult,
450
634
  resultSummary,
451
635
  };
452
636
  }
@@ -461,9 +645,7 @@ export const executeQwenCommand = async params => {
461
645
  sessionId,
462
646
  limitReached: false,
463
647
  limitResetTime: null,
464
- pricingInfo: null,
465
- publicPricingEstimate: null,
466
- tokenUsage: null,
648
+ ...usageResult,
467
649
  resultSummary,
468
650
  };
469
651
  } catch (error) {
@@ -115,6 +115,21 @@ export const SOLVE_OPTION_DEFINITIONS = {
115
115
  description: 'Upload the solution draft log file to the Pull Request on completion (⚠️ WARNING: May expose sensitive data)',
116
116
  default: false,
117
117
  },
118
+ 'dangerously-skip-output-sanitization': {
119
+ type: 'boolean',
120
+ description: 'DANGEROUS: skip pattern-based sanitization of generated output. Active local token masking stays enabled unless --dangerously-skip-active-tokens-output-sanitization is also set.',
121
+ default: false,
122
+ },
123
+ 'dangerously-skip-code-output-sanitization': {
124
+ type: 'boolean',
125
+ description: 'DANGEROUS: allow generated code/file output to keep pattern-matched token-looking strings. Active local token masking stays enabled unless explicitly disabled.',
126
+ default: false,
127
+ },
128
+ 'dangerously-skip-active-tokens-output-sanitization': {
129
+ type: 'boolean',
130
+ description: 'DANGEROUS: skip masking known active local tokens in output. This is separate from other sanitization skip flags and should only be used for controlled debugging.',
131
+ default: false,
132
+ },
118
133
  'auto-close-pull-request-on-fail': {
119
134
  type: 'boolean',
120
135
  description: 'Automatically close the pull request if execution fails',