diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c4fb04287..de113c6ecd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,29 @@ file is the human-readable summary of what changed and why.
## Unreleased
+### Privacy
+
+- **CoDev no longer asks for access to your Downloads, Photos, Desktop, or
+ Music library on macOS** — the app's file search indexed whatever folder a
+ request was rooted in, and two paths rooted it in your home directory:
+ typing a bare name into the project picker (its browser starts at `~`), and
+ any internal request that named no folder, which fell back to the app's
+ working directory — also `~`. Building that index walks everything under it,
+ and a walk of your home directory opens files inside Downloads, Desktop, and
+ the photo and music libraries, each of which macOS answers with a permission
+ prompt. None of it had anything to do with your session: the crawl was rooted
+ somewhere you never chose. Three changes close it off. The server now refuses
+ to index the home directory or a filesystem root — searches there answer
+ with the folder's own entries, read without stepping inside any of them; the
+ picker matches a bare name against the folders directly under `~` instead of
+ searching the whole disk; and the app parks its working directory in an empty
+ folder of its own instead of `~`, so directory-less requests have nothing to
+ crawl. Browsing into a protected folder yourself still asks, once, as it
+ should — that prompt is about a folder you chose. Permissions you already
+ granted can be revoked in System Settings → Privacy & Security; CoDev no
+ longer needs any of them.
+ ([#306](https://github.com/quickbeard/codev-code/pull/306))
+
### Install
- **Installing downloads about half as much** — the published platform packages
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 267f88b633..39bcb9720a 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -300,6 +300,8 @@ 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. |
+
## Known rebrand gaps
- **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA
diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts
index 5746410610..b42819e3e9 100644
--- a/packages/app/src/components/directory-picker-domain.test.ts
+++ b/packages/app/src/components/directory-picker-domain.test.ts
@@ -223,3 +223,62 @@ test("returns absolute directories and relative files", () => {
expect(selectedTreePath("/home/luke/repo/src", "index.ts", "file", "/home/luke/repo")).toBe("src/index.ts")
expect(selectedTreePath("/home/luke/repo", "src/", "file")).toBeUndefined()
})
+
+test("a bare word over the home directory matches its children instead of searching the disk", async () => {
+ const finds: string[] = []
+ const listed: string[] = []
+ const sdk = {
+ client: {
+ find: {
+ files: (input: { directory: string }) => {
+ finds.push(input.directory)
+ return Promise.resolve({ data: [] })
+ },
+ },
+ file: {
+ list: (input: { directory: string }) => {
+ listed.push(input.directory)
+ return Promise.resolve({
+ data: [
+ { name: "works", type: "directory", absolute: "/home/luke/works" },
+ { name: "Downloads", type: "directory", absolute: "/home/luke/Downloads" },
+ { name: "note.md", type: "file", absolute: "/home/luke/note.md" },
+ ],
+ })
+ },
+ },
+ },
+ } as unknown as Parameters[0]["sdk"]
+ const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
+
+ const results = await search("wor")
+
+ // The recursive server-side search would index everything under `~`; the
+ // shallow listing of `~` itself is the whole point of the guard.
+ expect(finds).toEqual([])
+ expect(listed).toEqual(["/home/luke"])
+ expect(results).toEqual(["/home/luke/works"])
+})
+
+test("a bare word over a filesystem root also stays shallow", async () => {
+ const finds: string[] = []
+ const sdk = {
+ client: {
+ find: {
+ files: (input: { directory: string }) => {
+ finds.push(input.directory)
+ return Promise.resolve({ data: [] })
+ },
+ },
+ file: {
+ list: () => Promise.resolve({ data: [{ name: "Users", type: "directory", absolute: "/Users" }] }),
+ },
+ },
+ } as unknown as Parameters[0]["sdk"]
+ const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/" })
+
+ const results = await search("use")
+
+ expect(finds).toEqual([])
+ expect(results).toEqual(["/Users"])
+})
diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts
index 9900265962..046666a10a 100644
--- a/packages/app/src/components/directory-picker-domain.ts
+++ b/packages/app/src/components/directory-picker-domain.ts
@@ -371,6 +371,19 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
const query = normalizePickerDrive(input.path)
if (!pathInput) {
+ // A recursive directory search rooted at the home directory (the
+ // picker's usual base) makes the server index every file under it — on
+ // macOS that walk reads inside Downloads and the photo library, and the
+ // OS answers with a permission prompt for each. The server refuses to
+ // index such roots (FileSystemSearch.unindexableDirectory); match its
+ // shallow answer here so a bare word typed over `~` completes against
+ // its direct children on old servers too, instead of the whole disk.
+ const base = trimPickerPath(input.directory)
+ if (base === trimPickerPath(args.home()) || base === pickerRoot(base)) {
+ const results = await match(base, query, 50)
+ if (!active()) return []
+ return results
+ }
const results = await args.sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((result) => result.data ?? [])
diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts
index f186835975..799e7b2bc4 100644
--- a/packages/core/src/filesystem/search.ts
+++ b/packages/core/src/filesystem/search.ts
@@ -11,6 +11,7 @@ import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Flag } from "../flag/flag"
+import { Global } from "../global"
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect
@@ -20,13 +21,89 @@ export interface Interface {
export class Service extends Context.Service()("@opencode/v2/FileSystem/Search") {}
+// A directory whose file index must never be built: the home directory and
+// filesystem roots. Materializing the search service walks everything under
+// its location — fff at creation, ripgrep as an eagerly forked scan — and a
+// walk of the home directory opens files inside Downloads, Desktop, Documents
+// and the photo/music library bundles, which macOS answers with a permission
+// prompt for each. The desktop app hits this whenever a search lands on a
+// location it never deliberately chose (the picker's `~` base, or the
+// cwd fallback for requests that name no directory).
+export function unindexableDirectory(directory: string) {
+ const resolved = path.resolve(directory)
+ if (resolved === path.parse(resolved).root) return true
+ return resolved === path.resolve(Global.Path.home)
+}
+
+// glob and grep run ripgrep on demand against the requested path; only find
+// answers from an eager index. Shared between ripgrepLayer and the shallow
+// layer for unindexable directories, which keeps these and drops the index.
+const ripgrepGlobGrep = Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const location = yield* Location.Service
+ const ripgrep = yield* Ripgrep.Service
+ return {
+ glob: (input: FileSystem.GlobInput) =>
+ Effect.gen(function* () {
+ const target = path.resolve(location.directory, input.path ?? ".")
+ const info = yield* fs.stat(target).pipe(Effect.orDie)
+ const cwd = info.type === "File" ? path.dirname(target) : target
+ return yield* ripgrep
+ .glob({
+ cwd,
+ pattern: input.pattern,
+ limit: input.limit ?? Number.MAX_SAFE_INTEGER,
+ })
+ .pipe(
+ Effect.map((result) =>
+ result.map((entry) =>
+ FileSystem.Entry.make({
+ ...entry,
+ path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
+ }),
+ ),
+ ),
+ Effect.orDie,
+ )
+ }),
+ grep: (input: FileSystem.GrepInput) =>
+ Effect.gen(function* () {
+ const target = path.resolve(location.directory, input.path ?? ".")
+ const info = yield* fs.stat(target).pipe(Effect.orDie)
+ const cwd = info.type === "File" ? path.dirname(target) : target
+ return yield* ripgrep
+ .grep({
+ cwd,
+ pattern: input.pattern,
+ file: info.type === "File" ? path.basename(target) : undefined,
+ include: input.include,
+ limit: input.limit ?? Number.MAX_SAFE_INTEGER,
+ })
+ .pipe(
+ Effect.map((result) =>
+ result.map((match) =>
+ FileSystem.Match.make({
+ ...match,
+ entry: FileSystem.Entry.make({
+ ...match.entry,
+ path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
+ }),
+ }),
+ ),
+ ),
+ Effect.orDie,
+ )
+ }),
+ }
+})
+
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
- const fs = yield* FSUtil.Service
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
+ const base = yield* ripgrepGlobGrep
const state = {
files: [] as string[],
directories: [] as string[],
@@ -60,57 +137,7 @@ export const ripgrepLayer = Layer.effect(
Effect.forkIn(scope),
)
return Service.of({
- glob: (input) =>
- Effect.gen(function* () {
- const target = path.resolve(location.directory, input.path ?? ".")
- const info = yield* fs.stat(target).pipe(Effect.orDie)
- const cwd = info.type === "File" ? path.dirname(target) : target
- return yield* ripgrep
- .glob({
- cwd,
- pattern: input.pattern,
- limit: input.limit ?? Number.MAX_SAFE_INTEGER,
- })
- .pipe(
- Effect.map((result) =>
- result.map((entry) =>
- FileSystem.Entry.make({
- ...entry,
- path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
- }),
- ),
- ),
- Effect.orDie,
- )
- }),
- grep: (input) =>
- Effect.gen(function* () {
- const target = path.resolve(location.directory, input.path ?? ".")
- const info = yield* fs.stat(target).pipe(Effect.orDie)
- const cwd = info.type === "File" ? path.dirname(target) : target
- return yield* ripgrep
- .grep({
- cwd,
- pattern: input.pattern,
- file: info.type === "File" ? path.basename(target) : undefined,
- include: input.include,
- limit: input.limit ?? Number.MAX_SAFE_INTEGER,
- })
- .pipe(
- Effect.map((result) =>
- result.map((match) =>
- FileSystem.Match.make({
- ...match,
- entry: FileSystem.Entry.make({
- ...match.entry,
- path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
- }),
- }),
- ),
- ),
- Effect.orDie,
- )
- }),
+ ...base,
find: (input) =>
Effect.gen(function* () {
const items =
@@ -259,7 +286,66 @@ export const fffLayer = Layer.effect(
}),
)
-const layer = Layer.unwrap(Effect.sync(() => (Flag.CODEV_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)))
+// What an unindexable directory gets instead of an index: find matches the
+// location's direct children only, read with a single readdir of the location
+// itself — which touches no protected folder. Deeper navigation stays with the
+// callers (the pickers list level by level as the user drills in), and glob
+// and grep keep their on-demand ripgrep behaviour: they run only when a caller
+// explicitly asks for that path, never as a side effect of materializing the
+// service.
+export const shallowLayer = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const location = yield* Location.Service
+ const base = yield* ripgrepGlobGrep
+ return Service.of({
+ ...base,
+ find: (input) =>
+ Effect.gen(function* () {
+ // Symlinks and specials are dropped rather than resolved: telling a
+ // directory link from a file link takes a stat of the target, which
+ // for a link out of `~` could itself land in a protected folder.
+ const children = yield* fs
+ .readDirectoryEntries(location.directory)
+ .pipe(Effect.catch(() => Effect.succeed([])))
+ const items = children
+ .filter((child) =>
+ input.type === "file"
+ ? child.type === "file"
+ : input.type === "directory"
+ ? child.type === "directory"
+ : child.type === "file" || child.type === "directory",
+ )
+ .map((child) => (child.type === "directory" ? child.name + "/" : child.name))
+ const entry = (relative: string) =>
+ FileSystem.Entry.make({
+ path: RelativePath.make(relative),
+ type: relative.endsWith("/") ? "directory" : "file",
+ })
+ // Same empty-query behaviour as the indexed layers: top entries.
+ if (!input.query) return items.slice(0, input.limit ?? 50).map(entry)
+ const query = input.query
+ .split("\\")
+ .join("/")
+ .replace(/\/{2,}/g, "/")
+ return fuzzysort.go(query, items, { limit: input.limit ?? 50 }).map((item) => entry(item.target))
+ }),
+ })
+ }),
+)
+
+const layer = Layer.unwrap(
+ Effect.gen(function* () {
+ const location = yield* Location.Service
+ // The home directory and filesystem roots never get an index — building
+ // one walks everything under them, which on macOS opens files inside
+ // Downloads and the photo/music libraries and triggers a permission
+ // prompt for each. See unindexableDirectory above.
+ if (unindexableDirectory(location.directory)) return shallowLayer
+ return Flag.CODEV_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer
+ }),
+)
export const locationLayer = layer
diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts
index 4a8e11f2d6..a2aafd0d14 100644
--- a/packages/core/test/filesystem/search.test.ts
+++ b/packages/core/test/filesystem/search.test.ts
@@ -178,3 +178,68 @@ describe("FileSystemSearch ripgrep fallback", () => {
}),
)
})
+
+const shallowSearchLayer = (directory: AbsolutePath) =>
+ FileSystemSearch.shallowLayer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ LayerNode.compile(FSUtil.node),
+ LayerNode.compile(Ripgrep.node),
+ Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
+ ),
+ ),
+ )
+
+describe("FileSystemSearch home guard", () => {
+ it.live("refuses to index the home directory and filesystem roots", () =>
+ withTmp((cwd) =>
+ Effect.sync(() => {
+ const previous = process.env.CODEV_TEST_HOME
+ process.env.CODEV_TEST_HOME = cwd
+ try {
+ expect(FileSystemSearch.unindexableDirectory(cwd)).toBe(true)
+ // Trailing slashes and dot segments must not defeat the comparison.
+ expect(FileSystemSearch.unindexableDirectory(path.join(cwd, "."))).toBe(true)
+ expect(FileSystemSearch.unindexableDirectory(path.join(cwd, "sub"))).toBe(false)
+ expect(FileSystemSearch.unindexableDirectory(path.parse(path.resolve("/")).root)).toBe(true)
+ } finally {
+ if (previous === undefined) delete process.env.CODEV_TEST_HOME
+ else process.env.CODEV_TEST_HOME = previous
+ }
+ }),
+ ),
+ )
+
+ it.live("find answers with direct children only, without walking deeper", () =>
+ withTmp((cwd) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() => fs.mkdir(path.join(cwd, "Downloads")))
+ yield* Effect.promise(() => fs.writeFile(path.join(cwd, "Downloads", "secret.txt"), "x\n"))
+ yield* Effect.promise(() => fs.writeFile(path.join(cwd, "note.md"), "x\n"))
+ const search = yield* FileSystemSearch.Service
+ const empty = yield* search.find(FileSystem.FindInput.make({ query: "" }))
+ expect(empty.map((entry) => entry.path).sort()).toEqual([
+ RelativePath.make("Downloads/"),
+ RelativePath.make("note.md"),
+ ])
+ // The nested file is invisible: nothing ever descended to see it.
+ const nested = yield* search.find(FileSystem.FindInput.make({ query: "secret" }))
+ expect(nested).toHaveLength(0)
+ const directories = yield* search.find(FileSystem.FindInput.make({ query: "down", type: "directory" }))
+ expect(directories.map((entry) => entry.path)).toEqual([RelativePath.make("Downloads/")])
+ }).pipe(Effect.provide(shallowSearchLayer(cwd))),
+ ),
+ )
+
+ it.live("glob still answers on demand for an explicitly requested path", () =>
+ withTmp((cwd) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(() => fs.mkdir(path.join(cwd, "sub")))
+ yield* Effect.promise(() => fs.writeFile(path.join(cwd, "sub", "match.ts"), "x\n"))
+ const search = yield* FileSystemSearch.Service
+ const found = yield* search.glob(FileSystem.GlobInput.make({ pattern: "**/*.ts" }))
+ expect(found.map((entry) => entry.path)).toEqual([RelativePath.make("sub/match.ts")])
+ }).pipe(Effect.provide(shallowSearchLayer(cwd))),
+ ),
+ )
+})
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index 6f4d1eb1ab..c356b7f44c 100644
--- a/packages/desktop/src/main/index.ts
+++ b/packages/desktop/src/main/index.ts
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import * as http from "node:http"
import { createServer } from "node:net"
-import { homedir, tmpdir } from "node:os"
+import { tmpdir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import type { Event } from "electron"
@@ -119,11 +119,6 @@ function ensureLoopbackNoProxy() {
const main = Effect.gen(function* () {
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
- // on macOS apps run in `/` which can cause issues with ripgrep
- try {
- process.chdir(homedir())
- } catch {}
-
process.env.CODEV_DISABLE_EMBEDDED_WEB_UI = "true"
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.codev.desktop.dev"
@@ -150,6 +145,19 @@ const main = Effect.gen(function* () {
)
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
initializeOldLayoutEligibility(app.getPath("userData"))
+ // Out of `/` (where macOS launches apps, which breaks ripgrep) but not into
+ // the home directory: the sidecar inherits this cwd, and server requests
+ // that name no directory fall back to it — a cwd of `~` had those requests
+ // boot an instance over the whole home directory, whose file indexing then
+ // read inside Downloads and the photo library and made macOS ask for
+ // permission to each. An empty folder of our own gives that fallback
+ // nothing to crawl. After setPath so it lands under the channel's own
+ // userData, not the default Electron one.
+ try {
+ const fallbackCwd = join(app.getPath("userData"), "cwd")
+ mkdirSync(fallbackCwd, { recursive: true })
+ process.chdir(fallbackCwd)
+ } catch {}
logger = initLogging()
initCrashReporter()