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 ( + + + {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 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,