diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54c34aa5a7..b071a3e8ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -438,6 +438,36 @@ already in use` and no way forward short of finding the file in a temporary
### Desktop / Web UI
+- **Auto or Manual, picked per session, next to the model** — the prompt bar
+ now carries a permission-mode select beside the model select. Manual is the
+ familiar behaviour: the agent stops and asks before it runs a command or
+ edits a file. Auto approves every request that your config does not
+ explicitly deny, so you can hand over a task and walk away. Code sessions
+ start on Manual and Work sessions start on Auto, which is what each side was
+ already doing — the difference is that the choice is now visible and yours to
+ change, per session, without opening the command palette. It is the same
+ setting the "Auto-accept permissions" command and the Settings switch have
+ always driven, so all three stay in step. Work sessions gained something
+ real here: their sandbox used to be configured to approve everything before
+ the app ever saw a request, which meant Manual could not have stopped
+ anything. The sandbox now asks like any other server and the walk-away
+ default lives in the app instead, so both modes work on both sides. Existing
+ sandboxes pick this up the next time they start, and until then their select
+ shows Auto with a note explaining why it is fixed.
+
+- **Turning auto-approve on asks first, on the Code side** — the first time in
+ an app run that you switch a Code session to Auto, CoDev now confirms: a
+ dialog that says permission requests will be approved automatically and that
+ this is dangerous, since commands run and files change without asking. It
+ opens focused on Cancel, so a stray Enter declines. Say yes and it does not
+ ask again until you restart the app. All three ways in ask — the new mode
+ select, the "Auto-accept permissions" command, and the Settings switch — so
+ the confirmation is not something one door skips. Turning auto-approve off
+ never asks, and Work sessions never ask either: auto is the default there,
+ inside a sandbox that confines the agent to the VM and one mounted folder.
+ This brings the app in line with the terminal, which has asked for the same
+ confirmation since auto-approve landed there.
+
- **The app's saved state is filed under CoDev's own name** — the desktop app
kept its settings, recent servers and per-project UI state in files named
after upstream (`opencode.settings`, `opencode.global.dat`,
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index d0ad1ce1a5..1213507df9 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -306,6 +306,7 @@ from the squash message first, or use a plain merge.
| `packages/app/src/pages/layout-new.tsx` (update prompt) | One import and one element: mounts the fork-only `src/components/dialog-update.tsx`, whose `` renders nothing and opens the update dialog the first time the desktop updater reports `available` or `ready`. | The main process has always checked for updates at launch (`updater.start()`), but on the new layout the only thing that ever said so was a small Update button in the titlebar, and only once the download had finished. Users went weeks without noticing a waiting update. The legacy layout's `UpdateAvailableToast` in `pages/layout.tsx` is unreachable — `newLayoutDesigns` is always true. | Keep the import and the element; take upstream's other changes. If upstream reworks this file's layout, the element can sit anywhere inside the shell: it renders nothing. |
| Home-directory crawl guard: `packages/core/src/filesystem/search.ts`, `packages/app/src/components/directory-picker-domain.ts`, `packages/desktop/src/main/index.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/app/src/components/directory-picker-domain.test.ts` | search.ts: new exported `unindexableDirectory(directory)` (true for `Global.Path.home` — honoring `CODEV_TEST_HOME` — and filesystem roots) and a new `shallowLayer`; the layer chooser becomes `Layer.unwrap(Effect.gen(...))` reading `Location.Service` and picks `shallowLayer` for unindexable directories, fff/ripgrep otherwise. `shallowLayer.find` answers from one `readDirectoryEntries` of the location itself (direct children only, symlinks/specials dropped, same fuzzysort/empty-query/limit semantics as the index layers); its glob/grep are the on-demand ripgrep implementations, extracted from `ripgrepLayer` into a shared `ripgrepGlobGrep` effect (ripgrepLayer now spreads it — no behavior change there). directory-picker-domain.ts: `createDirectorySearch`'s bare-word branch (no `/`, `~`, or drive root in the input) matches direct children via the existing `file.list`-based `match()` when the base is the picker's home or a filesystem root, instead of `find.files({directory: base})`. desktop index.ts: the startup `process.chdir(homedir())` becomes `process.chdir(join(app.getPath("userData"), "cwd"))` (mkdir first, after `app.setPath("userData", ...)` so it lands under the channel's own folder; still inside try/catch, `homedir` import dropped). | Materializing the search service walks everything under its location — fff at creation, ripgrep as an eagerly forked scan. Rooted at `~` that walk opens files inside Downloads, Desktop, Documents and the photo/music library bundles, so macOS raised a permission prompt for each (confirmed in the TCC database: Desktop, Media Library, Downloads granted; Photos denied — the last two one second apart, one walk). The desktop app reached a home-rooted location two ways: the picker's bare-word search runs `find.files` against its `~` base, and any server request naming no directory falls back to `process.cwd()` (workspace-routing), which the sidecar inherited as `~` from the main process's chdir. Reproduced end to end against a decoy home: one directoryless `find/file` request read sentinel files inside `Downloads/` and `Photos Library.photoslibrary/`; with the guard the same request returns home's direct children and no sentinel is touched. | Keep all three pieces and take upstream's surrounding changes. If upstream reworks the search layers, preserve the invariant: no eager index (fff create or ripgrep scan) is ever built for the home directory or a filesystem root — `find` there must answer from a single readdir of the location itself. If upstream moves the desktop chdir, keep it pointed at an app-owned empty folder, never `homedir()` (and never leave it in `/`, which breaks ripgrep). The picker guard is belt-and-braces for older/remote servers; do not "simplify" it away because the server also guards. |
+| Prompt permission mode: `packages/app/src/components/prompt-input-v2.tsx`, `packages/app/src/context/permission.tsx`, `packages/app/src/pages/session/use-session-commands.tsx`, `packages/app/src/components/settings-general.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es,vi}.ts` | prompt-input-v2.tsx: the `modelControl` slot now carries a fragment — upstream's `` plus the fork-only ``, an Auto/Manual select built on session-ui's exported `PromptInputV2Select` (riding the model slot keeps it immediately right of the model select without a new slot in session-ui). It reads and writes the same client-side auto-accept state the `permissions.autoaccept` command toggles: a session takes an explicit override, a draft steers the directory default (`src/components/prompt-input/permission-mode.ts`, fork-only, holds that decision). A directory whose server config says `permission: "allow"` renders a non-interactive Auto with a tooltip, since such a server never asks. permission.tsx: `enableConfiguredDirectory` gains an `autoByDefault` parameter, passed as `sideForServerKey(activeServer()) === "work"`, so Work directories seed to auto-accept, and exposes `side()` so the consent gate agrees with it. All three paths that turn auto-accept ON — the select, the `permissions.autoaccept` command in use-session-commands.tsx, and the Settings switch in both general.tsx files — now await `useAutoAcceptConsent()` (fork-only `src/components/dialog-auto-accept.tsx`): on Code, once per app run, a confirm dialog focused on Cancel plus a warning toast, matching the TUI's `enableAutoApprove`; Work never asks, since auto is the default there that nobody picked. Turning it off never asks. i18n: the `prompt.permission.*` and `dialog.autoAccept.*` families. | Users asked to pick Auto/Manual per session, with Code defaulting to manual and Work to auto. The Work default used to come from the sandbox guest config (`permission: "allow"`, dropped in `packages/desktop/src/main/sandbox/egress.ts` — fork-only, untracked here): a server told to allow everything never emits `permission.asked`, so Manual had nothing to gate. Moving the default to the host makes both modes real on both sides while leaving the walk-away behaviour unchanged. | Keep the fork's fragment and re-apply upstream's changes to the `` inside it. The three enable paths each gained one `await consent(...)` line — keep them together, or the warning becomes theatre that one door skips. If upstream adds its own permission or mode control to the prompt toolbar, drop ours and re-point the Work default at whatever state theirs uses. `enableConfiguredDirectory`'s extra parameter is additive — if upstream rewrites that function, keep the `autoByDefault` branch or the Work side loses its auto default. |
## Known rebrand gaps
diff --git a/packages/app/src/components/dialog-auto-accept.test.ts b/packages/app/src/components/dialog-auto-accept.test.ts
new file mode 100644
index 0000000000..ee5f90f9ab
--- /dev/null
+++ b/packages/app/src/components/dialog-auto-accept.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test"
+import { autoAcceptNeedsConsent } from "./dialog-auto-accept"
+
+describe("autoAcceptNeedsConsent", () => {
+ test("Code asks the first time and stays quiet after", () => {
+ expect(autoAcceptNeedsConsent({ side: "code", consented: false })).toBe(true)
+ expect(autoAcceptNeedsConsent({ side: "code", consented: true })).toBe(false)
+ })
+
+ // Auto is the Work default nobody picked, so a dialog there would greet every
+ // new sandbox session.
+ test("Work never asks", () => {
+ expect(autoAcceptNeedsConsent({ side: "work", consented: false })).toBe(false)
+ expect(autoAcceptNeedsConsent({ side: "work", consented: true })).toBe(false)
+ })
+})
diff --git a/packages/app/src/components/dialog-auto-accept.tsx b/packages/app/src/components/dialog-auto-accept.tsx
new file mode 100644
index 0000000000..2eb22e69aa
--- /dev/null
+++ b/packages/app/src/components/dialog-auto-accept.tsx
@@ -0,0 +1,100 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
+import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
+import { onMount } from "solid-js"
+import { useLanguage } from "@/context/language"
+import { showToast } from "@/utils/toast"
+import type { WorkspaceSide } from "@/workspace/side"
+
+// Once per app run rather than once per mount, matching the TUI's per-run
+// consent (packages/tui/src/app.tsx, enableAutoApprove) and dialog-update.tsx's
+// `prompted` beside it.
+let consented = false
+
+// Work is exempt: its sandbox confines the agent to the VM and one mounted
+// folder, auto is the default there, and asking about a default the user never
+// chose is a dialog in front of every new sandbox session. Code is where auto
+// means commands run against the real machine.
+export function autoAcceptNeedsConsent(input: { side: WorkspaceSide; consented: boolean }) {
+ if (input.side === "work") return false
+ return !input.consented
+}
+
+// The gate every "turn auto-accept on" path runs through — the prompt's mode
+// select, the `permissions.autoaccept` command, and the Settings switch. The
+// TUI funnels its two entry points through one gate for the same reason: a
+// warning that one of three doors skips is theatre.
+//
+// Resolves true when auto-accept may be enabled.
+export function useAutoAcceptConsent() {
+ const dialog = useDialog()
+ const language = useLanguage()
+
+ return async (side: WorkspaceSide) => {
+ if (!autoAcceptNeedsConsent({ side, consented })) return true
+
+ const decision = { confirmed: false }
+ // push, not show: this can be raised from the Settings dialog, which must
+ // still be there behind it.
+ await new Promise((resolve) => {
+ dialog.push(
+ () => (
+ {
+ decision.confirmed = true
+ dialog.close()
+ }}
+ />
+ ),
+ resolve,
+ )
+ })
+ if (!decision.confirmed) return false
+
+ consented = true
+ showToast({
+ icon: "warning",
+ title: language.t("dialog.autoAccept.toast.title"),
+ description: language.t("dialog.autoAccept.toast.description"),
+ })
+ return true
+ }
+}
+
+function DialogAutoAccept(props: { onConfirm: () => void }) {
+ const language = useLanguage()
+ const dialog = useDialog()
+ let cancel: HTMLButtonElement | undefined
+
+ // Cancel takes the focus so a stray Enter declines, the way the TUI's confirm
+ // starts on cancel. Twice: the prompt input restores focus to its editor a
+ // frame after its mode menu closes (restoreFocus in session-ui's
+ // interaction.ts), and without claiming it back after that frame, Enter would
+ // send the prompt out from under an open dialog.
+ onMount(() => {
+ cancel?.focus()
+ requestAnimationFrame(() => cancel?.focus())
+ })
+
+ return (
+
+ )
+}
diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx
index 523803f9cc..5da008207e 100644
--- a/packages/app/src/components/prompt-input-v2.tsx
+++ b/packages/app/src/components/prompt-input-v2.tsx
@@ -6,11 +6,14 @@ import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
+import { useParams } from "@solidjs/router"
import { createEffect, createMemo, on, Show } from "solid-js"
+import { useAutoAcceptConsent } from "@/components/dialog-auto-accept"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import type { PromptInputProps } from "@/components/prompt-input/contracts"
import { normalizePromptHistoryEntry, promptLength, type PromptHistoryComment } from "@/components/prompt-input/history"
import { createPersistedPromptInputHistory } from "@/components/prompt-input/history-store"
+import { permissionModeAction, type PromptPermissionMode } from "@/components/prompt-input/permission-mode"
import { promptDesignPlaceholder, promptPlaceholder } from "@/components/prompt-input/placeholder"
import { createPromptSubmit } from "@/components/prompt-input/submit"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -25,7 +28,11 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
-import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
+import {
+ PromptInputV2,
+ PromptInputV2Select,
+ type PromptInputV2Suggestion,
+} from "@opencode-ai/session-ui/v2/prompt-input"
import {
createPromptInputV2Controller,
createPromptInputV2State,
@@ -62,24 +69,29 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
- {
- // With the hosted free-model providers hidden (gateway/hidden-providers),
- // "unpaid" means nothing is connected, so offer the connect flow instead
- // of a free-models list that would render empty.
- void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
- void dialog.show(() => )
- })
- }}
- />
+ <>
+ {
+ // With the hosted free-model providers hidden (gateway/hidden-providers),
+ // "unpaid" means nothing is connected, so offer the connect flow instead
+ // of a free-models list that would render empty.
+ void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
+ void dialog.show(() => )
+ })
+ }}
+ />
+ {/* Fork: rides the model slot rather than a slot of its own so it
+ lands immediately right of the model select. */}
+
+ >
}
/>
@@ -556,6 +568,86 @@ function PromptInputV2ModelControl(props: {
)
}
+// Fork: Auto/Manual permission mode for the session, beside the model select.
+// "Auto" is the same client-side auto-accept the `permissions.autoaccept`
+// command toggles, so the select and the command always agree.
+function PromptInputV2PermissionControl(props: { onClose: () => void }) {
+ const language = useLanguage()
+ const command = useCommand()
+ const permission = usePermission()
+ const sdk = useSDK()
+ const params = useParams<{ id?: string }>()
+ const consent = useAutoAcceptConsent()
+
+ const directory = () => sdk().directory
+ const mode = createMemo(() =>
+ (params.id ? permission.isAutoAccepting(params.id, directory()) : permission.isAutoAcceptingDirectory(directory()))
+ ? "auto"
+ : "manual",
+ )
+ // A server whose config approves everything never raises a request, so Manual
+ // would be a promise this control cannot keep. Sandboxes created before their
+ // guest config stopped saying `permission: "allow"` are the case that turns up
+ // in practice; they become switchable on their next start.
+ const locked = createMemo(() => permission.isPermissionAllowAll(directory()))
+ const label = (value: PromptPermissionMode) =>
+ language.t(value === "auto" ? "prompt.permission.auto" : "prompt.permission.manual")
+
+ return (
+
+
+ {/* aria-disabled rather than disabled: a disabled button swallows the
+ pointer events the tooltip needs to explain why it is stuck. */}
+
+ {label("auto")}
+
+
+ }
+ >
+
+ {
+ if (!open) props.onClose()
+ }}
+ onSelect={(id) => {
+ const next = id === "auto" ? "auto" : "manual"
+ const action = permissionModeAction({ sessionID: params.id, current: mode(), next })
+ if (action.type === "none") return
+ void (async () => {
+ if (next === "auto" && !(await consent(permission.side()))) return
+ if (action.type === "directory") {
+ permission.toggleAutoAcceptDirectory(directory())
+ return
+ }
+ if (action.auto) permission.enableAutoAccept(action.sessionID, directory())
+ else permission.disableAutoAccept(action.sessionID, directory())
+ })()
+ }}
+ />
+
+
+
+ )
+}
+
function openComment(
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
props: PromptInputV2ControllerProps,
diff --git a/packages/app/src/components/prompt-input/permission-mode.test.ts b/packages/app/src/components/prompt-input/permission-mode.test.ts
new file mode 100644
index 0000000000..c14d6aaba1
--- /dev/null
+++ b/packages/app/src/components/prompt-input/permission-mode.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from "bun:test"
+import { permissionModeAction } from "./permission-mode"
+
+describe("permissionModeAction", () => {
+ test("a session takes an explicit override in both directions", () => {
+ expect(permissionModeAction({ sessionID: "ses_1", current: "manual", next: "auto" })).toEqual({
+ type: "session",
+ sessionID: "ses_1",
+ auto: true,
+ })
+ expect(permissionModeAction({ sessionID: "ses_1", current: "auto", next: "manual" })).toEqual({
+ type: "session",
+ sessionID: "ses_1",
+ auto: false,
+ })
+ })
+
+ test("a draft steers the directory default instead", () => {
+ expect(permissionModeAction({ current: "manual", next: "auto" })).toEqual({ type: "directory" })
+ expect(permissionModeAction({ current: "auto", next: "manual" })).toEqual({ type: "directory" })
+ })
+
+ // The directory side only has a toggle, so re-picking the mode already shown
+ // would flip it the wrong way without this.
+ test("re-picking the current mode does nothing", () => {
+ expect(permissionModeAction({ current: "auto", next: "auto" })).toEqual({ type: "none" })
+ expect(permissionModeAction({ sessionID: "ses_1", current: "manual", next: "manual" })).toEqual({ type: "none" })
+ })
+})
diff --git a/packages/app/src/components/prompt-input/permission-mode.ts b/packages/app/src/components/prompt-input/permission-mode.ts
new file mode 100644
index 0000000000..a699f3fea6
--- /dev/null
+++ b/packages/app/src/components/prompt-input/permission-mode.ts
@@ -0,0 +1,24 @@
+export type PromptPermissionMode = "auto" | "manual"
+
+export type PromptPermissionAction =
+ | { type: "none" }
+ | { type: "session"; sessionID: string; auto: boolean }
+ | { type: "directory" }
+
+// What picking `next` in the prompt's permission select has to do.
+//
+// A session takes an explicit override so it stops following its directory's
+// default — that is what makes the choice per-session. A draft has no session
+// to override yet, so it steers the directory default instead, which is also
+// what the session inherits once the draft is sent. The directory side only
+// has a toggle, so the no-op guard is what keeps a re-pick of the current mode
+// from flipping it the wrong way.
+export function permissionModeAction(input: {
+ sessionID?: string
+ current: PromptPermissionMode
+ next: PromptPermissionMode
+}): PromptPermissionAction {
+ if (input.next === input.current) return { type: "none" }
+ if (!input.sessionID) return { type: "directory" }
+ return { type: "session", sessionID: input.sessionID, auto: input.next === "auto" }
+}
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index 214783253a..89adbda31e 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -7,6 +7,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useParams } from "@solidjs/router"
+import { useAutoAcceptConsent } from "@/components/dialog-auto-accept"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
@@ -78,6 +79,7 @@ export const SettingsGeneral: Component = () => {
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
+ const consent = useAutoAcceptConsent()
const platform = usePlatform()
const params = useParams()
const settings = useSettings()
@@ -93,9 +95,12 @@ export const SettingsGeneral: Component = () => {
return permission.isAutoAccepting(params.id, value)
})
- const toggleAccept = (checked: boolean) => {
+ const toggleAccept = async (checked: boolean) => {
const value = dir()
if (!value) return
+ // Fork: same consent gate as the prompt's mode select and the
+ // `permissions.autoaccept` command; turning it off never asks.
+ if (checked && !(await consent(permission.side()))) return
if (!params.id) {
if (permission.isAutoAcceptingDirectory(value) === checked) return
diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx
index ab5c1bfbff..25944253a9 100644
--- a/packages/app/src/components/settings-v2/general.tsx
+++ b/packages/app/src/components/settings-v2/general.tsx
@@ -5,6 +5,7 @@ import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
+import { useAutoAcceptConsent } from "@/components/dialog-auto-accept"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
@@ -79,6 +80,7 @@ export const SettingsGeneralV2: Component<{
const theme = useTheme()
const language = useLanguage()
const permission = usePermission()
+ const consent = useAutoAcceptConsent()
const platform = usePlatform()
const settings = useSettings()
const serverSync = useServerSync()
@@ -97,11 +99,14 @@ export const SettingsGeneralV2: Component<{
return permission.isAutoAccepting(props.sessionID, value)
})
- const toggleAccept = (checked: boolean) => {
+ const toggleAccept = async (checked: boolean) => {
const value = dir()
if (!value || !props.sessionID) return
if (checked) {
+ // Fork: same consent gate as the prompt's mode select and the
+ // `permissions.autoaccept` command; turning it off never asks.
+ if (!(await consent(permission.side()))) return
permission.enableAutoAccept(props.sessionID, value)
return
}
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index 496d1ab4a2..428e84f3a8 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
+import { sideForServerKey } from "@/workspace/side"
import {
acceptKey,
directoryAcceptKey,
@@ -138,7 +139,12 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
createEffect(() => {
const directory = activeDirectory()
if (!directory) return
- selected().enableConfiguredDirectory(directory)
+ // Fork: Work (sandbox) directories default to auto-accept. That default
+ // used to come from the guest config, which shipped `permission: "allow"`
+ // — but a server that allows everything never asks, so the prompt's
+ // Manual option could not be honoured there. The guest now asks like any
+ // other server and the walk-away default lives here instead.
+ selected().enableConfiguredDirectory(directory, sideForServerKey(activeServer()) === "work")
})
const permissionsEnabled = createMemo(() => {
@@ -149,6 +155,10 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return {
ready: () => selected().ready(),
+ // Fork: the workspace side the permission state currently belongs to.
+ // Exposed so the auto-accept consent gate (dialog-auto-accept.tsx), which
+ // only asks on Code, agrees with the Work default seeded above.
+ side: () => sideForServerKey(activeServer()),
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
currentServerState: () => selected().api,
respond(input: Parameters[0]) {
@@ -210,10 +220,13 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
}),
)
- function enableConfiguredDirectory(directory: string) {
+ // `autoByDefault` is the fork's Work side, whose directories start on auto
+ // whatever the guest config says. Either way this only seeds a directory that
+ // has never been set: a user who picks Manual keeps it.
+ function enableConfiguredDirectory(directory: string, autoByDefault = false) {
if (meta.disposed || !ready()) return
const [childStore] = input.sync.child(directory)
- if (childStore.config.permission !== "allow") return
+ if (!autoByDefault && childStore.config.permission !== "allow") return
const key = directoryAcceptKey(directory)
if (store.autoAccept[key] !== undefined) return
setStore(
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index e977a6e60b..4fef895d9c 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -250,6 +250,16 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc to exit",
+ "prompt.permission.title": "Permission mode",
+ "prompt.permission.auto": "Auto",
+ "prompt.permission.manual": "Manual",
+ "prompt.permission.locked": "This workspace approves every action in its config, so the mode cannot be changed here.",
+ "dialog.autoAccept.title": "Auto-approve Permissions",
+ "dialog.autoAccept.description": "Permission requests that are not explicitly denied will be approved automatically.",
+ "dialog.autoAccept.warning": "This is dangerous: CoDev Code can run commands and edit files without asking.",
+ "dialog.autoAccept.confirm": "Enable auto-approve",
+ "dialog.autoAccept.toast.title": "Auto-approve is on",
+ "dialog.autoAccept.toast.description": "Commands run and files change without asking.",
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
"session.child.backToParent": "Back to main session.",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index a39b68e8a3..d6097ab26a 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -247,6 +247,18 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para salir",
+ "prompt.permission.title": "Modo de permisos",
+ "prompt.permission.auto": "Automático",
+ "prompt.permission.manual": "Manual",
+ "prompt.permission.locked":
+ "Este espacio de trabajo aprueba todas las acciones en su configuración, así que el modo no se puede cambiar aquí.",
+ "dialog.autoAccept.title": "Aprobación automática de permisos",
+ "dialog.autoAccept.description":
+ "Las solicitudes de permiso que no se denieguen explícitamente se aprobarán automáticamente.",
+ "dialog.autoAccept.warning": "Esto es peligroso: CoDev Code puede ejecutar comandos y editar archivos sin preguntar.",
+ "dialog.autoAccept.confirm": "Activar aprobación automática",
+ "dialog.autoAccept.toast.title": "Aprobación automática activada",
+ "dialog.autoAccept.toast.description": "Los comandos se ejecutan y los archivos cambian sin preguntar.",
"session.child.promptDisabled": "No se pueden enviar prompts a las sesiones de subagentes.",
"session.child.backToParent": "Volver a la sesión principal.",
diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts
index dc5ea718cc..a32ed451ab 100644
--- a/packages/app/src/i18n/vi.ts
+++ b/packages/app/src/i18n/vi.ts
@@ -250,6 +250,17 @@ export const dict = {
"prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc để thoát",
+ "prompt.permission.title": "Chế độ quyền",
+ "prompt.permission.auto": "Tự động",
+ "prompt.permission.manual": "Thủ công",
+ "prompt.permission.locked":
+ "Không gian làm việc này chấp thuận mọi hành động trong cấu hình của nó, nên không thể đổi chế độ ở đây.",
+ "dialog.autoAccept.title": "Tự động chấp thuận quyền",
+ "dialog.autoAccept.description": "Các yêu cầu quyền không bị từ chối rõ ràng sẽ được chấp thuận tự động.",
+ "dialog.autoAccept.warning": "Điều này nguy hiểm: CoDev Code có thể chạy lệnh và sửa tệp mà không hỏi.",
+ "dialog.autoAccept.confirm": "Bật tự động chấp thuận",
+ "dialog.autoAccept.toast.title": "Tự động chấp thuận đang bật",
+ "dialog.autoAccept.toast.description": "Lệnh sẽ chạy và tệp sẽ thay đổi mà không hỏi.",
"session.child.promptDisabled": "Không thể gửi prompt cho session của subagent.",
"session.child.backToParent": "Quay lại session chính.",
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 275e6ec4bc..abe93d9cb2 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/packages/app/src/pages/session/use-session-commands.tsx
@@ -1,5 +1,6 @@
import { useNavigate } from "@solidjs/router"
import { useCommand, type CommandOption } from "@/context/command"
+import { useAutoAcceptConsent } from "@/components/dialog-auto-accept"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file"
@@ -42,6 +43,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
+ const consent = useAutoAcceptConsent()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
@@ -284,8 +286,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
)
}
- const toggleAutoAccept = () => {
+ const toggleAutoAccept = async () => {
const sessionID = params.id
+ // Fork: turning it on goes through the same consent gate as the prompt's
+ // mode select and the Settings switch; turning it off never asks.
+ if (!isAutoAcceptActive() && !(await consent(permission.side()))) return
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
else permission.toggleAutoAcceptDirectory(sdk().directory)
diff --git a/packages/desktop/src/main/sandbox/egress.ts b/packages/desktop/src/main/sandbox/egress.ts
index 717753cd4c..396ca23197 100644
--- a/packages/desktop/src/main/sandbox/egress.ts
+++ b/packages/desktop/src/main/sandbox/egress.ts
@@ -92,13 +92,15 @@ export async function resolveSandboxEgress(opts: { guestBrokerPort: number }): P
...(gateway
? buildGatewayConfig({ apiKey: auth, baseUrl: guestBrokerBase, model: gateway.model, models })
: {}),
- // Everything the agent does is confined to the VM and the one mounted
- // project folder, so permission prompts buy nothing here — auto-allow
- // is what makes "hand it a task and walk away" work. The app surfaces
- // this as directory auto-accept (enableConfiguredDirectory), which the
- // user can still toggle off per directory, and a project config inside
- // the workspace can override it (project merges after CODEV_CONFIG).
- permission: "allow",
+ // No `permission: "allow"` here, deliberately. Everything the agent does
+ // is confined to the VM and the one mounted project folder, so a Work
+ // session should still run without prompting — but a server told to
+ // allow everything never asks, and the prompt's Manual mode then had
+ // nothing to gate. The guest asks like any other server now, and the
+ // walk-away default moved to the host: the app seeds every Work
+ // directory as auto-accepting (enableConfiguredDirectory in
+ // packages/app/src/context/permission.tsx), which the user can switch
+ // to Manual per session or per directory.
},
null,
2,