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
4 changes: 3 additions & 1 deletion apps/server/scripts/record-grok-acp-replay-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { ServerConfig } from "../src/config.ts";
import {
GROK_DEFAULT_INSTANCE_ID,
GROK_PROVIDER,
grokLaunchRuntimeMode,
makeGrokAdapterV2,
} from "../src/orchestration-v2/Adapters/GrokAdapterV2.ts";
import { ACP_PROTOCOL } from "../src/orchestration-v2/Adapters/AcpAdapterV2.ts";
Expand Down Expand Up @@ -455,14 +456,15 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s
selfInvocation: yield* resolveSelfInvocation(),
continuationRequests: yield* ProviderContinuationRequests.ProviderContinuationRequests,
// Production's runtime factory, with the protocol logger teeing raw lines.
makeRuntime: (input) =>
makeRuntime: ({ runtimePolicy, ...input }) =>
makeGrokAcpRuntime({
...input,
protocolLogging: tee.attachRuntime(),
interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
grokSettings: settings,
environment,
childProcessSpawner,
runtimeMode: grokLaunchRuntimeMode(runtimePolicy),
}),
});
// The scenario runs on the replay TestClock so its clock steps order
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ const ACP_DEFERRED_FINALIZE_DEBOUNCE: Duration.Input = "3000 millis";

export interface AcpAdapterV2RuntimeInput {
readonly cwd: string;
/**
* Policy the session opened with. A runtime-mode change reopens the session,
* so flavors that encode permissions in the launch command (Grok) read it here.
*/
readonly runtimePolicy: ProviderAdapterV2RuntimePolicy;
readonly mcpServers: ReadonlyArray<EffectAcpSchema.McpServer>;
readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
/** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */
Expand Down Expand Up @@ -1932,6 +1937,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV
const mcpContext = acpMcpContext(threadId, self);
return {
cwd: input.runtimePolicy.cwd ?? process.cwd(),
runtimePolicy: input.runtimePolicy,
mcpServers: mcpContext.servers,
acpMcpServers: mcpContext.acpServers,
...(mcpContext.processEnvironment === undefined
Expand Down
117 changes: 117 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,27 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
GrokSettings,
ProviderInstanceId,
ProviderSessionId,
type RuntimeMode,
ThreadId,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
import * as EffectAcpErrors from "effect-acp/errors";
import { xAiRateLimitedErrorCode } from "../../provider/acp/XAiAcpExtension.ts";
import { assert, describe, it } from "@effect/vitest";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import { ChildProcessSpawner } from "effect/unstable/process";
import type * as EffectAcpSchema from "effect-acp/compat";

import { ServerConfig } from "../../config.ts";
import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts";
import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts";
import {
Expand All @@ -14,10 +32,15 @@ import {
} from "./AcpAdapterV2.ts";
import {
makeGrokAcpAdapterFlavor,
makeGrokAdapterV2,
GrokProviderCapabilitiesV2,
type GrokAdapterV2Options,
} from "./GrokAdapterV2.ts";

const LAUNCH_TEST_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({
binaryPath: "grok-launch-test",
});

function permissionRequest(
kind: EffectAcpSchema.ToolKind,
): EffectAcpSchema.RequestPermissionRequest {
Expand Down Expand Up @@ -254,3 +277,97 @@ describe("ACP permission policy", () => {
);
});
});

describe("Grok launch permission mode", () => {
const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
prefix: "t3-grok-v2-launch-",
}).pipe(Layer.provide(NodeServices.layer));
const testLayer = Layer.mergeAll(NodeServices.layer, idAllocatorLayer, serverConfigLayer);

// Opens a session through the adapter's own Grok runtime factory and returns
// the argv it tried to launch. The spawn fails after recording, so no
// process starts.
const launchArgs = (runtimePolicy: ProviderAdapterV2RuntimePolicy) =>
Effect.gen(function* () {
const launches: Array<ReadonlyArray<string>> = [];
const childProcessSpawner = ChildProcessSpawner.make((command) => {
if (command._tag === "StandardCommand") launches.push(command.args);
return Effect.fail(
PlatformError.systemError({
_tag: "NotFound",
module: "grok-launch-test",
method: "spawn",
}),
);
});
const instanceId = ProviderInstanceId.make("grok-launch-test");
const adapter = makeGrokAdapterV2({
instanceId,
settings: LAUNCH_TEST_GROK_SETTINGS,
environment: {},
hostPlatform: "darwin",
childProcessSpawner,
crypto: yield* Crypto.Crypto,
fileSystem: yield* FileSystem.FileSystem,
idAllocator: yield* IdAllocatorV2,
serverConfig: yield* ServerConfig,
selfInvocation: yield* resolveSelfInvocation(),
});
yield* adapter
.openSession({
threadId: ThreadId.make("grok-launch-test"),
providerSessionId: ProviderSessionId.make("grok-launch-test"),
modelSelection: { instanceId, model: "grok-build" },
runtimePolicy,
})
.pipe(Effect.scoped, Effect.ignore);
return launches;
}).pipe(
// Keep the launch argv unwrapped by the Linux cgroup shim.
Effect.provideService(HostProcessPlatform, "darwin"),
Effect.provide(testLayer),
);

const policy = (
runtimeMode: RuntimeMode,
override: Partial<ProviderAdapterV2RuntimePolicy> = {},
) =>
ProviderAdapterV2RuntimePolicy.make({
runtimeMode,
interactionMode: "default",
cwd: process.cwd(),
...override,
});

for (const [runtimeMode, args] of [
["approval-required", ["--permission-mode", "default", "agent", "stdio"]],
["auto-accept-edits", ["--permission-mode", "default", "agent", "stdio"]],
["auto", ["--permission-mode", "auto", "agent", "stdio"]],
["full-access", ["agent", "--always-approve", "stdio"]],
] as const) {
it.effect(`launches ${runtimeMode} threads with ${args.join(" ")}`, () =>
Effect.gen(function* () {
assert.deepEqual(yield* launchArgs(policy(runtimeMode)), [args]);
}),
);
}

it.effect("launches asking when an explicit approval or sandbox policy governs the thread", () =>
Effect.gen(function* () {
const asking = [["--permission-mode", "default", "agent", "stdio"]];
assert.deepEqual(
yield* launchArgs(
policy("full-access", {
approvalPolicy: "never",
sandboxPolicy: { type: "workspaceWrite", writableRoots: [], networkAccess: false },
}),
),
asking,
);
assert.deepEqual(
yield* launchArgs(policy("full-access", { approvalPolicy: "on-request" })),
asking,
);
}),
);
});
17 changes: 15 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
GrokSettings,
ProviderDriverKind,
type OrchestrationV2ProviderCapabilities,
type RuntimeMode,
} from "@t3tools/contracts";
import * as Crypto from "effect/Crypto";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -51,7 +52,7 @@ import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
import { IdAllocatorV2 } from "../IdAllocator.ts";
import { ProviderContinuationRequests } from "../ProviderContinuationRequests.ts";
import { ProviderAdapterV2 } from "../ProviderAdapter.ts";
import { ProviderAdapterV2, type ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts";
import {
ProviderAdapterDriverCreateError,
type ProviderAdapterDriver,
Expand Down Expand Up @@ -206,6 +207,17 @@ const registerGrokAskUserQuestionExtensions = ({
{ discard: true },
);

/**
* Grok's permission mode is fixed at launch. Explicit approval or sandbox
* overrides launch it asking, so every mutating prompt reaches T3's policy
* check instead of being bypassed by always-approve or Grok's auto classifier.
*/
export function grokLaunchRuntimeMode(runtimePolicy: ProviderAdapterV2RuntimePolicy): RuntimeMode {
return runtimePolicy.approvalPolicy === undefined && runtimePolicy.sandboxPolicy === undefined
? runtimePolicy.runtimeMode
: "approval-required";
}

export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdapterV2Flavor {
return {
driver: GROK_PROVIDER,
Expand Down Expand Up @@ -251,13 +263,14 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap
}),
makeRuntime:
options.makeRuntime ??
((input) =>
(({ runtimePolicy, ...input }) =>
makeGrokAcpRuntime({
...input,
interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
grokSettings: options.settings,
environment: options.environment,
childProcessSpawner: options.childProcessSpawner,
runtimeMode: grokLaunchRuntimeMode(runtimePolicy),
})),
promptFailure: (cause) =>
makeProviderFailure({
Expand Down
28 changes: 27 additions & 1 deletion apps/server/src/provider/acp/AcpClientPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ describe("acpPermissionDisposition", () => {
assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny");
});

it("auto-accept-edits approves file changes without locations and asks for the rest", () => {
// Grok's session/request_permission carries no locations.
const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd };
for (const kind of ["edit", "delete", "move"] as const) {
assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow");
}
for (const kind of ["execute", "fetch", "other"] as const) {
assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "ask");
}
assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest("read")), "allow");
assert.equal(
acpPermissionDisposition(
{ ...autoAcceptEdits, approvalPolicy: "on-request" },
permissionRequest("edit"),
),
"ask",
);
});

it("auto-allows read-kind permission requests under on-request approval", () => {
for (const runtimePolicy of [
{ ...policy, approvalPolicy: "on-request" },
Expand Down Expand Up @@ -285,14 +304,21 @@ describe("client-mediated dispositions", () => {
});

it("allows in auto and full-access modes without an explicit sandbox", () => {
for (const runtimeMode of ["auto", "auto-accept-edits", "full-access"] as const) {
for (const runtimeMode of ["auto", "full-access"] as const) {
const policy: AcpRuntimePolicy = { runtimeMode, cwd };
assert.equal(acpClientReadDisposition(policy), "allow");
assert.equal(acpClientWriteDisposition(policy, NodePath.join(cwd, "file.ts")), "allow");
assert.equal(acpClientExecuteDisposition(policy), "allow");
}
});

it("allows writes but asks for terminals in auto-accept-edits mode", () => {
const policy: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd };
assert.equal(acpClientReadDisposition(policy), "allow");
assert.equal(acpClientWriteDisposition(policy, NodePath.join(cwd, "file.ts")), "allow");
assert.equal(acpClientExecuteDisposition(policy), "ask");
});

it("allows reads but denies writes and terminals under an explicit read-only sandbox", () => {
const policy: AcpRuntimePolicy = {
runtimeMode: "full-access",
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/provider/acp/AcpClientPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function acpOperationDisposition(
case "readOnly":
return "deny";
case "workspaceWrite":
if (toolKind === "edit" || toolKind === "delete" || toolKind === "move") {
if (isAcpMutationKind(toolKind)) {
return acpWorkspaceWriteAllowsMutation(
runtimePolicy,
sandboxPolicy ?? {},
Expand All @@ -207,12 +207,26 @@ function acpOperationDisposition(
case "externalSandbox":
return "allow";
case undefined:
return runtimePolicy.runtimeMode === "approval-required" ? "deny" : "allow";
if (runtimePolicy.runtimeMode === "approval-required") return "deny";
// Auto-accept edits approves file changes wherever the agent makes them
// (Grok's prompts carry no locations to confine); other actions still ask.
if (
runtimePolicy.runtimeMode === "auto-accept-edits" &&
runtimePolicy.approvalPolicy === undefined &&
!isAcpMutationKind(toolKind)
) {
return "ask";
}
return "allow";
default:
return "deny";
}
}

function isAcpMutationKind(toolKind: string): boolean {
return toolKind === "edit" || toolKind === "delete" || toolKind === "move";
}

export function acpPermissionDisposition(
runtimePolicy: AcpRuntimePolicy,
request: EffectAcpSchema.RequestPermissionRequest,
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/acp/GrokAcpSupport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ describe("grokAcpSpawnArgs", () => {
expect(grokAcpSpawnArgs("full-access")).toEqual(["agent", "--always-approve", "stdio"]);
});

it("maps Auto-accept edits and Auto onto Grok permission modes", () => {
it("launches Auto-accept edits asking and Auto on Grok's classifier", () => {
expect(grokAcpSpawnArgs("auto-accept-edits")).toEqual([
"--permission-mode",
"acceptEdits",
"default",
"agent",
"stdio",
]);
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/provider/acp/GrokAcpSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ interface GrokAcpRuntimeInput extends Omit<
readonly runtimeMode?: RuntimeMode;
}

/**
* Launch argv for a runtime mode. `--permission-mode` on the argv beats the
* user's Grok config, so Supervised cannot inherit a configured always-approve.
* `grok agent` only wires always-approve and auto at launch; `acceptEdits`
* would behave exactly like `default`, so Auto-accept edits launches asking
* and T3's ACP client policy approves the edit prompts.
*/
export function grokAcpSpawnArgs(runtimeMode?: RuntimeMode): ReadonlyArray<string> {
switch (runtimeMode) {
case "approval-required":
return ["--permission-mode", "default", "agent", "stdio"];
case "auto-accept-edits":
return ["--permission-mode", "acceptEdits", "agent", "stdio"];
return ["--permission-mode", "default", "agent", "stdio"];
case "auto":
return ["--permission-mode", "auto", "agent", "stdio"];
case "full-access":
Expand Down
Loading