mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
fix(acp): allow attachments by adding a permission prompt (#23680)
This commit is contained in:
@@ -21,13 +21,13 @@ import {
|
|||||||
AuthType,
|
AuthType,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
StreamEventType,
|
StreamEventType,
|
||||||
isWithinRoot,
|
|
||||||
ReadManyFilesTool,
|
ReadManyFilesTool,
|
||||||
type GeminiChat,
|
type GeminiChat,
|
||||||
type Config,
|
type Config,
|
||||||
type MessageBus,
|
type MessageBus,
|
||||||
LlmRole,
|
LlmRole,
|
||||||
type GitService,
|
type GitService,
|
||||||
|
processSingleFileContent,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
SettingScope,
|
SettingScope,
|
||||||
@@ -111,7 +111,6 @@ vi.mock(
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
logToolCall: vi.fn(),
|
logToolCall: vi.fn(),
|
||||||
isWithinRoot: vi.fn().mockReturnValue(true),
|
|
||||||
LlmRole: {
|
LlmRole: {
|
||||||
MAIN: 'main',
|
MAIN: 'main',
|
||||||
SUBAGENT: 'subagent',
|
SUBAGENT: 'subagent',
|
||||||
@@ -134,6 +133,7 @@ vi.mock(
|
|||||||
Cancelled: 'cancelled',
|
Cancelled: 'cancelled',
|
||||||
AwaitingApproval: 'awaiting_approval',
|
AwaitingApproval: 'awaiting_approval',
|
||||||
},
|
},
|
||||||
|
processSingleFileContent: vi.fn(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -177,6 +177,10 @@ describe('GeminiAgent', () => {
|
|||||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||||
|
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||||
|
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||||
|
addReadOnlyPath: vi.fn(),
|
||||||
|
}),
|
||||||
get config() {
|
get config() {
|
||||||
return this;
|
return this;
|
||||||
},
|
},
|
||||||
@@ -191,6 +195,7 @@ describe('GeminiAgent', () => {
|
|||||||
mockArgv = {} as unknown as CliArgs;
|
mockArgv = {} as unknown as CliArgs;
|
||||||
mockConnection = {
|
mockConnection = {
|
||||||
sessionUpdate: vi.fn(),
|
sessionUpdate: vi.fn(),
|
||||||
|
requestPermission: vi.fn(),
|
||||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||||
|
|
||||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||||
@@ -648,6 +653,7 @@ describe('Session', () => {
|
|||||||
shouldIgnoreFile: vi.fn().mockReturnValue(false),
|
shouldIgnoreFile: vi.fn().mockReturnValue(false),
|
||||||
}),
|
}),
|
||||||
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
||||||
|
getFileSystemService: vi.fn().mockReturnValue({}),
|
||||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
||||||
getDebugMode: vi.fn().mockReturnValue(false),
|
getDebugMode: vi.fn().mockReturnValue(false),
|
||||||
@@ -657,6 +663,10 @@ describe('Session', () => {
|
|||||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||||
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
||||||
|
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||||
|
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||||
|
addReadOnlyPath: vi.fn(),
|
||||||
|
}),
|
||||||
waitForMcpInit: vi.fn(),
|
waitForMcpInit: vi.fn(),
|
||||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||||
get config() {
|
get config() {
|
||||||
@@ -1356,7 +1366,6 @@ describe('Session', () => {
|
|||||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||||
isDirectory: () => false,
|
isDirectory: () => false,
|
||||||
});
|
});
|
||||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
|
||||||
|
|
||||||
const stream = createMockStream([
|
const stream = createMockStream([
|
||||||
{
|
{
|
||||||
@@ -1414,7 +1423,6 @@ describe('Session', () => {
|
|||||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||||
isDirectory: () => false,
|
isDirectory: () => false,
|
||||||
});
|
});
|
||||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
|
||||||
|
|
||||||
const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
|
const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
|
||||||
MockReadManyFilesTool.mockImplementationOnce(() => ({
|
MockReadManyFilesTool.mockImplementationOnce(() => ({
|
||||||
@@ -1468,6 +1476,172 @@ describe('Session', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle @path validation error and bubble it to user', async () => {
|
||||||
|
mockConfig.getTargetDir.mockReturnValue('/workspace');
|
||||||
|
(path.resolve as unknown as Mock).mockReturnValue('/tmp/disallowed.txt');
|
||||||
|
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
|
||||||
|
|
||||||
|
// Force fs.stat to fail to skip direct reading and triggers the warning
|
||||||
|
(fs.stat as unknown as Mock).mockRejectedValue(new Error('File not found'));
|
||||||
|
|
||||||
|
const stream = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: { candidates: [] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
await session.prompt({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: 'resource_link',
|
||||||
|
uri: 'file://disallowed.txt',
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
name: 'disallowed.txt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify warning sent via sendUpdate
|
||||||
|
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
update: expect.objectContaining({
|
||||||
|
sessionUpdate: 'agent_thought_chunk',
|
||||||
|
content: expect.objectContaining({
|
||||||
|
text: expect.stringContaining(
|
||||||
|
'Warning: skipping access to `disallowed.txt`. Reason: Path is outside workspace',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read absolute file directly if outside workspace', async () => {
|
||||||
|
mockConfig.getTargetDir.mockReturnValue('/workspace');
|
||||||
|
const testFilePath = '/tmp/custom.txt';
|
||||||
|
(path.resolve as unknown as Mock).mockReturnValue(testFilePath);
|
||||||
|
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
|
||||||
|
|
||||||
|
mockConnection.requestPermission.mockResolvedValue({
|
||||||
|
outcome: {
|
||||||
|
outcome: 'selected',
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||||
|
},
|
||||||
|
} as unknown as acp.RequestPermissionResponse);
|
||||||
|
|
||||||
|
const mockStats = {
|
||||||
|
isFile: () => true,
|
||||||
|
isDirectory: () => false,
|
||||||
|
};
|
||||||
|
(fs.stat as unknown as Mock).mockResolvedValue(mockStats);
|
||||||
|
(processSingleFileContent as unknown as Mock).mockResolvedValue({
|
||||||
|
llmContent: 'Absolute File Content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: { candidates: [] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
await session.prompt({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: 'resource_link',
|
||||||
|
uri: `file://${testFilePath}`,
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
name: 'custom.txt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(processSingleFileContent).toHaveBeenCalledWith(
|
||||||
|
testFilePath,
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify content appended to sendMessageStream parts
|
||||||
|
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
text: 'Absolute File Content',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
expect.anything(),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should read escaping relative file directly if outside workspace', async () => {
|
||||||
|
mockConfig.getTargetDir.mockReturnValue('/workspace');
|
||||||
|
const testFilePath = '../../custom.txt';
|
||||||
|
(path.resolve as unknown as Mock).mockReturnValue('/custom.txt');
|
||||||
|
mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
|
||||||
|
|
||||||
|
mockConnection.requestPermission.mockResolvedValue({
|
||||||
|
outcome: {
|
||||||
|
outcome: 'selected',
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||||
|
},
|
||||||
|
} as unknown as acp.RequestPermissionResponse);
|
||||||
|
|
||||||
|
const mockStats = {
|
||||||
|
isFile: () => true,
|
||||||
|
isDirectory: () => false,
|
||||||
|
};
|
||||||
|
(fs.stat as unknown as Mock).mockResolvedValue(mockStats);
|
||||||
|
(processSingleFileContent as unknown as Mock).mockResolvedValue({
|
||||||
|
llmContent: 'Escaping Relative File Content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = createMockStream([
|
||||||
|
{
|
||||||
|
type: StreamEventType.CHUNK,
|
||||||
|
value: { candidates: [] },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||||
|
|
||||||
|
await session.prompt({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
prompt: [
|
||||||
|
{
|
||||||
|
type: 'resource_link',
|
||||||
|
uri: `file://${testFilePath}`,
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
name: 'custom.txt',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(processSingleFileContent).toHaveBeenCalledWith(
|
||||||
|
'/custom.txt',
|
||||||
|
expect.any(String),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
text: 'Escaping Relative File Content',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
expect.anything(),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle cancellation during prompt', async () => {
|
it('should handle cancellation during prompt', async () => {
|
||||||
let streamController: ReadableStreamDefaultController<unknown>;
|
let streamController: ReadableStreamDefaultController<unknown>;
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
@@ -1666,7 +1840,6 @@ describe('Session', () => {
|
|||||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||||
isDirectory: () => true,
|
isDirectory: () => true,
|
||||||
});
|
});
|
||||||
(isWithinRoot as unknown as Mock).mockReturnValue(true);
|
|
||||||
|
|
||||||
const stream = createMockStream([
|
const stream = createMockStream([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
DEFAULT_GEMINI_MODEL_AUTO,
|
DEFAULT_GEMINI_MODEL_AUTO,
|
||||||
PREVIEW_GEMINI_MODEL_AUTO,
|
PREVIEW_GEMINI_MODEL_AUTO,
|
||||||
getDisplayString,
|
getDisplayString,
|
||||||
|
processSingleFileContent,
|
||||||
type AgentLoopContext,
|
type AgentLoopContext,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import * as acp from '@agentclientprotocol/sdk';
|
import * as acp from '@agentclientprotocol/sdk';
|
||||||
@@ -73,6 +74,17 @@ import { runExitCleanup } from '../utils/cleanup.js';
|
|||||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||||
|
|
||||||
import { CommandHandler } from './commandHandler.js';
|
import { CommandHandler } from './commandHandler.js';
|
||||||
|
|
||||||
|
const RequestPermissionResponseSchema = z.object({
|
||||||
|
outcome: z.discriminatedUnion('outcome', [
|
||||||
|
z.object({ outcome: z.literal('cancelled') }),
|
||||||
|
z.object({
|
||||||
|
outcome: z.literal('selected'),
|
||||||
|
optionId: z.string(),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
export async function runAcpClient(
|
export async function runAcpClient(
|
||||||
config: Config,
|
config: Config,
|
||||||
settings: LoadedSettings,
|
settings: LoadedSettings,
|
||||||
@@ -1011,10 +1023,12 @@ export class Session {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
const output = RequestPermissionResponseSchema.parse(
|
||||||
const output = await this.connection.requestPermission(params);
|
await this.connection.requestPermission(params),
|
||||||
|
);
|
||||||
|
|
||||||
const outcome =
|
const outcome =
|
||||||
output.outcome.outcome === CoreToolCallStatus.Cancelled
|
output.outcome.outcome === 'cancelled'
|
||||||
? ToolConfirmationOutcome.Cancel
|
? ToolConfirmationOutcome.Cancel
|
||||||
: z
|
: z
|
||||||
.nativeEnum(ToolConfirmationOutcome)
|
.nativeEnum(ToolConfirmationOutcome)
|
||||||
@@ -1225,6 +1239,11 @@ export class Session {
|
|||||||
const pathSpecsToRead: string[] = [];
|
const pathSpecsToRead: string[] = [];
|
||||||
const contentLabelsForDisplay: string[] = [];
|
const contentLabelsForDisplay: string[] = [];
|
||||||
const ignoredPaths: string[] = [];
|
const ignoredPaths: string[] = [];
|
||||||
|
const directContents: Array<{
|
||||||
|
spec: string;
|
||||||
|
content?: string;
|
||||||
|
part?: Part;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const toolRegistry = this.context.toolRegistry;
|
const toolRegistry = this.context.toolRegistry;
|
||||||
const readManyFilesTool = new ReadManyFilesTool(
|
const readManyFilesTool = new ReadManyFilesTool(
|
||||||
@@ -1247,28 +1266,197 @@ export class Session {
|
|||||||
}
|
}
|
||||||
let currentPathSpec = pathName;
|
let currentPathSpec = pathName;
|
||||||
let resolvedSuccessfully = false;
|
let resolvedSuccessfully = false;
|
||||||
|
let readDirectly = false;
|
||||||
try {
|
try {
|
||||||
const absolutePath = path.resolve(
|
const absolutePath = path.resolve(
|
||||||
this.context.config.getTargetDir(),
|
this.context.config.getTargetDir(),
|
||||||
pathName,
|
pathName,
|
||||||
);
|
);
|
||||||
if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) {
|
|
||||||
const stats = await fs.stat(absolutePath);
|
let validationError = this.context.config.validatePathAccess(
|
||||||
if (stats.isDirectory()) {
|
absolutePath,
|
||||||
currentPathSpec = pathName.endsWith('/')
|
'read',
|
||||||
? `${pathName}**`
|
);
|
||||||
: `${pathName}/**`;
|
|
||||||
|
// We ask the user for explicit permission to read them if outside sandboxed workspace boundaries (and not already authorized).
|
||||||
|
if (
|
||||||
|
validationError &&
|
||||||
|
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(absolutePath);
|
||||||
|
if (stats.isFile()) {
|
||||||
|
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
|
||||||
|
const params = {
|
||||||
|
sessionId: this.id,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||||
|
name: 'Allow once',
|
||||||
|
kind: 'allow_once',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
optionId: ToolConfirmationOutcome.Cancel,
|
||||||
|
name: 'Deny',
|
||||||
|
kind: 'reject_once',
|
||||||
|
},
|
||||||
|
] as acp.PermissionOption[],
|
||||||
|
toolCall: {
|
||||||
|
toolCallId: syntheticCallId,
|
||||||
|
status: 'pending',
|
||||||
|
title: `Allow access to absolute path: ${pathName}`,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'content',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
locations: [],
|
||||||
|
kind: 'read',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = RequestPermissionResponseSchema.parse(
|
||||||
|
await this.connection.requestPermission(params),
|
||||||
|
);
|
||||||
|
|
||||||
|
const outcome =
|
||||||
|
output.outcome.outcome === 'cancelled'
|
||||||
|
? ToolConfirmationOutcome.Cancel
|
||||||
|
: z
|
||||||
|
.nativeEnum(ToolConfirmationOutcome)
|
||||||
|
.parse(output.outcome.optionId);
|
||||||
|
|
||||||
|
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
|
||||||
|
this.context.config
|
||||||
|
.getWorkspaceContext()
|
||||||
|
.addReadOnlyPath(absolutePath);
|
||||||
|
validationError = null;
|
||||||
|
} else {
|
||||||
|
this.debug(
|
||||||
|
`Direct read authorization denied for absolute path ${pathName}`,
|
||||||
|
);
|
||||||
|
directContents.push({
|
||||||
|
spec: pathName,
|
||||||
|
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
this.debug(
|
this.debug(
|
||||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
|
||||||
);
|
);
|
||||||
} else {
|
await this.sendUpdate({
|
||||||
this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`);
|
sessionUpdate: 'agent_thought_chunk',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validationError) {
|
||||||
|
// If it's an absolute path that is authorized (e.g. added via readOnlyPaths),
|
||||||
|
// read it directly to avoid ReadManyFilesTool absolute path resolution issues.
|
||||||
|
if (
|
||||||
|
(path.isAbsolute(pathName) ||
|
||||||
|
!isWithinRoot(
|
||||||
|
absolutePath,
|
||||||
|
this.context.config.getTargetDir(),
|
||||||
|
)) &&
|
||||||
|
!readDirectly
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(absolutePath);
|
||||||
|
if (stats.isFile()) {
|
||||||
|
const fileReadResult = await processSingleFileContent(
|
||||||
|
absolutePath,
|
||||||
|
this.context.config.getTargetDir(),
|
||||||
|
this.context.config.getFileSystemService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fileReadResult.error) {
|
||||||
|
if (
|
||||||
|
typeof fileReadResult.llmContent === 'object' &&
|
||||||
|
'inlineData' in fileReadResult.llmContent
|
||||||
|
) {
|
||||||
|
directContents.push({
|
||||||
|
spec: pathName,
|
||||||
|
part: fileReadResult.llmContent,
|
||||||
|
});
|
||||||
|
} else if (typeof fileReadResult.llmContent === 'string') {
|
||||||
|
let contentToPush = fileReadResult.llmContent;
|
||||||
|
if (fileReadResult.isTruncated) {
|
||||||
|
contentToPush = `[WARNING: This file was truncated]\n\n${contentToPush}`;
|
||||||
|
}
|
||||||
|
directContents.push({
|
||||||
|
spec: pathName,
|
||||||
|
content: contentToPush,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
readDirectly = true;
|
||||||
|
resolvedSuccessfully = true;
|
||||||
|
} else {
|
||||||
|
this.debug(
|
||||||
|
`Direct read failed for absolute path ${pathName}: ${fileReadResult.error}`,
|
||||||
|
);
|
||||||
|
await this.sendUpdate({
|
||||||
|
sessionUpdate: 'agent_thought_chunk',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Warning: file read failed for \`${pathName}\`. Reason: ${fileReadResult.error}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.debug(
|
||||||
|
`File stat/access error for absolute path ${pathName}: ${getErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
await this.sendUpdate({
|
||||||
|
sessionUpdate: 'agent_thought_chunk',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Warning: file access failed for \`${pathName}\`. Reason: ${getErrorMessage(error)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readDirectly) {
|
||||||
|
const stats = await fs.stat(absolutePath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
currentPathSpec = pathName.endsWith('/')
|
||||||
|
? `${pathName}**`
|
||||||
|
: `${pathName}/**`;
|
||||||
|
this.debug(
|
||||||
|
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.debug(
|
||||||
|
`Path ${pathName} resolved to file: ${currentPathSpec}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolvedSuccessfully = true;
|
||||||
}
|
}
|
||||||
resolvedSuccessfully = true;
|
|
||||||
} else {
|
} else {
|
||||||
this.debug(
|
this.debug(
|
||||||
`Path ${pathName} is outside the project directory. Skipping.`,
|
`Path ${pathName} access disallowed: ${validationError}. Skipping.`,
|
||||||
);
|
);
|
||||||
|
await this.sendUpdate({
|
||||||
|
sessionUpdate: 'agent_thought_chunk',
|
||||||
|
content: {
|
||||||
|
type: 'text',
|
||||||
|
text: `Warning: skipping access to \`${pathName}\`. Reason: ${validationError}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
@@ -1328,7 +1516,9 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resolvedSuccessfully) {
|
if (resolvedSuccessfully) {
|
||||||
pathSpecsToRead.push(currentPathSpec);
|
if (!readDirectly) {
|
||||||
|
pathSpecsToRead.push(currentPathSpec);
|
||||||
|
}
|
||||||
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
|
atPathToResolvedSpecMap.set(pathName, currentPathSpec);
|
||||||
contentLabelsForDisplay.push(pathName);
|
contentLabelsForDisplay.push(pathName);
|
||||||
}
|
}
|
||||||
@@ -1389,7 +1579,11 @@ export class Session {
|
|||||||
|
|
||||||
const processedQueryParts: Part[] = [{ text: initialQueryText }];
|
const processedQueryParts: Part[] = [{ text: initialQueryText }];
|
||||||
|
|
||||||
if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) {
|
if (
|
||||||
|
pathSpecsToRead.length === 0 &&
|
||||||
|
embeddedContext.length === 0 &&
|
||||||
|
directContents.length === 0
|
||||||
|
) {
|
||||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
||||||
debugLogger.warn('No valid file paths found in @ commands to read.');
|
debugLogger.warn('No valid file paths found in @ commands to read.');
|
||||||
return [{ text: initialQueryText }];
|
return [{ text: initialQueryText }];
|
||||||
@@ -1481,6 +1675,30 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (directContents.length > 0) {
|
||||||
|
const hasReferenceStart = processedQueryParts.some(
|
||||||
|
(p) =>
|
||||||
|
'text' in p &&
|
||||||
|
typeof p.text === 'string' &&
|
||||||
|
p.text.includes(REFERENCE_CONTENT_START),
|
||||||
|
);
|
||||||
|
if (!hasReferenceStart) {
|
||||||
|
processedQueryParts.push({
|
||||||
|
text: `\n${REFERENCE_CONTENT_START}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of directContents) {
|
||||||
|
processedQueryParts.push({
|
||||||
|
text: `\nContent from @${item.spec}:\n`,
|
||||||
|
});
|
||||||
|
if (item.content) {
|
||||||
|
processedQueryParts.push({ text: item.content });
|
||||||
|
} else if (item.part) {
|
||||||
|
processedQueryParts.push(item.part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (embeddedContext.length > 0) {
|
if (embeddedContext.length > 0) {
|
||||||
processedQueryParts.push({
|
processedQueryParts.push({
|
||||||
text: '\n--- Content from referenced context ---',
|
text: '\n--- Content from referenced context ---',
|
||||||
|
|||||||
Reference in New Issue
Block a user