From afc18531caf9f70722f14313117999e8feb18c36 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 00:00:17 -0500 Subject: [PATCH 1/5] test: fix OAuth assertions and server readiness --- packages/core/test/mcp-oauth.test.ts | 2 +- packages/server/test/fetch.test.ts | 64 ++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/packages/core/test/mcp-oauth.test.ts b/packages/core/test/mcp-oauth.test.ts index 6b9a3302d9b6..bb99ff0b2188 100644 --- a/packages/core/test/mcp-oauth.test.ts +++ b/packages/core/test/mcp-oauth.test.ts @@ -162,6 +162,6 @@ describe("MCP OAuth", () => { }) test("rejects an invalid redirect URL", async () => { - await expect(authorize("not a URL")).rejects.toThrow("cannot be parsed as a URL") + await expect(authorize("not a URL")).rejects.toThrow(TypeError) }) }) diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 3074d5916707..a8e3a0eea976 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -3,7 +3,9 @@ import { createServer } from "node:http" import { makeMemoryDriver } from "@opencode-ai/core/environment/index" import { Workspace } from "@opencode-ai/core/workspace" import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" -import { Effect } from "effect" +import { Agent } from "@opencode-ai/schema/agent" +import { Integration } from "@opencode-ai/schema/integration" +import { Effect, Schedule, Schema } from "effect" import { tmpdir } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { ServerFetch } from "../src/fetch" @@ -58,19 +60,35 @@ function occupy(port: number, cancel = false) { }) } -const ready = (handler: Handler) => - Effect.promise(() => handler(new Request("http://opencode.local/api/model/default"))) - const connectOpenAI = (handler: Handler) => - Effect.promise(() => - handler( - new Request("http://opencode.local/api/integration/openai/connect/oauth", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ methodID: "chatgpt-browser" }), - }), - ), - ) + Effect.gen(function* () { + // Catalog endpoints no longer wait for plugin initialization. + yield* Effect.gen(function* () { + const response = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/integration"))) + expect(response.status).toBe(200) + const body = Schema.decodeUnknownSync(Schema.Struct({ data: Schema.Array(Integration.Info) }))( + yield* Effect.promise(() => response.json()), + ) + return body.data.some( + (integration) => + integration.id === "openai" && + integration.methods.some((method) => method.type === "oauth" && method.id === "chatgpt-browser"), + ) + }).pipe( + Effect.filterOrFail((ready) => ready), + Effect.retry(Schedule.spaced("10 millis")), + Effect.timeout("2 seconds"), + ) + return yield* Effect.promise(() => + handler( + new Request("http://opencode.local/api/integration/openai/connect/oauth", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ methodID: "chatgpt-browser" }), + }), + ), + ) + }) const workspaceDriver = WorkspaceDriver.make({ create: ({ workspaceID }) => Effect.succeed({ binding: { workspaceID } }), @@ -189,7 +207,6 @@ it.live("cancels a stale OpenAI OAuth callback server before falling back", () = Effect.gen(function* () { const requests = yield* occupy(1455, true) const handler = yield* ServerFetch.make(options) - yield* ready(handler) const response = yield* connectOpenAI(handler) expect(response.status).toBe(200) @@ -203,7 +220,6 @@ it.live("falls back to port 1457 when OpenAI OAuth port 1455 remains busy", () = Effect.gen(function* () { const requests = yield* occupy(1455) const handler = yield* ServerFetch.make(options) - yield* ready(handler) const response = yield* connectOpenAI(handler) expect(response.status).toBe(200) @@ -220,7 +236,6 @@ it.live( yield* occupy(1455) yield* occupy(1457) const handler = yield* ServerFetch.make(options) - yield* ready(handler) const response = yield* connectOpenAI(handler) expect(response.status).toBe(400) @@ -439,7 +454,22 @@ it.live("routes pending requests by Session without loading an instance", () => expect(yield* Effect.promise(() => globalForms.json())).toMatchObject({ data: [{ title: "Global form" }] }) // Agent permission policy is installed by plugin activation. - expect((yield* ready(handler)).status).toBe(200) + yield* Effect.gen(function* () { + const response = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/agent"))) + expect(response.status).toBe(200) + const body = Schema.decodeUnknownSync(Schema.Struct({ data: Schema.Array(Agent.Info) }))( + yield* Effect.promise(() => response.json()), + ) + return body.data.some( + (agent) => + agent.id === "build" && + agent.permissions.some((rule) => rule.action === "shell" && rule.resource === "*" && rule.effect === "ask"), + ) + }).pipe( + Effect.filterOrFail((ready) => ready), + Effect.retry(Schedule.spaced("10 millis")), + Effect.timeout("2 seconds"), + ) const createdPermission = yield* Effect.promise(() => handler( new Request(`http://opencode.local/api/session/${created.data.id}/permission`, { From 963e957dc9576ceaf9336c2efca16d9669631cfe Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 00:12:58 -0500 Subject: [PATCH 2/5] test(core): use a relative archive path for npm fixtures --- packages/core/test/npm.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 13de58519878..3e717477393e 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -66,7 +66,7 @@ async function createRegistryFixture(directory: string) { exports: "./index.js", }) await Bun.write(path.join(root, "package", "index.js"), `export const version = "${version}"\n`) - await Bun.$`tar -czf ${path.join(root, "package.tgz")} -C ${root} package` + await Bun.$`tar -czf package.tgz package`.cwd(root) tarballs.set(version, await Bun.file(path.join(root, "package.tgz")).bytes()) } const state = { latest: "1.0.0" } From 16e2c77267e520193f9fd28cd1618ba782b0eb61 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 00:14:31 -0500 Subject: [PATCH 3/5] fix(util): defer staged package entrypoint resolution --- packages/core/test/npm.test.ts | 2 +- packages/util/src/npm.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 3e717477393e..b2ce86a884b7 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -252,7 +252,7 @@ describe("Npm.add", () => { } }).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise) - expect(entries.added.entrypoint).toEndWith("/index.js") + expect(entries.added.entrypoint).toBe(pathToFileURL(path.join(entries.added.directory, "index.js")).href) expect(entries.added.version).toBe(fixture.commit) expect(entries.cached).toEqual(entries.added) expect(entries.resolved).toEqual(entries.added) diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index efd9032612a3..3b6683c01ee8 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -293,7 +293,8 @@ const layer = Layer.effect( installedNameValue, installed?.path ?? path.join(staging, "node_modules", installedNameValue), target, - subpaths, + // Resolve installed entrypoints after rename so Bun cannot hold the staging directory open on Windows. + installed ? [] : subpaths, ) if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging }) return { name: installedNameValue, result } From ce9291cf90becca303fec63e34b20d7b88839293 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 00:27:54 -0500 Subject: [PATCH 4/5] fix(util): preserve Windows links when publishing npm installs --- packages/core/test/npm.test.ts | 3 ++- packages/util/src/npm.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index b2ce86a884b7..928fc9084637 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -231,6 +231,7 @@ describe("Npm.add", () => { expect(entries.tui.entrypoint).toEndWith("/tui.js") expect(entries.fallback.entrypoint).toEndWith("/index.js") + expect(await fs.realpath(entries.tui.directory)).toBe(await fs.realpath(path.join(tmp.path, "fixture-provider"))) }) test("installs and resolves named and unnamed Git packages with dependencies", async () => { @@ -262,7 +263,7 @@ describe("Npm.add", () => { expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec))) expect(entries.added.directory).toContain("node_modules") } - }) + }, 30_000) test("installs a Git package from an npm ::path: subdirectory", async () => { await using tmp = await tmpdir() diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index 3b6683c01ee8..17e33c34be6d 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -133,10 +133,13 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[ interface ArboristNode { name: string path: string + realpath: string + isLink: boolean } interface ArboristTree { edgesOut: Map + inventory: { values(): IterableIterator } } const PackageJson = Schema.Struct({ @@ -297,7 +300,13 @@ const layer = Layer.effect( installed ? [] : subpaths, ) if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging }) - return { name: installedNameValue, result } + const links = + process.platform === "win32" + ? Array.from(tree.inventory.values()).filter( + (node) => node.isLink && FSUtil.contains(staging, node.path) && FSUtil.contains(staging, node.realpath), + ) + : [] + return { name: installedNameValue, result, links } }).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore))) if (active) { @@ -311,6 +320,22 @@ const layer = Layer.effect( const completedAt = yield* Clock.currentTimeMillis const newest = Number((yield* generations(dir)).at(-1) ?? 0) const generation = path.join(dir, String(Math.max(completedAt, newest + 1))) + // Windows junctions use absolute targets, so internal links must follow the published generation. + if (staged.links.length > 0) { + const { unlink, symlink } = yield* Effect.promise(() => import("node:fs/promises")) + yield* Effect.forEach( + staged.links, + (link) => + Effect.tryPromise({ + try: async () => { + await unlink(link.path) + await symlink(path.join(generation, path.relative(staging, link.realpath)), link.path, "junction") + }, + catch: (cause) => new InstallFailedError({ dir, cause }), + }), + { discard: true }, + ) + } yield* rename(staging, generation, dir) return yield* entry( generation, From d3a5a8ff45b8dfe48533dfa14544245dbbb638ec Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 1 Sep 2026 00:29:11 -0500 Subject: [PATCH 5/5] Revert "fix(util): preserve Windows links when publishing npm installs" This reverts commit ce9291cf90becca303fec63e34b20d7b88839293. --- packages/core/test/npm.test.ts | 3 +-- packages/util/src/npm.ts | 27 +-------------------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 928fc9084637..b2ce86a884b7 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -231,7 +231,6 @@ describe("Npm.add", () => { expect(entries.tui.entrypoint).toEndWith("/tui.js") expect(entries.fallback.entrypoint).toEndWith("/index.js") - expect(await fs.realpath(entries.tui.directory)).toBe(await fs.realpath(path.join(tmp.path, "fixture-provider"))) }) test("installs and resolves named and unnamed Git packages with dependencies", async () => { @@ -263,7 +262,7 @@ describe("Npm.add", () => { expect(entries.added.directory).toContain(path.join("npm", await Npm.cacheKey(spec))) expect(entries.added.directory).toContain("node_modules") } - }, 30_000) + }) test("installs a Git package from an npm ::path: subdirectory", async () => { await using tmp = await tmpdir() diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index 17e33c34be6d..3b6683c01ee8 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -133,13 +133,10 @@ const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[ interface ArboristNode { name: string path: string - realpath: string - isLink: boolean } interface ArboristTree { edgesOut: Map - inventory: { values(): IterableIterator } } const PackageJson = Schema.Struct({ @@ -300,13 +297,7 @@ const layer = Layer.effect( installed ? [] : subpaths, ) if (!installed && !result.entrypoint) return yield* new InstallFailedError({ add: [pkg], dir: staging }) - const links = - process.platform === "win32" - ? Array.from(tree.inventory.values()).filter( - (node) => node.isLink && FSUtil.contains(staging, node.path) && FSUtil.contains(staging, node.realpath), - ) - : [] - return { name: installedNameValue, result, links } + return { name: installedNameValue, result } }).pipe(Effect.onError(() => remove(staging, dir).pipe(Effect.ignore))) if (active) { @@ -320,22 +311,6 @@ const layer = Layer.effect( const completedAt = yield* Clock.currentTimeMillis const newest = Number((yield* generations(dir)).at(-1) ?? 0) const generation = path.join(dir, String(Math.max(completedAt, newest + 1))) - // Windows junctions use absolute targets, so internal links must follow the published generation. - if (staged.links.length > 0) { - const { unlink, symlink } = yield* Effect.promise(() => import("node:fs/promises")) - yield* Effect.forEach( - staged.links, - (link) => - Effect.tryPromise({ - try: async () => { - await unlink(link.path) - await symlink(path.join(generation, path.relative(staging, link.realpath)), link.path, "junction") - }, - catch: (cause) => new InstallFailedError({ dir, cause }), - }), - { discard: true }, - ) - } yield* rename(staging, generation, dir) return yield* entry( generation,