diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index bd9bcdeca8b..c1b9a3cb210 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -18,6 +18,20 @@ const channels = [ ] as const for (const channel of channels) { + test(`includes the Windows sandbox permission hook for ${channel.channel}`, async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = channel.channel + try { + const config = (await import(`./electron-builder.config.ts?channel=${channel.channel}`)).default as Configuration + const include = path.join(import.meta.dirname, "resources/windows/installer.nsh") + expect(config.nsis?.include).toBe(include) + expect(await Bun.file(include).exists()).toBe(true) + } finally { + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + } + }) + test(`uses one Linux desktop identity for ${channel.channel}`, async () => { const previous = process.env.OPENCODE_CHANNEL process.env.OPENCODE_CHANNEL = channel.channel diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 38051674e3e..f92052ce199 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -124,6 +124,7 @@ const getBase = (appId: string): Configuration => ({ verifyUpdateCodeSignature: false, }, nsis: { + include: path.join(packageDir, "resources", "windows", "installer.nsh"), oneClick: true, perMachine: false, installerIcon: `resources/icons/icon.ico`, diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c3d3eb0b209..01700f81098 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -11,6 +11,7 @@ }, "scripts": { "typecheck": "tsgo -b", + "test": "bun test --timeout 30000", "dev": "bun ./scripts/dev.ts", "prebuild": "bun ./scripts/prebuild.ts", "build": "electron-vite build", diff --git a/packages/desktop/resources/windows/installer.nsh b/packages/desktop/resources/windows/installer.nsh new file mode 100644 index 00000000000..5487b3a23e3 --- /dev/null +++ b/packages/desktop/resources/windows/installer.nsh @@ -0,0 +1,6 @@ +!macro customInstall + ; Chromium's sandbox needs read/execute access to the installed runtime files. + ; https://github.com/electron/electron/issues/49143#issuecomment-3618354787 + nsExec::ExecToLog '"$SYSDIR\icacls.exe" "$INSTDIR" /grant "*S-1-15-2-2:(OI)(CI)(RX)"' + Pop $0 +!macroend diff --git a/packages/desktop/scripts/windows-installer.test.ts b/packages/desktop/scripts/windows-installer.test.ts new file mode 100644 index 00000000000..ab19b4f4cd2 --- /dev/null +++ b/packages/desktop/scripts/windows-installer.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from "bun:test" +import { mkdtemp, rm } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +test.skipIf(process.platform !== "win32")( + "installer grants sandbox access without changing unrelated permissions", + async () => { + const { getMakeNsisPath } = await import("app-builder-lib/out/toolsets/windows") + const config = (await import("../electron-builder.config")).default + const compiler = await getMakeNsisPath(config.toolsets?.nsis, config.nsis?.customNsisBinary) + const dir = await mkdtemp(path.join(os.tmpdir(), "opencode-installer-")) + const run = async (cmd: string[]) => { + const result = Bun.spawn(cmd, { + env: { ...process.env, ...compiler.env, OPENCODE_INSTALLER_TEST_ROOT: dir }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, code] = await Promise.all([ + new Response(result.stdout).text(), + new Response(result.stderr).text(), + result.exited, + ]) + expect(code, `${stdout}\n${stderr}`).toBe(0) + return stdout.trim() + } + const acl = (file: string) => + run([ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Acl -LiteralPath (Join-Path $env:OPENCODE_INSTALLER_TEST_ROOT '${file}')).Sddl`, + ]) + + try { + await Bun.write(path.join(dir, "app/runtime/icudtl.dat"), "runtime fixture") + await Bun.write(path.join(dir, "unrelated.txt"), "outside the installation") + await run(["icacls.exe", path.join(dir, "app"), "/grant", "*S-1-15-2-999-999-999:(OI)(CI)(F)"]) + const parent = await acl(".") + const unrelated = await acl("unrelated.txt") + await Bun.write( + path.join(dir, "installer.nsi"), + `Unicode true +RequestExecutionLevel user +SilentInstall silent +OutFile "${path.join(dir, "installer.exe")}" +!include "${config.nsis?.include}" +Section + StrCpy $INSTDIR "$EXEDIR\\app" + !insertmacro customInstall +SectionEnd +`, + ) + await run([compiler.path, path.join(dir, "installer.nsi")]) + await run([path.join(dir, "installer.exe")]) + const installed = await acl("app/runtime/icudtl.dat") + expect(installed).toContain("(A;ID;0x1200a9;;;S-1-15-2-2)") + expect(installed).toContain("S-1-15-2-999-999-999") + await run([path.join(dir, "installer.exe")]) + expect(await acl("app/runtime/icudtl.dat")).toBe(installed) + expect(await acl(".")).toBe(parent) + expect(await acl("unrelated.txt")).toBe(unrelated) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }, + 120_000, +) diff --git a/turbo.json b/turbo.json index 99b7ea9c03a..95bbe3f6881 100644 --- a/turbo.json +++ b/turbo.json @@ -49,6 +49,10 @@ "@opencode-ai/function#test": { "outputs": [] }, + "@opencode-ai/desktop#test": { + "outputs": [], + "cache": false + }, "@opencode-ai/httpapi-codegen#test": { "outputs": [] },