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
35 changes: 33 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,22 @@ export interface AcpAdapterV2Flavor {
}) => Effect.Effect<string | undefined, EffectAcpErrors.AcpError>;
/** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */
readonly sessionModeForPolicy?: (policy: ProviderAdapterV2RuntimePolicy) => string | undefined;
/**
* Serves the agent's `fs/read_text_file` and `fs/write_text_file` requests in
* place of the generic handlers, after the runtime policy guard. Receives the
* cwd of the policy active when the request arrives, which is null when the
* session has no workspace. Antigravity confines them to its workspace.
*/
readonly clientFileSystem?: {
readonly readTextFile: (
request: EffectAcpSchema.ReadTextFileRequest,
cwd: string | null,
) => Effect.Effect<EffectAcpSchema.ReadTextFileResponse, EffectAcpErrors.AcpError>;
readonly writeTextFile: (
request: EffectAcpSchema.WriteTextFileRequest,
cwd: string | null,
) => Effect.Effect<EffectAcpSchema.WriteTextFileResponse, EffectAcpErrors.AcpError>;
};
/**
* Permission requests that are really questions (Antigravity `interaction_*`
* tool calls). Returns the question and a response builder; undefined routes
Expand Down Expand Up @@ -5379,14 +5395,29 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV
Effect.succeed(request),
requestContext.requestId,
);
// A flavor's own handlers replace the generic ones: effect-acp keeps
// only the last handler registered per method. They confine requests
// to the workspace of the policy the guard checks at request time,
// not the one the session opened with.
const clientFileSystem = flavor.clientFileSystem;
yield* targetRuntime.handleReadTextFile((request) =>
guardClientFsRead(request.path).pipe(
Effect.andThen(acpReadTextFile(options.fileSystem, request)),
Effect.andThen(clientPolicyContext),
Effect.flatMap(({ policy }) =>
clientFileSystem === undefined
? acpReadTextFile(options.fileSystem, request)
: clientFileSystem.readTextFile(request, policy.cwd),
),
),
);
yield* targetRuntime.handleWriteTextFile((request) =>
guardClientFsWrite(request.path).pipe(
Effect.andThen(acpWriteTextFile(options.fileSystem, request)),
Effect.andThen(clientPolicyContext),
Effect.flatMap(({ policy }) =>
clientFileSystem === undefined
? acpWriteTextFile(options.fileSystem, request)
: clientFileSystem.writeTextFile(request, policy.cwd),
),
),
);
if (handlerOptions.mcp !== false) {
Expand Down
327 changes: 325 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,36 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import { ProviderInstanceId } from "@t3tools/contracts";
import {
MessageId,
NodeId,
ProjectId,
ProviderInstanceId,
ProviderSessionId,
RunAttemptId,
RunId,
ThreadId,
} from "@t3tools/contracts";
import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import { ChildProcessSpawner } from "effect/unstable/process";
import type * as EffectAcpSchema from "effect-acp/compat";

import { ServerConfig } from "../../config.ts";
import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts";
import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
import { makeAntigravityAcpAdapterFlavor } from "./AntigravityAdapterV2.ts";
import {
makeAntigravityAcpAdapterFlavor,
makeAntigravityAdapterV2,
} from "./AntigravityAdapterV2.ts";

const flavor = makeAntigravityAcpAdapterFlavor({
instanceId: ProviderInstanceId.make("antigravity-test"),
Expand Down Expand Up @@ -99,3 +125,300 @@ describe("AntigravityAdapterV2 flavor", () => {
);
});
});

const sessionLayer = Layer.mergeAll(
NodeServices.layer,
idAllocatorLayer,
ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-v2-adapter-" }).pipe(
Layer.provide(NodeServices.layer),
),
);

describe("AntigravityAdapterV2 client file system", () => {
it.effect("confines agent file requests to the workspace under full access", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfig;
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
// effect-acp keeps the last handler registered per method; so does this.
let readTextFile: Parameters<RuntimeService["handleReadTextFile"]>[0] | undefined;
let writeTextFile: Parameters<RuntimeService["handleWriteTextFile"]>[0] | undefined;
const crypto = yield* Crypto.Crypto;
const instanceId = ProviderInstanceId.make("antigravity-containment-test");
const adapter = makeAntigravityAdapterV2({
instanceId,
crypto,
selfInvocation: yield* resolveSelfInvocation(),
fileSystem,
path,
idAllocator: yield* IdAllocatorV2,
serverConfig,
makeRuntime: (input) =>
makeAntigravityAcpRuntime({
...input,
childProcessSpawner,
spawn: {
command: process.execPath,
args: [mockAgentPath],
cwd: input.cwd,
env: { T3_ACP_ANTIGRAVITY: "1" },
},
}).pipe(
Effect.provideService(Crypto.Crypto, crypto),
Effect.map((runtime): RuntimeService => ({
...runtime,
handleReadTextFile: (handler) =>
Effect.sync(() => {
readTextFile = handler;
}).pipe(Effect.andThen(runtime.handleReadTextFile(handler))),
handleWriteTextFile: (handler) =>
Effect.sync(() => {
writeTextFile = handler;
}).pipe(Effect.andThen(runtime.handleWriteTextFile(handler))),
})),
),
withProcess: (_stop, task) => task,
defaultModel: Effect.succeed(undefined),
});
const workspace = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-antigravity-workspace-",
});
const outside = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-antigravity-outside-",
});
const outsideFile = path.join(outside, "secret.txt");
yield* fileSystem.writeFileString(outsideFile, "secret");
const attachment = path.join(serverConfig.attachmentsDir, "pasted.txt");
yield* fileSystem.writeFileString(attachment, "pasted");

const threadId = ThreadId.make("thread-antigravity-containment");
const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: workspace,
});
const modelSelection = { instanceId, model: "gemini-test-low" } as const;
const session = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-antigravity-containment"),
modelSelection,
runtimePolicy,
});
yield* session.ensureThread({ threadId, modelSelection, runtimePolicy });
if (readTextFile === undefined || writeTextFile === undefined) {
return yield* Effect.die("Antigravity sessions must serve client file requests");
}
const context = (method: string) => ({ requestId: `test-${method}`, method });

const insidePath = path.join(workspace, "src", "inside.ts");
yield* writeTextFile(
{ sessionId: "mock-session-1", path: insidePath, content: "inside" },
context("fs/write_text_file"),
);
assert.equal(yield* fileSystem.readFileString(insidePath), "inside");
const pasted = yield* readTextFile(
{ sessionId: "mock-session-1", path: attachment },
context("fs/read_text_file"),
);
assert.equal(pasted.content, "pasted");

const outsideRead = yield* readTextFile(
{ sessionId: "mock-session-1", path: outsideFile },
context("fs/read_text_file"),
).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(outsideRead));
const outsideWrite = yield* writeTextFile(
{ sessionId: "mock-session-1", path: path.join(outside, "planted.txt"), content: "x" },
context("fs/write_text_file"),
).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(outsideWrite));
assert.isFalse(yield* fileSystem.exists(path.join(outside, "planted.txt")));

// An in-workspace symlink to an outside file must not carry a read or
// write out of the workspace.
const linkPath = path.join(workspace, "linked-secret.txt");
yield* fileSystem.symlink(outsideFile, linkPath);
const linkedRead = yield* readTextFile(
{ sessionId: "mock-session-1", path: linkPath },
context("fs/read_text_file"),
).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(linkedRead), "a read through an escaping symlink is denied");
const linkedWrite = yield* writeTextFile(
{ sessionId: "mock-session-1", path: linkPath, content: "overwritten" },
context("fs/write_text_file"),
).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(linkedWrite), "a write through an escaping symlink is denied");
assert.equal(yield* fileSystem.readFileString(outsideFile), "secret");
// A dangling in-workspace symlink to an outside path must not create it.
const plantedTarget = path.join(outside, "created-through-link.txt");
const danglingLink = path.join(workspace, "dangling.txt");
yield* fileSystem.symlink(plantedTarget, danglingLink);
const danglingWrite = yield* writeTextFile(
{ sessionId: "mock-session-1", path: danglingLink, content: "planted" },
context("fs/write_text_file"),
).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(danglingWrite), "a write through a dangling symlink is denied");
assert.isFalse(yield* fileSystem.exists(plantedTarget));
// A new file under an in-workspace directory link to outside is denied.
yield* fileSystem.symlink(outside, path.join(workspace, "linked-dir"));
const linkedDirWrite = yield* writeTextFile(
{
sessionId: "mock-session-1",
path: path.join(workspace, "linked-dir", "new.txt"),
content: "planted",
},
context("fs/write_text_file"),
).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(linkedDirWrite), "a write under an escaping directory link");
assert.isFalse(yield* fileSystem.exists(path.join(outside, "new.txt")));
// A symlink that stays inside the workspace keeps working.
const insideLink = path.join(workspace, "inside-link.ts");
yield* fileSystem.symlink(insidePath, insideLink);
const viaInsideLink = yield* readTextFile(
{ sessionId: "mock-session-1", path: insideLink },
context("fs/read_text_file"),
);
assert.equal(viaInsideLink.content, "inside");
}).pipe(Effect.provide(sessionLayer), Effect.scoped),
);
});

describe("AntigravityAdapterV2 workspace changes", () => {
it.effect("confines file requests to the workspace of the turn in progress", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfig;
const crypto = yield* Crypto.Crypto;
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"];
let readTextFile: Parameters<RuntimeService["handleReadTextFile"]>[0] | undefined;
const instanceId = ProviderInstanceId.make("antigravity-workspace-change-test");
const adapter = makeAntigravityAdapterV2({
instanceId,
crypto,
selfInvocation: yield* resolveSelfInvocation(),
fileSystem,
path,
idAllocator: yield* IdAllocatorV2,
serverConfig,
makeRuntime: (input) =>
makeAntigravityAcpRuntime({
...input,
childProcessSpawner,
spawn: {
command: process.execPath,
args: [mockAgentPath],
cwd: input.cwd,
env: { T3_ACP_ANTIGRAVITY: "1", T3_ACP_HANG_PROMPT_FOREVER: "1" },
},
}).pipe(
Effect.provideService(Crypto.Crypto, crypto),
Effect.map((runtime): RuntimeService => ({
...runtime,
handleReadTextFile: (handler) =>
Effect.sync(() => {
readTextFile = handler;
}).pipe(Effect.andThen(runtime.handleReadTextFile(handler))),
})),
),
withProcess: (_stop, task) => task,
defaultModel: Effect.succeed(undefined),
});
const workspaceA = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ag-a-" });
const workspaceB = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ag-b-" });
yield* fileSystem.writeFileString(path.join(workspaceA, "a.txt"), "from a");
yield* fileSystem.writeFileString(path.join(workspaceB, "b.txt"), "from b");
const policyFor = (cwd: string) =>
ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd,
});
const threadId = ThreadId.make("thread-antigravity-workspace-change");
const modelSelection = { instanceId, model: "gemini-test-low" } as const;
const session = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-antigravity-workspace-change"),
modelSelection,
runtimePolicy: policyFor(workspaceA),
});
const providerThread = yield* session.ensureThread({
threadId,
modelSelection,
runtimePolicy: policyFor(workspaceA),
});
// The session opened for A now runs a turn for B.
const now = yield* DateTime.now;
yield* session
.startTurn({
appThread: {
createdBy: "user",
creationSource: "web",
id: threadId,
projectId: ProjectId.make("project-antigravity-workspace-change"),
title: "Antigravity workspace change",
providerInstanceId: instanceId,
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: workspaceB,
activeProviderThreadId: providerThread.id,
lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
forkedFrom: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
settledOverride: null,
settledAt: null,
lastVisitedAt: null,
deletedAt: null,
},
threadId,
runId: RunId.make("run-antigravity-workspace-change"),
runOrdinal: 1,
providerTurnOrdinal: 1,
attemptId: RunAttemptId.make("attempt-antigravity-workspace-change"),
rootNodeId: NodeId.make("node-antigravity-workspace-change"),
providerThread,
message: {
createdBy: "user",
creationSource: "web",
messageId: MessageId.make("message-antigravity-workspace-change"),
text: "read b.txt",
attachments: [],
},
modelSelection,
runtimePolicy: policyFor(workspaceB),
})
.pipe(Effect.forkScoped);
yield* session.events.pipe(
Stream.filter((event) => event.type === "provider_turn.updated"),
Stream.runHead,
);
if (readTextFile === undefined) {
return yield* Effect.die("Antigravity sessions must serve client file requests");
}
const context = { requestId: "test-read", method: "fs/read_text_file" };
const fromB = yield* readTextFile(
{ sessionId: "mock-session-1", path: path.join(workspaceB, "b.txt") },
context,
);
assert.equal(fromB.content, "from b");
const fromA = yield* readTextFile(
{ sessionId: "mock-session-1", path: path.join(workspaceA, "a.txt") },
context,
).pipe(Effect.exit);
assert.isTrue(Exit.isFailure(fromA), "the previous workspace is no longer readable");
}).pipe(Effect.provide(sessionLayer), Effect.scoped),
);
});
Loading
Loading