diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 80d8586a91b7..65a8c97fe668 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,4 +1,5 @@ import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -49,7 +50,7 @@ import { } from "../ProviderDriver.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { withInstanceIdentity } from "./instanceIdentity.ts"; -import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -91,6 +92,7 @@ export const AntigravityDriver: ProviderDriver !enabled ? provider.snapshot.getSnapshot - : discoverAntigravitySkills({ cwd, profileDirectory }).pipe( + : discoverAntigravitySkills({ cwd, userHome }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index bb3381216d72..180ef9968fac 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; +import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { @@ -24,20 +24,57 @@ const makeWorkspace = Effect.fn("makeWorkspace")(function* () { }); return { cwd: path.join(temporaryDirectory, "workspace"), - profileDirectory: path.join(temporaryDirectory, "profile"), + userHome: path.join(temporaryDirectory, "home"), }; }); it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { + it.effect("does not read user skills from a nested project or from ~/.agents", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const nested = { ...input, cwd: path.join(input.userHome, "AI", "Projects", "Something") }; + const skillPath = yield* writeSkill( + path.join(input.userHome, ".gemini", "config", "skills", "review"), + "---\nname: review\ndescription: Review changes.\n---\n", + ); + yield* writeSkill( + path.join(input.userHome, ".agents", "skills", "ignored"), + "---\nname: ignored\n---\n", + ); + + assert.deepEqual(yield* discoverAntigravitySkills(nested), [ + { + name: "review", + description: "Review changes.", + path: skillPath, + scope: "user", + enabled: true, + }, + ]); + // A project rooted at the home directory sees ~/.agents/skills as its own. + assert.deepEqual( + (yield* discoverAntigravitySkills({ ...input, cwd: input.userHome })).map((skill) => [ + skill.name, + skill.scope, + ]), + [ + ["ignored", "project"], + ["review", "user"], + ], + ); + }), + ); + it.effect("reads skill names, descriptions and paths from the current native roots", () => Effect.gen(function* () { const path = yield* Path.Path; const input = yield* makeWorkspace(); const roots = [ - { directory: path.join(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: path.join(input.userHome, ".gemini", "config", "skills"), scope: "user" }, { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" }, { - directory: path.join(input.profileDirectory, "antigravity-cli", "skills"), + directory: path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), scope: "user", }, { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" }, @@ -91,9 +128,9 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const path = yield* Path.Path; const input = yield* makeWorkspace(); const roots = [ - path.join(input.profileDirectory, "config", "skills"), + path.join(input.userHome, ".gemini", "config", "skills"), path.join(input.cwd, ".gemini", "skills"), - path.join(input.profileDirectory, "antigravity-cli", "skills"), + path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), path.join(input.cwd, ".agents", "skills"), path.join(input.cwd, ".agent", "skills"), ]; @@ -218,7 +255,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const input = yield* makeWorkspace(); const root = path.join(input.cwd, ".agents", "skills"); yield* writeSkill( - path.join(input.profileDirectory, "config", "skills", "review"), + path.join(input.userHome, ".gemini", "config", "skills", "review"), "---\nname: [invalid\n---\n", ); const nativeOrder = [" space-copy", "!-copy", "ΓΈ-copy", "a-copy"]; @@ -247,7 +284,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const input = yield* makeWorkspace(); - const sourceDirectory = path.join(input.profileDirectory, "shared-review"); + const sourceDirectory = path.join(input.userHome, "shared-review"); yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); const root = path.join(input.cwd, ".agents", "skills"); const linkedDirectory = path.join(root, "review"); @@ -304,3 +341,20 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); }); + +it("resolves the home the agent expands ~ against", () => { + assert.equal( + resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "/home/user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), + "C:\\Users\\user", + ); + assert.equal( + resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }), + "D:\\Users\\alice", + ); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b "); + assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true); +}); diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index a8b206a89279..bb238e4e0fa6 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import type { ServerProviderSkill } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -7,6 +9,45 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { parse as parseYamlDocument } from "yaml"; +/** + * The home directory the agent expands `~` against, matching Python's + * `os.path.expanduser` in the launch environment T3 hands the process: + * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` + * elsewhere. Values are used verbatim; a path may contain spaces. + */ +export function resolveAntigravityUserHome( + platform: NodeJS.Platform, + environment: NodeJS.ProcessEnv, +): string { + if (platform === "win32") { + if (environment.USERPROFILE) return environment.USERPROFILE; + if (environment.HOMEDRIVE && environment.HOMEPATH) { + return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; + } + return NodeOS.homedir(); + } + return environment.HOME || NodeOS.homedir(); +} + +/** + * The agent's two user-global skill directories under a Gemini home, in + * native precedence order: `config/skills` is shared with the Antigravity IDE + * and CLI, and `antigravity-cli/skills` is where the `agy` CLI installs + * skills. The agent resolves both under `GEMINI_HOME`, which T3 points at a + * private profile, so the profile links these back to the user's `~/.gemini`. + * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a + * project directory. + */ +export function antigravityUserSkillDirectories( + path: Path.Path, + geminiHome: string, +): readonly [configSkills: string, cliSkills: string] { + return [ + path.join(geminiHome, "config", "skills"), + path.join(geminiHome, "antigravity-cli", "skills"), + ]; +} + const MAX_SKILL_BYTES = 1_000_000; const MAX_SCAN_BYTES = 8_000_000; const MAX_SCAN_ENTRIES = 10_000; @@ -118,7 +159,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( */ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { readonly cwd: string; - readonly profileDirectory: string; + readonly userHome: string; }): Effect.fn.Return< ReadonlyArray, AntigravitySkillsProbeError, @@ -126,13 +167,14 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( > { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + path, + path.join(input.userHome, ".gemini"), + ); const roots = [ - { directory: path.resolve(input.profileDirectory, "config", "skills"), scope: "user" }, + { directory: configSkills, scope: "user" }, { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, - { - directory: path.resolve(input.profileDirectory, "antigravity-cli", "skills"), - scope: "user", - }, + { directory: cliSkills, scope: "user" }, { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, ]; diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 03189018bd31..7f065caa4f6a 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -15,6 +15,7 @@ import * as Ndjson from "effect/unstable/encoding/Ndjson"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as AcpErrors from "effect-acp/errors"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { ANTIGRAVITY_AUTH_BROWSER_MARKER, @@ -510,6 +511,42 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "links the user's global skill directories into the profile without touching real content", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const userHome = path.join(temporaryDirectory, "home"); + const profileDirectory = path.join(temporaryDirectory, "profile"); + const configSkills = path.join(userHome, ".gemini", "config", "skills"); + const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); + yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); + + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + const configLink = path.join(profileDirectory, "config", "skills"); + const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); + expect(yield* fs.readLink(configLink)).toBe(configSkills); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); + // Only the skill directories are shared; the rest of the profile stays private. + expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( + false, + ); + + // A stale link is repointed; a real directory the user placed there is kept. + yield* fs.remove(cliLink); + yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); + yield* fs.remove(configLink); + yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); + expect((yield* fs.stat(configLink)).type).toBe("Directory"); + }), + ); + it.effect("rewrites the GCP block on every launch and never stores the API key", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index dd55df5973ba..8e0040ae0c3e 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -1,4 +1,6 @@ import * as NodeCrypto from "node:crypto"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. +import * as NodeFSP from "node:fs/promises"; // @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. import * as NodePath from "node:path"; @@ -16,6 +18,10 @@ import * as AcpErrors from "effect-acp/errors"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; +import { + antigravityUserSkillDirectories, + resolveAntigravityUserHome, +} from "./Drivers/AntigravitySkills.ts"; export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = "Open the following link to authenticate the ACP server: "; @@ -219,6 +225,56 @@ function antigravityEnvironment( }; } +/** + * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points + * at the private profile. Link the two skill directories back to the user's + * real `~/.gemini` so global skills load, while MCP servers, hooks, and + * credentials stay isolated. Best effort: a link that cannot be made only + * costs global skills, never the session. A real directory at the link path + * is the user's own content and is left alone. + */ +const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { + readonly profileDirectory: string; + readonly userHome: string; + readonly platform: NodeJS.Platform; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const links = antigravityUserSkillDirectories(path, input.profileDirectory); + const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini")); + for (const [link, target] of [ + [links[0], targets[0]], + [links[1], targets[1]], + ] as const) { + yield* Effect.gen(function* () { + const existing = yield* fs.readLink(link).pipe( + Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), + Effect.catch((error) => + error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), + ), + ); + if (existing === target) return; + if (existing !== undefined) { + yield* fs.remove(link); + } + yield* fs.makeDirectory(path.dirname(link), { recursive: true }); + yield* Effect.tryPromise(() => + NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"), + ); + }).pipe( + // A non-symlink at the link path fails `readLink`; anything else is a + // filesystem refusal. Both leave the profile usable. + Effect.catch((error) => + Effect.logWarning("Antigravity user skills are not linked into the profile.", { + link, + target, + error, + }), + ), + ); + } +}); + /** Prepares a private profile without reading or copying Google credentials. */ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { readonly profileDirectory: string; @@ -226,12 +282,16 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( readonly runtimeExecutablePath?: string; readonly platform?: NodeJS.Platform; readonly auth?: AntigravityAuthConfig; + /** Home the agent expands `~` against. Defaults to the launch environment's. */ + readonly userHome?: string; }) { const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const platform = input.platform ?? (yield* HostProcessPlatform); + const userHome = + input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); const helperExecutable = platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; @@ -326,6 +386,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( authSupportError("The Antigravity profile settings could not be written."), ), ); + yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); return profile; }); diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 6d7582aad8d5..ec40c49810dc 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -25,7 +25,9 @@ See the [adapter](../../apps/server/src/provider/Layers/OpenCodeAdapter.ts). Antigravity separates account profiles per instance while sharing installed executables across the environment. It forces file-based credential storage because the native macOS keychain entry would otherwise be shared across instances. The launch environment removes ambient Google credentials, -so an instance cannot silently use another account or billing project. +so an instance cannot silently use another account or billing project. The agent also resolves +its user-global skill directories under that profile, so the profile links those two directories +back to the user's real `~/.gemini`; MCP servers, hooks, and rules there stay out of the profile. See [profile isolation](../../apps/server/src/provider/antigravityAuthSupport.ts). The [Antigravity installer](../../apps/server/src/provider/AntigravityInstallation.ts) outlives diff --git a/docs/user/providers-antigravity.md b/docs/user/providers-antigravity.md index 7466cba309ac..29f9787f1c44 100644 --- a/docs/user/providers-antigravity.md +++ b/docs/user/providers-antigravity.md @@ -88,6 +88,10 @@ legacy `.agent/skills` directory. Among these project locations, the first copy wins in this order: `.gemini/skills`, `.agents/skills`, `.agent/skills`. See [commands and skills](./composer.md#commands-and-skills) for invoking them. +Skills for every project go in `~/.gemini/config/skills` or +`~/.gemini/antigravity-cli/skills`. Antigravity does not read `~/.agents/skills`, +so a skill there only appears when the project itself is your home directory. + Antigravity accepts images, PDFs, text files, and supported audio formats directly. Its limits are 1 MiB per text file, 10 MiB per image, 20 MiB per audio clip, and 50 MiB total attachments per message. Unsupported formats are rejected. These