diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index b281ecc16ab84..d9d2767db4890 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -36,6 +36,7 @@ import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSession import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; import { normalizeAntigravitySessionUpdate } from "../../provider/acp/AntigravityProtocol.ts"; import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE } from "../../provider/antigravityAuthSupport.ts"; +import { prepareAntigravityPluginSkills } from "../../provider/Drivers/AntigravitySkills.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import { @@ -292,6 +293,20 @@ describe("AntigravityAdapterV2 client file system", () => { const requestLog = path.join(logs, "requests.ndjson"); const crypto = yield* SecureRandom.SecureRandom; const instanceId = ProviderInstanceId.make("antigravity-containment-test"); + const userHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-home-", + }); + const plugin = path.join(userHome, ".gemini", "config", "plugins", "alpha"); + const pluginSkill = path.join(plugin, "skills", "review", "SKILL.md"); + const pluginResource = path.join(path.dirname(pluginSkill), "checklist.md"); + yield* fileSystem.makeDirectory(path.dirname(pluginSkill), { recursive: true }); + yield* fileSystem.writeFileString(pluginSkill, "---\nname: review\n---\nUse checklist.md\n"); + yield* fileSystem.writeFileString(pluginResource, "checklist"); + yield* fileSystem.writeFileString(path.join(plugin, "plugin.json"), "{}"); + const clientFileRoots = yield* prepareAntigravityPluginSkills({ + userHome, + runtimeTempDirectory: yield* fileSystem.makeTempDirectoryScoped(), + }); const adapter = yield* makeAntigravityAdapterV2({ instanceId, selfInvocation: yield* resolveSelfInvocation(), @@ -307,13 +322,16 @@ describe("AntigravityAdapterV2 client file system", () => { }, }).pipe( Effect.provideService(SecureRandom.SecureRandom, crypto), - Effect.map((runtime): RuntimeService => ({ + Effect.map((runtime) => ({ ...runtime, - handleReadTextFile: (handler) => + clientFileRoots, + handleReadTextFile: (handler: Parameters[0]) => Effect.sync(() => { readTextFile = handler; }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))), - handleWriteTextFile: (handler) => + handleWriteTextFile: ( + handler: Parameters[0], + ) => Effect.sync(() => { writeTextFile = handler; }).pipe(Effect.andThen(runtime.handleWriteTextFile(handler))), @@ -416,6 +434,19 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/read_text_file"), ); assert.equal(viaInsideLink.content, "inside"); + + const wrapper = path.join(clientFileRoots[0]!, ".agents", "skills", "0", "SKILL.md"); + for (const [file, expected] of [ + [wrapper, JSON.stringify(pluginSkill)], + [pluginSkill, "Use checklist.md"], + [pluginResource, "checklist"], + ] as const) { + const read = yield* readTextFile( + { sessionId: "mock-session-1", path: file }, + context("fs/read_text_file"), + ); + assert.include(read.content, expected); + } }).pipe(Effect.provide(layerSession)), ); }); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index b6f746269a6f2..2b2df0e13784c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -76,7 +76,10 @@ export interface AntigravityAdapterV2Options { readonly makeRuntime: ( input: Omit, ) => Effect.Effect< - AcpSessionRuntime.AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"] & { + /** Session-local roots outside the workspace, such as prepared plugin skills. */ + readonly clientFileRoots?: ReadonlyArray; + }, EffectAcpErrors.AcpError | ProviderSetupError, Scope.Scope >; @@ -161,6 +164,16 @@ export function makeAntigravityAcpAdapterFlavor( handleReadTextFile: (handler) => runtime.handleReadTextFile((request, context) => handler(request, context).pipe( + Effect.catch((cause) => + runtime.clientFileRoots?.length + ? readAntigravityClientTextFile({ + fileSystem: options.fileSystem, + path: options.path, + allowedRoots: runtime.clientFileRoots, + request, + }).pipe(Effect.mapError(() => cause)) + : Effect.fail(cause), + ), Effect.onExit((exit) => { const served = reads.attempted(request.path, Exit.isSuccess(exit)); if (served === undefined || projectUpdate === undefined) return Effect.void; diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 447745fc673a0..3d4c299931fbe 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -66,6 +66,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const nodePath = yield* HostProcess.ExecutablePath; const baseEnv = yield* HostProcess.Environment; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-driver-" }); + const userHome = path.join(root, "home"); const instanceId = ProviderInstanceId.make(path.basename(root)); const mockAgentPath = yield* path.fromFileUrl( new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), @@ -211,6 +212,8 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( enabled: options.enabled ?? true, config: { ...AntigravityDriver.defaultConfig(), ...options.config }, environment: [ + { name: "HOME", value: userHome }, + { name: "USERPROFILE", value: userHome }, { name: "PATH", value: instancePath }, { name: "T3_ACP_ANTIGRAVITY", value: "1" }, { name: "T3_ACP_REQUEST_LOG_PATH", value: requestLog }, @@ -245,6 +248,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( }); return { instance, + userHome, refresh, makeExecutable, fs, @@ -297,10 +301,13 @@ const layerTest = ProviderHostLive.layer.pipe( it.layer(layerTest)("AntigravityDriver", (it) => { it.effect.skipIf(windowsHost)( - "refreshes the selected executable and limits form capabilities to chat", + "refreshes the selected executable despite plugin preparation failure and limits form capabilities to chat", () => Effect.gen(function* () { const h = yield* makeHarness(); + const plugin = h.path.join(h.userHome, ".gemini", "config", "plugins", "broken"); + yield* h.fs.makeDirectory(plugin, { recursive: true }); + yield* h.fs.writeFileString(h.path.join(plugin, "plugin.json"), "x".repeat(1_000_001)); yield* h.refresh(); const snapshot = yield* h.instance.snapshot.getSnapshot; expect(snapshot.status).toBe("ready"); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 75bca5acc87be..0b2ef5deadcdb 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -72,7 +72,11 @@ import { } from "@t3tools/provider-core/server/driver"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { discoverAntigravitySkills, resolveAntigravityUserHome } from "./AntigravitySkills.ts"; +import { + discoverAntigravitySkills, + prepareAntigravityPluginSkills, + resolveAntigravityUserHome, +} from "./AntigravitySkills.ts"; const DRIVER = ProviderDriverKind.make("antigravity"); const decodeSettings = Schema.decodeSync(AntigravitySettings); @@ -200,7 +204,9 @@ export const AntigravityDriver: ProviderDriver< const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( input: Omit, ): Effect.fn.Return< - AcpSessionRuntime.AcpSessionRuntime["Service"], + AcpSessionRuntime.AcpSessionRuntime["Service"] & { + readonly clientFileRoots: ReadonlyArray; + }, AcpErrors.AcpError | ProviderSetupError, Scope.Scope > { @@ -267,8 +273,21 @@ export const AntigravityDriver: ProviderDriver< ), ), ); + const pluginDirectories = yield* prepareAntigravityPluginSkills({ + userHome, + runtimeTempDirectory, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.catch((cause) => + Effect.logWarning("Antigravity plugin skills are unavailable.", { + errorTag: cause._tag, + }).pipe(Effect.as([])), + ), + ); const runtime = yield* makeAntigravityAcpRuntime({ ...input, + additionalDirectories: [...(input.additionalDirectories ?? []), ...pluginDirectories], runtimeVersion: executable.version, authMethod: auth.authMethod, childProcessSpawner: spawner, @@ -283,6 +302,7 @@ export const AntigravityDriver: ProviderDriver< }).pipe(Effect.provideService(SecureRandom.SecureRandom, random)); return { ...runtime, + clientFileRoots: pluginDirectories, ...(executable.harnessPath === null ? { setMode: (modeId: string) => runtime.setMode(antigravityCliSessionMode(modeId)) } : {}), diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts index c81b38a00dbcb..1f3bced88ebc0 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.test.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.test.ts @@ -1,10 +1,11 @@ +import { matchInlineSkills } from "@t3tools/shared/inlineSkills"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; 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, prepareAntigravityPluginSkills } from "./AntigravitySkills.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const writeSkill = Effect.fn("writeSkill")(function* (directory: string, contents: string) { @@ -93,6 +94,114 @@ it.layer(NodeServices.layer)("discoverAntigravitySkills", (it) => { }), ); + it.effect( + "reads enabled plugin skills without merging same-name skills from different plugins", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const config = path.join(input.userHome, ".gemini", "config"); + const expected = []; + for (const name of ["alpha", "beta", "broken", "off", "disabled", "unregistered"]) { + const plugin = path.join(config, "plugins", name); + const skillPath = yield* writeSkill( + path.join(plugin, "skills", "review"), + "---\nname: review\ndescription: Review changes.\n---\n", + ); + if (name !== "unregistered") { + yield* fileSystem.writeFileString( + path.join(plugin, "plugin.json"), + name === "off" || name === "broken" + ? "{ invalid" + : JSON.stringify({ + name: `--- Display ${name} ---`, + disabled: name === "beta" || name === "disabled", + }), + ); + } + if (name === "alpha" || name === "beta") { + expected.push({ + name: `plugin:${name}:review`, + displayName: `${name}:review`, + description: "Review changes.", + path: skillPath, + scope: "user", + enabled: true, + }); + } + } + yield* fileSystem.writeFileString( + path.join(config, "config.json"), + JSON.stringify({ plugins: { beta: { enabled: true }, off: { enabled: false } } }), + ); + for (const [directory, scope] of [ + [path.join(input.cwd, ".agents", "skills"), "project"], + [path.join(input.userHome, ".gemini", "config", "skills"), "user"], + ] as const) { + const name = `standalone-${scope}`; + const skillPath = yield* writeSkill( + path.join(directory, name), + `---\nname: ${name}\n---\n`, + ); + expected.push({ name, path: skillPath, scope, enabled: true }); + } + assert.deepEqual(yield* discoverAntigravitySkills(input), expected); + const directories = yield* prepareAntigravityPluginSkills({ + userHome: input.userHome, + runtimeTempDirectory: yield* fileSystem.makeTempDirectoryScoped(), + }); + const nativeSkills = yield* discoverAntigravitySkills({ + cwd: directories[0]!, + userHome: path.join(input.userHome, "empty"), + }); + assert.deepEqual( + nativeSkills.map((skill) => skill.name), + ["plugin:alpha:review", "plugin:beta:review"], + ); + for (const [index, skill] of nativeSkills.entries()) { + assert.include( + yield* fileSystem.readFileString(skill.path), + JSON.stringify(expected[index]!.path), + ); + assert.equal( + directories[index + 1], + path.dirname(path.dirname(path.dirname(expected[index]!.path))), + ); + assert.include(yield* fileSystem.readFileString(expected[index]!.path), "name: review\n"); + } + for (const contents of ["{ invalid", '{"plugins":{"alpha":{"enabled":"yes"}}}']) { + yield* fileSystem.writeFileString(path.join(config, "config.json"), contents); + assert.deepEqual(yield* discoverAntigravitySkills(input), expected.slice(2)); + } + }), + ); + + it.effect("keeps punctuation and prefix variants distinct and invocable", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const input = yield* makeWorkspace(); + const directories = ["foo.bar", "foo_bar", "_alpha", "plugin_alpha", "alpha"]; + for (const name of directories) { + const plugin = path.join(input.userHome, ".gemini", "config", "plugins", name); + yield* writeSkill(path.join(plugin, "skills", "review"), "---\nname: review\n---\n"); + yield* fileSystem.writeFileString(path.join(plugin, "plugin.json"), "{}"); + } + const skills = yield* discoverAntigravitySkills(input); + assert.lengthOf(skills, directories.length); + assert.equal( + new Set(skills.map((skill) => skill.name.toLowerCase())).size, + directories.length, + ); + for (const skill of skills) { + const matches = [...matchInlineSkills("$" + skill.name, skills)]; + assert.lengthOf(matches, 1); + assert.equal(matches[0]?.skill.path, skill.path); + } + }), + ); + it.effect("uses native root order for duplicate names", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index 96e1fe61109f4..fe69ac6d97e6e 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -162,32 +162,17 @@ const readSkillFrontmatter = Effect.fn("readAntigravitySkill")(function* ( return bytes.toString("utf8", 0, frontmatterEnd); }); -/** Keep read errors typed so workspace snapshots cannot cache partial results. */ -export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { - readonly cwd: string; - readonly userHome: string; -}): Effect.fn.Return< - ReadonlyArray, - AntigravitySkillsProbeError, - FileSystem.FileSystem | Path.Path -> { +interface SkillRoot { + readonly directory: string; + readonly scope: string; +} + +const scanSkillDirectories = Effect.fn("scanAntigravitySkillDirectories")(function* ( + roots: ReadonlyArray, + budget: ScanBudget, +) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const [configSkills, cliSkills] = antigravityUserSkillDirectories( - path, - path.join(input.userHome, ".gemini"), - ); - const roots = [ - { directory: configSkills, scope: "user" }, - { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, - { directory: cliSkills, scope: "user" }, - { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, - { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, - ]; - const budget: ScanBudget = { - remainingBytes: MAX_SCAN_BYTES, - remainingEntries: MAX_SCAN_ENTRIES, - }; const skillsByName = new Map(); const scanDirectory = Effect.fn("scanAntigravitySkillDirectory")(function* ( @@ -241,3 +226,175 @@ export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")( } return [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)); }); + +const readJsonFile = Effect.fn("readAntigravityPluginConfig")(function* ( + filePath: string, + budget: ScanBudget, +) { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* readIfPresent(fileSystem.stat(filePath), filePath); + if (info?.type !== "File") return undefined; + if (info.size > BigInt(Math.min(MAX_SKILL_BYTES, budget.remainingBytes))) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: filePath, + }); + } + const contents = yield* readIfPresent(fileSystem.readFileString(filePath), filePath); + if (contents === undefined) return undefined; + budget.remainingBytes -= Buffer.byteLength(contents); + return contents; +}); + +const PluginManifest = Schema.fromJsonString( + Schema.Struct({ disabled: Schema.optional(Schema.Boolean) }), +); +const PluginPreferences = Schema.fromJsonString( + Schema.Struct({ + plugins: Schema.optional( + Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.optional(Schema.Boolean) })), + ), + }), +); + +const decodePluginManifest = Schema.decodeEffect(PluginManifest); +const decodePluginPreferences = Schema.decodeEffect(PluginPreferences); +const encodeString = Schema.encodeSync(Schema.fromJsonString(Schema.String)); + +const discoverAntigravityPlugins = Effect.fn("discoverAntigravityPlugins")(function* ( + userHome: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = path.join(userHome, ".gemini", "config"); + const directory = path.join(config, "plugins"); + const entries = yield* readIfPresent(fileSystem.readDirectory(directory), directory); + if (entries === undefined) return []; + const budget: ScanBudget = { + remainingBytes: MAX_SCAN_BYTES, + remainingEntries: MAX_SCAN_ENTRIES, + }; + if (entries.length > budget.remainingEntries) { + return yield* new AntigravitySkillsProbeError({ + reason: "scan-budget-exhausted", + path: directory, + }); + } + budget.remainingEntries -= entries.length; + const preferencesPath = path.join(config, "config.json"); + const preferencesContents = yield* readJsonFile(preferencesPath, budget); + const preferences = + preferencesContents === undefined + ? { plugins: {} } + : yield* decodePluginPreferences(preferencesContents).pipe( + Effect.catchTags({ + SchemaError: () => + Effect.logWarning("Antigravity plugin preferences are invalid.", { + errorTag: "invalid-config", + }).pipe(Effect.as(undefined)), + }), + ); + if (preferences === undefined) return []; + const plugins = []; + for (const entry of entries.toSorted()) { + const pluginDirectory = path.join(directory, entry); + if ( + (yield* readIfPresent(fileSystem.stat(pluginDirectory), pluginDirectory))?.type !== + "Directory" + ) + continue; + const enabled = preferences.plugins?.[entry]?.enabled; + if (enabled === false) continue; + const manifestPath = path.join(pluginDirectory, "plugin.json"); + const contents = yield* readJsonFile(manifestPath, budget); + if (contents === undefined) continue; + const manifest = yield* decodePluginManifest(contents).pipe( + Effect.catchTags({ + SchemaError: () => + Effect.logWarning("Skipped an Antigravity plugin with an invalid manifest.", { + errorTag: "invalid-config", + }).pipe(Effect.as(undefined)), + }), + ); + if (manifest === undefined) continue; + if (manifest.disabled && enabled !== true) continue; + const skills = yield* scanSkillDirectories( + [{ directory: path.join(pluginDirectory, "skills"), scope: "user" }], + budget, + ); + if (skills.length === 0) continue; + plugins.push({ + directory: pluginDirectory, + skills: skills.map((skill) => ({ + ...skill, + name: `plugin:${[entry, skill.name] + .map((part) => + part.replace( + /[^a-z0-9-]/gu, + (character) => `_${Buffer.from(character).toString("hex")}_`, + ), + ) + .join(":")}`, + displayName: `${entry}:${skill.name}`, + })), + }); + } + return plugins; +}); + +/** Keep read errors typed so workspace snapshots cannot cache partial results. */ +export const discoverAntigravitySkills = Effect.fn("discoverAntigravitySkills")(function* (input: { + readonly cwd: string; + readonly userHome: string; +}): Effect.fn.Return< + ReadonlyArray, + AntigravitySkillsProbeError, + FileSystem.FileSystem | Path.Path +> { + const path = yield* Path.Path; + const [configSkills, cliSkills] = antigravityUserSkillDirectories( + path, + path.join(input.userHome, ".gemini"), + ); + const skills = yield* scanSkillDirectories( + [ + { directory: configSkills, scope: "user" }, + { directory: path.resolve(input.cwd, ".gemini", "skills"), scope: "project" }, + { directory: cliSkills, scope: "user" }, + { directory: path.resolve(input.cwd, ".agents", "skills"), scope: "project" }, + { directory: path.resolve(input.cwd, ".agent", "skills"), scope: "project" }, + ], + { remainingBytes: MAX_SCAN_BYTES, remainingEntries: MAX_SCAN_ENTRIES }, + ); + const plugins = yield* discoverAntigravityPlugins(input.userHome); + return [...skills, ...plugins.flatMap((plugin) => plugin.skills)].sort((left, right) => + left.name.localeCompare(right.name), + ); +}); + +// ACP accepts standalone skills, so plugin entries use a process-owned native catalog. +export const prepareAntigravityPluginSkills = Effect.fn("prepareAntigravityPluginSkills")( + function* (input: { readonly userHome: string; readonly runtimeTempDirectory: string }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const plugins = yield* discoverAntigravityPlugins(input.userHome); + if (plugins.length === 0) return []; + const directory = path.join(input.runtimeTempDirectory, "plugin-skills"); + yield* Effect.forEach( + plugins.flatMap((plugin) => + plugin.skills.map((skill) => ({ pluginDirectory: plugin.directory, skill })), + ), + ({ pluginDirectory, skill }, index) => + Effect.gen(function* () { + const skillDirectory = path.join(directory, ".agents", "skills", String(index)); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(skillDirectory, "SKILL.md"), + `---\nname: ${encodeString(skill.name)}\ndescription: ${encodeString(skill.description ?? "")}\n---\n\nRead and follow the skill at ${encodeString(skill.path)}. Resolve its relative paths from ${encodeString(path.dirname(skill.path))}. The plugin root is ${encodeString(pluginDirectory)}.\n`, + ); + }), + { concurrency: 8, discard: true }, + ); + return [directory, ...plugins.map((plugin) => plugin.directory)]; + }, +);