Compare commits

...

17 Commits

Author SHA1 Message Date
Coco Sheng ad5e01ceaf perf: batch label updates to improve efficiency and reduce audit log noise 2026-05-21 11:37:42 -04:00
Coco Sheng 957195be4c fix: restrict triage to issues only and remove PR write access 2026-05-20 17:29:52 -04:00
Coco Sheng ca91aab74e fix: add pull-requests write permission for triage workflow 2026-05-20 17:24:50 -04:00
Coco Sheng 651ad55357 fix: exclude label changes from meaningful activity check 2026-05-20 17:22:33 -04:00
Coco Sheng fc8c1bee0c fix: ignore bot activity when checking for meaningful issue interaction 2026-05-20 17:20:19 -04:00
Coco Sheng ac81288e1e chore: clarify dry run status in lifecycle manager logs 2026-05-20 17:17:48 -04:00
Coco Sheng 6cfef6d15d chore: remove accidental debugging limit 2026-05-20 17:10:15 -04:00
Coco Sheng ccd1854c66 temp: limit 2 for ci test 2026-05-14 18:26:56 -04:00
Coco Sheng 3fdc43734b ci: robust stale issue lifecycle and consolidated triage bot labels 2026-05-14 17:02:08 -04:00
Tommaso Sciortino 74e9079e5b chore: add execution permission to scripts/review.sh (#27009) 2026-05-13 12:22:00 -07:00
AK 9da30b8831 fix(core): isolate subagent thread context (#26449) 2026-05-13 18:55:17 +00:00
Dev Randalpura 71a2c0264e fix(ui): clamped table column widths (#26991)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-13 18:43:49 +00:00
Sahil Kirad fd01cc03bf fix(core): refresh MCP OAuth token usage after re-auth (#26312)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-13 12:01:27 -07:00
Coco Sheng fc4054446f ci: suppress bot comments during standard triage maintenance (#27006) 2026-05-13 18:43:07 +00:00
Coco Sheng 08abe4542d fix(cli): auto-approve shell redirections in AUTO_EDIT mode (#27003) 2026-05-13 18:28:30 +00:00
Coco Sheng 63b4bbfb5d fix(core): handle EISDIR on virtual drives in memory discovery (#26985) 2026-05-13 17:41:49 +00:00
Coco Sheng 1e7063bb0b fix(cli): allow keychain auth for --list-sessions and non-interactive mode (#26921) 2026-05-13 17:35:21 +00:00
35 changed files with 1133 additions and 397 deletions
+161 -128
View File
@@ -5,131 +5,178 @@
*/
module.exports = async ({ github, context, core }) => {
const rawLabels = process.env.LABELS_OUTPUT;
core.info(`Raw labels JSON: ${rawLabels}`);
let parsedLabels;
try {
// First, try to parse the raw output as JSON.
parsedLabels = JSON.parse(rawLabels);
} catch (jsonError) {
// If that fails, check for a markdown code block.
core.warning(
`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`,
);
const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
parsedLabels = JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.setFailed(
`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawLabels}`,
);
return;
const extractJson = (raw) => {
if (!raw || raw === '[]' || raw === '') return [];
try {
// First, try to parse the raw output as JSON.
return JSON.parse(raw);
} catch (jsonError) {
// If that fails, check for a markdown code block.
core.info('Direct JSON parsing failed. Trying to extract from a markdown block.');
const jsonMatch = raw.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.warning(`Failed to parse extracted JSON from markdown block: ${markdownError.message}`);
}
}
} else {
// If no markdown block, try to find a raw JSON array in the output.
// The CLI may include debug/log lines (e.g. telemetry init, YOLO mode)
// before the actual JSON response.
const jsonArrayMatch = rawLabels.match(
/\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/,
);
// Try to find a raw JSON array in the output.
const jsonArrayMatch = raw.match(/\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/);
if (jsonArrayMatch) {
try {
parsedLabels = JSON.parse(jsonArrayMatch[0]);
return JSON.parse(jsonArrayMatch[0]);
} catch (extractError) {
// It's possible the regex matched from a `[STARTUP]` log all the way to the end
// of the JSON array. We need to be more aggressive and find the FIRST `[ { "issue_number"`
core.warning(
`Strict array match failed: ${extractError.message}. Attempting to clean leading noisy brackets.`,
);
const fallbackMatch = rawLabels.match(
/(\[\s*\{\s*"issue_number"[\s\S]*)/,
);
const fallbackMatch = raw.match(/(\[\s*\{\s*"issue_number"[\s\S]*)/);
if (fallbackMatch) {
try {
// We might have grabbed trailing noise too, so we find the last closing bracket
const cleaned = fallbackMatch[0].substring(
0,
fallbackMatch[0].lastIndexOf(']') + 1,
);
parsedLabels = JSON.parse(cleaned);
const cleaned = fallbackMatch[0].substring(0, fallbackMatch[0].lastIndexOf(']') + 1);
return JSON.parse(cleaned);
} catch (fallbackError) {
core.setFailed(
`Found JSON-like content but failed to parse: ${fallbackError.message}\nRaw output: ${rawLabels}`,
);
return;
core.warning(`Failed to parse extracted JSON using fallback regex: ${fallbackError.message}`);
}
} else {
core.setFailed(
`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawLabels}`,
);
return;
}
}
} else {
core.setFailed(
`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawLabels}`,
);
return;
}
}
}
core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`);
core.warning('No valid JSON could be extracted from input.');
return [];
};
for (const entry of parsedLabels) {
const issueNumber = entry.issue_number;
if (!issueNumber) {
core.info(
`Skipping entry with no issue number: ${JSON.stringify(entry)}`,
);
continue;
// Collect all outputs from environment variables
// Prioritize EFFORT results over STANDARD results by processing Effort FIRST
// so that its labels appear first in the merged arrays (and thus win in mutually exclusive logic)
const effortRaw = process.env.LABELS_OUTPUT_EFFORT;
const standardRaw = process.env.LABELS_OUTPUT_STANDARD;
const genericRaw = process.env.LABELS_OUTPUT;
const resultsByIssue = new Map();
const processResults = (results, sourceName) => {
for (const entry of results) {
const issueNumber = entry.issue_number;
if (!issueNumber) continue;
if (!resultsByIssue.has(issueNumber)) {
resultsByIssue.set(issueNumber, {
issue_number: issueNumber,
labels_to_add: [...(entry.labels_to_add || [])],
labels_to_remove: [...(entry.labels_to_remove || [])],
explanation: entry.explanation || '',
effort_analysis: entry.effort_analysis || '',
});
} else {
const existing = resultsByIssue.get(issueNumber);
// Combine labels
existing.labels_to_add = [...new Set([...existing.labels_to_add, ...(entry.labels_to_add || [])])];
existing.labels_to_remove = [...new Set([...existing.labels_to_remove, ...(entry.labels_to_remove || [])])];
// Combine explanations (if different)
if (entry.explanation && !existing.explanation.includes(entry.explanation)) {
existing.explanation = existing.explanation
? `${existing.explanation}\n\n${entry.explanation}`
: entry.explanation;
}
// Take effort analysis if present
if (entry.effort_analysis && !existing.effort_analysis) {
existing.effort_analysis = entry.effort_analysis;
}
}
}
};
// Order matters: Effort first so its labels win in conflict resolution
processResults(extractJson(effortRaw), 'EFFORT');
processResults(extractJson(standardRaw), 'STANDARD');
processResults(extractJson(genericRaw), 'GENERIC');
const finalResults = Array.from(resultsByIssue.values());
core.info(`Aggregated triage results for ${finalResults.length} issues.`);
for (const entry of finalResults) {
const issueNumber = entry.issue_number;
let labelsToAdd = entry.labels_to_add || [];
let labelsToRemove = entry.labels_to_remove || [];
let existingLabels = [];
// Fetch existing labels early
try {
const { data: issueData } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
existingLabels = issueData.labels.map((l) =>
typeof l === 'string' ? l : l.name,
);
} catch (e) {
core.warning(
`Failed to fetch existing labels for #${issueNumber}: ${e.message}`,
);
}
// Programmatic Priority Downgrade Logic
if (labelsToAdd.includes('status/need-information')) {
const targetPriority = labelsToAdd.find((l) => l.startsWith('priority/'));
if (targetPriority) {
let downgradedPriority = null;
if (targetPriority === 'priority/p0') downgradedPriority = 'priority/p1';
if (targetPriority === 'priority/p1') downgradedPriority = 'priority/p2';
if (downgradedPriority) {
core.info(`Programmatically downgrading ${targetPriority} to ${downgradedPriority} due to status/need-information`);
labelsToAdd = labelsToAdd.filter((l) => l !== targetPriority);
labelsToAdd.push(downgradedPriority);
}
}
}
labelsToRemove.push('status/need-triage');
if (labelsToAdd.includes('status/manual-triage')) {
// If the AI flagged it for manual triage, remove bot-triaged if it exists
if (
labelsToAdd.includes('status/manual-triage') ||
existingLabels.includes('status/manual-triage')
) {
labelsToRemove.push('status/bot-triaged');
// Ensure we don't accidentally try to add bot-triaged if the AI returned it
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
} else {
// Standard successful bot triage
labelsToAdd.push('status/bot-triaged');
}
// Deduplicate arrays
labelsToAdd = [...new Set(labelsToAdd)];
labelsToRemove = [...new Set(labelsToRemove)];
// Enforce mutually exclusive area labels
const areaLabelsToAdd = labelsToAdd.filter((l) => l.startsWith('area/'));
if (areaLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple area labels to add: ${areaLabelsToAdd.join(', ')}. Keeping only the first one.`,
);
const firstArea = areaLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('area/') || l === firstArea,
);
// Resolve internal conflicts (e.g., adding P1 and P2)
// We already resolved these by putting Effort first in the combined list
// Resolve external conflicts with existing labels
if (labelsToAdd.some((l) => l.startsWith('area/'))) {
labelsToRemove.push(...existingLabels.filter((l) => l.startsWith('area/')));
}
if (labelsToAdd.some((l) => l.startsWith('priority/'))) {
labelsToRemove.push(...existingLabels.filter((l) => l.startsWith('priority/')));
}
if (labelsToAdd.some((l) => l.startsWith('kind/'))) {
labelsToRemove.push(...existingLabels.filter((l) => l.startsWith('kind/')));
}
// Enforce mutually exclusive priority labels
const priorityLabelsToAdd = labelsToAdd.filter((l) =>
l.startsWith('priority/'),
// Enforce mutual exclusivity in the TO-ADD list (Architect wins)
const exclusivePrefixes = ['area/', 'priority/', 'kind/'];
for (const prefix of exclusivePrefixes) {
const filtered = labelsToAdd.filter(l => l.startsWith(prefix));
if (filtered.length > 1) {
const winner = filtered[0]; // First one wins
core.info(`Issue #${issueNumber} has multiple ${prefix} labels suggested. Keeping "${winner}" and discarding others.`);
labelsToAdd = labelsToAdd.filter(l => !l.startsWith(prefix) || l === winner);
}
}
// Final deduplication and cleanup
labelsToRemove = [...new Set(labelsToRemove)].filter(
(l) => !labelsToAdd.includes(l) && existingLabels.includes(l),
);
if (priorityLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple priority labels to add: ${priorityLabelsToAdd.join(', ')}. Keeping only the first one.`,
);
const firstPriority = priorityLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('priority/') || l === firstPriority,
);
}
labelsToAdd = [...new Set(labelsToAdd)].filter((l) => !existingLabels.includes(l));
// Batch label operations
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -137,11 +184,7 @@ module.exports = async ({ github, context, core }) => {
issue_number: issueNumber,
labels: labelsToAdd,
});
const explanation = entry.explanation ? ` - ${entry.explanation}` : '';
core.info(
`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`,
);
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
}
if (labelsToRemove.length > 0) {
@@ -154,43 +197,33 @@ module.exports = async ({ github, context, core }) => {
name: label,
});
} catch (e) {
if (e.status !== 404) {
core.warning(
`Failed to remove label ${label} from #${issueNumber}: ${e.message}`,
);
}
if (e.status !== 404) core.warning(`Failed to remove label ${label} from #${issueNumber}: ${e.message}`);
}
}
core.info(
`Successfully removed labels for #${issueNumber}: ${labelsToRemove.join(', ')}`,
);
core.info(`Successfully removed labels for #${issueNumber}: ${labelsToRemove.join(', ')}`);
}
if (entry.explanation || entry.effort_analysis) {
// Post comment if needed
const needsInfoAdded = labelsToAdd.includes('status/need-information') && !existingLabels.includes('status/need-information');
const hasEffortAnalysis = !!entry.effort_analysis;
if (needsInfoAdded || hasEffortAnalysis) {
let commentBody = '';
if (entry.explanation) {
commentBody += entry.explanation;
}
if (entry.effort_analysis) {
if (needsInfoAdded && entry.explanation) commentBody += entry.explanation;
if (hasEffortAnalysis) {
if (commentBody) commentBody += '\n\n';
commentBody += `**Effort Analysis:**\n${entry.effort_analysis}`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
}
if (
(!entry.labels_to_add || entry.labels_to_add.length === 0) &&
(!entry.labels_to_remove || entry.labels_to_remove.length === 0)
) {
core.info(
`No labels to add or remove for #${issueNumber}, leaving as is`,
);
if (commentBody) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
core.info(`Posted required comment for #${issueNumber}`);
}
}
}
};
+36 -12
View File
@@ -22,29 +22,53 @@ module.exports = async ({ github, context, core }) => {
for (const issue of issuesToCleanup) {
try {
await github.rest.issues.removeLabel({
const { data: issueData } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/need-triage',
});
core.info(
`Successfully removed status/need-triage from #${issue.number}`,
const labels = issueData.labels.map((l) =>
typeof l === 'string' ? l : l.name,
);
} catch (error) {
if (error.status === 404) {
if (
labels.includes('status/bot-triaged') &&
labels.includes('status/need-triage')
) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/need-triage',
});
core.info(
`Label status/need-triage not found on #${issue.number}, skipping.`,
);
} else {
core.warning(
`Failed to remove label from #${issue.number}: ${error.message}`,
`Successfully removed status/need-triage from #${issue.number}`,
);
}
if (
labels.includes('status/bot-triaged') &&
labels.includes('status/manual-triage')
) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/bot-triaged',
});
core.info(
`Successfully removed status/bot-triaged from #${issue.number} because it requires manual triage`,
);
}
} catch (error) {
core.warning(
`Failed to clean up labels for #${issue.number}: ${error.message}`,
);
}
}
core.info(
`Cleaned up status/need-triage from ${issuesToCleanup.length} issues.`,
`Cleaned up conflicting labels from ${issuesToCleanup.length} issues.`,
);
};
@@ -1,60 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
module.exports = async ({ github, context, core }) => {
core.info('Fetching open issues to check for conflicting labels...');
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const conflictingLabelIssues = [];
for (const issue of issues) {
if (issue.pull_request) continue;
const areaLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('area/'))
.map((l) => l.name);
const priorityLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('priority/'))
.map((l) => l.name);
if (areaLabels.length > 1 || priorityLabels.length > 1) {
let message = `Issue #${issue.number} has conflicting labels:`;
if (areaLabels.length > 1)
message += ` multiple areas (${areaLabels.join(', ')}).`;
if (priorityLabels.length > 1)
message += ` multiple priorities (${priorityLabels.join(', ')}).`;
core.info(message);
conflictingLabelIssues.push({
number: issue.number,
title: issue.title,
body: issue.body || '',
});
}
}
// Limit to 50 to avoid overwhelming the AI in a single run
const issuesToProcess = conflictingLabelIssues.slice(0, 50);
fs.writeFileSync(
'conflicting_labels_issues.json',
JSON.stringify(issuesToProcess, null, 2),
);
core.info(
`Found ${conflictingLabelIssues.length} issues with conflicting labels. Wrote ${issuesToProcess.length} to conflicting_labels_issues.json`,
);
};
+142 -30
View File
@@ -16,6 +16,8 @@ module.exports = async ({ github, context, core }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
core.info(`Running in ${dryRun ? 'DRY RUN' : 'PRODUCTION'} mode.`);
const STALE_LABEL = 'stale';
const NEED_INFO_LABEL = 'status/need-information';
const EXEMPT_LABELS = [
@@ -79,14 +81,16 @@ module.exports = async ({ github, context, core }) => {
async function processItems(query, callback) {
core.info(`Searching: ${query}`);
try {
const response = await github.rest.search.issuesAndPullRequests({
q: query,
per_page: 100,
sort: 'updated',
order: 'asc',
});
const items = response.data.items;
core.info(`Found ${items.length} items (batch limited).`);
let items = await github.paginate(
github.rest.search.issuesAndPullRequests,
{
q: query,
per_page: 100,
sort: 'updated',
order: 'asc',
},
);
core.info(`Found ${items.length} items.`);
for (const item of items) {
try {
await callback(item);
@@ -114,16 +118,21 @@ module.exports = async ({ github, context, core }) => {
per_page: 5,
});
// Check if the last comment is from a non-maintainer
// Check if the last comment is from a non-maintainer and not a bot
const lastComment = comments[0];
if (
lastComment &&
lastComment.user?.type !== 'Bot' &&
!(await isMaintainer(lastComment.user, lastComment.author_association))
) {
core.info(
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would remove ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
} else {
core.info(
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
await github.rest.issues
.removeLabel({
owner,
@@ -141,10 +150,14 @@ module.exports = async ({ github, context, core }) => {
await processItems(
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
async (item) => {
core.info(
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would close #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
} else {
core.info(
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
await github.rest.issues.createComment({
owner,
repo,
@@ -156,6 +169,7 @@ module.exports = async ({ github, context, core }) => {
repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
},
@@ -163,11 +177,21 @@ module.exports = async ({ github, context, core }) => {
// 2. Handle Stale Mark (60 days inactivity, no stale label)
const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
await processItems(
`repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
async (item) => {
core.info(`Marking #${item.number} as stale.`);
if (!dryRun) {
const isBug = item.labels.some((l) =>
(typeof l === 'string' ? l : l.name).toLowerCase().includes('bug'),
);
const bodyText = isBug
? `This bug report has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. Many issues are resolved in newer releases. Please verify if the issue persists in the latest Gemini CLI version. If it does, please leave a comment to keep this open. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`
: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`;
if (dryRun) {
core.info(`[DRY RUN] Would mark #${item.number} as stale.`);
} else {
core.info(`Marking #${item.number} as stale.`);
await github.rest.issues.addLabels({
owner,
repo,
@@ -178,18 +202,97 @@ module.exports = async ({ github, context, core }) => {
owner,
repo,
issue_number: item.number,
body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
body: bodyText,
});
}
},
);
// 3. Handle Stale Close (14 days with stale label)
// 3. Handle Stale Removal & Close
await processItems(
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery}`,
async (item) => {
core.info(`Closing stale item #${item.number}.`);
if (!dryRun) {
// Fetch full timeline to see events and comments
const timeline = await github.paginate(
github.rest.issues.listEventsForTimeline,
{
owner,
repo,
issue_number: item.number,
per_page: 100,
},
);
// Find exactly when the Stale label was added
// We look for the last 'labeled' event for STALE_LABEL
const staleEventIndex = timeline.findLastIndex(
(e) =>
e.event === 'labeled' &&
e.label?.name?.toLowerCase() === STALE_LABEL.toLowerCase(),
);
if (staleEventIndex === -1) return; // Fallback if no event found
const staleEvent = timeline[staleEventIndex];
const eventsAfterStale = timeline.slice(staleEventIndex + 1);
// Check for meaningful activity after the Stale label was applied
const meaningfulEvents = eventsAfterStale.filter((e) => {
const actor = e.actor?.login || '';
const isBot =
actor.includes('[bot]') || actor.includes('github-actions');
if (isBot) return false;
// Explicit whitelist of meaningful events for humans
if (
[
'commented',
'cross-referenced',
'connected',
'reopened',
'assigned',
].includes(e.event)
) {
return true;
}
return false;
});
if (meaningfulEvents.length > 0) {
// Activity detected, remove Stale label
if (dryRun) {
core.info(
`[DRY RUN] Would remove ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
);
} else {
core.info(
`Removing ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
);
await github.rest.issues
.removeLabel({
owner,
repo,
issue_number: item.number,
name: STALE_LABEL,
})
.catch(() => {});
}
return;
}
// No meaningful activity. Check if 14 days have passed.
const labeledDate = new Date(staleEvent.created_at);
if (labeledDate > closeThreshold) {
// Has not been 14 days since it was ACTUALLY marked stale
return;
}
if (dryRun) {
core.info(`[DRY RUN] Would close stale item #${item.number}.`);
} else {
core.info(`Closing stale item #${item.number}.`);
await github.rest.issues.createComment({
owner,
repo,
@@ -201,6 +304,7 @@ module.exports = async ({ github, context, core }) => {
repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
},
@@ -222,8 +326,12 @@ module.exports = async ({ github, context, core }) => {
async (pr) => {
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(`Nudging PR #${pr.number} for contribution policy.`);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would nudge PR #${pr.number} for contribution policy.`,
);
} else {
core.info(`Nudging PR #${pr.number} for contribution policy.`);
await github.rest.issues.addLabels({
owner,
repo,
@@ -246,10 +354,14 @@ module.exports = async ({ github, context, core }) => {
async (pr) => {
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would close PR #${pr.number} per contribution policy (no 'help wanted').`,
);
} else {
core.info(
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
);
await github.rest.issues.createComment({
owner,
repo,
@@ -68,6 +68,7 @@ jobs:
ISSUE_NUMBER: '${{ github.event.issue.number }}'
REPOSITORY: '${{ github.repository }}'
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -335,14 +335,41 @@ jobs:
return;
}
const newAreaLabel = labelsToAdd[0];
// Get current labels to resolve conflicts
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const currentLabelNames = currentLabels.map(l => l.name);
const currentAreaLabels = currentLabelNames.filter(name => name.startsWith('area/'));
const labelsToRemove = currentAreaLabels.filter(name => name !== newAreaLabel);
for (const label of labelsToRemove) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: label
});
core.info(`Removed conflicting area label: ${label}`);
} catch (e) {
core.warning(`Failed to remove label ${label}: ${e.message}`);
}
}
// Set labels based on triage result
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
labels: [newAreaLabel]
});
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
core.info(`Successfully added labels for #${issueNumber}: ${newAreaLabel}`);
- name: 'Post Issue Analysis Failure Comment'
if: |-
@@ -1,10 +1,6 @@
name: '📋 Gemini Scheduled Issue Triage'
on:
issues:
types:
- 'opened'
- 'reopened'
schedule:
- cron: '0 * * * *' # Runs every hour
workflow_dispatch:
@@ -66,12 +62,20 @@ jobs:
- name: 'Find Issues with Conflicting Labels'
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const findConflictingLabels = require('./.github/scripts/find-conflicting-labels.cjs');
await findConflictingLabels({ github, context, core });
env:
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
set -euo pipefail
echo '🔍 Fetching open issues to find conflicts...'
# Fetch up to 2000 open issues in one quick GraphQL-backed query
gh issue list --repo "${GITHUB_REPOSITORY}" --search "is:issue is:open" --limit 2000 --json number,title,body,labels > all_open_issues.json
echo '🧹 Filtering issues with multiple area/ or priority/ labels...'
jq -c '[ .[] | select( (.labels | map(select(.name | startswith("area/"))) | length) > 1 or (.labels | map(select(.name | startswith("priority/"))) | length) > 1 ) ] | .[0:50]' all_open_issues.json > conflicting_labels_issues.json
CONFLICT_COUNT=$(jq 'length' conflicting_labels_issues.json)
echo "Found ${CONFLICT_COUNT} issues with conflicting labels (capped at 50 for processing)."
- name: 'Find untriaged issues'
if: |-
@@ -85,19 +89,19 @@ jobs:
echo '🔍 Finding issues missing area labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 50 --json number,title,body > no_area_issues.json
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 50 --json number,title,body,labels > no_area_issues.json
echo '🔍 Finding issues missing kind labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body > no_kind_issues.json
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body,labels > no_kind_issues.json
echo '🏷️ Finding issues missing priority labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body > no_priority_issues.json
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body,labels > no_priority_issues.json
echo '📏 Finding issues missing effort labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 5 --json number,title,body > no_effort_issues.json
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 20 --json number,title,body,labels > no_effort_issues.json
echo '🔄 Merging and deduplicating standard triage issues...'
if [ ! -f conflicting_labels_issues.json ]; then echo "[]" > conflicting_labels_issues.json; fi
@@ -162,6 +166,7 @@ jobs:
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
GEMINI_MODEL: 'gemini-3-flash-preview'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -178,7 +183,7 @@ jobs:
"read_file"
],
"telemetry": {
"enabled": true,
"enabled": false,
"target": "gcp"
}
}
@@ -192,12 +197,12 @@ jobs:
## Steps
1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage.
3. Review the issue title, body and any comments provided in the JSON file.
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage (including their current labels).
3. Review the issue title, body, current labels, and any comments provided in the JSON file.
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, and priority/*.
5. Label Policy:
- If the issue already has a kind/ label, do not change it.
- If the issue has exactly ONE priority/ label, do not change it.
- If the issue has exactly ONE priority/ label, do not change it (unless you are explicitly re-evaluating an ambiguous priority).
- If the issue is missing a priority/ label, OR if the issue currently has MULTIPLE priority/ labels, you must evaluate the issue's impact to determine exactly ONE priority level (priority/p0, priority/p1, priority/p2, priority/p3, or priority/unknown) based the guidelines. If you are fixing an issue with multiple priority/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
- If the issue has exactly ONE area/ label, do not change it.
- If the issue is missing an area/ label, OR if the issue currently has MULTIPLE area/ labels, select exactly ONE area/ label that best fits the issue. Issues MUST NOT have multiple area/ labels. If you are fixing an issue with multiple area/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
@@ -217,11 +222,12 @@ jobs:
]
```
If an issue cannot be classified, do not include it in the output array.
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
9. For each issue, carefully check if the CLI version is present. It is usually found under the "### Client information" header, as a bullet point (e.g., "• CLI Version: 0.33.1"), or in the output of the `/about` command.
- If the version is provided but is more than 6 minor versions older than the most recent release, apply the status/need-information label and leave a comment politely asking the user to verify if the issue persists in the latest version.
10. If the issue does not have sufficient information, recommend the status/need-information label and leave a comment politely requesting the missing details. For example, if repro steps are missing, ask for them; if the CLI version is completely missing, ask for the version information in the explanation section below. Do not ask for version info if it is already in the issue body.
11. If you think an issue is a Priority/P0, you MUST apply the priority/p1 label AND the status/manual-triage label, and include a note in your explanation that it likely requires P0 escalation.
12. If the issue is highly ambiguous, completely lacks a description, or you are torn between two lower priorities (like P2 vs P3), you MUST retain the existing priority label if one is already present. Do not toggle the priority if you do not have enough information to make a definitive change.
13. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
## Guidelines
@@ -234,12 +240,14 @@ jobs:
- Identify exactly ONE area/ label. Do NOT assign multiple area/ labels to a single issue.
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
- Identify exactly ONE priority/ label. Do NOT assign multiple priority/ labels to a single issue.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
- **Do not manually downgrade the priority.** Always assign the true priority based on the guidelines. The system will handle downgrades programmatically if information is missing.
- **NEVER mention label names, label removals, or label additions in your `explanation`.** The explanation must be purely written for the user (e.g., "Please provide your CLI version.") without exposing internal triage mechanics (e.g., do NOT say "Removing area/unknown to leave only area/core").
Categorization Guidelines (Priority):
P0 - Urgent Blocking Issues:
- DO NOT APPLY THE priority/p0 LABEL AUTOMATICALLY. Instead apply priority/p1 and status/manual-triage.
- Definition: Critical failures breaking core functionality for a large portion of users. Examples: CLI fails to launch globally, core commands (gemini run) crash on valid input, unhandled promise rejections on boot, critical security vulnerability.
- Note: You must apply status/manual-triage instead of priority/p0.
- Note: You must apply priority/p1 and status/manual-triage instead of priority/p0.
P1 - Critical but Workable:
- Definition: Severe issues without a reasonable workaround, significantly degrading the developer experience but not globally blocking. Examples: Specific tools failing consistently (e.g., `web_search` returns 500s), persistent PTY streaming hangs, memory leaks leading to OOM after short use.
P2 - Significant Issues:
@@ -279,6 +287,7 @@ jobs:
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
GEMINI_MODEL: 'gemini-3-flash-preview'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -297,7 +306,7 @@ jobs:
"read_file"
],
"telemetry": {
"enabled": true,
"enabled": false,
"target": "gcp"
}
}
@@ -383,29 +392,14 @@ jobs:
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
- When users report that they dont expect the model to change those would be categorized as feature requests.
- name: 'Apply Standard Labels to Issues'
- name: 'Apply Triaged Labels'
if: |-
${{ steps.gemini_standard_issue_analysis.outcome == 'success' &&
steps.gemini_standard_issue_analysis.outputs.summary != '[]' &&
steps.gemini_standard_issue_analysis.outputs.summary != '' }}
always() &&
( (steps.gemini_standard_issue_analysis.outcome == 'success' && steps.gemini_standard_issue_analysis.outputs.summary != '[]' && steps.gemini_standard_issue_analysis.outputs.summary != '') ||
(steps.gemini_effort_issue_analysis.outcome == 'success' && steps.gemini_effort_issue_analysis.outputs.summary != '[]' && steps.gemini_effort_issue_analysis.outputs.summary != '') )
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const applyLabels = require('./.github/scripts/apply-issue-labels.cjs');
await applyLabels({ github, context, core });
- name: 'Apply Effort Labels to Issues'
if: |-
${{ steps.gemini_effort_issue_analysis.outcome == 'success' &&
steps.gemini_effort_issue_analysis.outputs.summary != '[]' &&
steps.gemini_effort_issue_analysis.outputs.summary != '' }}
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
LABELS_OUTPUT_STANDARD: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
LABELS_OUTPUT_EFFORT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
@@ -431,9 +425,12 @@ jobs:
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
set -euo pipefail
echo '🧹 Finding issues that have both bot-triaged and need-triage labels...'
echo '🧹 Finding issues that have conflicting status labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > issues_to_cleanup.json
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > cleanup_1.json
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:status/bot-triaged label:status/manual-triage' --limit 50 --json number > cleanup_2.json
jq -c -s 'add | unique_by(.number)' cleanup_1.json cleanup_2.json > issues_to_cleanup.json
- name: 'Clean Up Triage Labels'
if: |-
+1 -1
View File
@@ -172,7 +172,7 @@ describe('file-system', () => {
).toBeDefined();
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe('1.0.1');
expect(newFileContent.trimEnd()).toBe('1.0.1');
});
it.skip('should replace multiple instances of a string', async () => {
+11 -2
View File
@@ -8,6 +8,15 @@ import { AuthType } from '@google/gemini-cli-core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { validateAuthMethod } from './auth.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
loadApiKey: vi.fn().mockResolvedValue(null),
};
});
vi.mock('./settings.js', () => ({
loadEnvironment: vi.fn(),
loadSettings: vi.fn().mockReturnValue({
@@ -90,10 +99,10 @@ describe('validateAuthMethod', () => {
envs: {},
expected: 'Invalid auth method selected.',
},
])('$description', ({ authType, envs, expected }) => {
])('$description', async ({ authType, envs, expected }) => {
for (const [key, value] of Object.entries(envs)) {
vi.stubEnv(key, value as string);
}
expect(validateAuthMethod(authType)).toBe(expected);
expect(await validateAuthMethod(authType)).toBe(expected);
});
});
+6 -3
View File
@@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@google/gemini-cli-core';
import { AuthType, loadApiKey } from '@google/gemini-cli-core';
import { loadEnvironment, loadSettings } from './settings.js';
export function validateAuthMethod(authMethod: string): string | null {
export async function validateAuthMethod(
authMethod: string,
): Promise<string | null> {
loadEnvironment(loadSettings().merged, process.cwd());
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
@@ -17,7 +19,8 @@ export function validateAuthMethod(authMethod: string): string | null {
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
const key = process.env['GEMINI_API_KEY'] || (await loadApiKey());
if (!key) {
return (
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
'Update your environment and try again (no reload needed if using .env)!'
+42
View File
@@ -275,6 +275,10 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
validateNonInteractiveAuth: vi.fn().mockResolvedValue('google'),
}));
vi.mock('./config/auth.js', () => ({
validateAuthMethod: vi.fn().mockResolvedValue(null),
}));
describe('gemini.tsx main function', () => {
let originalIsTTY: boolean | undefined;
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
@@ -1276,6 +1280,44 @@ describe('gemini.tsx main function exit codes', () => {
}
});
it('should exit with 41 for validateAuthMethod failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue(
createMockSandboxConfig({
command: 'docker',
image: 'test-image',
}),
);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
refreshAuth: vi.fn().mockResolvedValue(undefined),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
}),
);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
security: { auth: { selectedType: 'google', useExternal: false } },
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
const authModule = await import('./config/auth.js');
vi.mocked(authModule.validateAuthMethod).mockResolvedValueOnce(
'Auth method invalid',
);
try {
await main();
expect.fail('Should have thrown MockProcessExitError');
} catch (e) {
expect(e).toBeInstanceOf(MockProcessExitError);
expect((e as MockProcessExitError).code).toBe(41);
}
});
it('should exit with 41 for auth failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue(
+1 -1
View File
@@ -514,7 +514,7 @@ export async function main() {
partialConfig.isInteractive() &&
settings.merged.security.auth.selectedType
) {
const err = validateAuthMethod(
const err = await validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (err) {
+34
View File
@@ -150,6 +150,9 @@ vi.mock('./hooks/useQuotaAndFallback.js');
vi.mock('./hooks/useHistoryManager.js');
vi.mock('./hooks/useThemeCommand.js');
vi.mock('./auth/useAuth.js');
vi.mock('../config/auth.js', () => ({
validateAuthMethod: vi.fn().mockResolvedValue(null),
}));
vi.mock('./hooks/useEditorSettings.js');
vi.mock('./hooks/useSettingsCommand.js');
vi.mock('./hooks/useModelCommand.js');
@@ -217,6 +220,7 @@ vi.mock('../utils/cleanup.js');
import { useHistory } from './hooks/useHistoryManager.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js';
import { validateAuthMethod } from '../config/auth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
@@ -576,6 +580,36 @@ describe('AppContainer State Management', () => {
});
describe('State Initialization', () => {
it('calls validateAuthMethod and onAuthError if validation fails', async () => {
const mockOnAuthError = vi.fn();
mockedUseAuthCommand.mockReturnValue({
authState: 'authenticated',
setAuthState: vi.fn(),
authError: null,
onAuthError: mockOnAuthError,
});
vi.mocked(validateAuthMethod).mockResolvedValueOnce('Validation Failed');
const { unmount } = await act(async () =>
renderAppContainer({
settings: createMockSettings({
merged: {
security: {
auth: { selectedType: 'oauth-personal', useExternal: false },
},
},
}),
}),
);
await waitFor(() => {
expect(validateAuthMethod).toHaveBeenCalledWith('oauth-personal');
expect(mockOnAuthError).toHaveBeenCalledWith('Validation Failed');
});
unmount();
});
it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
+16 -6
View File
@@ -912,12 +912,22 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
const error = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (error) {
onAuthError(error);
}
const authMethod = settings.merged.security.auth.selectedType;
void (async () => {
try {
const error = await validateAuthMethod(authMethod);
if (
error &&
authMethod === settings.merged.security.auth.selectedType
) {
onAuthError(error);
}
} catch (e) {
if (authMethod === settings.merged.security.auth.selectedType) {
onAuthError(getErrorMessage(e));
}
}
})();
}
}, [
settings.merged.security.auth.selectedType,
+11 -11
View File
@@ -215,11 +215,11 @@ describe('AuthDialog', () => {
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
mockedValidateAuthMethod.mockResolvedValue('Invalid method');
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
await handleAuthSelect(AuthType.USE_GEMINI);
expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
@@ -231,7 +231,7 @@ describe('AuthDialog', () => {
});
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -245,7 +245,7 @@ describe('AuthDialog', () => {
it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -259,7 +259,7 @@ describe('AuthDialog', () => {
it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', '');
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -270,7 +270,7 @@ describe('AuthDialog', () => {
});
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -281,7 +281,7 @@ describe('AuthDialog', () => {
});
it('always shows API key dialog even when env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
@@ -297,7 +297,7 @@ describe('AuthDialog', () => {
});
it('always shows API key dialog even when env var is empty string', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
@@ -313,7 +313,7 @@ describe('AuthDialog', () => {
});
it('shows API key dialog on initial setup if no env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
@@ -329,7 +329,7 @@ describe('AuthDialog', () => {
});
it('always shows API key dialog on re-auth even if env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// Simulate switching from a different auth method (e.g., Google Login → API key)
props.settings.merged.security.auth.selectedType =
@@ -353,7 +353,7 @@ describe('AuthDialog', () => {
.mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
+5 -2
View File
@@ -154,8 +154,11 @@ export function AuthDialog({
[settings, config, setAuthState, exiting, setAuthContext],
);
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethodWithSettings(authMethod, settings);
const handleAuthSelect = async (authMethod: AuthType) => {
const error = await validateAuthMethodWithSettings(
authMethod,
settings,
).catch((e) => (e instanceof Error ? e.message : String(e)));
if (error) {
onAuthError(error);
} else {
+10 -10
View File
@@ -45,7 +45,7 @@ describe('useAuth', () => {
});
describe('validateAuthMethodWithSettings', () => {
it('should return error if auth type is enforced and does not match', () => {
it('should return error if auth type is enforced and does not match', async () => {
const settings = {
merged: {
security: {
@@ -56,14 +56,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toContain('Authentication is enforced to be oauth');
});
it('should return null if useExternal is true', () => {
it('should return null if useExternal is true', async () => {
const settings = {
merged: {
security: {
@@ -74,14 +74,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
expect(error).toBeNull();
});
it('should return null if authType is USE_GEMINI', () => {
it('should return null if authType is USE_GEMINI', async () => {
const settings = {
merged: {
security: {
@@ -90,14 +90,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toBeNull();
});
it('should call validateAuthMethod for other auth types', () => {
it('should call validateAuthMethod for other auth types', async () => {
const settings = {
merged: {
security: {
@@ -106,8 +106,8 @@ describe('useAuth', () => {
},
} as LoadedSettings;
mockValidateAuthMethod.mockReturnValue('Validation Error');
const error = validateAuthMethodWithSettings(
mockValidateAuthMethod.mockResolvedValue('Validation Error');
const error = await validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
@@ -265,7 +265,7 @@ describe('useAuth', () => {
});
it('should set error if validation fails', async () => {
mockValidateAuthMethod.mockReturnValue('Validation Failed');
mockValidateAuthMethod.mockResolvedValue('Validation Failed');
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
+7 -3
View File
@@ -18,10 +18,10 @@ import { getErrorMessage } from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js';
export function validateAuthMethodWithSettings(
export async function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
): string | null {
): Promise<string | null> {
const enforcedType = settings.merged.security.auth.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
@@ -111,7 +111,11 @@ export const useAuthCommand = (
}
}
const error = validateAuthMethodWithSettings(authType, settings);
const error = await validateAuthMethodWithSettings(
authType,
settings,
).catch((e: unknown) => getErrorMessage(e));
if (error) {
onAuthError(error);
return;
@@ -49,6 +49,7 @@ import {
debugLogger,
coreEvents,
CoreEvent,
SHELL_TOOL_NAME,
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
@@ -2449,6 +2450,44 @@ describe('useGeminiStream', () => {
);
});
it('should auto-approve shell commands with redirection when switching to AUTO_EDIT mode', async () => {
const shellCall = createMockToolCall(
SHELL_TOOL_NAME,
'call-shell',
'info',
);
shellCall.request.args = { command: 'ls > files.txt' };
const { result } = await renderTestHook([shellCall]);
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.AUTO_EDIT);
});
// Shell command with redirection should be auto-approved
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({ correlationId: 'corr-call-shell' }),
);
});
it('should NOT auto-approve shell commands without redirection when switching to AUTO_EDIT mode', async () => {
const shellCall = createMockToolCall(
SHELL_TOOL_NAME,
'call-shell',
'info',
);
shellCall.request.args = { command: 'ls -la' };
const { result } = await renderTestHook([shellCall]);
await act(async () => {
await result.current.handleApprovalModeChange(ApprovalMode.AUTO_EDIT);
});
// Regular shell command should NOT be auto-approved
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
it('should not auto-approve any tools when switching to REQUIRE_CONFIRMATION mode', async () => {
const awaitingApprovalToolCalls: TrackedToolCall[] = [
createMockToolCall('replace', 'call1', 'edit'),
+16 -3
View File
@@ -26,6 +26,8 @@ import {
debugLogger,
runInDevTraceSpan,
EDIT_TOOL_NAMES,
SHELL_TOOL_NAME,
hasRedirection,
processRestorableToolCalls,
recordToolCallInteractions,
ToolErrorType,
@@ -1820,10 +1822,21 @@ export const useGeminiStream = (
);
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
// or shell commands with redirection (which act as edits).
if (newApprovalMode === ApprovalMode.AUTO_EDIT) {
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) =>
EDIT_TOOL_NAMES.has(call.request.name),
);
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) => {
if (EDIT_TOOL_NAMES.has(call.request.name)) {
return true;
}
if (call.request.name === SHELL_TOOL_NAME) {
const command = (call.request.args as { command?: string })
.command;
return command && hasRedirection(command);
}
return false;
});
}
// Process pending tool calls sequentially to reduce UI chaos
@@ -265,6 +265,24 @@ describe('TableRenderer', () => {
unmount();
});
it('handles extremely small terminal widths without crashing', async () => {
const headers = ['Col 1', 'Col 2'];
const rows = [['Data 1', 'Data 2']];
// This width is much smaller than the overhead, which could lead to negative column widths
const terminalWidth = 1;
const renderResult = await renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
terminalWidth={terminalWidth}
/>,
);
const { unmount } = renderResult;
// If it didn't throw RangeError: Invalid count value, the test passes
unmount();
});
it.each([
{
name: 'handles non-ASCII characters (emojis and Asian scripts) correctly',
+3 -1
View File
@@ -250,7 +250,9 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
};
const char = chars[type];
const borderParts = adjustedWidths.map((w) => char.horizontal.repeat(w));
const borderParts = adjustedWidths.map((w) =>
char.horizontal.repeat(Math.max(0, w || 0)),
);
const border = char.left + borderParts.join(char.middle) + char.right;
return <Text color={theme.border.default}>{border}</Text>;
@@ -59,7 +59,7 @@ describe('validateNonInterActiveAuth', () => {
.mockImplementation((code?: string | number | null | undefined) => {
throw new Error(`process.exit(${code}) called`);
});
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue(null);
vi.spyOn(auth, 'validateAuthMethod').mockResolvedValue(null);
mockSettings = {
system: { path: '', settings: {} },
systemDefaults: { path: '', settings: {} },
@@ -247,7 +247,7 @@ describe('validateNonInterActiveAuth', () => {
it('exits if validateAuthMethod returns error', async () => {
// Mock validateAuthMethod to return error
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
vi.spyOn(auth, 'validateAuthMethod').mockResolvedValue('Auth error!');
const nonInteractiveConfig = createLocalMockConfig({
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi
@@ -277,7 +277,7 @@ describe('validateNonInterActiveAuth', () => {
// Mock validateAuthMethod to return error to ensure it's not being called
const validateAuthMethodSpy = vi
.spyOn(auth, 'validateAuthMethod')
.mockReturnValue('Auth error!');
.mockResolvedValue('Auth error!');
const nonInteractiveConfig = createLocalMockConfig({});
// Even with an invalid auth type, it should not exit
// because validation is skipped.
@@ -432,7 +432,7 @@ describe('validateNonInterActiveAuth', () => {
});
it(`prints JSON error when validateAuthMethod fails and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => {
vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!');
vi.spyOn(auth, 'validateAuthMethod').mockResolvedValue('Auth error!');
process.env['GEMINI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createLocalMockConfig({
@@ -42,7 +42,7 @@ export async function validateNonInteractiveAuth(
const authType: AuthType = effectiveAuthType;
if (!useExternalAuth) {
const err = validateAuthMethod(String(authType));
const err = await validateAuthMethod(String(authType));
if (err != null) {
throw new Error(err);
}
+11 -12
View File
@@ -49,6 +49,7 @@ vi.mock('../tools/mcp-client-manager.js', () => ({
}));
import { debugLogger } from '../utils/debugLogger.js';
import { runWithToolCallContext } from '../utils/toolCallContext.js';
import { LocalAgentExecutor, type ActivityCallback } from './local-executor.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ToolRegistry } from '../tools/tool-registry.js';
@@ -708,21 +709,19 @@ describe('LocalAgentExecutor', () => {
expect(agentRegistry.getTool(MOCK_TOOL_NOT_ALLOWED.name)).toBeUndefined();
});
it('should use parentPromptId from context to create agentId', async () => {
const parentId = 'parent-id';
Object.defineProperty(mockConfig, 'promptId', {
get: () => parentId,
configurable: true,
});
it('should not include parentCallId in agentId even when available', async () => {
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
const parentCallId = 'parent-call-123';
const executor = await runWithToolCallContext(
{ callId: parentCallId, schedulerId: 'test-scheduler' },
() => LocalAgentExecutor.create(definition, mockConfig, onActivity),
);
expect(executor['agentId']).toBeDefined();
expect(executor['agentId']).not.toContain(parentCallId);
expect(executor['agentId']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
it('should correctly apply templates to initialMessages', async () => {
+2 -1
View File
@@ -6,6 +6,7 @@
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { reportError } from '../utils/errorReporting.js';
import { randomUUID } from 'node:crypto';
import { ApprovalMode } from '../policy/types.js';
import { GeminiChat, StreamEventType } from '../core/geminiChat.js';
import {
@@ -315,7 +316,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.parentCallId = parentCallId;
this.cache = new LRUCache<string, string>(10);
this.agentId = Math.random().toString(36).slice(2, 8);
this.agentId = randomUUID();
}
/**
+70
View File
@@ -616,4 +616,74 @@ ${authUrl}
return null;
}
async getValidTokenWithMetadata(
serverName: string,
config: MCPOAuthConfig,
): Promise<{
accessToken: string;
tokenType: string;
expiresAt?: number;
scope?: string;
refreshToken?: string;
} | null> {
const credentials = await this.tokenStorage.getCredentials(serverName);
if (!credentials) return null;
let current = credentials.token;
if (this.tokenStorage.isTokenExpired(current)) {
const clientId = config.clientId ?? credentials.clientId;
if (current.refreshToken && clientId && credentials.tokenUrl) {
try {
const newTokenResponse = await this.refreshAccessToken(
config,
current.refreshToken,
credentials.tokenUrl,
credentials.mcpServerUrl,
);
const refreshed: OAuthToken = {
accessToken: newTokenResponse.access_token,
tokenType: newTokenResponse.token_type,
refreshToken:
newTokenResponse.refresh_token || current.refreshToken,
scope: newTokenResponse.scope || current.scope,
};
if (newTokenResponse.expires_in) {
refreshed.expiresAt =
Date.now() + newTokenResponse.expires_in * 1000;
}
await this.tokenStorage.saveToken(
serverName,
refreshed,
clientId,
credentials.tokenUrl,
credentials.mcpServerUrl,
);
current = refreshed;
} catch (error) {
coreEvents.emitFeedback(
'error',
'Failed to refresh auth token.',
error,
);
await this.tokenStorage.deleteCredentials(serverName);
return null;
}
} else {
return null;
}
}
return {
accessToken: current.accessToken,
tokenType: current.tokenType || 'Bearer',
expiresAt: current.expiresAt,
scope: current.scope,
refreshToken: current.refreshToken,
};
}
}
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import type {
OAuthClientInformation,
OAuthClientMetadata,
OAuthTokens,
} from '@modelcontextprotocol/sdk/shared/auth.js';
import type { MCPServerConfig } from '../config/config.js';
import { MCPOAuthProvider } from './oauth-provider.js';
import { FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
export class DynamicStoredOAuthProvider implements OAuthClientProvider {
readonly redirectUrl = '';
readonly clientMetadata: OAuthClientMetadata = {
client_name: 'Gemini CLI (Stored OAuth)',
redirect_uris: [],
grant_types: [],
response_types: [],
token_endpoint_auth_method: 'none',
};
private clientInfo?: OAuthClientInformation;
private readonly oauthProvider = new MCPOAuthProvider();
private cachedToken?: OAuthTokens;
private tokenExpiryTime?: number;
constructor(
private readonly serverName: string,
private readonly serverConfig: MCPServerConfig,
) {}
clientInformation(): OAuthClientInformation | undefined {
return this.clientInfo;
}
saveClientInformation(clientInformation: OAuthClientInformation): void {
this.clientInfo = clientInformation;
}
private isCachedTokenValid(): boolean {
return !!(
this.cachedToken?.access_token &&
this.tokenExpiryTime &&
Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS
);
}
async tokens(): Promise<OAuthTokens | undefined> {
if (this.isCachedTokenValid()) {
return this.cachedToken;
}
const oauthConfig =
this.serverConfig.oauth?.enabled && this.serverConfig.oauth
? this.serverConfig.oauth
: {};
const tokenMeta = await this.oauthProvider.getValidTokenWithMetadata(
this.serverName,
oauthConfig,
);
if (!tokenMeta?.accessToken) {
this.cachedToken = undefined;
this.tokenExpiryTime = undefined;
return undefined;
}
const freshTokens: OAuthTokens = {
access_token: tokenMeta.accessToken,
token_type: tokenMeta.tokenType || 'Bearer',
expires_in: tokenMeta.expiresAt
? Math.max(0, Math.floor((tokenMeta.expiresAt - Date.now()) / 1000))
: undefined,
scope: tokenMeta.scope,
refresh_token: tokenMeta.refreshToken,
};
if (freshTokens.expires_in !== undefined) {
this.cachedToken = freshTokens;
this.tokenExpiryTime = Date.now() + freshTokens.expires_in * 1000;
return this.cachedToken;
}
this.cachedToken = undefined;
this.tokenExpiryTime = undefined;
return freshTokens;
}
saveTokens(_tokens: OAuthTokens): void {}
redirectToAuthorization(_authorizationUrl: URL): void {}
saveCodeVerifier(_codeVerifier: string): void {}
codeVerifier(): string {
return '';
}
}
+223 -15
View File
@@ -1780,41 +1780,249 @@ describe('mcp-client', () => {
describe('createTransport', () => {
describe('should connect via httpUrl', () => {
it('without headers', async () => {
it('uses MCP SDK authProvider token() path for oauth-enabled servers', async () => {
const mockGetValidTokenWithMetadata = vi.fn().mockResolvedValue({
accessToken: 'fresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10 * 60 * 1000,
});
vi.mocked(MCPOAuthProvider).mockReturnValue({
getValidTokenWithMetadata: mockGetValidTokenWithMetadata,
} as unknown as MCPOAuthProvider);
vi.mocked(MCPOAuthTokenStorage).mockReturnValue({
getCredentials: vi.fn().mockResolvedValue({
clientId: 'cid',
token: {
accessToken: 'fresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10 * 60 * 1000,
},
}),
} as unknown as MCPOAuthTokenStorage);
const transport = await createTransport(
'test-server',
{
httpUrl: 'http://test-server',
url: 'http://test-server',
type: 'http',
oauth: { enabled: true },
},
false,
MOCK_CONTEXT,
);
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
expect(transport).toMatchObject({
_url: new URL('http://test-server'),
_requestInit: { headers: {} },
});
const testableTransport = transport as unknown as {
_authProvider?: {
tokens: () => Promise<{ access_token: string } | undefined>;
};
};
expect(testableTransport._authProvider).toBeDefined();
const tokens = await testableTransport._authProvider!.tokens();
expect(tokens?.access_token).toBe('fresh-token');
});
it('uses storage-backed expiry instead of long fallback cache for dynamic authProvider', async () => {
const now = Date.now();
const soonExpiry = now + 10 * 60 * 1000; // 10 minutes
const mockGetValidTokenWithMetadata = vi.fn().mockResolvedValue({
accessToken: 'fresh-token',
tokenType: 'Bearer',
expiresAt: soonExpiry,
});
const mockGetCredentials = vi.fn().mockImplementation(async () => ({
clientId: 'cid',
token: {
accessToken: 'fresh-token',
tokenType: 'Bearer',
expiresAt: soonExpiry,
},
}));
vi.mocked(MCPOAuthProvider).mockReturnValue({
getValidTokenWithMetadata: mockGetValidTokenWithMetadata,
} as unknown as MCPOAuthProvider);
vi.mocked(MCPOAuthTokenStorage).mockReturnValue({
getCredentials: mockGetCredentials,
} as unknown as MCPOAuthTokenStorage);
it('with headers', async () => {
const transport = await createTransport(
'test-server',
{
httpUrl: 'http://test-server',
headers: { Authorization: 'derp' },
url: 'http://test-server',
type: 'http',
},
false,
MOCK_CONTEXT,
);
expect(transport).toBeInstanceOf(StreamableHTTPClientTransport);
expect(transport).toMatchObject({
_url: new URL('http://test-server'),
_requestInit: {
headers: { Authorization: 'derp' },
const testableTransport = transport as unknown as {
_authProvider?: {
tokens: () => Promise<
{ access_token: string; expires_in?: number } | undefined
>;
};
};
expect(testableTransport._authProvider).toBeDefined();
const tokens = await testableTransport._authProvider!.tokens();
expect(tokens?.access_token).toBe('fresh-token');
expect(tokens?.expires_in).toBeDefined();
expect((tokens?.expires_in ?? 0) <= 10 * 60).toBe(true);
expect(mockGetValidTokenWithMetadata).toHaveBeenCalledTimes(1);
expect(mockGetCredentials).toHaveBeenCalledTimes(1);
});
it('uses dynamic authProvider when stored OAuth token exists', async () => {
const mockGetValidTokenWithMetadata = vi.fn().mockResolvedValue({
accessToken: 'stored-fresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10 * 60 * 1000,
});
vi.mocked(MCPOAuthProvider).mockReturnValue({
getValidTokenWithMetadata: mockGetValidTokenWithMetadata,
} as unknown as MCPOAuthProvider);
vi.mocked(MCPOAuthTokenStorage).mockReturnValue({
getCredentials: vi.fn().mockResolvedValue({
clientId: 'cid',
token: {
accessToken: 'stored-fresh-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10 * 60 * 1000,
},
}),
} as unknown as MCPOAuthTokenStorage);
const transport = await createTransport(
'test-server',
{
url: 'http://test-server',
type: 'http',
},
false,
MOCK_CONTEXT,
);
const testableTransport = transport as unknown as {
_authProvider?: {
tokens: () => Promise<{ access_token: string } | undefined>;
};
};
expect(testableTransport._authProvider).toBeDefined();
const tokens = await testableTransport._authProvider!.tokens();
expect(tokens?.access_token).toBe('stored-fresh-token');
});
it('caches OAuth tokens in dynamic authProvider and avoids repeated lookups', async () => {
const mockGetValidTokenWithMetadata = vi.fn().mockResolvedValue({
accessToken: 'cached-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10 * 60 * 1000,
});
const mockGetCredentials = vi.fn().mockResolvedValue({
clientId: 'cid',
token: {
accessToken: 'cached-token',
tokenType: 'Bearer',
expiresAt: Date.now() + 10 * 60 * 1000,
},
});
vi.mocked(MCPOAuthProvider).mockReturnValue({
getValidTokenWithMetadata: mockGetValidTokenWithMetadata,
} as unknown as MCPOAuthProvider);
vi.mocked(MCPOAuthTokenStorage).mockReturnValue({
getCredentials: mockGetCredentials,
} as unknown as MCPOAuthTokenStorage);
const transport = await createTransport(
'test-server',
{
url: 'http://test-server',
type: 'http',
},
false,
MOCK_CONTEXT,
);
const testableTransport = transport as unknown as {
_authProvider?: {
tokens: () => Promise<{ access_token: string } | undefined>;
};
};
expect(testableTransport._authProvider).toBeDefined();
const t1 = await testableTransport._authProvider!.tokens();
const t2 = await testableTransport._authProvider!.tokens();
expect(t1?.access_token).toBe('cached-token');
expect(t2?.access_token).toBe('cached-token');
// one call from createTransport fallback detection + one call in first tokens();
// second tokens() should come from in-memory cache
expect(mockGetCredentials).toHaveBeenCalledTimes(1);
expect(mockGetValidTokenWithMetadata).toHaveBeenCalledTimes(1);
});
it('does not long-cache token when metadata has no expiresAt', async () => {
const mockGetValidTokenWithMetadata = vi.fn().mockResolvedValue({
accessToken: 'no-exp-token',
tokenType: 'Bearer',
// expiresAt intentionally omitted
});
const mockGetCredentials = vi.fn().mockResolvedValue({
clientId: 'cid',
token: {
accessToken: 'no-exp-token',
tokenType: 'Bearer',
// expiresAt intentionally omitted
},
});
vi.mocked(MCPOAuthProvider).mockReturnValue({
getValidTokenWithMetadata: mockGetValidTokenWithMetadata,
} as unknown as MCPOAuthProvider);
vi.mocked(MCPOAuthTokenStorage).mockReturnValue({
getCredentials: mockGetCredentials,
} as unknown as MCPOAuthTokenStorage);
const transport = await createTransport(
'test-server',
{
url: 'http://test-server',
type: 'http',
},
false,
MOCK_CONTEXT,
);
const testableTransport = transport as unknown as {
_authProvider?: {
tokens: () => Promise<
{ access_token: string; expires_in?: number } | undefined
>;
};
};
expect(testableTransport._authProvider).toBeDefined();
const t1 = await testableTransport._authProvider!.tokens();
const t2 = await testableTransport._authProvider!.tokens();
expect(t1?.access_token).toBe('no-exp-token');
expect(t2?.access_token).toBe('no-exp-token');
expect(t1?.expires_in).toBeUndefined();
expect(t2?.expires_in).toBeUndefined();
// no-expiry tokens should not be long-cached in memory
expect(mockGetValidTokenWithMetadata).toHaveBeenCalledTimes(2);
});
it('wraps fetch to convert GET 404 to 405 for POST-only servers (e.g. n8n)', async () => {
+35 -31
View File
@@ -5,6 +5,7 @@
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv';
import type {
jsonSchemaValidator,
@@ -54,9 +55,11 @@ import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { randomUUID } from 'node:crypto';
import type { McpAuthProvider } from '../mcp/auth-provider.js';
import { MCPOAuthProvider } from '../mcp/oauth-provider.js';
import { MCPOAuthTokenStorage } from '../mcp/oauth-token-storage.js';
import { MCPOAuthProvider } from '../mcp/oauth-provider.js';
import { DynamicStoredOAuthProvider } from '../mcp/stored-token-provider.js';
import { OAuthUtils } from '../mcp/oauth-utils.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import {
getErrorMessage,
@@ -82,6 +85,7 @@ import {
type EnvironmentSanitizationConfig,
} from '../services/environmentSanitization.js';
import { expandEnvVars } from '../utils/envExpansion.js';
import {
GEMINI_CLI_IDENTIFICATION_ENV_VAR,
GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE,
@@ -1025,6 +1029,16 @@ function createAuthProvider(
}
return undefined;
}
/**
* Creates an OAuth token provider for transports so token lookup/refresh happens
* at request/auth time instead of freezing a single token at transport creation.
*/
function createDynamicOAuthTokenProvider(
mcpServerName: string,
mcpServerConfig: MCPServerConfig,
): McpAuthProvider {
return new DynamicStoredOAuthProvider(mcpServerName, mcpServerConfig);
}
/**
* Create a transport with OAuth token for the given server configuration.
@@ -2205,41 +2219,32 @@ export async function createTransport(
}
}
if (mcpServerConfig.httpUrl || mcpServerConfig.url) {
const authProvider = createAuthProvider(mcpServerConfig);
let authProvider = createAuthProvider(mcpServerConfig);
const headers: Record<string, string> =
(await authProvider?.getRequestHeaders?.()) ?? {};
if (authProvider === undefined) {
// Check if we have OAuth configuration or stored tokens
let accessToken: string | null = null;
if (mcpServerConfig.oauth?.enabled && mcpServerConfig.oauth) {
const tokenStorage = new MCPOAuthTokenStorage();
const mcpAuthProvider = new MCPOAuthProvider(tokenStorage);
accessToken = await mcpAuthProvider.getValidToken(
mcpServerName,
mcpServerConfig.oauth,
);
const tokenStorage = new MCPOAuthTokenStorage();
const credentials = await tokenStorage.getCredentials(mcpServerName);
const shouldUseDynamicOAuthProvider = !!credentials;
if (!accessToken) {
// Emit info message (not error) since this is expected behavior
cliConfig.emitMcpDiagnostic(
'info',
`MCP server '${mcpServerName}' requires authentication using: /mcp auth ${mcpServerName}`,
undefined,
mcpServerName,
);
}
} else {
// Check if we have stored OAuth tokens for this server (from previous authentication)
accessToken = await getStoredOAuthToken(mcpServerName);
if (accessToken) {
debugLogger.log(
`Found stored OAuth token for server '${mcpServerName}'`,
);
}
if (!shouldUseDynamicOAuthProvider && mcpServerConfig.oauth?.enabled) {
cliConfig.emitMcpDiagnostic(
'info',
`MCP server '${mcpServerName}' requires authentication using: /mcp auth ${mcpServerName}`,
undefined,
mcpServerName,
);
}
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
if (shouldUseDynamicOAuthProvider) {
debugLogger.log(
`Found stored OAuth token for server '${mcpServerName}'`,
);
authProvider = createDynamicOAuthTokenProvider(
mcpServerName,
mcpServerConfig,
);
}
}
@@ -2339,7 +2344,6 @@ export async function createTransport(
`Invalid configuration: missing httpUrl (for Streamable HTTP), url (for SSE), and command (for stdio).`,
);
}
interface NamedTool {
name?: string;
}
@@ -46,6 +46,15 @@ import { Config, type GeminiCLIExtension } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { SimpleExtensionLoader } from './extensionLoader.js';
import { CoreEvent, coreEvents } from './events.js';
import * as fs from 'node:fs';
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
realpathSync: vi.fn(actual.realpathSync),
};
});
vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal<typeof os>();
@@ -743,6 +752,40 @@ included directory memory
expect(result.filePaths).toContain(projectContextFile);
});
it('should not crash when fs.realpathSync throws EISDIR on virtual drive roots', async () => {
// Mock realpathSync to throw EISDIR for a specific path, simulating
// the Windows virtual drive issue (#25216).
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr.includes('virtual-drive')) {
const error = new Error(
"EISDIR: illegal operation on a directory, realpath 'A:\\a'",
);
(error as NodeJS.ErrnoException).code = 'EISDIR';
throw error;
}
// For other paths, we need to return something sensible.
// Since it's a mock, we can just return the path string itself.
return pathStr;
});
const virtualDriveCwd = path.join(testRootDir, 'virtual-drive');
await fsPromises.mkdir(virtualDriveCwd, { recursive: true });
// This should now succeed instead of throwing
const result = await loadServerHierarchicalMemory(
virtualDriveCwd,
[],
new FileDiscoveryService(projectRoot),
new SimpleExtensionLoader([]),
DEFAULT_FOLDER_TRUST,
);
expect(result).toBeDefined();
expect(result.fileCount).toBe(0);
});
it('silently skips a GEMINI.md symlink that points to a directory', async () => {
// Create a real directory elsewhere and symlink GEMINI.md to it.
const realDir = await createEmptyDir(path.join(cwd, '.geminimd-target'));
+3 -4
View File
@@ -24,6 +24,7 @@ import {
isSubpath,
normalizePath,
toAbsolutePath,
resolveToRealPath,
} from './paths.js';
import type { ExtensionLoader } from './extensionLoader.js';
import { debugLogger } from './debugLogger.js';
@@ -702,10 +703,8 @@ export async function loadServerHierarchicalMemory(
boundaryMarkers: readonly string[] = ['.git'],
): Promise<LoadServerHierarchicalMemoryResponse> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = normalizePath(
await fs.realpath(path.resolve(currentWorkingDirectory)),
);
const realHome = normalizePath(await fs.realpath(path.resolve(homedir())));
const realCwd = normalizePath(resolveToRealPath(currentWorkingDirectory));
const realHome = normalizePath(resolveToRealPath(homedir()));
const isHomeDirectory = realCwd === realHome;
// If it is the home directory, pass an empty string to the core memory
+2 -2
View File
@@ -55,7 +55,7 @@ describe('GeminiCliAgent Skills Integration', () => {
// Expect pirate speak
expect(responseText.toLowerCase()).toContain('arrr');
}, 60000);
}, 120000);
it('loads and activates a skill from a root', async () => {
const goldenFile = getGoldenPath('skill-root-success');
@@ -88,5 +88,5 @@ describe('GeminiCliAgent Skills Integration', () => {
// Expect confirmation or pirate speak
expect(responseText.toLowerCase()).toContain('arrr');
}, 60000);
}, 120000);
});
+3 -3
View File
@@ -57,7 +57,7 @@ describe('GeminiCliAgent Tool Integration', () => {
.join('');
expect(responseText).toContain('8');
});
}, 20000);
it('handles ModelVisibleError correctly', async () => {
const goldenFile = getGoldenPath('tool-error-recovery');
@@ -103,7 +103,7 @@ describe('GeminiCliAgent Tool Integration', () => {
// The model should see the error "Tool failed visibly" and report it back.
expect(responseText).toContain('Tool failed visibly');
});
}, 20000);
it('handles sendErrorsToModel: true correctly', async () => {
const goldenFile = getGoldenPath('tool-catchall-error');
@@ -145,5 +145,5 @@ describe('GeminiCliAgent Tool Integration', () => {
// The model should report the caught standard error.
expect(responseText.toLowerCase()).toContain('error');
});
}, 20000);
});
Regular → Executable
View File