diff --git a/src/application/GeminiClientSettings.ts b/src/application/GeminiClientSettings.ts index 440ccf9d6e..ae503ccf57 100644 --- a/src/application/GeminiClientSettings.ts +++ b/src/application/GeminiClientSettings.ts @@ -1,4 +1,4 @@ -import { realpath } from "node:fs/promises"; +import { realpath, stat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { stripComments } from "jsonc-parser"; import { z } from "zod"; @@ -149,12 +149,27 @@ export const readClientPolicyBlock = async ( ".gemini", "settings.json", ); - const document = await readConfigurationFile(path, "json", resources); - if (!document.ok) return document; - if (document.value !== undefined) { - const trusted = await workspaceTrusted(settings, sources, resources); - if (!trusted.ok) return trusted; - if (trusted.value) { + // Only ENOENT proves absence; preserve other metadata failures as unreadable. + const present = await stat(path).then( + () => ok(true), + (cause: unknown) => + cause instanceof Error && "code" in cause && cause.code === "ENOENT" + ? ok(false) + : err({ + kind: "unreadable" as const, + path, + detail: `Configuration ${path} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + ); + if (!present.ok) return present; + const includeWorkspace = present.value + ? await workspaceTrusted(settings, sources, resources) + : ok(false); + if (!includeWorkspace.ok) return includeWorkspace; + if (includeWorkspace.value) { + const document = await readConfigurationFile(path, "json", resources); + if (!document.ok) return document; + if (document.value !== undefined) { const source = { path, document: document.value }; const systemIndex = sources.findIndex( (item) => item.path === settings.systemPath, diff --git a/tests/acceptance/setup/geminiWorkspaceTrust.test.ts b/tests/acceptance/setup/geminiWorkspaceTrust.test.ts new file mode 100644 index 0000000000..a1d19c0125 --- /dev/null +++ b/tests/acceptance/setup/geminiWorkspaceTrust.test.ts @@ -0,0 +1,220 @@ +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest, type TestCli } from "../../support/cli/cliFixture.js"; + +const setupArguments = ["setup", "--client", "gemini_cli", "--yes", "--json"]; +const doctorArguments = ["doctor", "--client", "gemini_cli", "--json"]; + +const geminiWorkspace = async (cli: TestCli) => { + const home = await createTestTempDirectory("rea-gemini-workspace-trust-"); + const project = join(home, "project"); + const workspace = join(project, ".gemini/settings.json"); + const user = join(home, ".gemini/settings.json"); + await mkdir(dirname(workspace), { recursive: true }); + const environment: NodeJS.ProcessEnv = { + HOME: home, + USERPROFILE: home, + GEMINI_CLI_HOME: home, + GEMINI_CLI_SYSTEM_SETTINGS_PATH: join(home, "system.json"), + GEMINI_CLI_SYSTEM_DEFAULTS_PATH: join(home, "defaults.json"), + GEMINI_CLI_TRUSTED_FOLDERS_PATH: join(home, "trust.json"), + }; + const initial = await cli.run({ + arguments: setupArguments, + cwd: home, + environment, + }); + expect(initial.exitCode, initial.stdout).toBe(0); + const originalUser = await readFile(user); + const run = (arguments_: readonly string[]) => + cli.run({ arguments: arguments_, cwd: project, environment }); + return { workspace, user, originalUser, environment, run }; +}; + +cliTest( + "Gemini does not require workspace trust until workspace settings exist", + async ({ cli }) => { + const fixture = await geminiWorkspace(cli); + const trust = join(dirname(fixture.user), "trustedFolders.json"); + fixture.environment.GEMINI_CLI_TRUSTED_FOLDERS_PATH = trust; + for (const text of ["{}", "{ not json"]) { + await writeFile(trust, text); + const setup = await fixture.run(setupArguments); + expect(setup.exitCode, setup.stdout).toBe(0); + expect(setup.json).toMatchObject({ status: "ready", appliedActions: [] }); + const doctor = await fixture.run(doctorArguments); + expect(doctor.exitCode, doctor.stdout).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "aligned", + remediation: null, + }), + ]), + }, + }); + await expect(access(fixture.workspace)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(await readFile(trust, "utf8")).toBe(text); + expect(await readFile(fixture.user)).toEqual(fixture.originalUser); + } + + const workspaceText = "{ not json"; + await writeFile(fixture.workspace, workspaceText); + const setup = await fixture.run(setupArguments); + expect(setup.exitCode, setup.stdout).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(trust), + }); + const doctor = await fixture.run(doctorArguments); + expect(doctor.exitCode, doctor.stdout).toBe(1); + expect(doctor.json).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "invalid", + remediation: expect.stringContaining(trust), + }), + ]), + }, + }); + expect(await readFile(fixture.workspace, "utf8")).toBe(workspaceText); + expect(await readFile(trust, "utf8")).toBe("{ not json"); + expect(await readFile(fixture.user)).toEqual(fixture.originalUser); + }, +); + +for (const scenario of [ + { name: "an unlisted workspace", environment: {} }, + { + name: "an explicitly untrusted workspace", + environment: { GEMINI_CLI_TRUST_WORKSPACE: "false" }, + }, + { + name: "a restricted workspace with an explicit trust opt-in", + environment: { + GEMINI_CLI_TRUST_WORKSPACE: "true", + GEMINI_RESTRICTED_MODE: "true", + }, + }, +]) + cliTest( + `Gemini setup and doctor ignore malformed settings in ${scenario.name}`, + async ({ cli }) => { + const fixture = await geminiWorkspace(cli); + Object.assign(fixture.environment, scenario.environment); + const malformed = Buffer.from("{ not json"); + await writeFile(fixture.workspace, malformed); + + const setup = await fixture.run(setupArguments); + expect(setup.exitCode, setup.stdout).toBe(0); + expect(setup.json).toMatchObject({ status: "ready", appliedActions: [] }); + const doctor = await fixture.run(doctorArguments); + expect(doctor.exitCode, doctor.stdout).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "aligned", + remediation: null, + }), + ]), + }, + }); + expect(await readFile(fixture.workspace)).toEqual(malformed); + expect(await readFile(fixture.user)).toEqual(fixture.originalUser); + await expect( + access(`${fixture.workspace}.rea.backup`), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + + if (scenario.name === "an explicitly untrusted workspace") { + const excluded = Buffer.from( + JSON.stringify({ mcp: { excluded: ["rea"] } }), + ); + await writeFile(fixture.workspace, excluded); + const excludedSetup = await fixture.run(setupArguments); + expect(excludedSetup.exitCode, excludedSetup.stdout).toBe(0); + const excludedDoctor = await fixture.run(doctorArguments); + expect(excludedDoctor.exitCode, excludedDoctor.stdout).toBe(0); + expect(excludedDoctor.json).toMatchObject({ + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: "aligned", + }), + ]), + }, + }); + expect(await readFile(fixture.workspace)).toEqual(excluded); + } + }, + ); + +cliTest( + "Gemini still rejects malformed settings and exclusions in a trusted workspace", + async ({ cli }) => { + const fixture = await geminiWorkspace(cli); + fixture.environment.GEMINI_CLI_TRUST_WORKSPACE = "true"; + for (const scenario of [ + { + text: "{ not json", + state: "invalid", + diagnostic: "Invalid JSON/JSONC", + }, + { + text: JSON.stringify({ mcp: { excluded: ["rea"] } }), + state: "stale", + diagnostic: "mcp.excluded", + }, + ]) { + const original = Buffer.from(scenario.text); + await writeFile(fixture.workspace, original); + const setup = await fixture.run(setupArguments); + expect(setup.exitCode, setup.stdout).toBe(1); + expect(setup.json).toMatchObject({ + status: "needs_human", + plannedActions: [], + appliedActions: [], + remediation: expect.stringContaining(scenario.diagnostic), + }); + const doctor = await fixture.run(doctorArguments); + expect(doctor.exitCode, doctor.stdout).toBe(1); + expect(doctor.json).toMatchObject({ + healthy: false, + identity: { + registrations: expect.arrayContaining([ + expect.objectContaining({ + client: "gemini_cli", + state: scenario.state, + remediation: expect.stringContaining(scenario.diagnostic), + }), + ]), + }, + }); + expect(await readFile(fixture.workspace)).toEqual(original); + expect(await readFile(fixture.user)).toEqual(fixture.originalUser); + await expect( + access(`${fixture.workspace}.rea.backup`), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + } + }, +);