From abe99b1ce47c947da45bd48aad832b5da2b7988b Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 25 Aug 2026 09:13:22 +0700 Subject: [PATCH 1/2] feat(app): pick Auto or Manual permission mode per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a permission-mode select to the prompt bar, immediately right of the model select. Code sessions default to Manual, Work sessions to Auto — which is what each side already did; the change is that the choice is now visible and per-session. It drives the same client-side auto-accept state as the `permissions.autoaccept` command and the Settings switch, so all three agree. A session takes an explicit override; a draft steers the directory default, matching what that command already does there. Work needed the mode to become real. Its sandbox guest config shipped `permission: "allow"`, and a server told to allow everything never emits `permission.asked`, so Manual would have had nothing to gate. The guest now asks like any other server and the walk-away default moved host-side, where `enableConfiguredDirectory` seeds Work directories to auto-accept. Existing sandboxes pick this up on their next start; until then their select renders a non-interactive Auto explaining why it is fixed. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 +++ DIVERGENCES.md | 1 + .../app/src/components/prompt-input-v2.tsx | 128 +++++++++++++++--- .../prompt-input/permission-mode.test.ts | 29 ++++ .../prompt-input/permission-mode.ts | 24 ++++ packages/app/src/context/permission.tsx | 15 +- packages/app/src/i18n/en.ts | 4 + packages/app/src/i18n/es.ts | 5 + packages/app/src/i18n/vi.ts | 5 + packages/desktop/src/main/sandbox/egress.ts | 16 ++- 10 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 packages/app/src/components/prompt-input/permission-mode.test.ts create mode 100644 packages/app/src/components/prompt-input/permission-mode.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c34aa5a7..cb5c940db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -438,6 +438,23 @@ 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. + - **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..6b668daacd 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/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. i18n: the `prompt.permission.*` family. | 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. 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/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 523803f9cc..09263e77b9 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -6,11 +6,13 @@ 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 { 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 +27,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 +68,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 +567,85 @@ 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 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 action = permissionModeAction({ + sessionID: params.id, + current: mode(), + next: id === "auto" ? "auto" : "manual", + }) + if (action.type === "none") 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/context/permission.tsx b/packages/app/src/context/permission.tsx index 496d1ab4a2..43c85e2cbd 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(() => { @@ -210,10 +216,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..eb95e15b3a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -250,6 +250,10 @@ 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.", "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..8a44848e1a 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -247,6 +247,11 @@ 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í.", "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..b95a6142c9 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -250,6 +250,11 @@ 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.", "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/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, From 541ea30c8d4681e7f2fed9f52025adeb6e383be4 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Tue, 25 Aug 2026 09:29:51 +0700 Subject: [PATCH 2/2] feat(app): confirm before turning auto-approve on for Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the TUI's consent gate. The first Code-side enable in an app run raises a confirm dialog — "permission requests that are not explicitly denied will be approved automatically", and that this is dangerous — focused on Cancel so a stray Enter declines, followed by a warning toast. Later enables in the same run are silent, and turning it off never asks. All three doors go through one gate: the prompt's mode select, the `permissions.autoaccept` command, and the Settings switch. A warning one of them skips is theatre. Work never asks. Auto is the default there, which nobody chose, and the sandbox confines the agent to the VM and one mounted folder — so a dialog would greet every new Work session with a question about a decision it did not make. The dialog claims focus twice, on mount and on the next frame: the prompt input restores focus to its editor a frame after its menu closes, and without the second claim Enter would send the prompt out from under an open dialog. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++ DIVERGENCES.md | 2 +- .../src/components/dialog-auto-accept.test.ts | 16 +++ .../app/src/components/dialog-auto-accept.tsx | 100 ++++++++++++++++++ .../app/src/components/prompt-input-v2.tsx | 24 +++-- .../app/src/components/settings-general.tsx | 7 +- .../src/components/settings-v2/general.tsx | 7 +- packages/app/src/context/permission.tsx | 4 + packages/app/src/i18n/en.ts | 6 ++ packages/app/src/i18n/es.ts | 7 ++ packages/app/src/i18n/vi.ts | 6 ++ .../pages/session/use-session-commands.tsx | 7 +- 12 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 packages/app/src/components/dialog-auto-accept.test.ts create mode 100644 packages/app/src/components/dialog-auto-accept.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5c940db9..b071a3e8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -455,6 +455,19 @@ already in use` and no way forward short of finding the file in a temporary 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 6b668daacd..1213507df9 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -306,7 +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/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. i18n: the `prompt.permission.*` family. | 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. 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. | +| 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 ( + + + {language.t("dialog.autoAccept.title")} + + + +

{language.t("dialog.autoAccept.description")}

+

{language.t("dialog.autoAccept.warning")}

+
+ + dialog.close()}> + {language.t("common.cancel")} + + + {language.t("dialog.autoAccept.confirm")} + + +
+ ) +} diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 09263e77b9..5da008207e 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -8,6 +8,7 @@ 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" @@ -576,6 +577,7 @@ function PromptInputV2PermissionControl(props: { onClose: () => void }) { const permission = usePermission() const sdk = useSDK() const params = useParams<{ id?: string }>() + const consent = useAutoAcceptConsent() const directory = () => sdk().directory const mode = createMemo(() => @@ -626,18 +628,18 @@ function PromptInputV2PermissionControl(props: { onClose: () => void }) { if (!open) props.onClose() }} onSelect={(id) => { - const action = permissionModeAction({ - sessionID: params.id, - current: mode(), - next: id === "auto" ? "auto" : "manual", - }) + const next = id === "auto" ? "auto" : "manual" + const action = permissionModeAction({ sessionID: params.id, current: mode(), next }) if (action.type === "none") return - if (action.type === "directory") { - permission.toggleAutoAcceptDirectory(directory()) - return - } - if (action.auto) permission.enableAutoAccept(action.sessionID, directory()) - else permission.disableAutoAccept(action.sessionID, directory()) + 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()) + })() }} /> 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 43c85e2cbd..428e84f3a8 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -155,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]) { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index eb95e15b3a..4fef895d9c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -254,6 +254,12 @@ export const dict = { "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 8a44848e1a..d6097ab26a 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -252,6 +252,13 @@ export const dict = { "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 b95a6142c9..a32ed451ab 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -255,6 +255,12 @@ export const dict = { "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)