From 3ae7707b32ec8c89d899ad4016b8f84fc8314208 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 09:30:39 -0400 Subject: [PATCH 1/4] feat(fx): offer Full access where the installed fx supports it fx 0.0.14+ advertises a `full-access` ACP session mode, but T3 only offered Supervised and Auto for fx, so a Full access thread silently ran in `ask`. T3 Full access now runs fx's `full-access` mode. The provider snapshot advertises Full access only when `fx status --json` lists that mode, so on fx 0.0.13 the picker hides it and RuntimePolicy keeps running such threads in Supervised instead of failing the mode write. An explicit approval or sandbox policy still runs `ask`. Prompts fx sends in Full access are answered by the shared ACP policy, like other ACP providers. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/FxAdapterV2.test.ts | 67 ++++++++++++++++--- docs/user/permission-modes.md | 4 +- docs/user/providers-fx.md | 14 ++-- .../provider-fx/src/server/acpSupport.test.ts | 20 ++++++ packages/provider-fx/src/server/acpSupport.ts | 45 ++++++++----- .../provider-fx/src/server/status.test.ts | 31 +++++++++ packages/provider-fx/src/server/status.ts | 37 +++++++--- 7 files changed, 175 insertions(+), 43 deletions(-) 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/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 430f7a705a499..dccfc59b68451 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, outside its sandbox. 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. @@ -51,7 +52,6 @@ fx doesn't pass on a sub-agent's permission requests, so a sub-agent that needs ## Not supported yet - Plan mode. -- **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..9830ac3833f7b 100644 --- a/packages/provider-fx/src/server/acpSupport.test.ts +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -30,4 +30,24 @@ 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", + ); + }); }); 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, + }, ); }); From ad79dbc8f8bc2bd8dc3107abe218c1f19ab8eb8d Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 09:42:23 -0400 Subject: [PATCH 2/4] test(fx): run 0.0.13 replay fixtures as RuntimePolicy resolves them The shared replay fixtures default threads to Full access, and their fx transcripts were recorded on fx 0.0.13, which has no full-access mode. In production the fx snapshot omits Full access there, so RuntimePolicy runs those threads Supervised; the replay harness skips that step, so the test now applies the same resolution. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/RuntimePolicy.ts | 2 +- .../FxReplayFixtures.integration.test.ts | 19 ++++++++++++++++++- packages/provider-fx/src/testing.ts | 6 +++++- 3 files changed, 24 insertions(+), 3 deletions(-) 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/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"; From 31037c3966da7dffee5c62bdad28867f34703687 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 09:46:22 -0400 Subject: [PATCH 3/4] docs(fx): Full access disables permission checks, not a sandbox fx's ask and auto modes add no command sandbox, so describing Full access as running outside one implied an isolation boundary fx does not provide. Co-Authored-By: Claude Opus 5.5 --- docs/user/providers-fx.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index dccfc59b68451..2cf9760512fda 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -33,7 +33,7 @@ Type `$` in the composer to list the skills fx finds in its skill folders, in th | --------------- | ----------------------------------------------------------------------------------- | | **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, outside its sandbox. Needs fx 0.0.14 or later. | +| **Full access** | fx runs every action without asking. Needs fx 0.0.14 or later. | 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**. From f32cafaacf9221d81c26b8208810cc8fdaac7aed Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 03:37:47 +0000 Subject: [PATCH 4/4] test(fx): assert explicit policies keep Full access in ask mode --- packages/provider-fx/src/server/acpSupport.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index 9830ac3833f7b..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", @@ -49,5 +49,14 @@ describe("fx permission prompts", () => { ), "ask", ); + for (const confined of [ + { approvalPolicy: "on-request" }, + { sandboxPolicy: { type: "readOnly" } }, + ] as const) { + assert.equal( + fxSessionModeForPolicy(ProviderAdapterV2RuntimePolicy.make({ ...fullAccess, ...confined })), + "ask", + ); + } }); });