1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import {
5 MessageId,
6 NodeId,
7 ProjectId,
8 ProviderInstanceId,
9 ProviderSessionId,
10 RunAttemptId,
11 RunId,
12 ThreadId,
13} from "@t3tools/contracts";
14import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
15import * as Crypto from "effect/Crypto";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as Exit from "effect/Exit";
19import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";
21import * as Path from "effect/Path";
22import * as Stream from "effect/Stream";
23import { ChildProcessSpawner } from "effect/process";
24import type * as EffectAcpSchema from "effect-acp/compat";
25
26import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
27import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
28import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
29import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
30import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts";
31import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
32import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
33import {
34 makeAntigravityAcpAdapterFlavor,
35 makeAntigravityAdapterV2,
36} from "./AntigravityAdapterV2.ts";
37
38const flavor = makeAntigravityAcpAdapterFlavor({
39 instanceId: ProviderInstanceId.make("antigravity-test"),
40 crypto: undefined as never,
41 fileSystem: undefined as never,
42 path: undefined as never,
43 host: undefined as never,
44 selfInvocation: undefined as never,
45 makeRuntime: () => Effect.die("not spawned in this test"),
46 withProcess: (_stop, task) => task,
47 defaultModel: Effect.succeed(undefined),
48});
49
50function permissionRequest(
51 toolCallId: string,
52 options: ReadonlyArray<EffectAcpSchema.PermissionOption>,
53): EffectAcpSchema.RequestPermissionRequest {
54 return {
55 sessionId: "session-1",
56 options,
57 toolCall: { toolCallId, title: "Which branch?" },
58 };
59}
60
61describe("AntigravityAdapterV2 flavor", () => {
62 it("keeps a successful subagent launch running until the parent turn becomes idle", () => {
63 const batch = flavor.extractSubagentUpdate?.({
64 toolCallId: "trajectory:4",
65 title: "Running start_subagent",
66 kind: "other",
67 status: "completed",
68 data: { rawOutput: "Started two agents." },
69 });
70 assert.equal(batch?.status, "running");
71 assert.equal(batch?.prompt, "Started two agents.");
72 assert.isNull(batch?.result);
73 assert.isTrue(flavor.subagentsIdleOnTurnCompletion);
74 });
75
76 it("maps runtime modes to the agent's native permission modes", () => {
77 const mode = (runtimeMode: "approval-required" | "auto-accept-edits" | "full-access") =>
78 flavor.sessionModeForPolicy?.(
79 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
80 runtimeMode,
81 interactionMode: "default",
82 cwd: "/workspace",
83 }),
84 );
85 assert.equal(mode("approval-required"), "default");
86 assert.equal(mode("auto-accept-edits"), "auto_edit");
87 assert.equal(mode("full-access"), "yolo");
88 });
89
90 it("exposes Antigravity's /compact command through the v2 compaction path", () => {
91 assert.isTrue(flavor.supportsCompaction);
92 });
93
94 it("routes interaction_* permission requests to the question card", () => {
95 const question = flavor.extractPermissionQuestion?.(
96 permissionRequest("interaction_1", [
97 { optionId: "main", name: "main", kind: "allow_once" },
98 { optionId: "dev", name: "dev", kind: "allow_once" },
99 ]),
100 );
101 assert.isDefined(question);
102 assert.deepEqual(
103 question?.question.options.map((option) => option.label),
104 ["main", "dev"],
105 );
106 assert.deepEqual(question?.respond({ interaction_1: "dev" }), {
107 outcome: { outcome: "selected", optionId: "dev" },
108 });
109 assert.isUndefined(question?.respond({ interaction_1: "nope" }));
110 assert.isUndefined(
111 flavor.extractPermissionQuestion?.(
112 permissionRequest("tool_1", [{ optionId: "allow", name: "Allow", kind: "allow_once" }]),
113 ),
114 );
115 });
116
117 it("advertises only the approval decisions the request can honor", () => {
118 const options = flavor.approvalOptions?.(
119 permissionRequest("tool_1", [
120 { optionId: "once", name: "Allow", kind: "allow_once" },
121 { optionId: "deny", name: "Deny", kind: "reject_once" },
122 ]),
123 );
124 assert.deepEqual(
125 options?.map((option) => option.decision),
126 ["accept", "decline", "cancel"],
127 );
128 });
129});
130
131const layerSession = Layer.mergeAll(
132 NodeServices.layer,
133 IdAllocator.layer,
134 McpProviderSessions.layer,
135 ScriptProgress.layerUnavailable,
136 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
137);
138
139describe("AntigravityAdapterV2 client file system", () => {
140 it.effect("confines agent file requests to the workspace under full access", () =>
141 Effect.gen(function* () {
142 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
143 const fileSystem = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const host = yield* ProviderHost.ProviderHost;
146 const mockAgentPath = yield* path.fromFileUrl(
147 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
148 );
149 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
150 // effect-acp keeps the last handler registered per method; so does this.
151 let readTextFile: Parameters<RuntimeService["handleReadTextFile"]>[0] | undefined;
152 let writeTextFile: Parameters<RuntimeService["handleWriteTextFile"]>[0] | undefined;
153 const crypto = yield* Crypto.Crypto;
154 const instanceId = ProviderInstanceId.make("antigravity-containment-test");
155 const adapter = yield* makeAntigravityAdapterV2({
156 instanceId,
157 selfInvocation: yield* resolveSelfInvocation(),
158 makeRuntime: (input) =>
159 makeAntigravityAcpRuntime({
160 ...input,
161 childProcessSpawner,
162 spawn: {
163 command: process.execPath,
164 args: [mockAgentPath],
165 cwd: input.cwd,
166 env: { T3_ACP_ANTIGRAVITY: "1" },
167 },
168 }).pipe(
169 Effect.provideService(Crypto.Crypto, crypto),
170 Effect.map((runtime): RuntimeService => ({
171 ...runtime,
172 handleReadTextFile: (handler) =>
173 Effect.sync(() => {
174 readTextFile = handler;
175 }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))),
176 handleWriteTextFile: (handler) =>
177 Effect.sync(() => {
178 writeTextFile = handler;
179 }).pipe(Effect.andThen(runtime.handleWriteTextFile(handler))),
180 })),
181 ),
182 withProcess: (_stop, task) => task,
183 defaultModel: Effect.succeed(undefined),
184 });
185 const workspace = yield* fileSystem.makeTempDirectoryScoped({
186 prefix: "t3-antigravity-workspace-",
187 });
188 const outside = yield* fileSystem.makeTempDirectoryScoped({
189 prefix: "t3-antigravity-outside-",
190 });
191 const outsideFile = path.join(outside, "secret.txt");
192 yield* fileSystem.writeFileString(outsideFile, "secret");
193 const attachment = path.join(host.paths.attachmentsDir, "pasted.txt");
194 yield* fileSystem.writeFileString(attachment, "pasted");
195
196 const threadId = ThreadId.make("thread-antigravity-containment");
197 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
198 runtimeMode: "full-access",
199 interactionMode: "default",
200 cwd: workspace,
201 });
202 const modelSelection = { instanceId, model: "gemini-test-low" } as const;
203 const session = yield* adapter.openSession({
204 threadId,
205 providerSessionId: ProviderSessionId.make("provider-session-antigravity-containment"),
206 modelSelection,
207 runtimePolicy,
208 });
209 yield* session.ensureThread({ threadId, modelSelection, runtimePolicy });
210 if (readTextFile === undefined || writeTextFile === undefined) {
211 return yield* Effect.die("Antigravity sessions must serve client file requests");
212 }
213 const context = (method: string) => ({ requestId: `test-${method}`, method });
214
215 const insidePath = path.join(workspace, "src", "inside.ts");
216 yield* writeTextFile(
217 { sessionId: "mock-session-1", path: insidePath, content: "inside" },
218 context("fs/write_text_file"),
219 );
220 assert.equal(yield* fileSystem.readFileString(insidePath), "inside");
221 const pasted = yield* readTextFile(
222 { sessionId: "mock-session-1", path: attachment },
223 context("fs/read_text_file"),
224 );
225 assert.equal(pasted.content, "pasted");
226
227 const outsideRead = yield* readTextFile(
228 { sessionId: "mock-session-1", path: outsideFile },
229 context("fs/read_text_file"),
230 ).pipe(Effect.exit);
231 assert.isTrue(Exit.isFailure(outsideRead));
232 const outsideWrite = yield* writeTextFile(
233 { sessionId: "mock-session-1", path: path.join(outside, "planted.txt"), content: "x" },
234 context("fs/write_text_file"),
235 ).pipe(Effect.exit);
236 assert.isTrue(Exit.isFailure(outsideWrite));
237 assert.isFalse(yield* fileSystem.exists(path.join(outside, "planted.txt")));
238
239 // An in-workspace symlink to an outside file must not carry a read or
240 // write out of the workspace.
241 const linkPath = path.join(workspace, "linked-secret.txt");
242 yield* fileSystem.symlink(outsideFile, linkPath);
243 const linkedRead = yield* readTextFile(
244 { sessionId: "mock-session-1", path: linkPath },
245 context("fs/read_text_file"),
246 ).pipe(Effect.exit);
247 assert.isTrue(Exit.isFailure(linkedRead), "a read through an escaping symlink is denied");
248 const linkedWrite = yield* writeTextFile(
249 { sessionId: "mock-session-1", path: linkPath, content: "overwritten" },
250 context("fs/write_text_file"),
251 ).pipe(Effect.exit);
252 assert.isTrue(Exit.isFailure(linkedWrite), "a write through an escaping symlink is denied");
253 assert.equal(yield* fileSystem.readFileString(outsideFile), "secret");
254 // A dangling in-workspace symlink to an outside path must not create it.
255 const plantedTarget = path.join(outside, "created-through-link.txt");
256 const danglingLink = path.join(workspace, "dangling.txt");
257 yield* fileSystem.symlink(plantedTarget, danglingLink);
258 const danglingWrite = yield* writeTextFile(
259 { sessionId: "mock-session-1", path: danglingLink, content: "planted" },
260 context("fs/write_text_file"),
261 ).pipe(Effect.exit);
262 assert.isTrue(Exit.isFailure(danglingWrite), "a write through a dangling symlink is denied");
263 assert.isFalse(yield* fileSystem.exists(plantedTarget));
264 // A new file under an in-workspace directory link to outside is denied.
265 yield* fileSystem.symlink(outside, path.join(workspace, "linked-dir"));
266 const linkedDirWrite = yield* writeTextFile(
267 {
268 sessionId: "mock-session-1",
269 path: path.join(workspace, "linked-dir", "new.txt"),
270 content: "planted",
271 },
272 context("fs/write_text_file"),
273 ).pipe(Effect.exit);
274 assert.isTrue(Exit.isFailure(linkedDirWrite), "a write under an escaping directory link");
275 assert.isFalse(yield* fileSystem.exists(path.join(outside, "new.txt")));
276 // A symlink that stays inside the workspace keeps working.
277 const insideLink = path.join(workspace, "inside-link.ts");
278 yield* fileSystem.symlink(insidePath, insideLink);
279 const viaInsideLink = yield* readTextFile(
280 { sessionId: "mock-session-1", path: insideLink },
281 context("fs/read_text_file"),
282 );
283 assert.equal(viaInsideLink.content, "inside");
284 }).pipe(Effect.provide(layerSession), Effect.scoped),
285 );
286});
287
288describe("AntigravityAdapterV2 workspace changes", () => {
289 it.effect("confines file requests to the workspace of the turn in progress", () =>
290 Effect.gen(function* () {
291 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
292 const fileSystem = yield* FileSystem.FileSystem;
293 const path = yield* Path.Path;
294 const crypto = yield* Crypto.Crypto;
295 const mockAgentPath = yield* path.fromFileUrl(
296 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
297 );
298 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
299 let readTextFile: Parameters<RuntimeService["handleReadTextFile"]>[0] | undefined;
300 const instanceId = ProviderInstanceId.make("antigravity-workspace-change-test");
301 const adapter = yield* makeAntigravityAdapterV2({
302 instanceId,
303 selfInvocation: yield* resolveSelfInvocation(),
304 makeRuntime: (input) =>
305 makeAntigravityAcpRuntime({
306 ...input,
307 childProcessSpawner,
308 spawn: {
309 command: process.execPath,
310 args: [mockAgentPath],
311 cwd: input.cwd,
312 env: { T3_ACP_ANTIGRAVITY: "1", T3_ACP_HANG_PROMPT_FOREVER: "1" },
313 },
314 }).pipe(
315 Effect.provideService(Crypto.Crypto, crypto),
316 Effect.map((runtime): RuntimeService => ({
317 ...runtime,
318 handleReadTextFile: (handler) =>
319 Effect.sync(() => {
320 readTextFile = handler;
321 }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))),
322 })),
323 ),
324 withProcess: (_stop, task) => task,
325 defaultModel: Effect.succeed(undefined),
326 });
327 const workspaceA = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ag-a-" });
328 const workspaceB = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ag-b-" });
329 yield* fileSystem.writeFileString(path.join(workspaceA, "a.txt"), "from a");
330 yield* fileSystem.writeFileString(path.join(workspaceB, "b.txt"), "from b");
331 const policyFor = (cwd: string) =>
332 ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
333 runtimeMode: "full-access",
334 interactionMode: "default",
335 cwd,
336 });
337 const threadId = ThreadId.make("thread-antigravity-workspace-change");
338 const modelSelection = { instanceId, model: "gemini-test-low" } as const;
339 const session = yield* adapter.openSession({
340 threadId,
341 providerSessionId: ProviderSessionId.make("provider-session-antigravity-workspace-change"),
342 modelSelection,
343 runtimePolicy: policyFor(workspaceA),
344 });
345 const providerThread = yield* session.ensureThread({
346 threadId,
347 modelSelection,
348 runtimePolicy: policyFor(workspaceA),
349 });
350 // The session opened for A now runs a turn for B.
351 const now = yield* DateTime.now;
352 yield* session
353 .startTurn({
354 appThread: {
355 createdBy: "user",
356 creationSource: "web",
357 id: threadId,
358 projectId: ProjectId.make("project-antigravity-workspace-change"),
359 title: "Antigravity workspace change",
360 providerInstanceId: instanceId,
361 modelSelection,
362 runtimeMode: "full-access",
363 interactionMode: "default",
364 branch: null,
365 worktreePath: workspaceB,
366 activeProviderThreadId: providerThread.id,
367 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
368 forkedFrom: null,
369 createdAt: now,
370 updatedAt: now,
371 archivedAt: null,
372 settledOverride: null,
373 settledAt: null,
374 lastVisitedAt: null,
375 deletedAt: null,
376 },
377 threadId,
378 runId: RunId.make("run-antigravity-workspace-change"),
379 runOrdinal: 1,
380 providerTurnOrdinal: 1,
381 attemptId: RunAttemptId.make("attempt-antigravity-workspace-change"),
382 rootNodeId: NodeId.make("node-antigravity-workspace-change"),
383 providerThread,
384 message: {
385 createdBy: "user",
386 creationSource: "web",
387 messageId: MessageId.make("message-antigravity-workspace-change"),
388 text: "read b.txt",
389 attachments: [],
390 },
391 modelSelection,
392 runtimePolicy: policyFor(workspaceB),
393 })
394 .pipe(Effect.forkScoped);
395 yield* session.events.pipe(
396 Stream.filter((event) => event.type === "provider_turn.updated"),
397 Stream.runHead,
398 );
399 if (readTextFile === undefined) {
400 return yield* Effect.die("Antigravity sessions must serve client file requests");
401 }
402 const context = { requestId: "test-read", method: "fs/read_text_file" };
403 const fromB = yield* readTextFile(
404 { sessionId: "mock-session-1", path: path.join(workspaceB, "b.txt") },
405 context,
406 );
407 assert.equal(fromB.content, "from b");
408 const fromA = yield* readTextFile(
409 { sessionId: "mock-session-1", path: path.join(workspaceA, "a.txt") },
410 context,
411 ).pipe(Effect.exit);
412 assert.isTrue(Exit.isFailure(fromA), "the previous workspace is no longer readable");
413 }).pipe(Effect.provide(layerSession), Effect.scoped),
414 );
415});
416
417describe("AntigravityAdapterV2 client file system under restrictive policies", () => {
418 // Antigravity asks before each of its own edits, so T3 serves an opted-in
419 // write whatever the thread's policy says, confined to the workspace.
420 it.effect("serves in-workspace reads and writes and still refuses outside paths", () =>
421 Effect.gen(function* () {
422 const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
423 const fileSystem = yield* FileSystem.FileSystem;
424 const path = yield* Path.Path;
425 const crypto = yield* Crypto.Crypto;
426 const mockAgentPath = yield* path.fromFileUrl(
427 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
428 );
429 type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
430 const policies = [
431 {
432 name: "read-only sandbox",
433 policy: {
434 runtimeMode: "full-access",
435 approvalPolicy: "never",
436 sandboxPolicy: { type: "readOnly" },
437 },
438 },
439 { name: "approval-required", policy: { runtimeMode: "approval-required" } },
440 ] as const;
441 for (const { name, policy } of policies) {
442 let readTextFile: Parameters<RuntimeService["handleReadTextFile"]>[0] | undefined;
443 let writeTextFile: Parameters<RuntimeService["handleWriteTextFile"]>[0] | undefined;
444 const instanceId = ProviderInstanceId.make(`antigravity-restrictive-${policy.runtimeMode}`);
445 const adapter = yield* makeAntigravityAdapterV2({
446 instanceId,
447 selfInvocation: yield* resolveSelfInvocation(),
448 makeRuntime: (input) =>
449 makeAntigravityAcpRuntime({
450 ...input,
451 childProcessSpawner,
452 spawn: {
453 command: process.execPath,
454 args: [mockAgentPath],
455 cwd: input.cwd,
456 env: { T3_ACP_ANTIGRAVITY: "1" },
457 },
458 }).pipe(
459 Effect.provideService(Crypto.Crypto, crypto),
460 Effect.map((runtime): RuntimeService => ({
461 ...runtime,
462 handleReadTextFile: (handler) =>
463 Effect.sync(() => {
464 readTextFile = handler;
465 }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))),
466 handleWriteTextFile: (handler) =>
467 Effect.sync(() => {
468 writeTextFile = handler;
469 }).pipe(Effect.andThen(runtime.handleWriteTextFile(handler))),
470 })),
471 ),
472 withProcess: (_stop, task) => task,
473 defaultModel: Effect.succeed(undefined),
474 });
475 const workspace = yield* fileSystem.makeTempDirectoryScoped({
476 prefix: "t3-ag-restrictive-",
477 });
478 const outside = yield* fileSystem.makeTempDirectoryScoped({
479 prefix: "t3-ag-restrictive-outside-",
480 });
481 yield* fileSystem.writeFileString(path.join(workspace, "existing.ts"), "existing");
482 const threadId = ThreadId.make(`thread-antigravity-restrictive-${policy.runtimeMode}`);
483 const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
484 ...policy,
485 interactionMode: "default",
486 cwd: workspace,
487 });
488 const modelSelection = { instanceId, model: "gemini-test-low" } as const;
489 const session = yield* adapter.openSession({
490 threadId,
491 providerSessionId: ProviderSessionId.make(
492 `provider-session-antigravity-restrictive-${policy.runtimeMode}`,
493 ),
494 modelSelection,
495 runtimePolicy,
496 });
497 yield* session.ensureThread({ threadId, modelSelection, runtimePolicy });
498 if (readTextFile === undefined || writeTextFile === undefined) {
499 return yield* Effect.die("Antigravity sessions must serve client file requests");
500 }
501 const context = (method: string) => ({ requestId: `test-${method}`, method });
502 const read = yield* readTextFile(
503 { sessionId: "mock-session-1", path: path.join(workspace, "existing.ts") },
504 context("fs/read_text_file"),
505 );
506 assert.equal(read.content, "existing", name);
507 const written = path.join(workspace, "src", "edited.ts");
508 yield* writeTextFile(
509 { sessionId: "mock-session-1", path: written, content: "edited" },
510 context("fs/write_text_file"),
511 );
512 assert.equal(yield* fileSystem.readFileString(written), "edited", name);
513 const outsideWrite = yield* writeTextFile(
514 { sessionId: "mock-session-1", path: path.join(outside, "x.ts"), content: "x" },
515 context("fs/write_text_file"),
516 ).pipe(Effect.exit);
517 assert.isTrue(Exit.isFailure(outsideWrite), name);
518 assert.isFalse(yield* fileSystem.exists(path.join(outside, "x.ts")), name);
519 }
520 }).pipe(Effect.provide(layerSession), Effect.scoped),
521 );
522});