Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<UpdatePrompt />` 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 `<PromptInputV2ModelControl>` plus the fork-only `<PromptInputV2PermissionControl>`, 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 `<PromptInputV2ModelControl>` 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

Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/components/dialog-auto-accept.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
100 changes: 100 additions & 0 deletions packages/app/src/components/dialog-auto-accept.tsx
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => {
dialog.push(
() => (
<DialogAutoAccept
onConfirm={() => {
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 (
<Dialog fit>
<DialogHeader>
<DialogTitle>{language.t("dialog.autoAccept.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-w-[440px] flex-col gap-2 px-4 pt-3 pb-1">
<p class="text-14">{language.t("dialog.autoAccept.description")}</p>
<p class="text-14 text-v2-state-fg-danger">{language.t("dialog.autoAccept.warning")}</p>
</DialogBody>
<DialogFooter>
<ButtonV2 ref={cancel} variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={props.onConfirm}>
{language.t("dialog.autoAccept.confirm")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
Loading
Loading