Skip to content

Commit aebdba6

Browse files
authored
fix(core): improve Alpine shell compatibility (google-gemini#26770)
1 parent 26f8c0f commit aebdba6

4 files changed

Lines changed: 112 additions & 50 deletions

File tree

packages/core/src/services/gitService.ts

Lines changed: 25 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,30 @@ import {
2424
export const SHADOW_REPO_AUTHOR_NAME = 'Gemini CLI';
2525
export const SHADOW_REPO_AUTHOR_EMAIL = 'gemini-cli@google.com';
2626

27+
const SHADOW_REPO_UNSAFE_OPTIONS = {
28+
allowUnsafeAlias: true,
29+
allowUnsafeAskPass: true,
30+
allowUnsafeConfigEnvCount: true,
31+
allowUnsafeConfigPaths: true,
32+
allowUnsafeCredentialHelper: true,
33+
allowUnsafeCustomBinary: true,
34+
allowUnsafeDiffExternal: true,
35+
allowUnsafeDiffTextConv: true,
36+
allowUnsafeEditor: true,
37+
allowUnsafeFilter: true,
38+
allowUnsafeFsMonitor: true,
39+
allowUnsafeGitProxy: true,
40+
allowUnsafeGpgProgram: true,
41+
allowUnsafeHooksPath: true,
42+
allowUnsafeMergeDriver: true,
43+
allowUnsafePack: true,
44+
allowUnsafePager: true,
45+
allowUnsafeProtocolOverride: true,
46+
allowUnsafeSshCommand: true,
47+
allowUnsafeTemplateDir: true,
48+
} satisfies NonNullable<SimpleGitOptions['unsafe']> &
49+
Record<`allowUnsafe${string}`, boolean>;
50+
2751
/**
2852
* Common configuration for the shadow Git repository used for checkpointing.
2953
*
@@ -32,28 +56,7 @@ export const SHADOW_REPO_AUTHOR_EMAIL = 'gemini-cli@google.com';
3256
* regardless of the user's local environment (e.g., PAGER, EDITOR, or SSH settings).
3357
*/
3458
const SHADOW_REPO_GIT_OPTIONS: Partial<SimpleGitOptions> = {
35-
unsafe: {
36-
allowUnsafeAlias: true,
37-
allowUnsafeAskPass: true,
38-
allowUnsafeConfigEnvCount: true,
39-
allowUnsafeConfigPaths: true,
40-
allowUnsafeCredentialHelper: true,
41-
allowUnsafeCustomBinary: true,
42-
allowUnsafeDiffExternal: true,
43-
allowUnsafeDiffTextConv: true,
44-
allowUnsafeEditor: true,
45-
allowUnsafeFilter: true,
46-
allowUnsafeFsMonitor: true,
47-
allowUnsafeGitProxy: true,
48-
allowUnsafeGpgProgram: true,
49-
allowUnsafeHooksPath: true,
50-
allowUnsafeMergeDriver: true,
51-
allowUnsafePack: true,
52-
allowUnsafePager: true,
53-
allowUnsafeProtocolOverride: true,
54-
allowUnsafeSshCommand: true,
55-
allowUnsafeTemplateDir: true,
56-
},
59+
unsafe: SHADOW_REPO_UNSAFE_OPTIONS,
5760
};
5861

5962
export class GitService {

packages/core/src/tools/shell.test.ts

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ describe('ShellTool', () => {
152152
getGeminiClient: vi.fn().mockReturnValue({}),
153153
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
154154
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
155+
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
155156
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
156157
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
157158
getSandboxEnabled: vi.fn().mockReturnValue(false),
@@ -213,7 +214,7 @@ describe('ShellTool', () => {
213214
callback: (event: ShellOutputEvent) => void,
214215
) => {
215216
mockShellOutputCallback = callback;
216-
const match = cmd.match(/pgrep -g 0 >([^ ]+)/);
217+
const match = cmd.match(/_bgpids_file=([^\r\n]+)/);
217218
if (match) {
218219
extractedTmpFile = match[1].replace(/['"]/g, '');
219220
}
@@ -308,19 +309,22 @@ describe('ShellTool', () => {
308309
resolveExecutionPromise(fullResult);
309310
};
310311

311-
it('should wrap command on linux and parse pgrep output', async () => {
312+
it('should wrap command on linux and parse background PID output', async () => {
312313
const invocation = shellTool.build({ command: 'my-command &' });
313314
const promise = invocation.execute({ abortSignal: mockAbortSignal });
314315

315-
// Simulate pgrep output file creation by the shell command
316+
// Simulate background PID output file creation by the shell command
316317
fs.writeFileSync(extractedTmpFile, `54321${os.EOL}54322${os.EOL}`);
317318

318319
resolveShellExecution({ pid: 54321 });
319320

320321
const result = await promise;
322+
const wrappedCommand = mockShellExecutionService.mock.calls[0][0];
321323

322324
expect(mockShellExecutionService).toHaveBeenCalledWith(
323-
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
325+
expect.stringMatching(
326+
/_bgpids_file=.*gemini-shell-.*[/\\]bgpids\.tmp['"]?\n\(\n {2}trap 'jobs -p > "\$_bgpids_file"' EXIT/,
327+
),
324328
tempRootDir,
325329
expect.any(Function),
326330
expect.any(AbortSignal),
@@ -331,19 +335,67 @@ describe('ShellTool', () => {
331335
sandboxManager: expect.any(Object),
332336
}),
333337
);
338+
expect(wrappedCommand).toMatch(
339+
/^_bgpids_file=.*\n\(\n {2}trap 'jobs -p > "\$_bgpids_file"' EXIT\nmy-command &\n\)\n__code=\$\?\nexit \$__code$/,
340+
);
334341
expect(result.llmContent).toContain('Background PIDs: 54322');
335342
// The file should be deleted by the tool
336343
expect(fs.existsSync(extractedTmpFile)).toBe(false);
337344
});
338345

346+
it('should preserve exit code and capture background PIDs when command uses explicit exit', async () => {
347+
const invocation = shellTool.build({
348+
command: "sh -c 'sleep 60 & exit 1'",
349+
});
350+
const promise = invocation.execute({ abortSignal: mockAbortSignal });
351+
352+
fs.writeFileSync(extractedTmpFile, `67890${os.EOL}`);
353+
expect(fs.readFileSync(extractedTmpFile, 'utf8').trim()).toBe('67890');
354+
355+
resolveShellExecution({ exitCode: 1, output: '' });
356+
357+
const result = await promise;
358+
const wrappedCommand = mockShellExecutionService.mock.calls[0][0];
359+
360+
expect(wrappedCommand).toContain(
361+
'trap \'jobs -p > "$_bgpids_file"\' EXIT',
362+
);
363+
expect(wrappedCommand).toContain('sleep 60 & exit 1');
364+
expect(result.llmContent).toContain('Exit Code: 1');
365+
expect(result.llmContent).toContain('Background PIDs: 67890');
366+
expect(fs.existsSync(extractedTmpFile)).toBe(false);
367+
});
368+
369+
it('should disable PTY execution when interactive shell is unavailable', async () => {
370+
(mockConfig.getEnableInteractiveShell as Mock).mockReturnValue(true);
371+
(mockConfig.isInteractiveShellEnabled as Mock).mockReturnValue(false);
372+
373+
const invocation = shellTool.build({ command: 'python --version' });
374+
const promise = invocation.execute({ abortSignal: mockAbortSignal });
375+
resolveShellExecution();
376+
377+
await promise;
378+
379+
expect(mockShellExecutionService).toHaveBeenCalledWith(
380+
expect.any(String),
381+
tempRootDir,
382+
expect.any(Function),
383+
expect.any(AbortSignal),
384+
false,
385+
expect.objectContaining({
386+
pager: 'cat',
387+
}),
388+
);
389+
});
390+
339391
it('should add a space when command ends with a backslash to prevent escaping newline', async () => {
340392
const invocation = shellTool.build({ command: 'ls\\' });
341393
const promise = invocation.execute({ abortSignal: mockAbortSignal });
342394
resolveShellExecution();
343395
await promise;
344396

345397
expect(mockShellExecutionService).toHaveBeenCalledWith(
346-
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
398+
expect.stringMatching(/_bgpids_file=.*gemini-shell-.*[/\\]bgpids\.tmp/),
347399
tempRootDir,
348400
expect.any(Function),
349401
expect.any(AbortSignal),
@@ -359,7 +411,7 @@ describe('ShellTool', () => {
359411
await promise;
360412

361413
expect(mockShellExecutionService).toHaveBeenCalledWith(
362-
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
414+
expect.stringMatching(/_bgpids_file=.*gemini-shell-.*[/\\]bgpids\.tmp/),
363415
tempRootDir,
364416
expect.any(Function),
365417
expect.any(AbortSignal),
@@ -379,7 +431,7 @@ describe('ShellTool', () => {
379431
await promise;
380432

381433
expect(mockShellExecutionService).toHaveBeenCalledWith(
382-
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
434+
expect.stringMatching(/_bgpids_file=.*gemini-shell-.*[/\\]bgpids\.tmp/),
383435
subdir,
384436
expect.any(Function),
385437
expect.any(AbortSignal),
@@ -402,7 +454,7 @@ describe('ShellTool', () => {
402454
await promise;
403455

404456
expect(mockShellExecutionService).toHaveBeenCalledWith(
405-
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
457+
expect.stringMatching(/_bgpids_file=.*gemini-shell-.*[/\\]bgpids\.tmp/),
406458
path.join(tempRootDir, 'subdir'),
407459
expect.any(Function),
408460
expect.any(AbortSignal),
@@ -481,7 +533,7 @@ EOF`;
481533
await promise;
482534

483535
expect(mockShellExecutionService).toHaveBeenCalledWith(
484-
expect.stringMatching(/pgrep -g 0 >.*gemini-shell-.*[/\\]pgrep\.tmp/),
536+
expect.stringMatching(/_bgpids_file=.*gemini-shell-.*[/\\]bgpids\.tmp/),
485537
tempRootDir,
486538
expect.any(Function),
487539
expect.any(AbortSignal),
@@ -508,7 +560,7 @@ EOF`;
508560

509561
const result = await promise;
510562
expect(result.llmContent).toContain('Error: wrapped command failed');
511-
expect(result.llmContent).not.toContain('pgrep');
563+
expect(result.llmContent).not.toContain('background pid output');
512564
expect(result.display).toEqual(
513565
expect.objectContaining({
514566
name: 'Shell',
@@ -599,7 +651,7 @@ EOF`;
599651
it('should clean up the temp file on synchronous execution error', async () => {
600652
const error = new Error('sync spawn error');
601653
mockShellExecutionService.mockImplementation((cmd: string) => {
602-
const match = cmd.match(/pgrep -g 0 >([^ ]+)/);
654+
const match = cmd.match(/_bgpids_file=([^\r\n]+)/);
603655
if (match) {
604656
extractedTmpFile = match[1].replace(/['"]/g, ''); // remove any quotes if present
605657
// Create the temp file before throwing to simulate it being left behind
@@ -616,7 +668,7 @@ EOF`;
616668
expect(fs.existsSync(extractedTmpFile)).toBe(false);
617669
});
618670

619-
it('should not log "missing pgrep output" when process is backgrounded', async () => {
671+
it('should not log "missing background pid output" when process is backgrounded', async () => {
620672
vi.useFakeTimers();
621673
const debugErrorSpy = vi.spyOn(debugLogger, 'error');
622674

@@ -631,7 +683,9 @@ EOF`;
631683

632684
await promise;
633685

634-
expect(debugErrorSpy).not.toHaveBeenCalledWith('missing pgrep output');
686+
expect(debugErrorSpy).not.toHaveBeenCalledWith(
687+
'missing background pid output',
688+
);
635689
});
636690

637691
describe('Streaming to `updateOutput`', () => {

packages/core/src/tools/shell.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -108,15 +108,16 @@ export class ShellToolInvocation extends BaseToolInvocation<
108108
}
109109

110110
/**
111-
* Wraps a command in a subshell `()` to capture background process IDs (PIDs) using pgrep.
112-
* Uses newlines to prevent breaking heredocs or trailing comments.
111+
* Wraps a command in a subshell to capture background process IDs (PIDs)
112+
* using an EXIT trap. Uses newlines to prevent breaking heredocs or trailing
113+
* comments.
113114
*
114115
* @param command The raw command string to execute.
115116
* @param tempFilePath Path to the temporary file where PIDs will be written.
116117
* @param isWindows Whether the current platform is Windows (if true, the command is returned as-is).
117118
* @returns The wrapped command string.
118119
*/
119-
private wrapCommandForPgrep(
120+
private wrapCommandForBackgroundPIDs(
120121
command: string,
121122
tempFilePath: string,
122123
isWindows: boolean,
@@ -132,7 +133,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
132133
trimmed += ' ';
133134
}
134135
const escapedTempFilePath = escapeShellArg(tempFilePath, 'bash');
135-
return `(\n${trimmed}\n)\n__code=$?; pgrep -g 0 >${escapedTempFilePath} 2>&1; exit $__code;`;
136+
return `_bgpids_file=${escapedTempFilePath}\n(\n trap 'jobs -p > "$_bgpids_file"' EXIT\n${trimmed}\n)\n__code=$?\nexit $__code`;
136137
}
137138

138139
private getContextualDetails(): string {
@@ -497,10 +498,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
497498
const onAbort = () => combinedController.abort();
498499
try {
499500
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-shell-'));
500-
tempFilePath = path.join(tempDir, 'pgrep.tmp');
501+
tempFilePath = path.join(tempDir, 'bgpids.tmp');
501502

502-
// pgrep is not available on Windows, so we can't get background PIDs
503-
const commandToExecute = this.wrapCommandForPgrep(
503+
// Windows shells do not support the POSIX jobs output used here.
504+
const commandToExecute = this.wrapCommandForBackgroundPIDs(
504505
strippedCommand,
505506
tempFilePath,
506507
isWindows,
@@ -651,7 +652,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
651652
}
652653
},
653654
combinedController.signal,
654-
this.context.config.getEnableInteractiveShell(),
655+
this.context.config.isInteractiveShellEnabled(),
655656
{
656657
...shellExecutionConfig,
657658
sessionId: this.context.config?.getSessionId?.() ?? 'default',
@@ -736,12 +737,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
736737
}
737738

738739
if (tempFileExists) {
739-
const pgrepContent = await fsPromises.readFile(tempFilePath, 'utf8');
740-
const pgrepLines = pgrepContent
740+
const backgroundPIDContent = await fsPromises.readFile(
741+
tempFilePath,
742+
'utf8',
743+
);
744+
const backgroundPIDLines = backgroundPIDContent
741745
.split('\n')
742746
.map((line) => line.trim())
743747
.filter(Boolean);
744-
for (const line of pgrepLines) {
748+
for (const line of backgroundPIDLines) {
745749
if (!/^\d+$/.test(line)) {
746750
if (
747751
line.includes('sysmond service not found') ||
@@ -750,7 +754,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
750754
) {
751755
continue;
752756
}
753-
debugLogger.error(`pgrep: ${line}`);
757+
debugLogger.error(`background pid output: ${line}`);
754758
}
755759
const pid = Number(line);
756760
if (pid !== result.pid) {
@@ -759,7 +763,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
759763
}
760764
} else {
761765
if (!signal.aborted && !result.backgrounded) {
762-
debugLogger.error('missing pgrep output');
766+
debugLogger.error('missing background pid output');
763767
}
764768
}
765769
}
@@ -1089,7 +1093,7 @@ export class ShellTool extends BaseDeclarativeTool<
10891093
// Errors are surfaced when parsing commands.
10901094
});
10911095
const definition = getShellDefinition(
1092-
context.config.getEnableInteractiveShell(),
1096+
context.config.isInteractiveShellEnabled(),
10931097
context.config.getEnableShellOutputEfficiency(),
10941098
context.config.getSandboxEnabled(),
10951099
);
@@ -1139,7 +1143,7 @@ export class ShellTool extends BaseDeclarativeTool<
11391143

11401144
override getSchema(modelId?: string) {
11411145
const definition = getShellDefinition(
1142-
this.context.config.getEnableInteractiveShell(),
1146+
this.context.config.isInteractiveShellEnabled(),
11431147
this.context.config.getEnableShellOutputEfficiency(),
11441148
this.context.config.getSandboxEnabled(),
11451149
);

packages/core/src/tools/shell_proactive.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ describe('ShellTool Proactive Expansion', () => {
8383
getModeConfig: vi.fn().mockReturnValue({ readonly: false }),
8484
},
8585
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
86+
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
8687
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
8788
getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000),
8889
} as unknown as Config;

0 commit comments

Comments
 (0)