Skip to content
Merged
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
7 changes: 5 additions & 2 deletions apps/server/src/provider/Drivers/AntigravityDriver.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -91,6 +92,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
};
const authConfigIssue = antigravityAuthConfigIssue(auth);
const processEnvironment = mergeProviderInstanceEnvironment(environment);
const userHome = resolveAntigravityUserHome(yield* HostProcessPlatform, processEnvironment);
const profileDirectory = resolveAntigravityProfileDirectory(
serverConfig.stateDir,
instanceId,
Expand Down Expand Up @@ -146,6 +148,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
profileDirectory,
baseEnv: processEnvironment,
auth,
userHome,
}).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Expand Down Expand Up @@ -376,7 +379,7 @@ export const AntigravityDriver: ProviderDriver<AntigravitySettings, AntigravityD
snapshotForCwd: (cwd) =>
!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)),
Expand Down
70 changes: 62 additions & 8 deletions apps/server/src/provider/Drivers/AntigravitySkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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" },
Expand Down Expand Up @@ -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"),
];
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
});
54 changes: 48 additions & 6 deletions apps/server/src/provider/Drivers/AntigravitySkills.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -118,21 +159,22 @@ 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<ServerProviderSkill>,
AntigravitySkillsProbeError,
FileSystem.FileSystem | Path.Path
> {
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" },
];
Expand Down
37 changes: 37 additions & 0 deletions apps/server/src/provider/antigravityAuthSupport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
61 changes: 61 additions & 0 deletions apps/server/src/provider/antigravityAuthSupport.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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: ";
Expand Down Expand Up @@ -219,19 +225,73 @@ 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<void, never, FileSystem.FileSystem | Path.Path> {
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;
readonly baseEnv?: NodeJS.ProcessEnv;
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);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const runtimeExecutablePath = input.runtimeExecutablePath ?? (yield* HostProcessExecutablePath);
const helperExecutable =
platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath;
Expand Down Expand Up @@ -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;
});

Expand Down
Loading
Loading