From 30e171468b877014cbeeff0a9061089c3738af07 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 01:45:17 +0000 Subject: [PATCH 1/4] feat: strip all telemetry, phone-home, and default provider tracking Remove or disable all outbound network calls that are not explicitly user-initiated to prevent any phone-home behavior after installation: - Disable OpenTelemetry experimental_telemetry in LLM stream and agent - Remove x-opencode-* tracking headers and User-Agent from LLM requests - Replace ShareNext with no-op stubs (session sharing to opncd.ai) - Disable auto-update version checking (Installation.latest returns VERSION) - Disable auto-upgrade mechanism (cli/upgrade.ts is now a no-op) - Disable models.dev remote fetching and periodic refresh - Skip remote .well-known/opencode config fetching - Disable Control.token() enterprise phone-home - Disable external skill discovery remote fetching - Remove opencode/anthropic default providers from TUI priority list https://claude.ai/code/session_01U9pmLo7i6uNvmkykqVX3WA --- packages/opencode/src/agent/agent.ts | 5 +- .../cli/cmd/tui/component/dialog-provider.tsx | 39 +--- packages/opencode/src/cli/upgrade.ts | 25 +-- packages/opencode/src/config/config.ts | 28 +-- packages/opencode/src/control/index.ts | 38 +--- packages/opencode/src/installation/index.ts | 70 +----- packages/opencode/src/provider/models.ts | 31 +-- packages/opencode/src/session/llm.ts | 18 +- packages/opencode/src/share/share-next.ts | 203 +----------------- packages/opencode/src/skill/discovery.ts | 89 +------- 10 files changed, 32 insertions(+), 514 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index c53ca04e238..5491ae2a1cb 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -292,10 +292,7 @@ export namespace Agent { const params = { experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - }, + isEnabled: false, }, temperature: 0.3, messages: [ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 7bf189f0902..a2089c6fb1d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -15,12 +15,9 @@ import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" const PROVIDER_PRIORITY: Record = { - opencode: 0, - "opencode-go": 1, - openai: 2, - "github-copilot": 3, - anthropic: 4, - google: 5, + openai: 0, + "github-copilot": 1, + google: 2, } export function createDialogProviderOptions() { @@ -35,10 +32,7 @@ export function createDialogProviderOptions() { title: provider.name, value: provider.id, description: { - opencode: "(Recommended)", - anthropic: "(Claude Max or API key)", openai: "(ChatGPT Plus/Pro or API key)", - "opencode-go": "Low cost subscription for everyone", }[provider.id], category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", async onSelect() { @@ -215,32 +209,7 @@ function ApiMethod(props: ApiMethodProps) { - - OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API - key. - - - Go to https://opencode.ai/zen to get a key - - - ), - "opencode-go": ( - - - OpenCode Go is a $10 per month subscription that provides reliable access to popular open coding models - with generous usage limits. - - - Go to https://opencode.ai/zen and enable OpenCode Go - - - ), - }[props.providerID] ?? undefined - } + description={undefined} onConfirm={async (value) => { if (!value) return await sdk.client.auth.set({ diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index 2d46ae39fa2..7de6e5cfd76 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -1,25 +1,4 @@ -import { Bus } from "@/bus" -import { Config } from "@/config/config" -import { Flag } from "@/flag/flag" -import { Installation } from "@/installation" - +// Telemetry stripped: auto-upgrade disabled to prevent phone-home export async function upgrade() { - const config = await Config.global() - const method = await Installation.method() - const latest = await Installation.latest(method).catch(() => {}) - if (!latest) return - if (Installation.VERSION === latest) return - - if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) { - return - } - if (config.autoupdate === "notify") { - await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) - return - } - - if (method === "unknown") return - await Installation.upgrade(method, latest) - .then(() => Bus.publish(Installation.Event.Updated, { version: latest })) - .catch(() => {}) + // no-op } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 141f6156985..7dc9953b5cd 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -84,32 +84,8 @@ export namespace Config { // 6) Inline config (OPENCODE_CONFIG_CONTENT) // Managed config directory is enterprise-only and always overrides everything above. let result: Info = {} - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) - const response = await fetch(`${key}/.well-known/opencode`) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) - } - const wellknown = (await response.json()) as any - const remoteConfig = wellknown.config ?? {} - // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - result = mergeConfigConcatArrays( - result, - await load(JSON.stringify(remoteConfig), { - dir: path.dirname(`${key}/.well-known/opencode`), - source: `${key}/.well-known/opencode`, - }), - ) - log.debug("loaded remote config from well-known", { url: key }) - } - } - - const token = await Control.token() - if (token) { - } + // Telemetry stripped: skip remote .well-known/opencode config fetching + // and skip Control.token() phone-home // Global user config overrides remote config. result = mergeConfigConcatArrays(result, await global()) diff --git a/packages/opencode/src/control/index.ts b/packages/opencode/src/control/index.ts index f712e88281f..c42284f4687 100644 --- a/packages/opencode/src/control/index.ts +++ b/packages/opencode/src/control/index.ts @@ -27,41 +27,7 @@ export namespace Control { } export async function token(): Promise { - const row = Database.use((db) => - db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(), - ) - if (!row) return undefined - if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token - - const res = await fetch(`${row.url}/oauth/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: row.refresh_token, - }).toString(), - }) - - if (!res.ok) return - - const json = (await res.json()) as { - access_token: string - refresh_token?: string - expires_in?: number - } - - Database.use((db) => - db - .update(ControlAccountTable) - .set({ - access_token: json.access_token, - refresh_token: json.refresh_token ?? row.refresh_token, - token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined, - }) - .where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url))) - .run(), - ) - - return json.access_token + // Telemetry stripped: no control plane phone-home for token refresh + return undefined } } diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 47278bd5628..34de9bd1b8a 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -4,7 +4,6 @@ import { $ } from "bun" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Log } from "../util/log" -import { iife } from "@/util/iife" import { Flag } from "../flag/flag" declare global { @@ -45,7 +44,7 @@ export namespace Installation { export async function info() { return { version: VERSION, - latest: await latest(), + latest: VERSION, } } @@ -193,69 +192,8 @@ export namespace Installation { export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` - export async function latest(installMethod?: Method) { - const detectedMethod = installMethod || (await method()) - - if (detectedMethod === "brew") { - const formula = await getBrewFormula() - if (formula.includes("/")) { - const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text() - const info = JSON.parse(infoJson) - const version = info.formulae?.[0]?.versions?.stable - if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) - return version - } - return fetch("https://formulae.brew.sh/api/formula/opencode.json") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.versions.stable) - } - - if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { - const registry = await iife(async () => { - const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() - const reg = r || "https://registry.npmjs.org" - return reg.endsWith("/") ? reg.slice(0, -1) : reg - }) - const channel = CHANNEL - return fetch(`${registry}/opencode-ai/${channel}`) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.version) - } - - if (detectedMethod === "choco") { - return fetch( - "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", - { headers: { Accept: "application/json;odata=verbose" } }, - ) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.d.results[0].Version) - } - - if (detectedMethod === "scoop") { - return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { - headers: { Accept: "application/json" }, - }) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.version) - } - - return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.tag_name.replace(/^v/, "")) + export async function latest(_installMethod?: Method) { + // Telemetry stripped: no version check phone-home + return VERSION } } diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index bae33178467..8ed477b83ed 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -2,7 +2,6 @@ import { Global } from "../global" import { Log } from "../util/log" import path from "path" import z from "zod" -import { Installation } from "../installation" import { Flag } from "../flag/flag" import { lazy } from "@/util/lazy" import { Filesystem } from "../util/filesystem" @@ -93,9 +92,8 @@ export namespace ModelsDev { .then((m) => m.snapshot as Record) .catch(() => undefined) if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - const json = await fetch(`${url()}/api.json`).then((x) => x.text()) - return JSON.parse(json) + // Telemetry stripped: no remote fetch to models.dev + return {} }) export async function get() { @@ -104,29 +102,8 @@ export namespace ModelsDev { } export async function refresh() { - const result = await fetch(`${url()}/api.json`, { - headers: { - "User-Agent": Installation.USER_AGENT, - }, - signal: AbortSignal.timeout(10 * 1000), - }).catch((e) => { - log.error("Failed to fetch models.dev", { - error: e, - }) - }) - if (result && result.ok) { - await Filesystem.write(filepath, await result.text()) - ModelsDev.Data.reset() - } + // Telemetry stripped: no remote fetch to models.dev } } -if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - ModelsDev.refresh() - setInterval( - async () => { - await ModelsDev.refresh() - }, - 60 * 1000 * 60, - ).unref() -} +// Telemetry stripped: no periodic models.dev refresh diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4e42fb0d2ec..cff380df6cd 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -206,18 +206,6 @@ export namespace LLM { maxOutputTokens, abortSignal: input.abort, headers: { - ...(input.model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : input.model.providerID !== "anthropic" - ? { - "User-Agent": `opencode/${Installation.VERSION}`, - } - : undefined), ...input.model.headers, ...headers, }, @@ -246,11 +234,7 @@ export namespace LLM { ], }), experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID, - }, + isEnabled: false, }, }) } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index c36616b7ef9..e3195a067ca 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,210 +1,23 @@ -import { Bus } from "@/bus" -import { Config } from "@/config/config" -import { ulid } from "ulid" -import { Provider } from "@/provider/provider" -import { Session } from "@/session" -import { MessageV2 } from "@/session/message-v2" -import { Database, eq } from "@/storage/db" -import { SessionShareTable } from "./share.sql" import { Log } from "@/util/log" -import type * as SDK from "@opencode-ai/sdk/v2" export namespace ShareNext { const log = Log.create({ service: "share-next" }) export async function url() { - return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai") + return "" } - const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1" - export async function init() { - if (disabled) return - Bus.subscribe(Session.Event.Updated, async (evt) => { - await sync(evt.properties.info.id, [ - { - type: "session", - data: evt.properties.info, - }, - ]) - }) - Bus.subscribe(MessageV2.Event.Updated, async (evt) => { - await sync(evt.properties.info.sessionID, [ - { - type: "message", - data: evt.properties.info, - }, - ]) - if (evt.properties.info.role === "user") { - await sync(evt.properties.info.sessionID, [ - { - type: "model", - data: [ - await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then( - (m) => m, - ), - ], - }, - ]) - } - }) - Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => { - await sync(evt.properties.part.sessionID, [ - { - type: "part", - data: evt.properties.part, - }, - ]) - }) - Bus.subscribe(Session.Event.Diff, async (evt) => { - await sync(evt.properties.sessionID, [ - { - type: "session_diff", - data: evt.properties.diff, - }, - ]) - }) - } - - export async function create(sessionID: string) { - if (disabled) return { id: "", url: "", secret: "" } - log.info("creating share", { sessionID }) - const result = await fetch(`${await url()}/api/share`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ sessionID: sessionID }), - }) - .then((x) => x.json()) - .then((x) => x as { id: string; url: string; secret: string }) - Database.use((db) => - db - .insert(SessionShareTable) - .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url }) - .onConflictDoUpdate({ - target: SessionShareTable.session_id, - set: { id: result.id, secret: result.secret, url: result.url }, - }) - .run(), - ) - fullSync(sessionID) - return result - } - - function get(sessionID: string) { - const row = Database.use((db) => - db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(), - ) - if (!row) return - return { id: row.id, secret: row.secret, url: row.url } - } - - type Data = - | { - type: "session" - data: SDK.Session - } - | { - type: "message" - data: SDK.Message - } - | { - type: "part" - data: SDK.Part - } - | { - type: "session_diff" - data: SDK.FileDiff[] - } - | { - type: "model" - data: SDK.Model[] - } - - const queue = new Map }>() - async function sync(sessionID: string, data: Data[]) { - if (disabled) return - const existing = queue.get(sessionID) - if (existing) { - for (const item of data) { - existing.data.set("id" in item ? (item.id as string) : ulid(), item) - } - return - } - - const dataMap = new Map() - for (const item of data) { - dataMap.set("id" in item ? (item.id as string) : ulid(), item) - } - - const timeout = setTimeout(async () => { - const queued = queue.get(sessionID) - if (!queued) return - queue.delete(sessionID) - const share = get(sessionID) - if (!share) return - - await fetch(`${await url()}/api/share/${share.id}/sync`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - secret: share.secret, - data: Array.from(queued.data.values()), - }), - }) - }, 1000) - queue.set(sessionID, { timeout, data: dataMap }) + // Telemetry/share stripped: no-op + log.info("share disabled: telemetry stripped") } - export async function remove(sessionID: string) { - if (disabled) return - log.info("removing share", { sessionID }) - const share = get(sessionID) - if (!share) return - await fetch(`${await url()}/api/share/${share.id}`, { - method: "DELETE", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - secret: share.secret, - }), - }) - Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) + export async function create(_sessionID: string) { + // Telemetry/share stripped: no-op + return { id: "", url: "", secret: "" } } - async function fullSync(sessionID: string) { - log.info("full sync", { sessionID }) - const session = await Session.get(sessionID) - const diffs = await Session.diff(sessionID) - const messages = await Array.fromAsync(MessageV2.stream(sessionID)) - const models = await Promise.all( - messages - .filter((m) => m.info.role === "user") - .map((m) => (m.info as SDK.UserMessage).model) - .map((m) => Provider.getModel(m.providerID, m.modelID).then((m) => m)), - ) - await sync(sessionID, [ - { - type: "session", - data: session, - }, - ...messages.map((x) => ({ - type: "message" as const, - data: x.info, - })), - ...messages.flatMap((x) => x.parts.map((y) => ({ type: "part" as const, data: y }))), - { - type: "session_diff", - data: diffs, - }, - { - type: "model", - data: models, - }, - ]) + export async function remove(_sessionID: string) { + // Telemetry/share stripped: no-op } } diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 846002cdaee..cd723b35038 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,98 +1,17 @@ import path from "path" -import { mkdir } from "fs/promises" import { Log } from "../util/log" import { Global } from "../global" -import { Filesystem } from "../util/filesystem" export namespace Discovery { const log = Log.create({ service: "skill-discovery" }) - type Index = { - skills: Array<{ - name: string - description: string - files: string[] - }> - } - export function dir() { return path.join(Global.Path.cache, "skills") } - async function get(url: string, dest: string): Promise { - if (await Filesystem.exists(dest)) return true - return fetch(url) - .then(async (response) => { - if (!response.ok) { - log.error("failed to download", { url, status: response.status }) - return false - } - if (response.body) await Filesystem.writeStream(dest, response.body) - return true - }) - .catch((err) => { - log.error("failed to download", { url, err }) - return false - }) - } - - export async function pull(url: string): Promise { - const result: string[] = [] - const base = url.endsWith("/") ? url : `${url}/` - const index = new URL("index.json", base).href - const cache = dir() - const host = base.slice(0, -1) - - log.info("fetching index", { url: index }) - const data = await fetch(index) - .then(async (response) => { - if (!response.ok) { - log.error("failed to fetch index", { url: index, status: response.status }) - return undefined - } - return response - .json() - .then((json) => json as Index) - .catch((err) => { - log.error("failed to parse index", { url: index, err }) - return undefined - }) - }) - .catch((err) => { - log.error("failed to fetch index", { url: index, err }) - return undefined - }) - - if (!data?.skills || !Array.isArray(data.skills)) { - log.warn("invalid index format", { url: index }) - return result - } - - const list = data.skills.filter((skill) => { - if (!skill?.name || !Array.isArray(skill.files)) { - log.warn("invalid skill entry", { url: index, skill }) - return false - } - return true - }) - - await Promise.all( - list.map(async (skill) => { - const root = path.join(cache, skill.name) - await Promise.all( - skill.files.map(async (file) => { - const link = new URL(file, `${host}/${skill.name}/`).href - const dest = path.join(root, file) - await mkdir(path.dirname(dest), { recursive: true }) - await get(link, dest) - }), - ) - - const md = path.join(root, "SKILL.md") - if (await Filesystem.exists(md)) result.push(root) - }), - ) - - return result + export async function pull(_url: string): Promise { + // Telemetry stripped: no remote skill fetching + log.info("skill discovery disabled: telemetry stripped") + return [] } } From d3674b7202989d282130f94ae38c07f66e6ae5cd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 06:18:28 +0000 Subject: [PATCH 2/4] fix(test): fix all test failures in environment - Fix git commit signing in test fixture by passing -c commit.gpgsign=false (the environment has commit.gpgsign=true configured globally which causes test commits to fail when the signing server returns 400) - Update Discovery.pull tests to expect empty arrays since remote skill fetching has been disabled - Update well-known config test to verify fetch is NOT called since remote config fetching has been stripped - Skip chmod-based readonly tests when running as root (chmod 444 does not prevent writes for the root user) https://claude.ai/code/session_01U9pmLo7i6uNvmkykqVX3WA --- packages/opencode/test/config/config.test.ts | 10 +- packages/opencode/test/config/tui.test.ts | 7 ++ packages/opencode/test/fixture/fixture.ts | 2 +- .../opencode/test/skill/discovery.test.ts | 93 ++----------------- packages/opencode/test/tool/write.test.ts | 7 ++ 5 files changed, 28 insertions(+), 91 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f245dc3493d..3f8a638eaa1 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1460,7 +1460,7 @@ test("local .opencode config can override MCP from project config", async () => }) }) -test("project config overrides remote well-known config", async () => { +test("remote well-known config fetching is disabled", async () => { const originalFetch = globalThis.fetch let fetchedUrl: string | undefined const mockFetch = mock((url: string | URL | Request) => { @@ -1503,7 +1503,7 @@ test("project config overrides remote well-known config", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { - // Project config enables jira (overriding remote default) + // Project config enables jira await Filesystem.write( path.join(dir, "opencode.json"), JSON.stringify({ @@ -1523,9 +1523,9 @@ test("project config overrides remote well-known config", async () => { directory: tmp.path, fn: async () => { const config = await Config.get() - // Verify fetch was called for wellknown config - expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") - // Project config (enabled: true) should override remote (enabled: false) + // Verify fetch was NOT called (remote config fetching is stripped) + expect(fetchedUrl).toBeUndefined() + // Project config should still load expect(config.mcp?.jira?.enabled).toBe(true) }, }) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index f9de5b041b4..2a46a9b3fe1 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -197,6 +197,13 @@ test("skips migration when tui.json already exists", async () => { }) test("continues loading tui config when legacy source cannot be stripped", async () => { + // Skip when running as root since chmod 444 doesn't prevent writes for root + const isRoot = process.getuid?.() === 0 + if (isRoot) { + console.log("Skipping: test requires non-root user (chmod 444 does not restrict root)") + return + } + await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2)) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index ed8c5e344a8..947538bc8e0 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -20,7 +20,7 @@ export async function tmpdir(options?: TmpDirOptions) { await fs.mkdir(dirpath, { recursive: true }) if (options?.git) { await $`git init`.cwd(dirpath).quiet() - await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() + await $`git -c commit.gpgsign=false commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet() } if (options?.config) { await Bun.write( diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index 5664fa32b8a..6f7c51d9c0c 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,110 +1,33 @@ import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { Discovery } from "../../src/skill/discovery" -import { Filesystem } from "../../src/util/filesystem" import { rm } from "fs/promises" -import path from "path" - -let CLOUDFLARE_SKILLS_URL: string -let server: ReturnType -let downloadCount = 0 - -const fixturePath = path.join(import.meta.dir, "../fixture/skills") beforeAll(async () => { await rm(Discovery.dir(), { recursive: true, force: true }) - - server = Bun.serve({ - port: 0, - async fetch(req) { - const url = new URL(req.url) - - // route /.well-known/skills/* to the fixture directory - if (url.pathname.startsWith("/.well-known/skills/")) { - const filePath = url.pathname.replace("/.well-known/skills/", "") - const fullPath = path.join(fixturePath, filePath) - - if (await Filesystem.exists(fullPath)) { - if (!fullPath.endsWith("index.json")) { - downloadCount++ - } - return new Response(Bun.file(fullPath)) - } - } - - return new Response("Not Found", { status: 404 }) - }, - }) - - CLOUDFLARE_SKILLS_URL = `http://localhost:${server.port}/.well-known/skills/` }) afterAll(async () => { - server?.stop() await rm(Discovery.dir(), { recursive: true, force: true }) }) describe("Discovery.pull", () => { - test("downloads skills from cloudflare url", async () => { - const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL) - expect(dirs.length).toBeGreaterThan(0) - for (const dir of dirs) { - expect(dir).toStartWith(Discovery.dir()) - const md = path.join(dir, "SKILL.md") - expect(await Filesystem.exists(md)).toBe(true) - } + test("returns empty array (remote fetching disabled)", async () => { + const dirs = await Discovery.pull("http://localhost:9999/.well-known/skills/") + expect(dirs).toEqual([]) }) - test("url without trailing slash works", async () => { - const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, "")) - expect(dirs.length).toBeGreaterThan(0) - for (const dir of dirs) { - const md = path.join(dir, "SKILL.md") - expect(await Filesystem.exists(md)).toBe(true) - } + test("url without trailing slash returns empty array", async () => { + const dirs = await Discovery.pull("http://localhost:9999/.well-known/skills") + expect(dirs).toEqual([]) }) test("returns empty array for invalid url", async () => { - const dirs = await Discovery.pull(`http://localhost:${server.port}/invalid-url/`) + const dirs = await Discovery.pull("http://localhost:9999/invalid-url/") expect(dirs).toEqual([]) }) test("returns empty array for non-json response", async () => { - // any url not explicitly handled in server returns 404 text "Not Found" - const dirs = await Discovery.pull(`http://localhost:${server.port}/some-other-path/`) + const dirs = await Discovery.pull("http://localhost:9999/some-other-path/") expect(dirs).toEqual([]) }) - - test("downloads reference files alongside SKILL.md", async () => { - const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL) - // find a skill dir that should have reference files (e.g. agents-sdk) - const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk")) - expect(agentsSdk).toBeDefined() - if (agentsSdk) { - const refs = path.join(agentsSdk, "references") - expect(await Filesystem.exists(path.join(agentsSdk, "SKILL.md"))).toBe(true) - // agents-sdk has reference files per the index - const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })) - expect(refDir.length).toBeGreaterThan(0) - } - }) - - test("caches downloaded files on second pull", async () => { - // clear dir and downloadCount - await rm(Discovery.dir(), { recursive: true, force: true }) - downloadCount = 0 - - // first pull to populate cache - const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL) - expect(first.length).toBeGreaterThan(0) - const firstCount = downloadCount - expect(firstCount).toBeGreaterThan(0) - - // second pull should return same results from cache - const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL) - expect(second.length).toBe(first.length) - expect(second.sort()).toEqual(first.sort()) - - // second pull should NOT increment download count - expect(downloadCount).toBe(firstCount) - }) }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 695d48ccbbc..b611f373ab8 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -294,6 +294,13 @@ describe("tool.write", () => { describe("error handling", () => { test("throws error when OS denies write access", async () => { + // Skip when running as root since chmod 444 doesn't prevent writes for root + const isRoot = process.getuid?.() === 0 + if (isRoot) { + console.log("Skipping: test requires non-root user (chmod 444 does not restrict root)") + return + } + await using tmp = await tmpdir() const readonlyPath = path.join(tmp.path, "readonly.txt") From 0f66ac16223e991a4968248b6935f895b5a9a011 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 06:24:32 +0000 Subject: [PATCH 3/4] feat: disable opencode default provider auto-loading Prevent the opencode built-in provider from auto-loading even when models exist in the local snapshot. Only user-configured providers from opencode.json will be available. This ensures the TUI only shows providers the user has explicitly configured. https://claude.ai/code/session_01U9pmLo7i6uNvmkykqVX3WA --- packages/opencode/src/provider/provider.ts | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 022ec316795..fbb038074e4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -128,26 +128,10 @@ export namespace Provider { }, } }, - async opencode(input) { - const hasKey = await (async () => { - const env = Env.all() - if (input.env.some((item) => env[item])) return true - if (await Auth.get(input.id)) return true - const config = await Config.get() - if (config.provider?.["opencode"]?.options?.apiKey) return true - return false - })() - - if (!hasKey) { - for (const [key, value] of Object.entries(input.models)) { - if (value.cost.input === 0) continue - delete input.models[key] - } - } - + async opencode(_input) { + // Telemetry stripped: opencode default provider disabled return { - autoload: Object.keys(input.models).length > 0, - options: hasKey ? {} : { apiKey: "public" }, + autoload: false, } }, openai: async () => { From ca4447f36e10c7cd54e170d13889d628d07636e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 18:20:01 +0000 Subject: [PATCH 4/4] fix: remove reasoningSummary parameter from GPT model requests Remove the automatic inclusion of `reasoningSummary: "auto"` for OpenAI, Azure, and GitHub Copilot providers. This parameter is not supported by third-party OpenAI-compatible endpoints (e.g. LightLLM, Azure proxies) and causes "unknown parameter reasoningSummary" errors. https://claude.ai/code/session_017AMgq3eK3ZtDkCwveHVgBc --- packages/opencode/src/provider/transform.ts | 5 ----- packages/opencode/test/provider/transform.test.ts | 8 ++------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6980be05188..2e6d59062b7 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -447,7 +447,6 @@ export namespace ProviderTransform { effort, { reasoningEffort: effort, - reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }, ]), @@ -478,7 +477,6 @@ export namespace ProviderTransform { effort, { reasoningEffort: effort, - reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }, ]), @@ -508,7 +506,6 @@ export namespace ProviderTransform { effort, { reasoningEffort: effort, - reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }, ]), @@ -759,7 +756,6 @@ export namespace ProviderTransform { if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) { if (!input.model.api.id.includes("gpt-5-pro")) { result["reasoningEffort"] = "medium" - result["reasoningSummary"] = "auto" } // Only set textVerbosity for non-chat gpt-5.x models @@ -776,7 +772,6 @@ export namespace ProviderTransform { if (input.model.providerID.startsWith("opencode")) { result["promptCacheKey"] = input.sessionID result["include"] = ["reasoning.encrypted_content"] - result["reasoningSummary"] = "auto" } } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 2329846351c..2c0dd1b5ca8 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1923,7 +1923,6 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high"]) expect(result.low).toEqual({ reasoningEffort: "low", - reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }) }) @@ -1984,7 +1983,6 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"]) expect(result.xhigh).toEqual({ reasoningEffort: "xhigh", - reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }) }) @@ -2123,7 +2121,7 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) - test("standard azure models return custom efforts with reasoningSummary", () => { + test("standard azure models return custom efforts", () => { const model = createMockModel({ id: "o1", providerID: "azure", @@ -2137,7 +2135,6 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["low", "medium", "high"]) expect(result.low).toEqual({ reasoningEffort: "low", - reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }) }) @@ -2172,7 +2169,7 @@ describe("ProviderTransform.variants", () => { expect(result).toEqual({}) }) - test("standard openai models return custom efforts with reasoningSummary", () => { + test("standard openai models return custom efforts", () => { const model = createMockModel({ id: "gpt-5", providerID: "openai", @@ -2187,7 +2184,6 @@ describe("ProviderTransform.variants", () => { expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"]) expect(result.low).toEqual({ reasoningEffort: "low", - reasoningSummary: "auto", include: ["reasoning.encrypted_content"], }) })