| author | |
| committer | |
| log | ab67795dde448a3608d8689f7ad1975bb977e5a9 |
| tree | 2d9a4c31b05c392a86d08be7cdc394326c3a14f2 |
| parent | c8872fd22aadb8d155f1bedc10576d8314f93e56 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>7 files changed, 220 insertions(+), 17 deletions(-)
apps/server/src/provider/Drivers/AntigravityDriver.ts+5-2| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; | 1 | import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; |
| 2 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 2 | import * as Crypto from "effect/Crypto"; | 3 | import * as Crypto from "effect/Crypto"; |
| 3 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| ... | @@ -49,7 +50,7 @@ import { | ... | @@ -49,7 +50,7 @@ import { |
| 49 | } from "../ProviderDriver.ts"; | 50 | } from "../ProviderDriver.ts"; |
| 50 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; | 51 | import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; |
| 51 | import { withInstanceIdentity } from "./instanceIdentity.ts"; | 52 | import { withInstanceIdentity } from "./instanceIdentity.ts"; |
| 52 | import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; | 53 | import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; |
| 53 | 54 | ||
| 54 | const DRIVER = ProviderDriverKind.make("antigravity"); | 55 | const DRIVER = ProviderDriverKind.make("antigravity"); |
| 55 | const decodeSettings = Schema.decodeSync(AntigravitySettings); | 56 | const decodeSettings = Schema.decodeSync(AntigravitySettings); |
| ... | @@ -91,6 +92,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -91,6 +92,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 91 | }; | 92 | }; |
| 92 | const authConfigIssue = antigravityAuthConfigIssue(auth); | 93 | const authConfigIssue = antigravityAuthConfigIssue(auth); |
| 93 | const processEnvironment = mergeProviderInstanceEnvironment(environment); | 94 | const processEnvironment = mergeProviderInstanceEnvironment(environment); |
| 95 | const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment); | ||
| 94 | const profileDirectory = resolveAntigravityProfileDirectory( | 96 | const profileDirectory = resolveAntigravityProfileDirectory( |
| 95 | serverConfig.stateDir, | 97 | serverConfig.stateDir, |
| 96 | instanceId, | 98 | instanceId, |
| ... | @@ -146,6 +148,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -146,6 +148,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 146 | profileDirectory, | 148 | profileDirectory, |
| 147 | baseEnv: processEnvironment, | 149 | baseEnv: processEnvironment, |
| 148 | auth, | 150 | auth, |
| 151 | userHome, | ||
| 149 | }).pipe( | 152 | }).pipe( |
| 150 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 153 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 151 | Effect.provideService(Path.Path, path), | 154 | Effect.provideService(Path.Path, path), |
| ... | @@ -376,7 +379,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD | ... | @@ -376,7 +379,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD |
| 376 | snapshotForCwd: (cwd) => | 379 | snapshotForCwd: (cwd) => |
| 377 | !enabled | 380 | !enabled |
| 378 | ? provider.snapshot.getSnapshot | 381 | ? provider.snapshot.getSnapshot |
| 379 | : discoverAntigravitySkills({ cwd, profileDirectory }).pipe( | 382 | : discoverAntigravitySkills({ cwd, userHome }).pipe( |
| 380 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 383 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 381 | Effect.provideService(Path.Path, path), | 384 | Effect.provideService(Path.Path, path), |
| 382 | Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), | 385 | Effect.flatMap((skills) => provider.snapshotForCwd(cwd, skills)), |
apps/server/src/provider/Drivers/AntigravitySkills.test.ts+62-8| ... | @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Path from "effect/Path"; | 5 | import * as Path from "effect/Path"; |
| 6 | 6 | ||
| 7 | import { discoverAntigravitySkills } from "./AntigravitySkills.ts"; | 7 | import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; |
| 8 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | 8 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 9 | 9 | ||
| 10 | const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { | 10 | const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { |
| ... | @@ -24,20 +24,57 @@ const makeWorkspace = Effect.fn("makeWorkspace")(function* () { | ... | @@ -24,20 +24,57 @@ const makeWorkspace = Effect.fn("makeWorkspace")(function* () { |
| 24 | }); | 24 | }); |
| 25 | return { | 25 | return { |
| 26 | cwd: path.join(temporaryDirectory, "workspace"), | 26 | cwd: path.join(temporaryDirectory, "workspace"), |
| 27 | profileDirectory: path.join(temporaryDirectory, "profile"), | 27 | userHome: path.join(temporaryDirectory, "home"), |
| 28 | }; | 28 | }; |
| 29 | }); | 29 | }); |
| 30 | 30 | ||
| 31 | it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { | 31 | it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { |
| 32 | it.effect("does not read user skills from a nested project or from ~/.agents", () => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const path = yield* Path.Path; | ||
| 35 | const input = yield* makeWorkspace(); | ||
| 36 | const nested = { ...input, cwd: path.join(input.userHome, "AI", "Projects", "Something") }; | ||
| 37 | const skillPath = yield* writeSkill( | ||
| 38 | path.join(input.userHome, ".gemini", "config", "skills", "review"), | ||
| 39 | "---\nname: review\ndescription: Review changes.\n---\n", | ||
| 40 | ); | ||
| 41 | yield* writeSkill( | ||
| 42 | path.join(input.userHome, ".agents", "skills", "ignored"), | ||
| 43 | "---\nname: ignored\n---\n", | ||
| 44 | ); | ||
| 45 | |||
| 46 | assert.deepEqual(yield* discoverAntigravitySkills(nested), [ | ||
| 47 | { | ||
| 48 | name: "review", | ||
| 49 | description: "Review changes.", | ||
| 50 | path: skillPath, | ||
| 51 | scope: "user", | ||
| 52 | enabled: true, | ||
| 53 | }, | ||
| 54 | ]); | ||
| 55 | // A project rooted at the home directory sees ~/.agents/skills as its own. | ||
| 56 | assert.deepEqual( | ||
| 57 | (yield* discoverAntigravitySkills({ ...input, cwd: input.userHome })).map((skill) => [ | ||
| 58 | skill.name, | ||
| 59 | skill.scope, | ||
| 60 | ]), | ||
| 61 | [ | ||
| 62 | ["ignored", "project"], | ||
| 63 | ["review", "user"], | ||
| 64 | ], | ||
| 65 | ); | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | |||
| 32 | it.effect("reads skill names, descriptions and paths from the current native roots", () => | 69 | it.effect("reads skill names, descriptions and paths from the current native roots", () => |
| 33 | Effect.gen(function* () { | 70 | Effect.gen(function* () { |
| 34 | const path = yield* Path.Path; | 71 | const path = yield* Path.Path; |
| 35 | const input = yield* makeWorkspace(); | 72 | const input = yield* makeWorkspace(); |
| 36 | const roots = [ | 73 | const roots = [ |
| 37 | { directory: path.join(input.profileDirectory, "config", "skills"), scope: "user" }, | 74 | { directory: path.join(input.userHome, ".gemini", "config", "skills"), scope: "user" }, |
| 38 | { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" }, | 75 | { directory: path.join(input.cwd, ".gemini", "skills"), scope: "project" }, |
| 39 | { | 76 | { |
| 40 | directory: path.join(input.profileDirectory, "antigravity-cli", "skills"), | 77 | directory: path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), |
| 41 | scope: "user", | 78 | scope: "user", |
| 42 | }, | 79 | }, |
| 43 | { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" }, | 80 | { directory: path.join(input.cwd, ".agents", "skills"), scope: "project" }, |
| ... | @@ -91,9 +128,9 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { | ... | @@ -91,9 +128,9 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { |
| 91 | const path = yield* Path.Path; | 128 | const path = yield* Path.Path; |
| 92 | const input = yield* makeWorkspace(); | 129 | const input = yield* makeWorkspace(); |
| 93 | const roots = [ | 130 | const roots = [ |
| 94 | path.join(input.profileDirectory, "config", "skills"), | 131 | path.join(input.userHome, ".gemini", "config", "skills"), |
| 95 | path.join(input.cwd, ".gemini", "skills"), | 132 | path.join(input.cwd, ".gemini", "skills"), |
| 96 | path.join(input.profileDirectory, "antigravity-cli", "skills"), | 133 | path.join(input.userHome, ".gemini", "antigravity-cli", "skills"), |
| 97 | path.join(input.cwd, ".agents", "skills"), | 134 | path.join(input.cwd, ".agents", "skills"), |
| 98 | path.join(input.cwd, ".agent", "skills"), | 135 | path.join(input.cwd, ".agent", "skills"), |
| 99 | ]; | 136 | ]; |
| ... | @@ -218,7 +255,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { | ... | @@ -218,7 +255,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { |
| 218 | const input = yield* makeWorkspace(); | 255 | const input = yield* makeWorkspace(); |
| 219 | const root = path.join(input.cwd, ".agents", "skills"); | 256 | const root = path.join(input.cwd, ".agents", "skills"); |
| 220 | yield* writeSkill( | 257 | yield* writeSkill( |
| 221 | path.join(input.profileDirectory, "config", "skills", "review"), | 258 | path.join(input.userHome, ".gemini", "config", "skills", "review"), |
| 222 | "---\nname: [invalid\n---\n", | 259 | "---\nname: [invalid\n---\n", |
| 223 | ); | 260 | ); |
| 224 | const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"]; | 261 | const nativeOrder = [" space-copy", "!-copy", "ø-copy", "a-copy"]; |
| ... | @@ -247,7 +284,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { | ... | @@ -247,7 +284,7 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { |
| 247 | const fileSystem = yield* FileSystem.FileSystem; | 284 | const fileSystem = yield* FileSystem.FileSystem; |
| 248 | const path = yield* Path.Path; | 285 | const path = yield* Path.Path; |
| 249 | const input = yield* makeWorkspace(); | 286 | const input = yield* makeWorkspace(); |
| 250 | const sourceDirectory = path.join(input.profileDirectory, "shared-review"); | 287 | const sourceDirectory = path.join(input.userHome, "shared-review"); |
| 251 | yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); | 288 | yield* writeSkill(sourceDirectory, "---\nname: review\n---\n"); |
| 252 | const root = path.join(input.cwd, ".agents", "skills"); | 289 | const root = path.join(input.cwd, ".agents", "skills"); |
| 253 | const linkedDirectory = path.join(root, "review"); | 290 | const linkedDirectory = path.join(root, "review"); |
| ... | @@ -304,3 +341,20 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { | ... | @@ -304,3 +341,20 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { |
| 304 | }), | 341 | }), |
| 305 | ); | 342 | ); |
| 306 | }); | 343 | }); |
| 344 | |||
| 345 | it("resolves the home the agent expands ~ against", () => { | ||
| 346 | assert.equal( | ||
| 347 | resolveAntigravityUserHome("linux", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), | ||
| 348 | "/home/user", | ||
| 349 | ); | ||
| 350 | assert.equal( | ||
| 351 | resolveAntigravityUserHome("win32", { HOME: "/home/user", USERPROFILE: "C:\\Users\\user" }), | ||
| 352 | "C:\\Users\\user", | ||
| 353 | ); | ||
| 354 | assert.equal( | ||
| 355 | resolveAntigravityUserHome("win32", { HOMEDRIVE: "D:", HOMEPATH: "\\Users\\alice" }), | ||
| 356 | "D:\\Users\\alice", | ||
| 357 | ); | ||
| 358 | assert.equal(resolveAntigravityUserHome("darwin", { HOME: "/Users/a b " }), "/Users/a b "); | ||
| 359 | assert.equal(resolveAntigravityUserHome("darwin", { HOME: "" }).length > 0, true); | ||
| 360 | }); |
apps/server/src/provider/Drivers/AntigravitySkills.ts+48-6| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | 3 | import type { ServerProviderSkill } from "@t3tools/contracts"; |
| 2 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| ... | @@ -7,6 +9,45 @@ import * as Schema from "effect/Schema"; | ... | @@ -7,6 +9,45 @@ import * as Schema from "effect/Schema"; |
| 7 | import * as Stream from "effect/Stream"; | 9 | import * as Stream from "effect/Stream"; |
| 8 | import { parse as parseYamlDocument } from "yaml"; | 10 | import { parse as parseYamlDocument } from "yaml"; |
| 9 | 11 | ||
| 12 | /** | ||
| 13 | * The home directory the agent expands `~` against, matching Python's | ||
| 14 | * `os.path.expanduser` in the launch environment T3 hands the process: | ||
| 15 | * `USERPROFILE`, then `HOMEDRIVE` + `HOMEPATH`, on Windows and `HOME` | ||
| 16 | * elsewhere. Values are used verbatim; a path may contain spaces. | ||
| 17 | */ | ||
| 18 | export function resolveAntigravityUserHome( | ||
| 19 | platform: NodeJS.Platform, | ||
| 20 | environment: NodeJS.ProcessEnv, | ||
| 21 | ): string { | ||
| 22 | if (platform === "win32") { | ||
| 23 | if (environment.USERPROFILE) return environment.USERPROFILE; | ||
| 24 | if (environment.HOMEDRIVE && environment.HOMEPATH) { | ||
| 25 | return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; | ||
| 26 | } | ||
| 27 | return NodeOS.homedir(); | ||
| 28 | } | ||
| 29 | return environment.HOME || NodeOS.homedir(); | ||
| 30 | } | ||
| 31 | |||
| 32 | /** | ||
| 33 | * The agent's two user-global skill directories under a Gemini home, in | ||
| 34 | * native precedence order: `config/skills` is shared with the Antigravity IDE | ||
| 35 | * and CLI, and `antigravity-cli/skills` is where the `agy` CLI installs | ||
| 36 | * skills. The agent resolves both under `GEMINI_HOME`, which T3 points at a | ||
| 37 | * private profile, so the profile links these back to the user's `~/.gemini`. | ||
| 38 | * `~/.agents/skills` is not read: the agent only treats `.agents/skills` as a | ||
| 39 | * project directory. | ||
| 40 | */ | ||
| 41 | export function antigravityUserSkillDirectories( | ||
| 42 | path: Path.Path, | ||
| 43 | geminiHome: string, | ||
| 44 | ): readonly [configSkills: string, cliSkills: string] { | ||
| 45 | return [ | ||
| 46 | path.join(geminiHome, "config", "skills"), | ||
| 47 | path.join(geminiHome, "antigravity-cli", "skills"), | ||
| 48 | ]; | ||
| 49 | } | ||
| 50 | |||
| 10 | const MAX_SKILL_BYTES = 1_000_000; | 51 | const MAX_SKILL_BYTES = 1_000_000; |
| 11 | const MAX_SCAN_BYTES = 8_000_000; | 52 | const MAX_SCAN_BYTES = 8_000_000; |
| 12 | const MAX_SCAN_ENTRIES = 10_000; | 53 | const MAX_SCAN_ENTRIES = 10_000; |
| ... | @@ -118,7 +159,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( | ... | @@ -118,7 +159,7 @@ const readSkill = Effect.fn("readAntigravitySkill")(function* ( |
| 118 | */ | 159 | */ |
| 119 | export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { | 160 | export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { |
| 120 | readonly cwd: string; | 161 | readonly cwd: string; |
| 121 | readonly profileDirectory: string; | 162 | readonly userHome: string; |
| 122 | }): Effect.fn.Return< | 163 | }): Effect.fn.Return< |
| 123 | ReadonlyArray<ServerProviderSkill>, | 164 | ReadonlyArray<ServerProviderSkill>, |
| 124 | AntigravitySkillsProbeError, | 165 | AntigravitySkillsProbeError, |
| ... | @@ -126,13 +167,14 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( | ... | @@ -126,13 +167,14 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( |
| 126 | > { | 167 | > { |
| 127 | const fileSystem = yield* FileSystem.FileSystem; | 168 | const fileSystem = yield* FileSystem.FileSystem; |
| 128 | const path = yield* Path.Path; | 169 | const path = yield* Path.Path; |
| 170 | const [configSkills, cliSkills] = antigravityUserSkillDirectories( | ||
| 171 | path, | ||
| 172 | path.join(input.userHome, ".gemini"), | ||
| 173 | ); | ||
| 129 | const roots = [ | 174 | const roots = [ |
| 130 | { directory: path.resolve(input.profileDirectory, "config", "skills"), scope: "user" }, | 175 | { directory: configSkills, scope: "user" }, |
| 131 | { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, | 176 | { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, |
| 132 | { | 177 | { directory: cliSkills, scope: "user" }, |
| 133 | directory: path.resolve(input.profileDirectory, "antigravity-cli", "skills"), | ||
| 134 | scope: "user", | ||
| 135 | }, | ||
| 136 | { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, | 178 | { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, |
| 137 | { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, | 179 | { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, |
| 138 | ]; | 180 | ]; |
apps/server/src/provider/antigravityAuthSupport.test.ts+37| ... | @@ -15,6 +15,7 @@ import * as Ndjson from "effect/unstable/encoding/Ndjson"; | ... | @@ -15,6 +15,7 @@ import * as Ndjson from "effect/unstable/encoding/Ndjson"; |
| 15 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | 15 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 16 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 16 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| 17 | import * as AcpErrors from "effect-acp/errors"; | 17 | import * as AcpErrors from "effect-acp/errors"; |
| 18 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | ||
| 18 | 19 | ||
| 19 | import { | 20 | import { |
| 20 | ANTIGRAVITY_AUTH_BROWSER_MARKER, | 21 | ANTIGRAVITY_AUTH_BROWSER_MARKER, |
| ... | @@ -510,6 +511,42 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { | ... | @@ -510,6 +511,42 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { |
| 510 | }), | 511 | }), |
| 511 | ); | 512 | ); |
| 512 | 513 | ||
| 514 | it.effect.skipIf(!symlinksSupported)( | ||
| 515 | "links the user's global skill directories into the profile without touching real content", | ||
| 516 | () => | ||
| 517 | Effect.gen(function* () { | ||
| 518 | const fs = yield* FileSystem.FileSystem; | ||
| 519 | const path = yield* Path.Path; | ||
| 520 | const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); | ||
| 521 | const userHome = path.join(temporaryDirectory, "home"); | ||
| 522 | const profileDirectory = path.join(temporaryDirectory, "profile"); | ||
| 523 | const configSkills = path.join(userHome, ".gemini", "config", "skills"); | ||
| 524 | const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); | ||
| 525 | yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); | ||
| 526 | |||
| 527 | yield* prepareAntigravityProfile({ profileDirectory, userHome }); | ||
| 528 | const configLink = path.join(profileDirectory, "config", "skills"); | ||
| 529 | const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); | ||
| 530 | expect(yield* fs.readLink(configLink)).toBe(configSkills); | ||
| 531 | expect(yield* fs.readLink(cliLink)).toBe(cliSkills); | ||
| 532 | expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); | ||
| 533 | // Only the skill directories are shared; the rest of the profile stays private. | ||
| 534 | expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( | ||
| 535 | false, | ||
| 536 | ); | ||
| 537 | |||
| 538 | // A stale link is repointed; a real directory the user placed there is kept. | ||
| 539 | yield* fs.remove(cliLink); | ||
| 540 | yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); | ||
| 541 | yield* fs.remove(configLink); | ||
| 542 | yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); | ||
| 543 | yield* prepareAntigravityProfile({ profileDirectory, userHome }); | ||
| 544 | expect(yield* fs.readLink(cliLink)).toBe(cliSkills); | ||
| 545 | expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); | ||
| 546 | expect((yield* fs.stat(configLink)).type).toBe("Directory"); | ||
| 547 | }), | ||
| 548 | ); | ||
| 549 | |||
| 513 | it.effect("rewrites the GCP block on every launch and never stores the API key", () => | 550 | it.effect("rewrites the GCP block on every launch and never stores the API key", () => |
| 514 | Effect.gen(function* () { | 551 | Effect.gen(function* () { |
| 515 | const fs = yield* FileSystem.FileSystem; | 552 | const fs = yield* FileSystem.FileSystem; |
apps/server/src/provider/antigravityAuthSupport.ts+61| ... | @@ -1,4 +1,6 @@ | ... | @@ -1,4 +1,6 @@ |
| 1 | import * as NodeCrypto from "node:crypto"; | 1 | import * as NodeCrypto from "node:crypto"; |
| 2 | // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. | ||
| 3 | import * as NodeFSP from "node:fs/promises"; | ||
| 2 | // @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. | 4 | // @effect-diagnostics-next-line nodeBuiltinImport:off - resolveAntigravityProfileDirectory is a pure sync helper, so it cannot use the Path service. |
| 3 | import * as NodePath from "node:path"; | 5 | import * as NodePath from "node:path"; |
| 4 | 6 | ||
| ... | @@ -16,6 +18,10 @@ import * as AcpErrors from "effect-acp/errors"; | ... | @@ -16,6 +18,10 @@ import * as AcpErrors from "effect-acp/errors"; |
| 16 | 18 | ||
| 17 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | 19 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; |
| 18 | import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; | 20 | import type { AcpSpawnInput } from "./acp/AcpSessionRuntime.ts"; |
| 21 | import { | ||
| 22 | antigravityUserSkillDirectories, | ||
| 23 | resolveAntigravityUserHome, | ||
| 24 | } from "./Drivers/AntigravitySkills.ts"; | ||
| 19 | 25 | ||
| 20 | export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = | 26 | export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = |
| 21 | "Open the following link to authenticate the ACP server: "; | 27 | "Open the following link to authenticate the ACP server: "; |
| ... | @@ -219,6 +225,56 @@ function antigravityEnvironment( | ... | @@ -219,6 +225,56 @@ function antigravityEnvironment( |
| 219 | }; | 225 | }; |
| 220 | } | 226 | } |
| 221 | 227 | ||
| 228 | /** | ||
| 229 | * The agent reads its user-global skills under `GEMINI_HOME`, which T3 points | ||
| 230 | * at the private profile. Link the two skill directories back to the user's | ||
| 231 | * real `~/.gemini` so global skills load, while MCP servers, hooks, and | ||
| 232 | * credentials stay isolated. Best effort: a link that cannot be made only | ||
| 233 | * costs global skills, never the session. A real directory at the link path | ||
| 234 | * is the user's own content and is left alone. | ||
| 235 | */ | ||
| 236 | const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: { | ||
| 237 | readonly profileDirectory: string; | ||
| 238 | readonly userHome: string; | ||
| 239 | readonly platform: NodeJS.Platform; | ||
| 240 | }): Effect.fn.Return<void, never, FileSystem.FileSystem | Path.Path> { | ||
| 241 | const fs = yield* FileSystem.FileSystem; | ||
| 242 | const path = yield* Path.Path; | ||
| 243 | const links = antigravityUserSkillDirectories(path, input.profileDirectory); | ||
| 244 | const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini")); | ||
| 245 | for (const [link, target] of [ | ||
| 246 | [links[0], targets[0]], | ||
| 247 | [links[1], targets[1]], | ||
| 248 | ] as const) { | ||
| 249 | yield* Effect.gen(function* () { | ||
| 250 | const existing = yield* fs.readLink(link).pipe( | ||
| 251 | Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), | ||
| 252 | Effect.catch((error) => | ||
| 253 | error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), | ||
| 254 | ), | ||
| 255 | ); | ||
| 256 | if (existing === target) return; | ||
| 257 | if (existing !== undefined) { | ||
| 258 | yield* fs.remove(link); | ||
| 259 | } | ||
| 260 | yield* fs.makeDirectory(path.dirname(link), { recursive: true }); | ||
| 261 | yield* Effect.tryPromise(() => | ||
| 262 | NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"), | ||
| 263 | ); | ||
| 264 | }).pipe( | ||
| 265 | // A non-symlink at the link path fails `readLink`; anything else is a | ||
| 266 | // filesystem refusal. Both leave the profile usable. | ||
| 267 | Effect.catch((error) => | ||
| 268 | Effect.logWarning("Antigravity user skills are not linked into the profile.", { | ||
| 269 | link, | ||
| 270 | target, | ||
| 271 | error, | ||
| 272 | }), | ||
| 273 | ), | ||
| 274 | ); | ||
| 275 | } | ||
| 276 | }); | ||
| 277 | |||
| 222 | /** Prepares a private profile without reading or copying Google credentials. */ | 278 | /** Prepares a private profile without reading or copying Google credentials. */ |
| 223 | export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { | 279 | export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: { |
| 224 | readonly profileDirectory: string; | 280 | readonly profileDirectory: string; |
| ... | @@ -226,12 +282,16 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( | ... | @@ -226,12 +282,16 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( |
| 226 | readonly runtimeExecutablePath?: string; | 282 | readonly runtimeExecutablePath?: string; |
| 227 | readonly platform?: NodeJS.Platform; | 283 | readonly platform?: NodeJS.Platform; |
| 228 | readonly auth?: AntigravityAuthConfig; | 284 | readonly auth?: AntigravityAuthConfig; |
| 285 | /** Home the agent expands `~` against. Defaults to the launch environment's. */ | ||
| 286 | readonly userHome?: string; | ||
| 229 | }) { | 287 | }) { |
| 230 | const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; | 288 | const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH; |
| 231 | const fs = yield* FileSystem.FileSystem; | 289 | const fs = yield* FileSystem.FileSystem; |
| 232 | const path = yield* Path.Path; | 290 | const path = yield* Path.Path; |
| 233 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 291 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 234 | const platform = input.platform ?? (yield* HostProcessPlatform); | 292 | const platform = input.platform ?? (yield* HostProcessPlatform); |
| 293 | const userHome = | ||
| 294 | input.userHome ?? resolveAntigravityUserHome(platform, input.baseEnv ?? process.env); | ||
| 235 | const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); | 295 | const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath); |
| 236 | const helperExecutable = | 296 | const helperExecutable = |
| 237 | platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; | 297 | platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath; |
| ... | @@ -326,6 +386,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( | ... | @@ -326,6 +386,7 @@ export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")( |
| 326 | authSupportError("The Antigravity profile settings could not be written."), | 386 | authSupportError("The Antigravity profile settings could not be written."), |
| 327 | ), | 387 | ), |
| 328 | ); | 388 | ); |
| 389 | yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform }); | ||
| 329 | return profile; | 390 | return profile; |
| 330 | }); | 391 | }); |
| 331 | 392 |
docs/internals/providers.md+3-1| ... | @@ -25,7 +25,9 @@ See the [adapter](../../apps/server/src/provider/Layers/OpenCodeAdapter.ts). | ... | @@ -25,7 +25,9 @@ See the [adapter](../../apps/server/src/provider/Layers/OpenCodeAdapter.ts). |
| 25 | Antigravity separates account profiles per instance while sharing installed executables across the | 25 | Antigravity separates account profiles per instance while sharing installed executables across the |
| 26 | environment. It forces file-based credential storage because the native macOS keychain entry would | 26 | environment. It forces file-based credential storage because the native macOS keychain entry would |
| 27 | otherwise be shared across instances. The launch environment removes ambient Google credentials, | 27 | otherwise be shared across instances. The launch environment removes ambient Google credentials, |
| 28 | so an instance cannot silently use another account or billing project. | 28 | so an instance cannot silently use another account or billing project. The agent also resolves |
| 29 | its user-global skill directories under that profile, so the profile links those two directories | ||
| 30 | back to the user's real `~/.gemini`; MCP servers, hooks, and rules there stay out of the profile. | ||
| 29 | See [profile isolation](../../apps/server/src/provider/antigravityAuthSupport.ts). | 31 | See [profile isolation](../../apps/server/src/provider/antigravityAuthSupport.ts). |
| 30 | 32 | ||
| 31 | The [Antigravity installer](../../apps/server/src/provider/AntigravityInstallation.ts) outlives | 33 | The [Antigravity installer](../../apps/server/src/provider/AntigravityInstallation.ts) outlives |
docs/user/providers-antigravity.md+4| ... | @@ -88,6 +88,10 @@ legacy `.agent/skills` directory. Among these project locations, the first copy | ... | @@ -88,6 +88,10 @@ legacy `.agent/skills` directory. Among these project locations, the first copy |
| 88 | wins in this order: `.gemini/skills`, `.agents/skills`, `.agent/skills`. See | 88 | wins in this order: `.gemini/skills`, `.agents/skills`, `.agent/skills`. See |
| 89 | [commands and skills](./composer.md#commands-and-skills) for invoking them. | 89 | [commands and skills](./composer.md#commands-and-skills) for invoking them. |
| 90 | 90 | ||
| 91 | Skills for every project go in `~/.gemini/config/skills` or | ||
| 92 | `~/.gemini/antigravity-cli/skills`. Antigravity does not read `~/.agents/skills`, | ||
| 93 | so a skill there only appears when the project itself is your home directory. | ||
| 94 | |||
| 91 | Antigravity accepts images, PDFs, text files, and supported audio formats directly. | 95 | Antigravity accepts images, PDFs, text files, and supported audio formats directly. |
| 92 | Its limits are 1 MiB per text file, 10 MiB per image, 20 MiB per audio clip, and | 96 | Its limits are 1 MiB per text file, 10 MiB per image, 20 MiB per audio clip, and |
| 93 | 50 MiB total attachments per message. Unsupported formats are rejected. These | 97 | 50 MiB total attachments per message. Unsupported formats are rejected. These |