mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
Merge branch 'main' into galzahavi/add/sandboxed-tool
This commit is contained in:
@@ -14,7 +14,7 @@ import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
|
||||
export interface ToolCallData<HistoryType = unknown, ArgsType = unknown> {
|
||||
history?: HistoryType;
|
||||
clientHistory?: Content[];
|
||||
clientHistory?: readonly Content[];
|
||||
commitHash?: string;
|
||||
toolCall: {
|
||||
name: string;
|
||||
|
||||
@@ -392,7 +392,10 @@ describe('editor utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it(`should reject if ${editor} exits with non-zero code`, async () => {
|
||||
it(`should resolve and log warning if ${editor} exits with non-zero code`, async () => {
|
||||
const warnSpy = vi
|
||||
.spyOn(debugLogger, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'close') {
|
||||
cb(1);
|
||||
@@ -400,9 +403,73 @@ describe('editor utils', () => {
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await openDiff('old.txt', 'new.txt', editor);
|
||||
expect(warnSpy).toHaveBeenCalledWith(`${editor} exited with code 1`);
|
||||
});
|
||||
|
||||
it(`should emit ExternalEditorClosed when ${editor} exits successfully`, async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'close') {
|
||||
cb(0);
|
||||
}
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await openDiff('old.txt', 'new.txt', editor);
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
|
||||
});
|
||||
|
||||
it(`should emit ExternalEditorClosed when ${editor} exits with non-zero code`, async () => {
|
||||
vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'close') {
|
||||
cb(1);
|
||||
}
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await openDiff('old.txt', 'new.txt', editor);
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
|
||||
});
|
||||
|
||||
it(`should emit ExternalEditorClosed when ${editor} spawn errors`, async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const mockError = new Error('spawn error');
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'error') {
|
||||
cb(mockError);
|
||||
}
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
|
||||
`${editor} exited with code 1`,
|
||||
'spawn error',
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
|
||||
});
|
||||
|
||||
it(`should only emit ExternalEditorClosed once when ${editor} fires both error and close`, async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const callbacks: Record<string, (arg: unknown) => void> = {};
|
||||
const mockSpawnOn = vi.fn(
|
||||
(event: string, cb: (arg: unknown) => void) => {
|
||||
callbacks[event] = cb;
|
||||
},
|
||||
);
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
const promise = openDiff('old.txt', 'new.txt', editor);
|
||||
// Simulate Node.js behavior: error fires first, then close.
|
||||
callbacks['error'](new Error('spawn error'));
|
||||
callbacks['close'](1);
|
||||
|
||||
await expect(promise).rejects.toThrow('spawn error');
|
||||
const editorClosedEmissions = emitSpy.mock.calls.filter(
|
||||
(call) => call[0] === CoreEvent.ExternalEditorClosed,
|
||||
);
|
||||
expect(editorClosedEmissions).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -323,15 +323,30 @@ export async function openDiff(
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
// Guard against both 'error' and 'close' firing for a single failure,
|
||||
// which would emit ExternalEditorClosed twice and attempt to settle
|
||||
// the promise twice.
|
||||
let isSettled = false;
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${editor} exited with code ${code}`));
|
||||
if (isSettled) return;
|
||||
isSettled = true;
|
||||
|
||||
if (code !== 0) {
|
||||
// GUI editors (VS Code, Zed, etc.) can exit with non-zero codes
|
||||
// under normal circumstances (e.g., window closed while loading).
|
||||
// Log a warning instead of crashing the CLI process.
|
||||
debugLogger.warn(`${editor} exited with code ${code}`);
|
||||
}
|
||||
coreEvents.emit(CoreEvent.ExternalEditorClosed);
|
||||
resolve();
|
||||
});
|
||||
|
||||
childProcess.on('error', (error) => {
|
||||
if (isSettled) return;
|
||||
isSettled = true;
|
||||
|
||||
coreEvents.emit(CoreEvent.ExternalEditorClosed);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
|
||||
import {
|
||||
isPrivateIp,
|
||||
isPrivateIpAsync,
|
||||
isAddressPrivate,
|
||||
safeLookup,
|
||||
safeFetch,
|
||||
fetchWithTimeout,
|
||||
PrivateIpError,
|
||||
} from './fetch.js';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
import * as dns from 'node:dns';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
// We need to mock node:dns for safeLookup since it uses the callback API
|
||||
vi.mock('node:dns', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock global fetch
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('fetch utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('isAddressPrivate', () => {
|
||||
it('should identify private IPv4 addresses', () => {
|
||||
expect(isAddressPrivate('10.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('127.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('172.16.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.168.1.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify non-routable and reserved IPv4 addresses (RFC 6890)', () => {
|
||||
expect(isAddressPrivate('0.0.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('100.64.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.0.2.1')).toBe(true);
|
||||
expect(isAddressPrivate('192.88.99.1')).toBe(true);
|
||||
// Benchmark range (198.18.0.0/15)
|
||||
expect(isAddressPrivate('198.18.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('198.18.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('198.19.255.255')).toBe(true);
|
||||
expect(isAddressPrivate('198.51.100.1')).toBe(true);
|
||||
expect(isAddressPrivate('203.0.113.1')).toBe(true);
|
||||
expect(isAddressPrivate('224.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('240.0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify private IPv6 addresses', () => {
|
||||
expect(isAddressPrivate('::1')).toBe(true);
|
||||
expect(isAddressPrivate('fc00::')).toBe(true);
|
||||
expect(isAddressPrivate('fd00::')).toBe(true);
|
||||
expect(isAddressPrivate('fe80::')).toBe(true);
|
||||
expect(isAddressPrivate('febf::')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify special local addresses', () => {
|
||||
expect(isAddressPrivate('0.0.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('::')).toBe(true);
|
||||
expect(isAddressPrivate('localhost')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify link-local addresses', () => {
|
||||
expect(isAddressPrivate('169.254.169.254')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify IPv4-mapped IPv6 private addresses', () => {
|
||||
expect(isAddressPrivate('::ffff:127.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:10.0.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:169.254.169.254')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:192.168.1.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:172.16.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:0.0.0.0')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:100.64.0.1')).toBe(true);
|
||||
expect(isAddressPrivate('::ffff:a9fe:101')).toBe(true); // 169.254.1.1
|
||||
});
|
||||
|
||||
it('should identify public addresses as non-private', () => {
|
||||
expect(isAddressPrivate('8.8.8.8')).toBe(false);
|
||||
expect(isAddressPrivate('93.184.216.34')).toBe(false);
|
||||
expect(isAddressPrivate('2001:4860:4860::8888')).toBe(false);
|
||||
expect(isAddressPrivate('::ffff:8.8.8.8')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateIp', () => {
|
||||
it('should identify private IPs in URLs', () => {
|
||||
expect(isPrivateIp('http://10.0.0.1/')).toBe(true);
|
||||
expect(isPrivateIp('https://127.0.0.1:8080/')).toBe(true);
|
||||
expect(isPrivateIp('http://localhost/')).toBe(true);
|
||||
expect(isPrivateIp('http://[::1]/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify public IPs in URLs as non-private', () => {
|
||||
expect(isPrivateIp('http://8.8.8.8/')).toBe(false);
|
||||
expect(isPrivateIp('https://google.com/')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateIpAsync', () => {
|
||||
it('should identify private IPs directly', async () => {
|
||||
expect(await isPrivateIpAsync('http://10.0.0.1/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify domains resolving to private IPs', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockImplementation(
|
||||
async () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[{ address: '10.0.0.1', family: 4 }] as any,
|
||||
);
|
||||
expect(await isPrivateIpAsync('http://malicious.com/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify domains resolving to public IPs as non-private', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockImplementation(
|
||||
async () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[{ address: '8.8.8.8', family: 4 }] as any,
|
||||
);
|
||||
expect(await isPrivateIpAsync('http://google.com/')).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error if DNS resolution fails (fail closed)', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error'));
|
||||
await expect(isPrivateIpAsync('http://unreachable.com/')).rejects.toThrow(
|
||||
'Failed to verify if URL resolves to private IP',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false for invalid URLs instead of throwing verification error', async () => {
|
||||
expect(await isPrivateIpAsync('not-a-url')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeLookup', () => {
|
||||
it('should filter out private IPs', async () => {
|
||||
const addresses = [
|
||||
{ address: '8.8.8.8', family: 4 },
|
||||
{ address: '10.0.0.1', family: 4 },
|
||||
];
|
||||
|
||||
vi.mocked(dns.lookup).mockImplementation(((
|
||||
_h: string,
|
||||
_o: dns.LookupOptions,
|
||||
cb: (
|
||||
err: Error | null,
|
||||
addr: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
) => {
|
||||
cb(null, addresses);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any);
|
||||
|
||||
const result = await new Promise<
|
||||
Array<{ address: string; family: number }>
|
||||
>((resolve, reject) => {
|
||||
safeLookup('example.com', { all: true }, (err, filtered) => {
|
||||
if (err) reject(err);
|
||||
else resolve(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].address).toBe('8.8.8.8');
|
||||
});
|
||||
|
||||
it('should allow explicit localhost', async () => {
|
||||
const addresses = [{ address: '127.0.0.1', family: 4 }];
|
||||
|
||||
vi.mocked(dns.lookup).mockImplementation(((
|
||||
_h: string,
|
||||
_o: dns.LookupOptions,
|
||||
cb: (
|
||||
err: Error | null,
|
||||
addr: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
) => {
|
||||
cb(null, addresses);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any);
|
||||
|
||||
const result = await new Promise<
|
||||
Array<{ address: string; family: number }>
|
||||
>((resolve, reject) => {
|
||||
safeLookup('localhost', { all: true }, (err, filtered) => {
|
||||
if (err) reject(err);
|
||||
else resolve(filtered);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].address).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('should error if all resolved IPs are private', async () => {
|
||||
const addresses = [{ address: '10.0.0.1', family: 4 }];
|
||||
|
||||
vi.mocked(dns.lookup).mockImplementation(((
|
||||
_h: string,
|
||||
_o: dns.LookupOptions,
|
||||
cb: (
|
||||
err: Error | null,
|
||||
addr: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
) => {
|
||||
cb(null, addresses);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any);
|
||||
|
||||
await expect(
|
||||
new Promise((resolve, reject) => {
|
||||
safeLookup('malicious.com', { all: true }, (err, filtered) => {
|
||||
if (err) reject(err);
|
||||
else resolve(filtered);
|
||||
});
|
||||
}),
|
||||
).rejects.toThrow(PrivateIpError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeFetch', () => {
|
||||
it('should forward to fetch with dispatcher', async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue(new Response('ok'));
|
||||
|
||||
const response = await safeFetch('https://example.com');
|
||||
expect(response.status).toBe(200);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
expect.objectContaining({
|
||||
dispatcher: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Refusing to connect errors', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new PrivateIpError());
|
||||
|
||||
await expect(safeFetch('http://10.0.0.1')).rejects.toThrow(
|
||||
'Access to private network is blocked',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchWithTimeout', () => {
|
||||
it('should handle timeouts', async () => {
|
||||
vi.mocked(global.fetch).mockImplementation(
|
||||
(_input, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
if (init?.signal) {
|
||||
init.signal.addEventListener('abort', () => {
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
// @ts-expect-error - for mocking purposes
|
||||
error.code = 'ABORT_ERR';
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fetchWithTimeout('http://example.com', 50)).rejects.toThrow(
|
||||
'Request timed out after 50ms',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle private IP errors via handleFetchError', async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new PrivateIpError());
|
||||
|
||||
await expect(fetchWithTimeout('http://10.0.0.1', 1000)).rejects.toThrow(
|
||||
'Access to private network is blocked: http://10.0.0.1',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
import { getErrorMessage, isNodeError } from './errors.js';
|
||||
import { URL } from 'node:url';
|
||||
import * as dns from 'node:dns';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
|
||||
const DEFAULT_HEADERS_TIMEOUT = 300000; // 5 minutes
|
||||
const DEFAULT_BODY_TIMEOUT = 300000; // 5 minutes
|
||||
@@ -19,15 +22,20 @@ setGlobalDispatcher(
|
||||
}),
|
||||
);
|
||||
|
||||
const PRIVATE_IP_RANGES = [
|
||||
/^10\./,
|
||||
/^127\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||
/^192\.168\./,
|
||||
/^::1$/,
|
||||
/^fc00:/,
|
||||
/^fe80:/,
|
||||
];
|
||||
// Local extension of RequestInit to support Node.js/undici dispatcher
|
||||
interface NodeFetchInit extends RequestInit {
|
||||
dispatcher?: Agent | ProxyAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when a connection to a private IP address is blocked for security reasons.
|
||||
*/
|
||||
export class PrivateIpError extends Error {
|
||||
constructor(message = 'Refusing to connect to private IP address') {
|
||||
super(message);
|
||||
this.name = 'PrivateIpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
@@ -40,15 +48,234 @@ export class FetchError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a hostname by stripping IPv6 brackets if present.
|
||||
*/
|
||||
export function sanitizeHostname(hostname: string): string {
|
||||
return hostname.startsWith('[') && hostname.endsWith(']')
|
||||
? hostname.slice(1, -1)
|
||||
: hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a hostname is a local loopback address allowed for development/testing.
|
||||
*/
|
||||
export function isLoopbackHost(hostname: string): boolean {
|
||||
const sanitized = sanitizeHostname(hostname);
|
||||
return (
|
||||
sanitized === 'localhost' ||
|
||||
sanitized === '127.0.0.1' ||
|
||||
sanitized === '::1'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom DNS lookup implementation for undici agents that prevents
|
||||
* connection to private IP ranges (SSRF protection).
|
||||
*/
|
||||
export function safeLookup(
|
||||
hostname: string,
|
||||
options: dns.LookupOptions | number | null | undefined,
|
||||
callback: (
|
||||
err: Error | null,
|
||||
addresses: Array<{ address: string; family: number }>,
|
||||
) => void,
|
||||
): void {
|
||||
// Use the callback-based dns.lookup to match undici's expected signature.
|
||||
// We explicitly handle the 'all' option to ensure we get an array of addresses.
|
||||
const lookupOptions =
|
||||
typeof options === 'number' ? { family: options } : { ...options };
|
||||
const finalOptions = { ...lookupOptions, all: true };
|
||||
|
||||
dns.lookup(hostname, finalOptions, (err, addresses) => {
|
||||
if (err) {
|
||||
callback(err, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const addressArray = Array.isArray(addresses) ? addresses : [];
|
||||
const filtered = addressArray.filter(
|
||||
(addr) => !isAddressPrivate(addr.address) || isLoopbackHost(hostname),
|
||||
);
|
||||
|
||||
if (filtered.length === 0 && addressArray.length > 0) {
|
||||
callback(new PrivateIpError(), []);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, filtered);
|
||||
});
|
||||
}
|
||||
|
||||
// Dedicated dispatcher with connection-level SSRF protection (safeLookup)
|
||||
const safeDispatcher = new Agent({
|
||||
headersTimeout: DEFAULT_HEADERS_TIMEOUT,
|
||||
bodyTimeout: DEFAULT_BODY_TIMEOUT,
|
||||
connect: {
|
||||
lookup: safeLookup,
|
||||
},
|
||||
});
|
||||
|
||||
export function isPrivateIp(url: string): boolean {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return PRIVATE_IP_RANGES.some((range) => range.test(hostname));
|
||||
} catch (_e) {
|
||||
return isAddressPrivate(hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL resolves to a private IP address.
|
||||
* Performs DNS resolution to prevent DNS rebinding/SSRF bypasses.
|
||||
*/
|
||||
export async function isPrivateIpAsync(url: string): Promise<boolean> {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
// Fast check for literal IPs or localhost
|
||||
if (isAddressPrivate(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve DNS to check the actual target IP
|
||||
const addresses = await lookup(hostname, { all: true });
|
||||
return addresses.some((addr) => isAddressPrivate(addr.address));
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Error &&
|
||||
e.name === 'TypeError' &&
|
||||
e.message.includes('Invalid URL')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Failed to verify if URL resolves to private IP: ${url}`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IANA Benchmark Testing Range (198.18.0.0/15).
|
||||
* Classified as 'unicast' by ipaddr.js but is reserved and should not be
|
||||
* accessible as public internet.
|
||||
*/
|
||||
const IANA_BENCHMARK_RANGE = ipaddr.parseCIDR('198.18.0.0/15');
|
||||
|
||||
/**
|
||||
* Checks if an address falls within the IANA benchmark testing range.
|
||||
*/
|
||||
function isBenchmarkAddress(addr: ipaddr.IPv4 | ipaddr.IPv6): boolean {
|
||||
const [rangeAddr, rangeMask] = IANA_BENCHMARK_RANGE;
|
||||
return (
|
||||
addr instanceof ipaddr.IPv4 &&
|
||||
rangeAddr instanceof ipaddr.IPv4 &&
|
||||
addr.match(rangeAddr, rangeMask)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to check if an IP address string is in a private or reserved range.
|
||||
*/
|
||||
export function isAddressPrivate(address: string): boolean {
|
||||
const sanitized = sanitizeHostname(address);
|
||||
|
||||
if (sanitized === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ipaddr.isValid(sanitized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const addr = ipaddr.parse(sanitized);
|
||||
|
||||
// Special handling for IPv4-mapped IPv6 (::ffff:x.x.x.x)
|
||||
// We unmap it and check the underlying IPv4 address.
|
||||
if (addr instanceof ipaddr.IPv6 && addr.isIPv4MappedAddress()) {
|
||||
return isAddressPrivate(addr.toIPv4Address().toString());
|
||||
}
|
||||
|
||||
// Explicitly block IANA benchmark testing range.
|
||||
if (isBenchmarkAddress(addr)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return addr.range() !== 'unicast';
|
||||
} catch {
|
||||
// If parsing fails despite isValid(), we treat it as potentially unsafe.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to map varied fetch errors to a standardized FetchError.
|
||||
* Centralizes security-related error mapping (e.g. PrivateIpError).
|
||||
*/
|
||||
function handleFetchError(error: unknown, url: string): never {
|
||||
if (error instanceof PrivateIpError) {
|
||||
throw new FetchError(
|
||||
`Access to private network is blocked: ${url}`,
|
||||
'ERR_PRIVATE_NETWORK',
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new FetchError(
|
||||
getErrorMessage(error),
|
||||
isNodeError(error) ? error.code : undefined,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced fetch with SSRF protection.
|
||||
* Prevents access to private/internal networks at the connection level.
|
||||
*/
|
||||
export async function safeFetch(
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const nodeInit: NodeFetchInit = {
|
||||
...init,
|
||||
dispatcher: safeDispatcher,
|
||||
};
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return await fetch(input, nodeInit);
|
||||
} catch (error) {
|
||||
const url =
|
||||
input instanceof Request
|
||||
? input.url
|
||||
: typeof input === 'string'
|
||||
? input
|
||||
: input.toString();
|
||||
handleFetchError(error, url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an undici ProxyAgent that incorporates safe DNS lookup.
|
||||
*/
|
||||
export function createSafeProxyAgent(proxyUrl: string): ProxyAgent {
|
||||
return new ProxyAgent({
|
||||
uri: proxyUrl,
|
||||
connect: {
|
||||
lookup: safeLookup,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a fetch with a specified timeout and connection-level SSRF protection.
|
||||
*/
|
||||
export async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeout: number,
|
||||
@@ -67,17 +294,21 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
const nodeInit: NodeFetchInit = {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
dispatcher: safeDispatcher,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const response = await fetch(url, nodeInit);
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ABORT_ERR') {
|
||||
throw new FetchError(`Request timed out after ${timeout}ms`, 'ETIMEDOUT');
|
||||
}
|
||||
throw new FetchError(getErrorMessage(error), undefined, { cause: error });
|
||||
handleFetchError(error, url.toString());
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { PartUnion } from '@google/genai';
|
||||
|
||||
import mime from 'mime/lite';
|
||||
import type { FileSystemService } from '../services/fileSystemService.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
@@ -473,7 +472,7 @@ export async function processSingleFileContent(
|
||||
case 'text': {
|
||||
// Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently
|
||||
const content = await readFileWithEncoding(filePath);
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const originalLineCount = lines.length;
|
||||
|
||||
let sliceStart = 0;
|
||||
|
||||
@@ -134,21 +134,21 @@ describe('classifyGoogleError', () => {
|
||||
expect((result as TerminalQuotaError).cause).toBe(apiError);
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError for long retry delays', () => {
|
||||
it('should return TerminalQuotaError for retry delays over 5 minutes', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message: 'Too many requests',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||
retryDelay: '301s', // Any delay is now retryable
|
||||
retryDelay: '301s', // Over 5 min threshold => terminal
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
expect((result as RetryableQuotaError).retryDelayMs).toBe(301000);
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
expect((result as TerminalQuotaError).retryDelayMs).toBe(301000);
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError for short retry delays', () => {
|
||||
@@ -285,6 +285,34 @@ describe('classifyGoogleError', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for Cloud Code RATE_LIMIT_EXCEEDED with retry delay over 5 minutes', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message:
|
||||
'You have exhausted your capacity on this model. Your quota will reset after 10m.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'RATE_LIMIT_EXCEEDED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: {
|
||||
uiMessage: 'true',
|
||||
model: 'gemini-2.5-pro',
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||
retryDelay: '600s',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
expect((result as TerminalQuotaError).retryDelayMs).toBe(600000);
|
||||
expect((result as TerminalQuotaError).reason).toBe('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for Cloud Code QUOTA_EXHAUSTED', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
@@ -427,6 +455,40 @@ describe('classifyGoogleError', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError when fallback "Please retry in" delay exceeds 5 minutes', () => {
|
||||
const errorWithEmptyDetails = {
|
||||
error: {
|
||||
code: 429,
|
||||
message: 'Resource exhausted. Please retry in 400s',
|
||||
details: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = classifyGoogleError(errorWithEmptyDetails);
|
||||
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
if (result instanceof TerminalQuotaError) {
|
||||
expect(result.retryDelayMs).toBe(400000);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError when retry delay is exactly 5 minutes', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message: 'Too many requests',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.RetryInfo',
|
||||
retryDelay: '300s',
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
expect((result as RetryableQuotaError).retryDelayMs).toBe(300000);
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError without delay time for generic 429 without specific message', () => {
|
||||
const generic429 = {
|
||||
status: 429,
|
||||
|
||||
@@ -100,6 +100,13 @@ function parseDurationInSeconds(duration: string): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum retry delay (in seconds) before a retryable error is treated as terminal.
|
||||
* If the server suggests waiting longer than this, the user is effectively locked out,
|
||||
* so we trigger the fallback/credits flow instead of silently waiting.
|
||||
*/
|
||||
const MAX_RETRYABLE_DELAY_SECONDS = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Valid Cloud Code API domains for VALIDATION_REQUIRED errors.
|
||||
*/
|
||||
@@ -248,15 +255,15 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
if (match?.[1]) {
|
||||
const retryDelaySeconds = parseDurationInSeconds(match[1]);
|
||||
if (retryDelaySeconds !== null) {
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: status ?? 429,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
retryDelaySeconds,
|
||||
);
|
||||
const cause = googleApiError ?? {
|
||||
code: status ?? 429,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
};
|
||||
if (retryDelaySeconds > MAX_RETRYABLE_DELAY_SECONDS) {
|
||||
return new TerminalQuotaError(errorMessage, cause, retryDelaySeconds);
|
||||
}
|
||||
return new RetryableQuotaError(errorMessage, cause, retryDelaySeconds);
|
||||
}
|
||||
} else if (status === 429 || status === 499) {
|
||||
// Fallback: If it is a 429 or 499 but doesn't have a specific "retry in" message,
|
||||
@@ -325,10 +332,19 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
if (errorInfo.domain) {
|
||||
if (isCloudCodeDomain(errorInfo.domain)) {
|
||||
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
|
||||
const effectiveDelay = delaySeconds ?? 10;
|
||||
if (effectiveDelay > MAX_RETRYABLE_DELAY_SECONDS) {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}`,
|
||||
googleApiError,
|
||||
effectiveDelay,
|
||||
errorInfo.reason,
|
||||
);
|
||||
}
|
||||
return new RetryableQuotaError(
|
||||
`${googleApiError.message}`,
|
||||
googleApiError,
|
||||
delaySeconds ?? 10,
|
||||
effectiveDelay,
|
||||
);
|
||||
}
|
||||
if (errorInfo.reason === 'QUOTA_EXHAUSTED') {
|
||||
@@ -345,6 +361,13 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
|
||||
// 2. Check for delays in RetryInfo
|
||||
if (retryInfo?.retryDelay && delaySeconds) {
|
||||
if (delaySeconds > MAX_RETRYABLE_DELAY_SECONDS) {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`,
|
||||
googleApiError,
|
||||
delaySeconds,
|
||||
);
|
||||
}
|
||||
return new RetryableQuotaError(
|
||||
`${googleApiError.message}\nSuggested retry after ${retryInfo.retryDelay}.`,
|
||||
googleApiError,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getLanguageFromFilePath } from './language-detection.js';
|
||||
|
||||
describe('language-detection', () => {
|
||||
it('should return correct LSP identifiers for various extensions', () => {
|
||||
expect(getLanguageFromFilePath('test.ts')).toBe('typescript');
|
||||
expect(getLanguageFromFilePath('test.js')).toBe('javascript');
|
||||
expect(getLanguageFromFilePath('test.py')).toBe('python');
|
||||
expect(getLanguageFromFilePath('test.java')).toBe('java');
|
||||
expect(getLanguageFromFilePath('test.go')).toBe('go');
|
||||
expect(getLanguageFromFilePath('test.cs')).toBe('csharp');
|
||||
expect(getLanguageFromFilePath('test.cpp')).toBe('cpp');
|
||||
expect(getLanguageFromFilePath('test.sh')).toBe('shellscript');
|
||||
expect(getLanguageFromFilePath('test.bat')).toBe('bat');
|
||||
expect(getLanguageFromFilePath('test.json')).toBe('json');
|
||||
expect(getLanguageFromFilePath('test.md')).toBe('markdown');
|
||||
expect(getLanguageFromFilePath('test.tsx')).toBe('typescriptreact');
|
||||
expect(getLanguageFromFilePath('test.jsx')).toBe('javascriptreact');
|
||||
});
|
||||
|
||||
it('should handle uppercase extensions', () => {
|
||||
expect(getLanguageFromFilePath('TEST.TS')).toBe('typescript');
|
||||
});
|
||||
|
||||
it('should handle filenames without extensions but in map', () => {
|
||||
expect(getLanguageFromFilePath('.gitignore')).toBe('ignore');
|
||||
expect(getLanguageFromFilePath('.dockerfile')).toBe('dockerfile');
|
||||
expect(getLanguageFromFilePath('Dockerfile')).toBe('dockerfile');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown extensions', () => {
|
||||
expect(getLanguageFromFilePath('test.unknown')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for files without extension or known filename', () => {
|
||||
expect(getLanguageFromFilePath('just_a_file')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,98 +6,107 @@
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
/**
|
||||
* Maps file extensions or filenames to LSP 3.18 language identifiers.
|
||||
* See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocumentItem
|
||||
*/
|
||||
const extensionToLanguageMap: { [key: string]: string } = {
|
||||
'.ts': 'TypeScript',
|
||||
'.js': 'JavaScript',
|
||||
'.mjs': 'JavaScript',
|
||||
'.cjs': 'JavaScript',
|
||||
'.jsx': 'JavaScript',
|
||||
'.tsx': 'TypeScript',
|
||||
'.py': 'Python',
|
||||
'.java': 'Java',
|
||||
'.go': 'Go',
|
||||
'.rb': 'Ruby',
|
||||
'.php': 'PHP',
|
||||
'.phtml': 'PHP',
|
||||
'.cs': 'C#',
|
||||
'.cpp': 'C++',
|
||||
'.cxx': 'C++',
|
||||
'.cc': 'C++',
|
||||
'.c': 'C',
|
||||
'.h': 'C/C++',
|
||||
'.hpp': 'C++',
|
||||
'.swift': 'Swift',
|
||||
'.kt': 'Kotlin',
|
||||
'.rs': 'Rust',
|
||||
'.m': 'Objective-C',
|
||||
'.mm': 'Objective-C',
|
||||
'.pl': 'Perl',
|
||||
'.pm': 'Perl',
|
||||
'.lua': 'Lua',
|
||||
'.r': 'R',
|
||||
'.scala': 'Scala',
|
||||
'.sc': 'Scala',
|
||||
'.sh': 'Shell',
|
||||
'.ps1': 'PowerShell',
|
||||
'.bat': 'Batch',
|
||||
'.cmd': 'Batch',
|
||||
'.sql': 'SQL',
|
||||
'.html': 'HTML',
|
||||
'.htm': 'HTML',
|
||||
'.css': 'CSS',
|
||||
'.less': 'Less',
|
||||
'.sass': 'Sass',
|
||||
'.scss': 'Sass',
|
||||
'.json': 'JSON',
|
||||
'.xml': 'XML',
|
||||
'.yaml': 'YAML',
|
||||
'.yml': 'YAML',
|
||||
'.md': 'Markdown',
|
||||
'.markdown': 'Markdown',
|
||||
'.dockerfile': 'Dockerfile',
|
||||
'.vim': 'Vim script',
|
||||
'.vb': 'Visual Basic',
|
||||
'.fs': 'F#',
|
||||
'.clj': 'Clojure',
|
||||
'.cljs': 'Clojure',
|
||||
'.dart': 'Dart',
|
||||
'.ex': 'Elixir',
|
||||
'.erl': 'Erlang',
|
||||
'.hs': 'Haskell',
|
||||
'.lisp': 'Lisp',
|
||||
'.rkt': 'Racket',
|
||||
'.groovy': 'Groovy',
|
||||
'.jl': 'Julia',
|
||||
'.tex': 'LaTeX',
|
||||
'.ino': 'Arduino',
|
||||
'.asm': 'Assembly',
|
||||
'.s': 'Assembly',
|
||||
'.toml': 'TOML',
|
||||
'.vue': 'Vue',
|
||||
'.svelte': 'Svelte',
|
||||
'.gohtml': 'Go Template',
|
||||
'.hbs': 'Handlebars',
|
||||
'.ejs': 'EJS',
|
||||
'.erb': 'ERB',
|
||||
'.jsp': 'JSP',
|
||||
'.dockerignore': 'Docker',
|
||||
'.gitignore': 'Git',
|
||||
'.npmignore': 'npm',
|
||||
'.editorconfig': 'EditorConfig',
|
||||
'.prettierrc': 'Prettier',
|
||||
'.eslintrc': 'ESLint',
|
||||
'.babelrc': 'Babel',
|
||||
'.tsconfig': 'TypeScript',
|
||||
'.flow': 'Flow',
|
||||
'.graphql': 'GraphQL',
|
||||
'.proto': 'Protocol Buffers',
|
||||
'.ts': 'typescript',
|
||||
'.js': 'javascript',
|
||||
'.mjs': 'javascript',
|
||||
'.cjs': 'javascript',
|
||||
'.jsx': 'javascriptreact',
|
||||
'.tsx': 'typescriptreact',
|
||||
'.py': 'python',
|
||||
'.java': 'java',
|
||||
'.go': 'go',
|
||||
'.rb': 'ruby',
|
||||
'.php': 'php',
|
||||
'.phtml': 'php',
|
||||
'.cs': 'csharp',
|
||||
'.cpp': 'cpp',
|
||||
'.cxx': 'cpp',
|
||||
'.cc': 'cpp',
|
||||
'.c': 'c',
|
||||
'.h': 'c',
|
||||
'.hpp': 'cpp',
|
||||
'.swift': 'swift',
|
||||
'.kt': 'kotlin',
|
||||
'.rs': 'rust',
|
||||
'.m': 'objective-c',
|
||||
'.mm': 'objective-cpp',
|
||||
'.pl': 'perl',
|
||||
'.pm': 'perl',
|
||||
'.lua': 'lua',
|
||||
'.r': 'r',
|
||||
'.scala': 'scala',
|
||||
'.sc': 'scala',
|
||||
'.sh': 'shellscript',
|
||||
'.ps1': 'powershell',
|
||||
'.bat': 'bat',
|
||||
'.cmd': 'bat',
|
||||
'.sql': 'sql',
|
||||
'.html': 'html',
|
||||
'.htm': 'html',
|
||||
'.css': 'css',
|
||||
'.less': 'less',
|
||||
'.sass': 'sass',
|
||||
'.scss': 'scss',
|
||||
'.json': 'json',
|
||||
'.xml': 'xml',
|
||||
'.yaml': 'yaml',
|
||||
'.yml': 'yaml',
|
||||
'.md': 'markdown',
|
||||
'.markdown': 'markdown',
|
||||
'.dockerfile': 'dockerfile',
|
||||
'.vim': 'vim',
|
||||
'.vb': 'vb',
|
||||
'.fs': 'fsharp',
|
||||
'.clj': 'clojure',
|
||||
'.cljs': 'clojure',
|
||||
'.dart': 'dart',
|
||||
'.ex': 'elixir',
|
||||
'.erl': 'erlang',
|
||||
'.hs': 'haskell',
|
||||
'.lisp': 'lisp',
|
||||
'.rkt': 'racket',
|
||||
'.groovy': 'groovy',
|
||||
'.jl': 'julia',
|
||||
'.tex': 'latex',
|
||||
'.ino': 'arduino',
|
||||
'.asm': 'assembly',
|
||||
'.s': 'assembly',
|
||||
'.toml': 'toml',
|
||||
'.vue': 'vue',
|
||||
'.svelte': 'svelte',
|
||||
'.gohtml': 'gohtml', // Not in standard LSP well-known list but kept for compatibility
|
||||
'.hbs': 'handlebars',
|
||||
'.ejs': 'ejs',
|
||||
'.erb': 'erb',
|
||||
'.jsp': 'jsp',
|
||||
'.dockerignore': 'ignore',
|
||||
'.gitignore': 'ignore',
|
||||
'.npmignore': 'ignore',
|
||||
'.editorconfig': 'properties',
|
||||
'.prettierrc': 'json',
|
||||
'.eslintrc': 'json',
|
||||
'.babelrc': 'json',
|
||||
'.tsconfig': 'json',
|
||||
'.flow': 'javascript',
|
||||
'.graphql': 'graphql',
|
||||
'.proto': 'proto',
|
||||
};
|
||||
|
||||
export function getLanguageFromFilePath(filePath: string): string | undefined {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
if (extension) {
|
||||
return extensionToLanguageMap[extension];
|
||||
}
|
||||
const filename = path.basename(filePath).toLowerCase();
|
||||
return extensionToLanguageMap[`.${filename}`];
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
|
||||
const candidates = [
|
||||
extension, // 1. Standard extension (e.g., '.js')
|
||||
filename, // 2. Exact filename (e.g., 'dockerfile')
|
||||
`.${filename}`, // 3. Dot-prefixed filename (e.g., '.gitignore')
|
||||
];
|
||||
const match = candidates.find((key) => key in extensionToLanguageMap);
|
||||
|
||||
return match ? extensionToLanguageMap[match] : undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { jsonToMarkdown, safeJsonToMarkdown } from './markdownUtils.js';
|
||||
|
||||
describe('markdownUtils', () => {
|
||||
describe('jsonToMarkdown', () => {
|
||||
it('should handle primitives', () => {
|
||||
expect(jsonToMarkdown('hello')).toBe('hello');
|
||||
expect(jsonToMarkdown(123)).toBe('123');
|
||||
expect(jsonToMarkdown(true)).toBe('true');
|
||||
expect(jsonToMarkdown(null)).toBe('null');
|
||||
expect(jsonToMarkdown(undefined)).toBe('undefined');
|
||||
});
|
||||
|
||||
it('should handle simple arrays', () => {
|
||||
const data = ['a', 'b', 'c'];
|
||||
expect(jsonToMarkdown(data)).toBe('- a\n- b\n- c');
|
||||
});
|
||||
|
||||
it('should handle simple objects and convert camelCase to Space Case', () => {
|
||||
const data = { userName: 'Alice', userAge: 30 };
|
||||
expect(jsonToMarkdown(data)).toBe(
|
||||
'- **User Name**: Alice\n- **User Age**: 30',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty structures', () => {
|
||||
expect(jsonToMarkdown([])).toBe('[]');
|
||||
expect(jsonToMarkdown({})).toBe('{}');
|
||||
});
|
||||
|
||||
it('should handle nested structures with proper indentation', () => {
|
||||
const data = {
|
||||
userInfo: {
|
||||
fullName: 'Bob Smith',
|
||||
userRoles: ['admin', 'user'],
|
||||
},
|
||||
isActive: true,
|
||||
};
|
||||
const result = jsonToMarkdown(data);
|
||||
expect(result).toBe(
|
||||
'- **User Info**:\n' +
|
||||
' - **Full Name**: Bob Smith\n' +
|
||||
' - **User Roles**:\n' +
|
||||
' - admin\n' +
|
||||
' - user\n' +
|
||||
'- **Is Active**: true',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render tables for arrays of similar objects with Space Case keys', () => {
|
||||
const data = [
|
||||
{ userId: 1, userName: 'Item 1' },
|
||||
{ userId: 2, userName: 'Item 2' },
|
||||
];
|
||||
const result = jsonToMarkdown(data);
|
||||
expect(result).toBe(
|
||||
'| User Id | User Name |\n| --- | --- |\n| 1 | Item 1 |\n| 2 | Item 2 |',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle pipe characters, backslashes, and newlines in table data', () => {
|
||||
const data = [
|
||||
{ colInfo: 'val|ue', otherInfo: 'line\nbreak', pathInfo: 'C:\\test' },
|
||||
];
|
||||
const result = jsonToMarkdown(data);
|
||||
expect(result).toBe(
|
||||
'| Col Info | Other Info | Path Info |\n| --- | --- | --- |\n| val\\|ue | line break | C:\\\\test |',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to lists for arrays with mixed objects', () => {
|
||||
const data = [
|
||||
{ userId: 1, userName: 'Item 1' },
|
||||
{ userId: 2, somethingElse: 'Item 2' },
|
||||
];
|
||||
const result = jsonToMarkdown(data);
|
||||
expect(result).toContain('- **User Id**: 1');
|
||||
expect(result).toContain('- **Something Else**: Item 2');
|
||||
});
|
||||
|
||||
it('should properly indent nested tables', () => {
|
||||
const data = {
|
||||
items: [
|
||||
{ id: 1, name: 'A' },
|
||||
{ id: 2, name: 'B' },
|
||||
],
|
||||
};
|
||||
const result = jsonToMarkdown(data);
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('- **Items**:');
|
||||
expect(lines[1]).toBe(' | Id | Name |');
|
||||
expect(lines[2]).toBe(' | --- | --- |');
|
||||
expect(lines[3]).toBe(' | 1 | A |');
|
||||
expect(lines[4]).toBe(' | 2 | B |');
|
||||
});
|
||||
|
||||
it('should indent subsequent lines of multiline strings', () => {
|
||||
const data = {
|
||||
description: 'Line 1\nLine 2\nLine 3',
|
||||
};
|
||||
const result = jsonToMarkdown(data);
|
||||
expect(result).toBe('- **Description**: Line 1\n Line 2\n Line 3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsonToMarkdown', () => {
|
||||
it('should convert valid JSON', () => {
|
||||
const json = JSON.stringify({ keyName: 'value' });
|
||||
expect(safeJsonToMarkdown(json)).toBe('- **Key Name**: value');
|
||||
});
|
||||
|
||||
it('should return original string for invalid JSON', () => {
|
||||
const notJson = 'Not a JSON string';
|
||||
expect(safeJsonToMarkdown(notJson)).toBe(notJson);
|
||||
});
|
||||
|
||||
it('should handle plain strings that look like numbers or booleans but are valid JSON', () => {
|
||||
expect(safeJsonToMarkdown('123')).toBe('123');
|
||||
expect(safeJsonToMarkdown('true')).toBe('true');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Converts a camelCase string to a Space Case string.
|
||||
* e.g., "camelCaseString" -> "Camel Case String"
|
||||
*/
|
||||
function camelToSpace(text: string): string {
|
||||
const result = text.replace(/([A-Z])/g, ' $1');
|
||||
return result.charAt(0).toUpperCase() + result.slice(1).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JSON-compatible value into a readable Markdown representation.
|
||||
*
|
||||
* @param data The data to convert.
|
||||
* @param indent The current indentation level (for internal recursion).
|
||||
* @returns A Markdown string representing the data.
|
||||
*/
|
||||
export function jsonToMarkdown(data: unknown, indent = 0): string {
|
||||
const spacing = ' '.repeat(indent);
|
||||
|
||||
if (data === null) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
return 'undefined';
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
if (isArrayOfSimilarObjects(data)) {
|
||||
return renderTable(data, indent);
|
||||
}
|
||||
|
||||
return data
|
||||
.map((item) => {
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
Object.keys(item).length > 0
|
||||
) {
|
||||
const rendered = jsonToMarkdown(item, indent + 1);
|
||||
return `${spacing}-\n${rendered}`;
|
||||
}
|
||||
const rendered = jsonToMarkdown(item, indent + 1).trimStart();
|
||||
return `${spacing}- ${rendered}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length === 0) {
|
||||
return '{}';
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => {
|
||||
const displayKey = camelToSpace(key);
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.keys(value).length > 0
|
||||
) {
|
||||
const renderedValue = jsonToMarkdown(value, indent + 1);
|
||||
return `${spacing}- **${displayKey}**:\n${renderedValue}`;
|
||||
}
|
||||
const renderedValue = jsonToMarkdown(value, indent + 1).trimStart();
|
||||
return `${spacing}- **${displayKey}**: ${renderedValue}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data
|
||||
.split('\n')
|
||||
.map((line, i) => (i === 0 ? line : spacing + line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return String(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely attempts to parse a string as JSON and convert it to Markdown.
|
||||
* If parsing fails, returns the original string.
|
||||
*
|
||||
* @param text The text to potentially convert.
|
||||
* @returns The Markdown representation or the original text.
|
||||
*/
|
||||
export function safeJsonToMarkdown(text: string): string {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return jsonToMarkdown(parsed);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isArrayOfSimilarObjects(
|
||||
data: unknown[],
|
||||
): data is Array<Record<string, unknown>> {
|
||||
if (data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!data.every(isRecord)) return false;
|
||||
const firstKeys = Object.keys(data[0]).sort().join(',');
|
||||
return data.every((item) => Object.keys(item).sort().join(',') === firstKeys);
|
||||
}
|
||||
|
||||
function renderTable(data: Array<Record<string, unknown>>, indent = 0): string {
|
||||
const spacing = ' '.repeat(indent);
|
||||
const keys = Object.keys(data[0]);
|
||||
const displayKeys = keys.map(camelToSpace);
|
||||
const header = `${spacing}| ${displayKeys.join(' | ')} |`;
|
||||
const separator = `${spacing}| ${keys.map(() => '---').join(' | ')} |`;
|
||||
const rows = data.map(
|
||||
(item) =>
|
||||
`${spacing}| ${keys
|
||||
.map((key) => {
|
||||
const val = item[key];
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
return JSON.stringify(val)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\|/g, '\\|');
|
||||
}
|
||||
return String(val)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\n/g, ' ');
|
||||
})
|
||||
.join(' | ')} |`,
|
||||
);
|
||||
return [header, separator, ...rows].join('\n');
|
||||
}
|
||||
@@ -454,6 +454,7 @@ export async function exchangeCodeForToken(
|
||||
params.append('resource', resource);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -507,6 +508,7 @@ export async function refreshAccessToken(
|
||||
params.append('resource', resource);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -484,6 +484,10 @@ describe('shortenPath', () => {
|
||||
});
|
||||
|
||||
describe('resolveToRealPath', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description:
|
||||
@@ -524,6 +528,22 @@ describe('resolveToRealPath', () => {
|
||||
expect(resolveToRealPath(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return decoded path even if fs.realpathSync fails with EISDIR', () => {
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementationOnce(() => {
|
||||
const err = new Error(
|
||||
'Illegal operation on a directory',
|
||||
) as NodeJS.ErrnoException;
|
||||
err.code = 'EISDIR';
|
||||
throw err;
|
||||
});
|
||||
|
||||
const p = path.resolve('path', 'to', 'New Project');
|
||||
const input = pathToFileURL(p).toString();
|
||||
const expected = p;
|
||||
|
||||
expect(resolveToRealPath(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should recursively resolve symlinks for non-existent child paths', () => {
|
||||
const parentPath = path.resolve('/some/parent/path');
|
||||
const resolvedParentPath = path.resolve('/resolved/parent/path');
|
||||
@@ -542,6 +562,28 @@ describe('resolveToRealPath', () => {
|
||||
|
||||
expect(resolveToRealPath(childPath)).toBe(expectedPath);
|
||||
});
|
||||
|
||||
it('should prevent infinite recursion on malicious symlink structures', () => {
|
||||
const maliciousPath = path.resolve('malicious', 'symlink');
|
||||
|
||||
vi.spyOn(fs, 'realpathSync').mockImplementation(() => {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
});
|
||||
|
||||
vi.spyOn(fs, 'lstatSync').mockImplementation(
|
||||
() => ({ isSymbolicLink: () => true }) as fs.Stats,
|
||||
);
|
||||
|
||||
vi.spyOn(fs, 'readlinkSync').mockImplementation(() =>
|
||||
['..', 'malicious', 'symlink'].join(path.sep),
|
||||
);
|
||||
|
||||
expect(() => resolveToRealPath(maliciousPath)).toThrow(
|
||||
/Infinite recursion detected/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
|
||||
@@ -375,24 +375,45 @@ export function resolveToRealPath(pathStr: string): string {
|
||||
return robustRealpath(path.resolve(resolvedPath));
|
||||
}
|
||||
|
||||
function robustRealpath(p: string): string {
|
||||
function robustRealpath(p: string, visited = new Set<string>()): string {
|
||||
const key = process.platform === 'win32' ? p.toLowerCase() : p;
|
||||
if (visited.has(key)) {
|
||||
throw new Error(`Infinite recursion detected in robustRealpath: ${p}`);
|
||||
}
|
||||
visited.add(key);
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'code' in e && e.code === 'ENOENT') {
|
||||
if (
|
||||
e &&
|
||||
typeof e === 'object' &&
|
||||
'code' in e &&
|
||||
(e.code === 'ENOENT' || e.code === 'EISDIR')
|
||||
) {
|
||||
try {
|
||||
const stat = fs.lstatSync(p);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = fs.readlinkSync(p);
|
||||
const resolvedTarget = path.resolve(path.dirname(p), target);
|
||||
return robustRealpath(resolvedTarget);
|
||||
return robustRealpath(resolvedTarget, visited);
|
||||
}
|
||||
} catch (lstatError: unknown) {
|
||||
// Not a symlink, or lstat failed. Re-throw if it's not an expected
|
||||
// ENOENT (e.g., a permissions error), otherwise resolve parent.
|
||||
if (
|
||||
!(
|
||||
lstatError &&
|
||||
typeof lstatError === 'object' &&
|
||||
'code' in lstatError &&
|
||||
(lstatError.code === 'ENOENT' || lstatError.code === 'EISDIR')
|
||||
)
|
||||
) {
|
||||
throw lstatError;
|
||||
}
|
||||
} catch {
|
||||
// Not a symlink, or lstat failed. Just resolve parent.
|
||||
}
|
||||
const parent = path.dirname(p);
|
||||
if (parent === p) return p;
|
||||
return path.join(robustRealpath(parent), path.basename(p));
|
||||
return path.join(robustRealpath(parent, visited), path.basename(p));
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -4,27 +4,37 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
import os from 'node:os';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
import { killProcessGroup, SIGKILL_TIMEOUT_MS } from './process-utils.js';
|
||||
import { spawnAsync } from './shell-utils.js';
|
||||
|
||||
vi.mock('node:os');
|
||||
vi.mock('node:child_process');
|
||||
vi.mock('./shell-utils.js');
|
||||
|
||||
describe('process-utils', () => {
|
||||
const mockProcessKill = vi
|
||||
.spyOn(process, 'kill')
|
||||
.mockImplementation(() => true);
|
||||
const mockSpawn = vi.mocked(cpSpawn);
|
||||
let mockProcessKill: MockInstance;
|
||||
let mockSpawnAsync: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockProcessKill = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
||||
mockSpawnAsync = vi.mocked(spawnAsync);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('killProcessGroup', () => {
|
||||
@@ -33,7 +43,7 @@ describe('process-utils', () => {
|
||||
|
||||
await killProcessGroup({ pid: 1234 });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('taskkill', [
|
||||
expect(mockSpawnAsync).toHaveBeenCalledWith('taskkill', [
|
||||
'/pid',
|
||||
'1234',
|
||||
'/f',
|
||||
@@ -42,14 +52,20 @@ describe('process-utils', () => {
|
||||
expect(mockProcessKill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use pty.kill() on Windows if pty is provided', async () => {
|
||||
it('should use pty.kill() on Windows if pty is provided and also taskkill for descendants', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('win32');
|
||||
const mockPty = { kill: vi.fn() };
|
||||
|
||||
await killProcessGroup({ pid: 1234, pty: mockPty });
|
||||
|
||||
expect(mockPty.kill).toHaveBeenCalled();
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
// taskkill is also called to reap orphaned descendant processes
|
||||
expect(mockSpawnAsync).toHaveBeenCalledWith('taskkill', [
|
||||
'/pid',
|
||||
'1234',
|
||||
'/f',
|
||||
'/t',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should kill the process group on Unix with SIGKILL by default', async () => {
|
||||
@@ -130,5 +146,23 @@ describe('process-utils', () => {
|
||||
|
||||
expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
|
||||
it('should attempt process group kill on Unix after pty fallback to reap orphaned descendants', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
// First call (group kill) throws to trigger PTY fallback
|
||||
mockProcessKill.mockImplementationOnce(() => {
|
||||
throw new Error('ESRCH');
|
||||
});
|
||||
// Second call (group kill retry after pty.kill) should succeed
|
||||
mockProcessKill.mockImplementationOnce(() => true);
|
||||
const mockPty = { kill: vi.fn() };
|
||||
|
||||
await killProcessGroup({ pid: 1234, pty: mockPty });
|
||||
|
||||
// Group kill should be called first to ensure it's hit before PTY leader dies
|
||||
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
|
||||
// Then PTY kill should be called
|
||||
expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import os from 'node:os';
|
||||
import { spawn as cpSpawn } from 'node:child_process';
|
||||
|
||||
import { spawnAsync } from './shell-utils.js';
|
||||
|
||||
/** Default timeout for SIGKILL escalation on Unix systems. */
|
||||
export const SIGKILL_TIMEOUT_MS = 200;
|
||||
@@ -44,8 +45,12 @@ export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
} catch {
|
||||
// Ignore errors for dead processes
|
||||
}
|
||||
} else {
|
||||
cpSpawn('taskkill', ['/pid', pid.toString(), '/f', '/t']);
|
||||
}
|
||||
// Invoke taskkill to ensure the entire tree is terminated and any orphaned descendant processes are reaped.
|
||||
try {
|
||||
await spawnAsync('taskkill', ['/pid', pid.toString(), '/f', '/t']);
|
||||
} catch (_e) {
|
||||
// Ignore errors if the process tree is already dead
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -73,14 +78,24 @@ export async function killProcessGroup(options: KillOptions): Promise<void> {
|
||||
if (pty) {
|
||||
if (escalate) {
|
||||
try {
|
||||
// Attempt the group kill BEFORE the pty session leader dies
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
pty.kill('SIGTERM');
|
||||
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
|
||||
if (!isExited()) pty.kill('SIGKILL');
|
||||
if (!isExited()) {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
pty.kill('SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(-pid, 'SIGKILL'); // Group kill first
|
||||
pty.kill('SIGKILL');
|
||||
} catch {
|
||||
// Ignore
|
||||
|
||||
@@ -350,6 +350,25 @@ describe('retryWithBackoff', () => {
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should retry on 'Incomplete JSON segment' when retryFetchErrors is true", async () => {
|
||||
const mockFn = vi.fn();
|
||||
mockFn.mockRejectedValueOnce(
|
||||
new Error('Incomplete JSON segment at the end'),
|
||||
);
|
||||
mockFn.mockResolvedValueOnce('success');
|
||||
|
||||
const promise = retryWithBackoff(mockFn, {
|
||||
retryFetchErrors: true,
|
||||
initialDelayMs: 10,
|
||||
});
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('success');
|
||||
expect(mockFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should retry on common network error codes (ECONNRESET)', async () => {
|
||||
const mockFn = vi.fn();
|
||||
const error = new Error('read ECONNRESET');
|
||||
|
||||
@@ -99,7 +99,46 @@ function getNetworkErrorCode(error: unknown): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const FETCH_FAILED_MESSAGE = 'fetch failed';
|
||||
export const FETCH_FAILED_MESSAGE = 'fetch failed';
|
||||
export const INCOMPLETE_JSON_MESSAGE = 'incomplete json segment';
|
||||
|
||||
/**
|
||||
* Categorizes an error for retry telemetry purposes.
|
||||
* Returns a safe string without PII.
|
||||
*/
|
||||
export function getRetryErrorType(error: unknown): string {
|
||||
if (error === 'Invalid content') {
|
||||
return 'INVALID_CONTENT';
|
||||
}
|
||||
|
||||
const errorCode = getNetworkErrorCode(error);
|
||||
if (errorCode && RETRYABLE_NETWORK_CODES.includes(errorCode)) {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const lowerMessage = error.message.toLowerCase();
|
||||
if (lowerMessage.includes(FETCH_FAILED_MESSAGE)) {
|
||||
return 'FETCH_FAILED';
|
||||
}
|
||||
if (lowerMessage.includes(INCOMPLETE_JSON_MESSAGE)) {
|
||||
return 'INCOMPLETE_JSON';
|
||||
}
|
||||
}
|
||||
|
||||
const status = getErrorStatus(error);
|
||||
if (status !== undefined) {
|
||||
if (status === 429) return 'QUOTA_EXCEEDED';
|
||||
if (status >= 500 && status < 600) return 'SERVER_ERROR';
|
||||
return `HTTP_${status}`;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.name;
|
||||
}
|
||||
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
/**
|
||||
* Default predicate function to determine if a retry should be attempted.
|
||||
@@ -119,8 +158,12 @@ export function isRetryableError(
|
||||
}
|
||||
|
||||
if (retryFetchErrors && error instanceof Error) {
|
||||
// Check for generic fetch failed message (case-insensitive)
|
||||
if (error.message.toLowerCase().includes(FETCH_FAILED_MESSAGE)) {
|
||||
const lowerMessage = error.message.toLowerCase();
|
||||
// Check for generic fetch failed message or incomplete JSON segment (common stream error)
|
||||
if (
|
||||
lowerMessage.includes(FETCH_FAILED_MESSAGE) ||
|
||||
lowerMessage.includes(INCOMPLETE_JSON_MESSAGE)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ describe('summarizers', () => {
|
||||
getResolvedConfig: vi.fn().mockReturnValue(mockResolvedConfig),
|
||||
} as unknown as ModelConfigService;
|
||||
|
||||
// .config is already set correctly by the getter on the instance.
|
||||
Object.defineProperty(mockConfigInstance, 'promptId', {
|
||||
get: () => 'test-prompt-id',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockGeminiClient = new GeminiClient(mockConfigInstance);
|
||||
(mockGeminiClient.generateContent as Mock) = vi.fn();
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { detectIdeFromEnv } from '../ide/detect-ide.js';
|
||||
|
||||
/** Default surface value when no IDE/environment is detected. */
|
||||
export const SURFACE_NOT_SET = 'terminal';
|
||||
|
||||
/**
|
||||
* Determines the surface/distribution channel the CLI is running in.
|
||||
*
|
||||
* Priority:
|
||||
* 1. `GEMINI_CLI_SURFACE` env var (first-class override for enterprise customers)
|
||||
* 2. `SURFACE` env var (legacy override, kept for backward compatibility)
|
||||
* 3. Auto-detection via environment variables (Cloud Shell, GitHub Actions, IDE, etc.)
|
||||
*
|
||||
* @returns A human-readable surface identifier (e.g., "vscode", "cursor", "terminal").
|
||||
*/
|
||||
export function determineSurface(): string {
|
||||
// Priority 1 & 2: Explicit overrides from environment variables.
|
||||
const customSurface =
|
||||
process.env['GEMINI_CLI_SURFACE'] || process.env['SURFACE'];
|
||||
if (customSurface) {
|
||||
return customSurface;
|
||||
}
|
||||
|
||||
// Priority 3: Auto-detect IDE/environment.
|
||||
const ide = detectIdeFromEnv();
|
||||
|
||||
// `detectIdeFromEnv` falls back to 'vscode' for generic terminals.
|
||||
// If a specific IDE (e.g., Cloud Shell, Cursor, JetBrains) was detected,
|
||||
// its name will be something other than 'vscode', and we can use it directly.
|
||||
if (ide.name !== 'vscode') {
|
||||
return ide.name;
|
||||
}
|
||||
|
||||
// If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM confirms it.
|
||||
// This prevents generic terminals from being misidentified as VSCode.
|
||||
if (process.env['TERM_PROGRAM'] === 'vscode') {
|
||||
return ide.name;
|
||||
}
|
||||
|
||||
// Priority 4: GitHub Actions (checked after IDE detection so that
|
||||
// specific environments like Cloud Shell take precedence).
|
||||
if (process.env['GITHUB_SHA']) {
|
||||
return 'GitHub';
|
||||
}
|
||||
|
||||
// Priority 5: Fallback for all other cases (e.g., a generic terminal).
|
||||
return SURFACE_NOT_SET;
|
||||
}
|
||||
Reference in New Issue
Block a user