diff --git a/apps/server/scripts/record-grok-acp-replay-fixture.ts b/apps/server/scripts/record-grok-acp-replay-fixture.ts index 6182bc62aca7..d8d74c92fcdb 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -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"; @@ -455,7 +456,7 @@ 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(), @@ -463,6 +464,7 @@ const recordScenario = Effect.fn("recordGrokScenario")(function* (fixtureName: s grokSettings: settings, environment, childProcessSpawner, + runtimeMode: grokLaunchRuntimeMode(runtimePolicy), }), }); // The scenario runs on the replay TestClock so its clock steps order diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index f3f05a026a35..1ea037f09548 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -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; readonly acpMcpServers?: ReadonlyArray; /** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */ @@ -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 diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts index c1a6e89d0dfc..4aeca83b7762 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts @@ -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 { @@ -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 { @@ -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> = []; + 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.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, + ); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts index 43aafbe71985..4dfb9f3d527f 100644 --- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts @@ -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"; @@ -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, @@ -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, @@ -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({ diff --git a/apps/server/src/provider/acp/AcpClientPolicy.test.ts b/apps/server/src/provider/acp/AcpClientPolicy.test.ts index 78e8a808e1f7..463b8fcf1edc 100644 --- a/apps/server/src/provider/acp/AcpClientPolicy.test.ts +++ b/apps/server/src/provider/acp/AcpClientPolicy.test.ts @@ -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" }, @@ -285,7 +304,7 @@ 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"); @@ -293,6 +312,13 @@ describe("client-mediated dispositions", () => { } }); + 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", diff --git a/apps/server/src/provider/acp/AcpClientPolicy.ts b/apps/server/src/provider/acp/AcpClientPolicy.ts index 002d345868fc..ce3e18c07443 100644 --- a/apps/server/src/provider/acp/AcpClientPolicy.ts +++ b/apps/server/src/provider/acp/AcpClientPolicy.ts @@ -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 ?? {}, @@ -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, diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index 22ee12ba3acc..d766abad06d1 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -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", ]); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index ba541c4a89e7..ce3611a9097f 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -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 { 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":