diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts index a4732c6efe216..2b3e3a5237739 100644 --- a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts @@ -53,7 +53,7 @@ const NAMED_MODEL = "anthropic/claude-sonnet-5.5"; const CUSTOM_MODEL = "openai/gpt-6.1-sol"; const CODEX_MODEL = "gpt-6.1-sol"; const FX_0_0_13_MODES = ["code", "ask"]; -/** fx `main` after vercel-labs/fx#1189. */ +/** fx 0.0.14+, after vercel-labs/fx#1189. */ const FX_MAIN_MODES = ["auto", "ask", "full-access"]; type Frame = Record; @@ -112,11 +112,15 @@ const fxConfig = (input: { const fxSession = (input: { readonly model: string; readonly modes: ReadonlyArray; + readonly mode?: string; readonly models?: ReadonlyArray; }) => ({ sessionId: SESSION_ID, configOptions: fxConfig(input), - modes: { currentModeId: "ask", availableModes: input.modes.map((id) => ({ id, name: id })) }, + modes: { + currentModeId: input.mode ?? "ask", + availableModes: input.modes.map((id) => ({ id, name: id })), + }, }); const initialize: ReadonlyArray = [ @@ -265,16 +269,22 @@ const openFxSession = Effect.fn("openFxSession")(function* (input: { }); describe("FxAdapterV2", () => { - it.effect("runs Auto as fx's `code` mode on 0.0.13 and as `auto` where fx advertises it", () => + it.effect("runs Auto as `code` on 0.0.13, and Auto and Full access as fx names them later", () => Effect.forEach( [ - { scenario: "auto-0-0-13", modes: FX_0_0_13_MODES, written: "code" }, - { scenario: "auto-main", modes: FX_MAIN_MODES, written: "auto" }, - ], - ({ scenario, modes, written }) => + { scenario: "auto-0-0-13", runtimeMode: "auto", modes: FX_0_0_13_MODES, written: "code" }, + { scenario: "auto-main", runtimeMode: "auto", modes: FX_MAIN_MODES, written: "auto" }, + { + scenario: "full-access-main", + runtimeMode: "full-access", + modes: FX_MAIN_MODES, + written: "full-access", + }, + ] as const, + ({ scenario, runtimeMode, modes, written }) => openFxSession({ scenario, - runtimeMode: "auto", + runtimeMode, frames: [ ...initialize, outbound("session/new", { cwd: "", mcpServers: "" }), @@ -290,6 +300,47 @@ describe("FxAdapterV2", () => { ).pipe(Effect.provide(testLayer), Effect.scoped), ); + // A permission mode change restarts fx and loads the same session, which + // still reports the mode it last ran in. + it.effect("loads the same session into and out of Full access", () => + Effect.forEach( + [ + { + scenario: "into-full-access", + runtimeMode: "full-access", + from: "ask", + to: "full-access", + }, + { + scenario: "out-of-full-access", + runtimeMode: "approval-required", + from: "full-access", + to: "ask", + }, + ] as const, + ({ scenario, runtimeMode, from, to }) => + openFxSession({ + scenario, + runtimeMode, + loadSessionId: SESSION_ID, + frames: [ + ...initialize, + outbound("session/load"), + answer( + "session/load", + fxSession({ model: FX_DEFAULT_MODEL, modes: FX_MAIN_MODES, mode: from }), + ), + ...setConfig( + "mode", + to, + fxConfig({ model: FX_DEFAULT_MODEL, modes: FX_MAIN_MODES, mode: to }), + ), + ], + }), + { discard: true }, + ).pipe(Effect.provide(testLayer), Effect.scoped), + ); + it.effect("puts a loaded session that ran a named model back on fx's default for Default", () => openFxSession({ scenario: "default-model", diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index f0d0ae38577d1..ca7f70561046d 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -88,7 +88,7 @@ export const layer: Layer.Layer = Layer.succeed(RuntimePolicyV2 }), }); -function providerRuntimeMode( +export function providerRuntimeMode( runtimeMode: RuntimeMode, supportedRuntimeModes: ReadonlyArray | undefined, ): RuntimeMode { diff --git a/apps/server/src/orchestration-v2/testkit/FxReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/FxReplayFixtures.integration.test.ts index 60adbeb3f2d38..c226c2ba0f350 100644 --- a/apps/server/src/orchestration-v2/testkit/FxReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/FxReplayFixtures.integration.test.ts @@ -1,10 +1,27 @@ import { describe } from "@effect/vitest"; +import { FX_SUPPORTED_RUNTIME_MODES } from "@t3tools/provider-fx/testing"; import { FxOrchestratorReplayHarness } from "../Adapters/FxAdapterV2.testkit.ts"; +import { providerRuntimeMode } from "../RuntimePolicy.ts"; import { itRunsReplayFixtures, runReplayFixture } from "./ProviderReplayFixtures.ts"; describe("fx replay fixtures", () => { + // Recorded where fx's snapshot offers no Full access (0.0.13), so a thread + // set to it runs as RuntimePolicy resolves it there: Supervised. itRunsReplayFixtures("fx", (input) => - runReplayFixture({ ...input, harness: FxOrchestratorReplayHarness }), + runReplayFixture({ + ...input, + buildInput: () => { + const fixtureInput = input.buildInput(); + return { + ...fixtureInput, + runtimeMode: providerRuntimeMode( + fixtureInput.runtimeMode ?? "full-access", + FX_SUPPORTED_RUNTIME_MODES, + ), + }; + }, + harness: FxOrchestratorReplayHarness, + }), ); }); diff --git a/docs/user/permission-modes.md b/docs/user/permission-modes.md index 796e3f316122d..6a3fa01ab7e75 100644 --- a/docs/user/permission-modes.md +++ b/docs/user/permission-modes.md @@ -34,8 +34,8 @@ to you for approval. On fx, prompts it still sends also come to you. Muse Code offers only **Supervised** and **Full access**. A Muse thread already set to another mode runs in **Supervised**. -T3 Code offers no **Auto-accept edits** or **Full access** for fx. An fx thread already set to either runs in -**Supervised**. +T3 Code offers no **Auto-accept edits** for fx, and **Full access** only with fx 0.0.14 or later. An fx +thread set to a mode its fx lacks runs in **Supervised**. Grok offers no **Auto-accept edits**. A Grok thread already set to it runs in **Supervised**. Grok file-change approvals offer **Allow all edits this session**. Its command approvals have no diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 312ca7898f6c4..94851409eb137 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -29,14 +29,15 @@ Type `$` in the composer to list the skills fx finds in its skill folders, in th ## Permission modes -fx offers two composer modes. +| Mode | Behavior | +| --------------- | ----------------------------------------------------------------------------------- | +| **Supervised** | fx asks before it changes files or runs commands. Read requests run without asking. | +| **Auto** | fx handles routine actions itself. Anything it still asks about reaches you. | +| **Full access** | fx runs every action without asking. Needs fx 0.0.14 or later. | -| Mode | Behavior | -| -------------- | ----------------------------------------------------------------------------------- | -| **Supervised** | fx asks before it changes files or runs commands. Read requests run without asking. | -| **Auto** | fx handles routine actions itself. Anything it still asks about reaches you. | +T3 Code offers **Full access** only when the server's fx reports it. After you upgrade fx, refresh the provider in **Settings → Providers** to see it. On an older fx, or before the provider check finishes, a thread set to **Full access** runs in **Supervised**. -T3 Code does not offer **Full access** or **Auto-accept edits** for fx. A thread already set to one of those runs in **Supervised**. Changing the permission mode restarts the connection and reloads the same fx session. +T3 Code does not offer **Auto-accept edits** for fx. A thread already set to it runs in **Supervised**. Changing the permission mode restarts the connection and reloads the same fx session. On fx versions after [vercel-labs/fx#1189](https://github.com/vercel-labs/fx/pull/1189), fx saves the mode T3 Code sets as its default permission mode. T3 Code sets it each time it opens an fx thread, so fx in the terminal then starts in that thread's mode. @@ -50,7 +51,6 @@ fx doesn't pass on a sub-agent's permission requests, so a sub-agent that needs ## Not supported yet -- **Full access**. - **Auto-accept edits**. - Commit messages, pull request text, branch names, and thread titles. Pick another provider for those. diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index ae40665cb031c..7045a2c73df88 100644 --- a/packages/provider-fx/src/server/acpSupport.test.ts +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -3,7 +3,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; -import { fxPermissionDisposition } from "./acpSupport.ts"; +import { fxPermissionDisposition, fxSessionModeForPolicy } from "./acpSupport.ts"; const editRequest = { sessionId: "fx-session-01", @@ -30,4 +30,33 @@ describe("fx permission prompts", () => { "ask", ); }); + + it("answers Full access prompts itself unless an explicit policy confines them", () => { + const fullAccess = { + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/work", + } as const; + assert.equal( + fxPermissionDisposition(ProviderAdapterV2RuntimePolicy.make(fullAccess), editRequest), + "allow", + ); + // Runs fx's `ask`, so the policy decides what fx asks about. + assert.equal( + fxPermissionDisposition( + ProviderAdapterV2RuntimePolicy.make({ ...fullAccess, approvalPolicy: "on-request" }), + editRequest, + ), + "ask", + ); + for (const confined of [ + { approvalPolicy: "on-request" }, + { sandboxPolicy: { type: "readOnly" } }, + ] as const) { + assert.equal( + fxSessionModeForPolicy(ProviderAdapterV2RuntimePolicy.make({ ...fullAccess, ...confined })), + "ask", + ); + } + }); }); diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index b77a4431faf1a..be760460bd20a 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -50,36 +50,51 @@ export const FX_EFFORT_OPTION_DESCRIPTOR: ProviderOptionDescriptor = { })), }; +/** Every fx offers these; the provider snapshot adds Full access where `fx status` lists `full-access`. */ export const FX_SUPPORTED_RUNTIME_MODES = [ "approval-required", "auto", ] as const satisfies ReadonlyArray; +/** fx 0.0.14+; fx 0.0.13 has no such mode. */ +export const FX_FULL_ACCESS_MODE_ID = "full-access"; /** fx 0.0.13 answers a cancelled prompt with `stopReason: cancelled`. */ export const FX_ACP_CANCEL_BEHAVIOR = "wait-for-prompt" as const; /** - * Supervised runs fx's `ask` mode and Auto its automatic review. An explicit - * approval or sandbox policy must not launch automatic review, which would - * perform writes the policy meant to confine. + * Supervised runs fx's `ask` mode, Auto its automatic review and Full access + * its `full-access`. An explicit approval or sandbox policy runs `ask`, so it + * never launches a mode that performs writes the policy meant to confine. */ export function fxSessionModeForPolicy( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, -): "ask" | "auto" { - return policy.runtimeMode === "auto" && - policy.approvalPolicy === undefined && - policy.sandboxPolicy === undefined - ? "auto" - : "ask"; +): "ask" | "auto" | typeof FX_FULL_ACCESS_MODE_ID { + if (policy.approvalPolicy !== undefined || policy.sandboxPolicy !== undefined) return "ask"; + switch (policy.runtimeMode) { + case "auto": + return "auto"; + case "full-access": + return FX_FULL_ACCESS_MODE_ID; + default: + return "ask"; + } } -/** In automatic review fx asks only about what it refused itself, so every prompt reaches the user. */ +/** + * In automatic review fx asks only about what it refused itself, so every + * prompt reaches the user. Full access answers by the shared ACP policy. + */ export function fxPermissionDisposition( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, request: EffectAcpSchema.RequestPermissionRequest, ): AcpPermissionDisposition { - return fxSessionModeForPolicy(policy) === "auto" - ? "ask" - : acpPermissionDisposition({ ...policy, runtimeMode: "approval-required" }, request); + switch (fxSessionModeForPolicy(policy)) { + case "auto": + return "ask"; + case FX_FULL_ACCESS_MODE_ID: + return acpPermissionDisposition(policy, request); + case "ask": + return acpPermissionDisposition({ ...policy, runtimeMode: "approval-required" }, request); + } } /** @@ -90,8 +105,8 @@ export function fxPermissionDisposition( */ function fxModeId(requested: string, advertised: ReadonlyArray): string | undefined { const candidates = - requested === "ask" - ? ["ask"] + requested === "ask" || requested === FX_FULL_ACCESS_MODE_ID + ? [requested] : requested === "auto" || requested === "code" ? ["auto", "code"] : []; diff --git a/packages/provider-fx/src/server/status.test.ts b/packages/provider-fx/src/server/status.test.ts index 3f9ae01ae969a..8537d54436ebf 100644 --- a/packages/provider-fx/src/server/status.test.ts +++ b/packages/provider-fx/src/server/status.test.ts @@ -129,6 +129,37 @@ describe("checkFxProviderStatus", () => { // "Default" carries the model `fx status` reports, which the adapter selects for it. assert.deepEqual(signedIn.draft.models[0]?.aliases, ["anthropic/claude-opus-5.5"]); assert.isDefined(signedIn.draft.versionAdvisory); + // fx 0.0.13 lists no modes and has no full-access mode. + assert.deepEqual(signedIn.draft.supportedRuntimeModes, ["approval-required", "auto"]); + + // fx 0.0.14 lists the ACP session modes (src/core/output/output_contracts.zig). + const withModes = yield* check( + JSON.stringify({ + kind: "status", + model: "anthropic/claude-opus-5.5", + auth: "fx login", + permission_mode: "ask", + mode: "ask", + modes: [ + { + id: "auto", + name: "Auto", + description: "Check actions with an automatic safety review instead of asking", + }, + { id: "ask", name: "Ask", description: "Request permission before making any changes" }, + { + id: "full-access", + name: "Full access", + description: "Run every action without asking, outside the sandbox", + }, + ], + }), + ); + assert.deepEqual(withModes.draft.supportedRuntimeModes, [ + "approval-required", + "auto", + "full-access", + ]); const signedOut = yield* check( JSON.stringify({ diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index c71e909197df2..6594c3466f13a 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -14,6 +14,7 @@ import { FX_DEFAULT_MODEL_SLUG, FX_DRIVER_KIND, FX_EFFORT_OPTION_DESCRIPTOR, + FX_FULL_ACCESS_MODE_ID, FX_SUPPORTED_RUNTIME_MODES, } from "./acpSupport.ts"; import { @@ -28,11 +29,13 @@ import { spawnAndCollect, } from "@t3tools/provider-core/server/snapshotProbe"; -const FX_PRESENTATION = { +const fxPresentation = (fullAccess: boolean) => ({ displayName: "fx", showInteractionModeToggle: false, - supportedRuntimeModes: FX_SUPPORTED_RUNTIME_MODES, -} as const; + supportedRuntimeModes: fullAccess + ? [...FX_SUPPORTED_RUNTIME_MODES, "full-access" as const] + : FX_SUPPORTED_RUNTIME_MODES, +}); const FX_MODEL_CAPABILITIES = createModelCapabilities({ optionDescriptors: [FX_EFFORT_OPTION_DESCRIPTOR], }); @@ -73,16 +76,19 @@ const snapshot = ( settings: FxSettings, checkedAt: string, probe: ProviderProbeResult, - defaultModel?: string, - listed?: ReadonlyArray, + read: { + readonly defaultModel?: string | undefined; + readonly listed?: ReadonlyArray | undefined; + readonly fullAccess?: boolean; + } = {}, ): ServerProviderDraft => ({ ...buildServerProvider({ // Gives the snapshot its version advisory; fx runs no enrichment pass. driver: FX_DRIVER_KIND, - presentation: FX_PRESENTATION, + presentation: fxPresentation(read.fullAccess ?? false), enabled: settings.enabled, checkedAt, - models: fxModels(settings, defaultModel, listed), + models: fxModels(settings, read.defaultModel, read.listed), probe, }), // Every `fx acp` session is saved under `~/.fx` and ACP has no session @@ -121,11 +127,15 @@ const runFxCli = Effect.fn("runFxCli")(function* ( ); }); -/** `fx status --json` on fx 0.0.13: `auth` names the login method, `missing` when signed out. */ +/** + * `fx status --json`: `auth` names the login method, `missing` when signed + * out. fx 0.0.14+ also lists the ACP session `modes`; 0.0.13 omits them. + */ const FxStatus = Schema.fromJsonString( Schema.Struct({ auth: Schema.optional(Schema.String), model: Schema.optional(Schema.String), + modes: Schema.optional(Schema.Array(Schema.Struct({ id: Schema.String }))), }), ); const decodeFxStatus = Schema.decodeUnknownOption(FxStatus); @@ -145,7 +155,7 @@ function fxAuth(auth: string | undefined): ServerProviderAuth { } /** - * Reads `fx --version`, `fx status --json` (login and default model) and + * Reads `fx --version`, `fx status --json` (login, default model and modes) and * `fx models --json`. None of them opens an ACP session, which fx would save. */ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function* ( @@ -220,8 +230,13 @@ export const checkFxProviderStatus = Effect.fn("checkFxProviderStatus")(function settings, checkedAt, { installed: true, version, auth, ...probe }, - defaultModel, - Option.getOrUndefined(listed), + { + defaultModel, + listed: Option.getOrUndefined(listed), + fullAccess: + Option.getOrUndefined(status)?.modes?.some((mode) => mode.id === FX_FULL_ACCESS_MODE_ID) ?? + false, + }, ); }); diff --git a/packages/provider-fx/src/testing.ts b/packages/provider-fx/src/testing.ts index 8a3258b9ce5b8..6b962a2f5b6bc 100644 --- a/packages/provider-fx/src/testing.ts +++ b/packages/provider-fx/src/testing.ts @@ -4,4 +4,8 @@ * @module provider-fx/testing */ export { FX_DRIVER_KIND, makeFxAdapterV2 } from "./server/adapter.ts"; -export { FX_ACP_CANCEL_BEHAVIOR, makeFxAcpRuntime } from "./server/acpSupport.ts"; +export { + FX_ACP_CANCEL_BEHAVIOR, + FX_SUPPORTED_RUNTIME_MODES, + makeFxAcpRuntime, +} from "./server/acpSupport.ts";