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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ file is the human-readable summary of what changed and why.

## Unreleased

### Desktop / Web UI

- **WSL setup no longer depends on reaching Microsoft's servers** — on
Windows, **Install WSL** now downloads a pinned install bundle from the
CoDev storage host (the one host whitelist-only corporate proxies already
allow), enables the Virtual Machine Platform feature offline, and installs
the WSL runtime MSI quietly behind a single elevation prompt; the previous
`wsl --install` path against Microsoft's servers remains as an automatic
fallback when the bundle cannot be downloaded. Installing the
**Ubuntu-24.04** distro likewise imports the bundled image
(`wsl --install --from-file`) and only falls back to `--web-download` for
other distros or when the bundle is unavailable. Genuine install failures
still surface as errors rather than being retried behind a second
elevation prompt.

## 1.18.4-8

### Desktop / Web UI
Expand Down
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ from the squash message first, or use a plain merge.
| `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev Code" (Dev/Beta variants too) | Desktop app display name. | Keep our names. |
| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. |
| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). |
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
| `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. |
| `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. |
| `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for `<link rel="icon">`. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. |
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions packages/codev-sandbox/src/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ export const SANDBOX_MANIFEST_URL = `${SANDBOX_IMAGES_BASE_URL}/manifest.json`
// WSL MSI installers mirrored for corporate networks where only the storage
// host is whitelisted; the Microsoft `wsl --install` path stays as fallback.
export const INSTALLERS_BASE_URL = `${STORAGE_BASE}/installers`

// The WSL install bundle is one hand-uploaded zip so a corporate whitelist
// rule covers a single URL. The member names are part of the contract: the
// install flow extracts them from the zip by exact name. The MSI carries the
// pinned WSL runtime version in its filename; the .wsl file is the Ubuntu
// distro image for the WSL servers feature (`wsl --install --from-file`),
// not the sandbox rootfs, which ships via the image manifest instead.
export const WSL_INSTALLER_ZIP_URL = `${INSTALLERS_BASE_URL}/wsl.zip`
export const WSL_INSTALLER_MSI_NAME = "wsl.2.7.11.0.x64.msi"
export const WSL_DISTRO_IMAGE_NAME = "ubuntu-24.04.4-wsl-amd64.wsl"
1 change: 1 addition & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@codev/sandbox": "workspace:*",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
Expand Down
65 changes: 65 additions & 0 deletions packages/desktop/src/main/wsl/install-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from "bun:test"
import { mkdtemp, readFile, readdir } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME } from "@codev/sandbox"
import { bundleProvidesDistro, ensureWslInstallBundle, wslRuntimeInstallScript } from "./install-bundle"

// Built with zip.js rather than checked in as a fixture: the test only cares
// that members are found and extracted by exact name, not about zip bytes.
async function fixtureZip() {
const { BlobWriter, TextReader, ZipWriter, configure } = await import("@zip.js/zip.js")
configure({ useWebWorkers: false })
const writer = new ZipWriter(new BlobWriter("application/zip"))
await writer.add(WSL_INSTALLER_MSI_NAME, new TextReader("msi-bytes"))
await writer.add(WSL_DISTRO_IMAGE_NAME, new TextReader("distro-bytes"))
return writer.close()
}

test("downloads, extracts by member name, and never fetches twice", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-bundle-"))
const zip = await fixtureZip()
let fetches = 0
const fetchImpl = (async () => {
fetches += 1
return new Response(zip)
}) as typeof fetch

const first = await ensureWslInstallBundle({ dir, fetchImpl })
expect(await readFile(first.msiPath, "utf8")).toBe("msi-bytes")
expect(await readFile(first.distroImagePath, "utf8")).toBe("distro-bytes")
// The zip is deleted after extraction and nothing half-written survives.
expect((await readdir(dir)).toSorted()).toEqual([WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME].toSorted())

const second = await ensureWslInstallBundle({ dir, fetchImpl })
expect(second).toEqual(first)
expect(fetches).toBe(1)
})

test("fails loudly when the zip is missing a member", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-bundle-"))
const { BlobWriter, TextReader, ZipWriter, configure } = await import("@zip.js/zip.js")
configure({ useWebWorkers: false })
const writer = new ZipWriter(new BlobWriter("application/zip"))
await writer.add(WSL_INSTALLER_MSI_NAME, new TextReader("msi-bytes"))
const zip = await writer.close()
const fetchImpl = (async () => new Response(zip)) as typeof fetch

expect(ensureWslInstallBundle({ dir, fetchImpl })).rejects.toThrow(WSL_DISTRO_IMAGE_NAME)
})

test("elevated script tolerates reboot-required exits and quotes the MSI path", () => {
const script = wslRuntimeInstallScript(`C:\\Users\\O'Brien\\wsl.msi`)
expect(script).toContain("/featurename:VirtualMachinePlatform")
expect(script).toContain("'C:\\Users\\O''Brien\\wsl.msi'")
// 3010 = ERROR_SUCCESS_REBOOT_REQUIRED, expected from both dism and msiexec.
expect(script).toContain("($dism -ne 0) -and ($dism -ne 3010)")
expect(script).toContain("($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)")
})

test("only Ubuntu-24.04 is served from the bundle", () => {
expect(bundleProvidesDistro("Ubuntu-24.04")).toBe(true)
expect(bundleProvidesDistro("ubuntu-24.04")).toBe(true)
expect(bundleProvidesDistro("Ubuntu-22.04")).toBe(false)
expect(bundleProvidesDistro("Debian")).toBe(false)
})
Loading
Loading