fix(gemini): make Windows hooks and agent tools valid

This commit is contained in:
Tom Boucher
2026-05-10 17:39:06 -04:00
committed by GitHub
parent ab84538712
commit 19295f5ab6
6 changed files with 145 additions and 19 deletions

View File

@@ -103,6 +103,26 @@ describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner',
});
});
describe('Bug #3362: Windows PowerShell hook commands use the call operator', () => {
test('global install: .js hook command starts with & so quoted runners execute in PowerShell', () => {
const cmd = buildHookCommand('C:/Program Files/Gemini/.gemini', 'gsd-check-update.js', {
platform: 'win32',
});
assert.ok(cmd.startsWith('& '), `PowerShell commands need call operator, got: ${cmd}`);
assert.ok(cmd.includes('"C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"'));
});
test('portable install: .js hook command also uses & on Windows PowerShell', () => {
const home = require('node:os').homedir().replace(/\\/g, '/');
const cmd = buildHookCommand(`${home}/.gemini`, 'gsd-check-update.js', {
portableHooks: true,
platform: 'win32',
});
assert.ok(cmd.startsWith('& '), `PowerShell commands need call operator, got: ${cmd}`);
assert.equal(parseHookCommand(cmd.slice(2)).hookPath, '$HOME/.gemini/hooks/gsd-check-update.js');
});
});
describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => {
test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => {
const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh');
@@ -154,6 +174,55 @@ describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites ba
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
test('adds PowerShell call operator to existing quoted managed hooks on Windows', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"',
);
});
test('does NOT double-prefix managed hooks that already use the PowerShell call operator', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const before = settings.hooks.SessionStart[0].hooks[0].command;
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32' });
assert.equal(changed, false);
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
});
test('rewrites PowerShell bare-node managed hooks to absolute runner without dropping &', () => {
const settings = {
hooks: {
SessionStart: [{
hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/hooks/gsd-check-update.js"' }],
}],
},
};
const runner = '"/usr/local/bin/node"';
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32' });
assert.equal(changed, true);
assert.equal(
settings.hooks.SessionStart[0].hooks[0].command,
'& "/usr/local/bin/node" "C:/Users/me/.gemini/hooks/gsd-check-update.js"',
);
});
test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => {
const settings = {
hooks: {

View File

@@ -18,6 +18,7 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
const {
convertSlashCommandsToGeminiMentions,
convertClaudeToGeminiMarkdown,
convertClaudeToGeminiAgent,
_resetGsdCommandRoster,
install
} = require('../bin/install.js');
@@ -132,6 +133,24 @@ describe('Gemini Markdown Processor', () => {
assert.doesNotMatch(result, /<sub>|<\/sub>/, '<sub> tags must be stripped');
assert.match(result, /\/gsd:help/, 'slash command must still be converted');
});
test('removes AskUserQuestion and ask_user from Gemini agent tools and body (#3362)', () => {
const input = [
'---',
'name: tester',
'tools: Read, AskUserQuestion, ask_user',
'---',
'Use AskUserQuestion or ask_user to ask the user.'
].join('\n');
const result = convertClaudeToGeminiAgent(input);
assert.match(result, /^ - read_file$/m, 'Read should still map to Gemini read_file');
assert.doesNotMatch(result, /^ - ask_user$/m, 'ask_user must not be emitted as a Gemini tool');
assert.doesNotMatch(result, /\bAskUserQuestion\b/, 'Claude tool references must be neutralized');
assert.doesNotMatch(result, /\bask_user\b/, 'lowercase ask_user references must be neutralized');
assert.match(result, /conversational prompting/, 'body should use runtime-neutral wording');
});
});
describe('Gemini Install (Behavioral)', () => {

View File

@@ -265,7 +265,7 @@ Use \${PHASE} in shell examples.
assert.ok(!result.includes('${PHASE}'), 'removes Gemini template-string pattern');
});
test('excludes Claude agent dispatcher tools from Gemini frontmatter', () => {
test('excludes Claude-only agent interaction tools from Gemini frontmatter', () => {
const input = `---
name: gsd-debug-session-manager
description: Manages debug sessions.
@@ -274,17 +274,21 @@ tools: Read, Task, Agent, AskUserQuestion
<role>
Coordinate debugger agents.
Offer choices via AskUserQuestion when user input is needed.
</role>`;
const result = convertClaudeToGeminiAgent(input);
const frontmatter = result.split('---')[1] || '';
assert.ok(frontmatter.includes(' - read_file'), 'maps Read -> read_file');
assert.ok(frontmatter.includes(' - ask_user'), 'maps AskUserQuestion -> ask_user');
assert.ok(!frontmatter.includes(' - ask_user'), 'does not emit invalid Gemini ask_user tool');
assert.ok(!frontmatter.includes(' - task'), 'does not emit invalid Gemini task tool');
assert.ok(!frontmatter.includes(' - agent'), 'does not emit invalid Gemini agent tool');
assert.ok(!frontmatter.includes('Task'), 'does not preserve Claude-only Task tool');
assert.ok(!frontmatter.includes('Agent'), 'does not preserve Claude-only Agent tool');
assert.ok(!frontmatter.includes('AskUserQuestion'), 'does not preserve Claude-only AskUserQuestion tool');
assert.ok(!result.includes('AskUserQuestion'), 'does not leave Claude-only tool references in the body');
assert.ok(result.includes('conversational prompting'), 'uses runtime-neutral body wording for user prompts');
});
});