Skip to content

Commit 478f771

Browse files
authored
fix(cli): propagate resolved folder trust state in headless mode (#29031) (#29528)
1 parent 40d4dcc commit 478f771

2 files changed

Lines changed: 279 additions & 18 deletions

File tree

‎packages/cli/src/ui/hooks/useFolderTrust.test.ts‎

Lines changed: 259 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import {
2525
type LoadedTrustedFolders,
2626
} from '../../config/trustedFolders.js';
2727
import * as trustedFolders from '../../config/trustedFolders.js';
28-
import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core';
28+
import {
29+
coreEvents,
30+
ExitCodes,
31+
isHeadlessMode,
32+
FolderTrustDiscoveryService,
33+
} from '@google/gemini-cli-core';
2934
import { MessageType } from '../types.js';
3035

3136
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
@@ -366,7 +371,7 @@ describe('useFolderTrust', () => {
366371
});
367372

368373
describe('headless mode', () => {
369-
it('should force trust and hide dialog in headless mode', async () => {
374+
it('should propagate false to onTrustChange, hide dialog, and show warning when folder is untrusted', async () => {
370375
vi.mocked(isHeadlessMode).mockReturnValue(true);
371376
isWorkspaceTrustedSpy.mockReturnValue({
372377
isTrusted: false,
@@ -378,7 +383,8 @@ describe('useFolderTrust', () => {
378383
);
379384

380385
expect(result.current.isFolderTrustDialogOpen).toBe(false);
381-
expect(onTrustChange).toHaveBeenCalledWith(true);
386+
expect(result.current.isTrusted).toBe(false);
387+
expect(onTrustChange).toHaveBeenCalledWith(false);
382388
expect(addItem).toHaveBeenCalledWith(
383389
expect.objectContaining({
384390
type: MessageType.INFO,
@@ -387,5 +393,255 @@ describe('useFolderTrust', () => {
387393
expect.any(Number),
388394
);
389395
});
396+
397+
it('should propagate true to onTrustChange, hide dialog, and not show warning when folder is trusted', async () => {
398+
vi.mocked(isHeadlessMode).mockReturnValue(true);
399+
isWorkspaceTrustedSpy.mockReturnValue({
400+
isTrusted: true,
401+
source: 'file',
402+
});
403+
404+
const { result } = await renderHook(() =>
405+
useFolderTrust(mockSettings, onTrustChange, addItem),
406+
);
407+
408+
expect(result.current.isFolderTrustDialogOpen).toBe(false);
409+
expect(result.current.isTrusted).toBe(true);
410+
expect(onTrustChange).toHaveBeenCalledWith(true);
411+
expect(addItem).not.toHaveBeenCalled();
412+
});
413+
414+
it('should propagate undefined to onTrustChange and hide dialog when folder trust is undefined', async () => {
415+
vi.mocked(isHeadlessMode).mockReturnValue(true);
416+
isWorkspaceTrustedSpy.mockReturnValue({
417+
isTrusted: undefined,
418+
source: undefined,
419+
});
420+
421+
const { result } = await renderHook(() =>
422+
useFolderTrust(mockSettings, onTrustChange, addItem),
423+
);
424+
425+
expect(result.current.isFolderTrustDialogOpen).toBe(false);
426+
expect(result.current.isTrusted).toBeUndefined();
427+
expect(onTrustChange).toHaveBeenCalledWith(undefined);
428+
expect(addItem).not.toHaveBeenCalled();
429+
});
430+
});
431+
432+
describe('callback stability', () => {
433+
it('should not re-run effect or trigger onTrustChange again when callback references change', async () => {
434+
isWorkspaceTrustedSpy.mockReturnValue({
435+
isTrusted: true,
436+
source: 'file',
437+
});
438+
439+
const initialOnTrustChange = vi.fn();
440+
const initialAddItem = vi.fn();
441+
442+
const { rerender } = await renderHook(
443+
({ onTrustChangeCb, addItemCb }) =>
444+
useFolderTrust(mockSettings, onTrustChangeCb, addItemCb),
445+
{
446+
initialProps: {
447+
onTrustChangeCb: initialOnTrustChange,
448+
addItemCb: initialAddItem,
449+
},
450+
},
451+
);
452+
453+
expect(initialOnTrustChange).toHaveBeenCalledTimes(1);
454+
expect(initialOnTrustChange).toHaveBeenCalledWith(true);
455+
456+
const newOnTrustChange = vi.fn();
457+
const newAddItem = vi.fn();
458+
459+
rerender({
460+
onTrustChangeCb: newOnTrustChange,
461+
addItemCb: newAddItem,
462+
});
463+
464+
expect(newOnTrustChange).not.toHaveBeenCalled();
465+
expect(initialOnTrustChange).toHaveBeenCalledTimes(1);
466+
});
467+
468+
it('should not re-trigger FolderTrustDiscoveryService.discover when callback references change', async () => {
469+
isWorkspaceTrustedSpy.mockReturnValue({
470+
isTrusted: false,
471+
source: 'file',
472+
});
473+
const discoverSpy = vi.spyOn(FolderTrustDiscoveryService, 'discover');
474+
discoverSpy.mockClear();
475+
476+
const initialOnTrustChange = vi.fn();
477+
const initialAddItem = vi.fn();
478+
479+
const { rerender } = await renderHook(
480+
({ onTrustChangeCb, addItemCb }) =>
481+
useFolderTrust(mockSettings, onTrustChangeCb, addItemCb),
482+
{
483+
initialProps: {
484+
onTrustChangeCb: initialOnTrustChange,
485+
addItemCb: initialAddItem,
486+
},
487+
},
488+
);
489+
490+
expect(discoverSpy).toHaveBeenCalledTimes(1);
491+
492+
const newOnTrustChange = vi.fn();
493+
const newAddItem = vi.fn();
494+
495+
rerender({
496+
onTrustChangeCb: newOnTrustChange,
497+
addItemCb: newAddItem,
498+
});
499+
500+
expect(discoverSpy).toHaveBeenCalledTimes(1);
501+
});
502+
503+
it('should use updated onTrustChange callback in handleFolderTrustSelect when callback reference changes', async () => {
504+
isWorkspaceTrustedSpy.mockReturnValue({
505+
isTrusted: undefined,
506+
source: undefined,
507+
});
508+
509+
const initialOnTrustChange = vi.fn();
510+
const initialAddItem = vi.fn();
511+
512+
const { result, rerender } = await renderHook(
513+
({ onTrustChangeCb, addItemCb }) =>
514+
useFolderTrust(mockSettings, onTrustChangeCb, addItemCb),
515+
{
516+
initialProps: {
517+
onTrustChangeCb: initialOnTrustChange,
518+
addItemCb: initialAddItem,
519+
},
520+
},
521+
);
522+
523+
expect(initialOnTrustChange).toHaveBeenCalledWith(undefined);
524+
525+
const newOnTrustChange = vi.fn();
526+
const newAddItem = vi.fn();
527+
528+
rerender({
529+
onTrustChangeCb: newOnTrustChange,
530+
addItemCb: newAddItem,
531+
});
532+
533+
await act(async () => {
534+
await result.current.handleFolderTrustSelect(
535+
FolderTrustChoice.TRUST_FOLDER,
536+
);
537+
});
538+
539+
expect(newOnTrustChange).toHaveBeenCalledWith(true);
540+
expect(initialOnTrustChange).not.toHaveBeenCalledWith(true);
541+
});
542+
543+
it('should not re-run discovery effect when unrelated settings change', async () => {
544+
isWorkspaceTrustedSpy.mockReturnValue({
545+
isTrusted: true,
546+
source: 'file',
547+
});
548+
549+
const onTrustChangeCb = vi.fn();
550+
const addItemCb = vi.fn();
551+
552+
const initialSettings = {
553+
merged: {
554+
security: {
555+
folderTrust: {
556+
enabled: true,
557+
},
558+
},
559+
ui: {
560+
theme: 'default',
561+
},
562+
},
563+
setValue: vi.fn(),
564+
} as unknown as LoadedSettings;
565+
566+
const { rerender } = await renderHook(
567+
({ settings }) => useFolderTrust(settings, onTrustChangeCb, addItemCb),
568+
{
569+
initialProps: {
570+
settings: initialSettings,
571+
},
572+
},
573+
);
574+
575+
expect(onTrustChangeCb).toHaveBeenCalledTimes(1);
576+
577+
const updatedSettings = {
578+
merged: {
579+
security: {
580+
folderTrust: {
581+
enabled: true,
582+
},
583+
},
584+
ui: {
585+
theme: 'dark',
586+
},
587+
},
588+
setValue: vi.fn(),
589+
} as unknown as LoadedSettings;
590+
591+
rerender({
592+
settings: updatedSettings,
593+
});
594+
595+
expect(onTrustChangeCb).toHaveBeenCalledTimes(1);
596+
});
597+
598+
it('should re-run discovery effect when folderTrust setting changes', async () => {
599+
isWorkspaceTrustedSpy.mockReturnValue({
600+
isTrusted: true,
601+
source: 'file',
602+
});
603+
604+
const onTrustChangeCb = vi.fn();
605+
const addItemCb = vi.fn();
606+
607+
const initialSettings = {
608+
merged: {
609+
security: {
610+
folderTrust: {
611+
enabled: true,
612+
},
613+
},
614+
},
615+
setValue: vi.fn(),
616+
} as unknown as LoadedSettings;
617+
618+
const { rerender } = await renderHook(
619+
({ settings }) => useFolderTrust(settings, onTrustChangeCb, addItemCb),
620+
{
621+
initialProps: {
622+
settings: initialSettings,
623+
},
624+
},
625+
);
626+
627+
expect(onTrustChangeCb).toHaveBeenCalledTimes(1);
628+
629+
const updatedSettings = {
630+
merged: {
631+
security: {
632+
folderTrust: {
633+
enabled: false,
634+
},
635+
},
636+
},
637+
setValue: vi.fn(),
638+
} as unknown as LoadedSettings;
639+
640+
rerender({
641+
settings: updatedSettings,
642+
});
643+
644+
expect(onTrustChangeCb).toHaveBeenCalledTimes(2);
645+
});
390646
});
391647
});

‎packages/cli/src/ui/hooks/useFolderTrust.ts‎

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,23 @@ export const useFolderTrust = (
3535
const [isRestarting, setIsRestarting] = useState(false);
3636
const startupMessageSent = useRef(false);
3737

38+
const onTrustChangeRef = useRef(onTrustChange);
39+
const addItemRef = useRef(addItem);
40+
const settingsRef = useRef(settings);
41+
42+
useEffect(() => {
43+
onTrustChangeRef.current = onTrustChange;
44+
addItemRef.current = addItem;
45+
settingsRef.current = settings;
46+
}, [onTrustChange, addItem, settings]);
47+
3848
const folderTrust = settings.merged.security.folderTrust.enabled ?? true;
3949

4050
useEffect(() => {
4151
let isMounted = true;
42-
const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged);
52+
const { isTrusted: trusted } = isWorkspaceTrusted(
53+
settingsRef.current.merged,
54+
);
4355

4456
if (trusted === undefined || trusted === false) {
4557
void FolderTrustDiscoveryService.discover(process.cwd())
@@ -56,7 +68,7 @@ export const useFolderTrust = (
5668

5769
const showUntrustedMessage = () => {
5870
if (trusted === false && !startupMessageSent.current) {
59-
addItem(
71+
addItemRef.current(
6072
{
6173
type: MessageType.INFO,
6274
text: 'This folder is untrusted, project settings, hooks, MCPs, and GEMINI.md files will not be applied for this folder.\nUse the `/permissions` command to change the trust level.',
@@ -67,24 +79,17 @@ export const useFolderTrust = (
6779
}
6880
};
6981

70-
if (isHeadlessMode()) {
71-
if (isMounted) {
72-
setIsTrusted(trusted);
73-
setIsFolderTrustDialogOpen(false);
74-
onTrustChange(true);
75-
showUntrustedMessage();
76-
}
77-
} else if (isMounted) {
82+
if (isMounted) {
7883
setIsTrusted(trusted);
79-
setIsFolderTrustDialogOpen(trusted === undefined);
80-
onTrustChange(trusted);
84+
setIsFolderTrustDialogOpen(!isHeadlessMode() && trusted === undefined);
85+
onTrustChangeRef.current(trusted);
8186
showUntrustedMessage();
8287
}
8388

8489
return () => {
8590
isMounted = false;
8691
};
87-
}, [folderTrust, onTrustChange, settings.merged, addItem]);
92+
}, [folderTrust]);
8893

8994
const handleFolderTrustSelect = useCallback(
9095
async (choice: FolderTrustChoice) => {
@@ -118,7 +123,7 @@ export const useFolderTrust = (
118123
trustLevel === TrustLevel.TRUST_FOLDER ||
119124
trustLevel === TrustLevel.TRUST_PARENT;
120125

121-
onTrustChange(currentIsTrusted);
126+
onTrustChangeRef.current(currentIsTrusted);
122127
setIsTrusted(currentIsTrusted);
123128

124129
const wasTrusted = isTrusted ?? false;
@@ -130,7 +135,7 @@ export const useFolderTrust = (
130135
setIsFolderTrustDialogOpen(false);
131136
}
132137
},
133-
[onTrustChange, isTrusted],
138+
[isTrusted],
134139
);
135140

136141
return {

0 commit comments

Comments
 (0)