Skip to content

Commit f72c63f

Browse files
authored
Merge branch 'dev' into simplify-target-layouts
2 parents 145e8a3 + 5647ed8 commit f72c63f

4 files changed

Lines changed: 138 additions & 5 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import { Env } from "../env"
1313
import { applyEdits, modify } from "jsonc-parser"
1414
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
1515
import { existsSync } from "fs"
16+
import { Account } from "@/account/account"
1617
import { isRecord } from "@/util/record"
1718
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
1819
import { FSUtil } from "@opencode-ai/core/fs-util"
1920
import { InstanceState } from "@/effect/instance-state"
20-
import { Context, Duration, Effect, Exit, Fiber, Layer, Schema } from "effect"
21+
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
2122
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
2223
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
2324
import { containsPath, type InstanceContext } from "../project/instance-context"
@@ -176,6 +177,7 @@ export const layer = Layer.effect(
176177
Effect.gen(function* () {
177178
const fs = yield* FSUtil.Service
178179
const authSvc = yield* Auth.Service
180+
const accountSvc = yield* Account.Service
179181
const env = yield* Env.Service
180182
const npmSvc = yield* Npm.Service
181183
const http = yield* HttpClient.HttpClient
@@ -314,6 +316,7 @@ export const layer = Layer.effect(
314316

315317
let result: Info = {}
316318
const authEnv: Record<string, string> = {}
319+
const consoleManagedProviders = new Set<string>()
317320
let activeOrgName: string | undefined
318321

319322
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
@@ -471,6 +474,44 @@ export const layer = Layer.effect(
471474
yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT")
472475
}
473476

477+
const activeAccount = Option.getOrUndefined(
478+
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
479+
)
480+
if (activeAccount?.active_org_id) {
481+
const accountID = activeAccount.id
482+
const orgID = activeAccount.active_org_id
483+
const url = activeAccount.url
484+
yield* Effect.gen(function* () {
485+
const [configOpt, tokenOpt] = yield* Effect.all(
486+
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
487+
{ concurrency: 2 },
488+
)
489+
if (Option.isSome(tokenOpt)) {
490+
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
491+
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
492+
}
493+
494+
if (Option.isSome(configOpt)) {
495+
const source = `${url}/api/config`
496+
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
497+
dir: path.dirname(source),
498+
source,
499+
})
500+
for (const providerID of Object.keys(next.provider ?? {})) {
501+
consoleManagedProviders.add(providerID)
502+
}
503+
yield* merge(source, next, "global")
504+
}
505+
}).pipe(
506+
Effect.withSpan("Config.loadActiveOrgConfig"),
507+
Effect.catch((err) =>
508+
Effect.logDebug("failed to fetch remote account config", {
509+
error: err instanceof Error ? err.message : String(err),
510+
}),
511+
),
512+
)
513+
}
514+
474515
const managedDir = ConfigManaged.managedConfigDir()
475516
if (existsSync(managedDir)) {
476517
for (const file of ["opencode.json", "opencode.jsonc"]) {
@@ -546,7 +587,7 @@ export const layer = Layer.effect(
546587
directories,
547588
deps,
548589
consoleState: {
549-
consoleManagedProviders: [],
590+
consoleManagedProviders: Array.from(consoleManagedProviders),
550591
activeOrgName,
551592
switchableOrgCount: 0,
552593
},
@@ -635,10 +676,11 @@ export const defaultLayer = layer.pipe(
635676
Layer.provide(FSUtil.defaultLayer),
636677
Layer.provide(Env.defaultLayer),
637678
Layer.provide(Auth.defaultLayer),
679+
Layer.provide(Account.defaultLayer),
638680
Layer.provide(Npm.defaultLayer),
639681
Layer.provide(FetchHttpClient.layer),
640682
)
641683

642-
export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Env.node, Npm.node, httpClient])
684+
export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient])
643685

644686
export * as Config from "./config"

packages/opencode/src/mcp/oauth-callback.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { createServer } from "http"
33
import { escapeHtml } from "@/util/html"
44
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
55

6+
const OAUTH_CALLBACK_HOST = "127.0.0.1"
7+
68
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
79
let currentPort = OAUTH_CALLBACK_PORT
810
let currentPath = OAUTH_CALLBACK_PATH
@@ -162,7 +164,7 @@ export async function ensureRunning(redirectUri?: string): Promise<void> {
162164

163165
server = createServer(handleRequest)
164166
await new Promise<void>((resolve, reject) => {
165-
server!.listen(currentPort, () => {
167+
server!.listen(currentPort, OAUTH_CALLBACK_HOST, () => {
166168
resolve()
167169
})
168170
server!.on("error", reject)

packages/opencode/test/config/config.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
22
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
3-
import { Cause, Effect, Exit, Layer } from "effect"
3+
import { Cause, Effect, Exit, Layer, Option } from "effect"
44
import { NamedError } from "@opencode-ai/core/util/error"
55
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
66
import { NodeFileSystem, NodePath } from "@effect/platform-node"
@@ -13,6 +13,7 @@ import { InstanceRef } from "../../src/effect/instance-ref"
1313
import type { InstanceContext } from "../../src/project/instance-context"
1414
import { Auth } from "../../src/auth"
1515
import { Account } from "../../src/account/account"
16+
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
1617
import { FSUtil } from "@opencode-ai/core/fs-util"
1718
import { Env } from "../../src/env"
1819
import {
@@ -140,6 +141,7 @@ const clear = (wait = false) => Effect.runPromise(clearEffect(wait))
140141
// Get managed config directory from environment (set in preload.ts)
141142
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
142143
const originalTestToken = process.env.TEST_TOKEN
144+
const originalConsoleToken = process.env.OPENCODE_CONSOLE_TOKEN
143145

144146
beforeEach(async () => {
145147
await clear(true)
@@ -149,6 +151,8 @@ afterEach(async () => {
149151
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
150152
if (originalTestToken === undefined) delete process.env.TEST_TOKEN
151153
else process.env.TEST_TOKEN = originalTestToken
154+
if (originalConsoleToken === undefined) delete process.env.OPENCODE_CONSOLE_TOKEN
155+
else process.env.OPENCODE_CONSOLE_TOKEN = originalConsoleToken
152156
await clear(true)
153157
})
154158

@@ -560,6 +564,49 @@ it.instance("handles file inclusion with replacement tokens", () =>
560564
}),
561565
)
562566

567+
const accountTokenIt = configIt({
568+
account: Layer.mock(Account.Service)({
569+
active: () =>
570+
Effect.succeed(
571+
Option.some({
572+
id: AccountID.make("account-1"),
573+
email: "user@example.com",
574+
url: "https://control.example.com",
575+
active_org_id: OrgID.make("org-1"),
576+
}),
577+
),
578+
activeOrg: () =>
579+
Effect.succeed(
580+
Option.some({
581+
account: {
582+
id: AccountID.make("account-1"),
583+
email: "user@example.com",
584+
url: "https://control.example.com",
585+
active_org_id: OrgID.make("org-1"),
586+
},
587+
org: {
588+
id: OrgID.make("org-1"),
589+
name: "Example Org",
590+
},
591+
}),
592+
),
593+
config: () =>
594+
Effect.succeed(
595+
Option.some({
596+
provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
597+
}),
598+
),
599+
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
600+
}),
601+
})
602+
603+
accountTokenIt.instance("resolves env templates in account config with account token", () =>
604+
Effect.gen(function* () {
605+
const config = yield* Config.use.get()
606+
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
607+
}),
608+
)
609+
563610
it.instance("validates config schema and throws on invalid fields", () =>
564611
Effect.gen(function* () {
565612
const test = yield* TestInstance

packages/opencode/test/mcp/oauth-callback.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,41 @@
11
import { test, expect, describe, afterEach } from "bun:test"
2+
import { createConnection, createServer as createNetServer } from "net"
23
import { McpOAuthCallback } from "../../src/mcp/oauth-callback"
34
import { parseRedirectUri } from "../../src/mcp/oauth-provider"
45

6+
async function getFreeLoopbackPort(): Promise<number> {
7+
return new Promise((resolve, reject) => {
8+
const probe = createNetServer()
9+
probe.once("error", reject)
10+
probe.listen(0, "127.0.0.1", () => {
11+
const address = probe.address()
12+
probe.close(() => {
13+
if (typeof address === "object" && address) {
14+
resolve(address.port)
15+
return
16+
}
17+
reject(new Error("Could not allocate a loopback port"))
18+
})
19+
})
20+
})
21+
}
22+
23+
async function canConnect(host: string, port: number): Promise<boolean> {
24+
return new Promise((resolve) => {
25+
const socket = createConnection({ host, port })
26+
const done = (ok: boolean) => {
27+
socket.removeAllListeners()
28+
socket.destroy()
29+
resolve(ok)
30+
}
31+
32+
socket.setTimeout(500)
33+
socket.once("connect", () => done(true))
34+
socket.once("error", () => done(false))
35+
socket.once("timeout", () => done(false))
36+
})
37+
}
38+
539
describe("parseRedirectUri", () => {
640
test("returns defaults when no URI provided", () => {
741
const result = parseRedirectUri()
@@ -69,4 +103,12 @@ describe("McpOAuthCallback.ensureRunning", () => {
69103

70104
expect(await response.text()).toContain('<div class="error">The user denied access</div>')
71105
})
106+
107+
test("binds the callback server to IPv4 loopback", async () => {
108+
const port = await getFreeLoopbackPort()
109+
await McpOAuthCallback.ensureRunning(`http://127.0.0.1:${port}/custom/callback`)
110+
111+
expect(await canConnect("127.0.0.1", port)).toBe(true)
112+
expect(await canConnect("::1", port)).toBe(false)
113+
})
72114
})

0 commit comments

Comments
 (0)