fix(cliproxy): handle all invalid prefixes and reverse-order collisions

forceValidChars() now ensures leading char is letter or underscore
(catches dot, hyphen, colon, slash — not just digits). registerTools()
always registers in mapping so collisions are detected regardless of
tool registration order.
This commit is contained in:
Tam Nhu Tran
2026-04-29 18:04:19 -04:00
parent 218d0cf6c6
commit 6633bf456b
4 changed files with 63 additions and 5 deletions
@@ -300,5 +300,36 @@ describe('Tool Name Mapper', () => {
assert.ok(collisions[0].originals.includes('x__y__y'));
assert.ok(collisions[0].originals.includes('x__y'));
});
it('detects collision when unchanged tool appears before sanitized one', () => {
const mapper = new ToolNameMapper();
const tools = [
{ name: 'abc__def' }, // unchanged, but registered in mapping
{ name: 'abc__def__def' }, // sanitizes to 'abc__def', collides
];
const result = mapper.registerTools(tools);
assert.strictEqual(result[0].name, 'abc__def');
assert.strictEqual(result[1].name, 'abc__def_2');
assert.strictEqual(mapper.hasCollisions(), true);
});
it('restores correctly for reverse-order collision', () => {
const mapper = new ToolNameMapper();
const tools = [{ name: 'abc__def' }, { name: 'abc__def__def' }];
mapper.registerTools(tools);
const content = [
{ type: 'tool_use', id: '1', name: 'abc__def', input: {} },
{ type: 'tool_use', id: '2', name: 'abc__def_2', input: {} },
];
const restored = mapper.restoreToolUse(content);
assert.strictEqual(restored[0].name, 'abc__def');
assert.strictEqual(restored[1].name, 'abc__def__def');
});
});
});
@@ -203,5 +203,29 @@ describe('Tool Name Sanitizer', () => {
assert.ok(isValidToolName(result.sanitized), `Expected valid name, got: ${result.sanitized}`);
assert.ok(result.sanitized.length <= 64);
});
it('forces valid prefix for dot-starting name', () => {
const result = sanitizeToolName('.tool');
assert.ok(result.sanitized.startsWith('_'), `Expected _ prefix, got: ${result.sanitized}`);
assert.ok(isValidToolName(result.sanitized));
assert.strictEqual(result.changed, true);
});
it('forces valid prefix for hyphen-starting name', () => {
const result = sanitizeToolName('-tool');
assert.ok(result.sanitized.startsWith('_'), `Expected _ prefix, got: ${result.sanitized}`);
assert.ok(isValidToolName(result.sanitized));
assert.strictEqual(result.changed, true);
});
it('forces valid prefix for colon-starting name', () => {
const result = sanitizeToolName(':tool');
assert.ok(result.sanitized.startsWith('_'), `Expected _ prefix, got: ${result.sanitized}`);
assert.ok(isValidToolName(result.sanitized));
assert.strictEqual(result.changed, true);
});
});
});
@@ -107,9 +107,12 @@ export class ToolNameMapper {
finalName = disambiguated;
}
// Store mapping if name changed or was disambiguated
// Always register in mapping so later tools can detect collisions
// even when this tool's name was unchanged
this.mapping.set(finalName, tool.name);
// Record change only if name actually changed
if (finalName !== tool.name) {
this.mapping.set(finalName, tool.name);
this.changes.push({
original: tool.name,
sanitized: finalName,
@@ -103,7 +103,7 @@ export function smartTruncate(name: string, maxLen: number = GEMINI_MAX_TOOL_NAM
/**
* Force a string to comply with Gemini tool name character rules.
* Replaces invalid characters with underscores and ensures the name
* starts with a letter or underscore.
* starts with a letter or underscore (not a digit, dot, colon, etc.).
*/
function forceValidChars(name: string): string {
// Replace any character not in [a-zA-Z0-9_.:/-] with underscore
@@ -112,8 +112,8 @@ function forceValidChars(name: string): string {
// Collapse multiple consecutive underscores from replacement
fixed = fixed.replace(/_+/g, '_');
// Ensure starts with letter or underscore
if (fixed.length > 0 && /^[0-9]/.test(fixed)) {
// Ensure starts with letter or underscore (not digit, dot, colon, hyphen, slash)
if (fixed.length > 0 && !/^[a-zA-Z_]/.test(fixed)) {
fixed = '_' + fixed;
}