| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { assert, describe, it } from "@effect/vitest"; |
| 4 | import { |
| 5 | MessageId, |
| 6 | NodeId, |
| 7 | ProjectId, |
| 8 | ProviderInstanceId, |
| 9 | ProviderSessionId, |
| 10 | RunAttemptId, |
| 11 | RunId, |
| 12 | ThreadId, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; |
| 15 | import * as Crypto from "effect/Crypto"; |
| 16 | import * as DateTime from "effect/DateTime"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Exit from "effect/Exit"; |
| 19 | import * as FileSystem from "effect/FileSystem"; |
| 20 | import * as Layer from "effect/Layer"; |
| 21 | import * as Path from "effect/Path"; |
| 22 | import * as Stream from "effect/Stream"; |
| 23 | import { ChildProcessSpawner } from "effect/process"; |
| 24 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 25 | |
| 26 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 27 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 28 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 29 | import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 30 | import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; |
| 31 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 32 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 33 | import { |
| 34 | makeAntigravityAcpAdapterFlavor, |
| 35 | makeAntigravityAdapterV2, |
| 36 | } from "./AntigravityAdapterV2.ts"; |
| 37 | |
| 38 | const 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 | |
| 50 | function 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 | |
| 61 | describe("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 | |
| 131 | const 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 | |
| 139 | describe("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 | |
| 288 | describe("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 | |
| 417 | describe("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 | }); |