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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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. |

## Known rebrand gaps

- **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA
Expand Down
59 changes: 59 additions & 0 deletions packages/app/src/components/directory-picker-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createDirectorySearch>[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<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/" })

const results = await search("use")

expect(finds).toEqual([])
expect(results).toEqual(["/Users"])
})
13 changes: 13 additions & 0 deletions packages/app/src/components/directory-picker-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])
Expand Down
192 changes: 139 additions & 53 deletions packages/core/src/filesystem/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileSystem.Entry[]>
Expand All @@ -20,13 +21,89 @@ export interface Interface {

export class Service extends Context.Service<Service, Interface>()("@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[],
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading