Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,14 @@ Would you like to attempt to install via "git clone" instead?`,
extensionId,
ExtensionSettingScope.USER,
);
if (isWorkspaceTrusted(this.settings).isTrusted) {
// Respect `advanced.ignoreLocalEnv`: when set, don't read the
// workspace-local extension settings (.env) at all — this both honors
// the user's intent and avoids touching a file the process may not be
// allowed to read (e.g. EACCES under a sandbox).
if (
!this.settings.advanced?.ignoreLocalEnv &&
isWorkspaceTrusted(this.settings).isTrusted
) {
workspaceSettings = await getScopedEnvContents(
config,
extensionId,
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/config/extensions/extensionSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,46 @@ describe('extensionSettings', () => {
SENSITIVE_VAR: 'workspace-secret',
});
});

it('should skip an unreadable .env (EACCES) instead of throwing', async () => {
const workspaceEnvPath = path.join(
tempWorkspaceDir,
EXTENSION_SETTINGS_FILENAME,
);
await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
// Make the file exist but be unreadable, simulating EACCES under a
// sandbox. chmod has no effect on Windows or when running as root, so we
// only assert the strict EACCES outcome when the file is truly unreadable.
await fsPromises.chmod(workspaceEnvPath, 0o000);
let unreadable = false;
try {
fs.readFileSync(workspaceEnvPath, 'utf-8');
} catch {
unreadable = true;
}

const workspaceKeychain = new KeychainTokenStorage(
`Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
);
await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');

// Must not throw, regardless of whether the .env could be read.
const contents = await getScopedEnvContents(
config,
extensionId,
ExtensionSettingScope.WORKSPACE,
tempWorkspaceDir,
);
await fsPromises.chmod(workspaceEnvPath, 0o644); // restore for cleanup

if (unreadable) {
// The .env read failed and was skipped; keychain secret still resolves.
expect(contents).toEqual({ SENSITIVE_VAR: 'workspace-secret' });
} else {
// Could not simulate EACCES (root/Windows): just ensure no crash.
expect(contents).toHaveProperty('SENSITIVE_VAR', 'workspace-secret');
}
});
});

describe('getEnvContents (merged)', () => {
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/config/extensions/extensionSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,20 @@ export async function getScopedEnvContents(
const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
let customEnv: Record<string, string> = {};
if (fsSync.existsSync(envFilePath)) {
const stat = fsSync.statSync(envFilePath);
if (!stat.isDirectory()) {
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
customEnv = dotenv.parse(envFile);
try {
const stat = fsSync.statSync(envFilePath);
if (!stat.isDirectory()) {
const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
customEnv = dotenv.parse(envFile);
}
} catch (error) {
// The settings file may exist but be unreadable (e.g. EACCES under a
// sandbox where the workspace .env has restricted access). Don't let
// that abort extension loading — skip it and continue with whatever
// else we can resolve (e.g. keychain secrets below).
debugLogger.log(
`Skipping unreadable extension settings at ${envFilePath}: ${error}`,
);
}
}

Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3429,6 +3429,36 @@ MALICIOUS_VAR=allowed-because-trusted
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('env-vertex-project');
});

it('should not crash in Cloud Shell when the .env file exists but is unreadable (EACCES)', () => {
vi.stubEnv('CLOUD_SHELL', 'true');
process.argv = ['node', 'gemini', '-s', 'prompt'];
vi.mocked(isWorkspaceTrusted).mockReturnValue({
isTrusted: true,
source: 'file',
});

// The .env file exists, but reading it fails with EACCES (e.g. a
// sandbox that denies read access). This must not crash startup.
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockImplementation(() => {
const err = new Error(
'EACCES: permission denied',
) as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});

expect(() =>
loadEnvironment(
createMockSettings({ tools: { sandbox: false } }).merged,
MOCK_WORKSPACE_DIR,
),
).not.toThrow();

// Falls back to the Cloud Shell default instead of crashing.
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
});

it('should clear cloudshell-gca when switching to Vertex AI without an original project', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
Expand Down
20 changes: 13 additions & 7 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,14 +632,20 @@ export function setUpCloudShellEnvironment(
}

if (envFilePath && fs.existsSync(envFilePath)) {
const envFileContent = fs.readFileSync(envFilePath);
const parsedEnv = dotenv.parse(envFileContent);
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
// .env file takes precedence in Cloud Shell
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
if (!isTrusted && isSandboxed) {
value = sanitizeEnvVar(value);
try {
const envFileContent = fs.readFileSync(envFilePath);
const parsedEnv = dotenv.parse(envFileContent);
if (parsedEnv['GOOGLE_CLOUD_PROJECT']) {
// .env file takes precedence in Cloud Shell
value = parsedEnv['GOOGLE_CLOUD_PROJECT'];
if (!isTrusted) {
value = sanitizeEnvVar(value);
}
}
} catch {
// The .env file may exist but be unreadable (e.g. EACCES under a
// sandbox). Ignore read/parse errors and fall back to the default,
// matching the resilient handling in `loadEnvironment`.
}
}

Expand Down