From b9bd9bfc91cdbbefd3de010da6c85f7e16279534 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:09:23 -0500 Subject: [PATCH 1/7] fix(installer): keep a local checkout registered instead of replacing it A config that points OpenCode at a working clone of this repository lost that entry on the next install. Any path ending in `/oc-codex-multi-auth` was read as an installer-written reference, removed, and replaced with the published package name, so the next request ran against npm rather than the code being edited - silently, with the previous entry recoverable only from the backup file. Renaming the clone did not help. A differently named directory survived the filter, but the published name was still appended beside it, leaving both copies registered at once. No directory name produced a correct result. Entries are now identified by the package they resolve to, read from the nearest enclosing `package.json`, rather than by how the path is spelled. A path outside `node_modules` and the versioned package cache is somewhere a human deliberately pointed OpenCode, so it is kept byte-identical, whatever the directory is called and whether it is written as a path, a `file://` URL, or a build output directory. An entry carrying plugin options keeps its options. The published package name is appended only when nothing in the config resolves to this plugin. References the installer itself produced are still retired: a repeated bare name, version pins, the former `oc-chatgpt-multi-auth` name, and paths into `node_modules` or the versioned package cache. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 531a786 --- scripts/install-oc-codex-multi-auth-core.js | 200 +++++++++++++++++--- test/install-oc-codex-multi-auth.test.ts | 147 ++++++++++++++ 2 files changed, 317 insertions(+), 30 deletions(-) diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index ff5fb911..adde259a 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -1,7 +1,7 @@ -import { existsSync, realpathSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const PACKAGE_NAME = "oc-codex-multi-auth"; @@ -200,47 +200,185 @@ function parseUpdateArgs(argv) { }; } -function normalizePluginEntryForMatch(entry) { - const trimmed = entry.trim(); - let normalized = trimmed.toLowerCase(); +const MANAGED_PACKAGE_ENTRY = "managed-package"; +const LOCAL_CHECKOUT_ENTRY = "local-checkout"; +const UNRELATED_ENTRY = "unrelated"; +const DECLARED_NAME_LOOKUP_DEPTH = 3; + +function pluginEntrySpecifier(entry) { + if (typeof entry === "string") return entry; + // `[specifier, options]` configures a plugin without changing where it loads from. + if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0]; + return null; +} + +/** + * Relative paths come back unresolved on purpose: OpenCode resolves them + * against the config directory, not the installer's working directory, so + * resolving them here would invent a location that was never registered. + */ +function pluginEntryPath(specifier) { + const trimmed = specifier.trim(); + if (!trimmed) return null; + if (/^file:\/\//i.test(trimmed)) { + try { + return fileURLToPath(trimmed); + } catch { + return null; + } + } + if (!trimmed.includes("/") && !trimmed.includes("\\")) return null; + return trimmed; +} + +function pluginPathSegments(entryPath) { + return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean); +} + +function isPackageManagerPath(entryPath) { + const segments = pluginPathSegments(entryPath); + return segments.some( + (segment, index) => + segment === "node_modules" || + // OpenCode's plugin cache spells the version into the directory name. + // A `packages/` directory without one is an ordinary monorepo. + (segments[index - 1] === "packages" && segment.includes("@")), + ); +} + +/** + * Last-resort identification for a path that is not present on this machine. + * Spelling alone never authorizes deleting an entry; it only names the package a + * missing path was probably meant to point at. + */ +function managedNameFromPathSpelling(entryPath) { + const segments = pluginPathSegments(entryPath); + const last = segments.at(-1) === "dist" ? segments.at(-2) : segments.at(-1); + if (!last) return null; + let candidate = last.toLowerCase(); try { - normalized = decodeURIComponent(normalized); + candidate = decodeURIComponent(candidate); } catch { - // Keep the raw lowercased value when a malformed URI escape is present. + // Keep the raw segment when it carries a malformed escape. } - normalized = normalized.replace(/\\/g, "/").replace(/\/+$/g, ""); - if (normalized.endsWith("/dist")) { - normalized = normalized.slice(0, -"/dist".length); + const versionSuffix = candidate.indexOf("@"); + if (versionSuffix > 0) candidate = candidate.slice(0, versionSuffix); + return getManagedPackageNames().find((name) => name.toLowerCase() === candidate) ?? null; +} + +function readDeclaredPackageName(directoryPath) { + try { + const parsed = JSON.parse(readFileSync(join(directoryPath, "package.json"), "utf8")); + const name = parsed?.name; + return typeof name === "string" && name.trim() ? name.trim() : null; + } catch { + return null; } - return normalized; } -function isManagedPluginEntry(entry) { - if (typeof entry !== "string") return false; - const trimmed = entry.trim().toLowerCase(); - const normalized = normalizePluginEntryForMatch(entry); - return getManagedPackageNames().some((name) => { - const lowerName = name.toLowerCase(); - return trimmed === lowerName || - trimmed.startsWith(`${lowerName}@`) || - normalized.endsWith(`/${lowerName}`) || - normalized.endsWith(`/node_modules/${lowerName}`); - }); +/** An entry may point at a build output inside the package, so walk upwards. */ +function resolveDeclaredPackageName(entryPath) { + if (!isAbsolute(entryPath)) return null; + let current = resolve(entryPath); + for (let depth = 0; depth <= DECLARED_NAME_LOOKUP_DEPTH; depth += 1) { + const name = readDeclaredPackageName(current); + if (name) return name; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; } -function normalizePluginList(list) { - const entries = Array.isArray(list) ? list.filter(Boolean) : []; - const filtered = entries.filter((entry) => !isManagedPluginEntry(entry)); - return [...filtered, PACKAGE_NAME]; +/** + * Decides what a plugin entry is, by identity rather than by spelling. + * + * The distinction that matters is not which package an entry names but who + * chose the location. A bare specifier or a path inside `node_modules` is a + * reference the installer itself produced and may retire. Any other path is + * somewhere a human deliberately pointed OpenCode - a checkout of this package + * being developed on, most often - and is never the installer's to remove. + */ +function classifyPluginEntry(entry, resolveDeclaredName = resolveDeclaredPackageName) { + const specifier = pluginEntrySpecifier(entry); + if (specifier === null) return { kind: UNRELATED_ENTRY, name: null }; + + const entryPath = pluginEntryPath(specifier); + if (entryPath === null) { + const bare = specifier.trim().toLowerCase(); + const name = getManagedPackageNames().find( + (managed) => + bare === managed.toLowerCase() || bare.startsWith(`${managed.toLowerCase()}@`), + ); + return name + ? { kind: MANAGED_PACKAGE_ENTRY, name } + : { kind: UNRELATED_ENTRY, name: null }; + } + + const declaredName = resolveDeclaredName(entryPath); + const managedName = declaredName + ? getManagedPackageNames().find( + (managed) => managed.toLowerCase() === declaredName.toLowerCase(), + ) ?? null + : managedNameFromPathSpelling(entryPath); + + if (!managedName) return { kind: UNRELATED_ENTRY, name: null }; + + return isPackageManagerPath(entryPath) + ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName } + : { kind: LOCAL_CHECKOUT_ENTRY, name: managedName, path: entryPath }; +} + +/** + * Ensures this plugin is registered exactly once, without changing how an + * existing registration is spelled. Appending the published package name is the + * fallback for a config that does not reference the plugin at all, not the + * canonical form every config is rewritten into. + */ +function normalizePluginList(list, onNotice) { + const entries = Array.isArray(list) + ? list.filter((entry) => entry !== null && entry !== undefined && entry !== "") + : []; + const kept = []; + let registered = false; + let keptPublishedName = false; + + for (const entry of entries) { + const classification = classifyPluginEntry(entry); + + if (classification.kind === LOCAL_CHECKOUT_ENTRY) { + kept.push(entry); + registered = true; + onNotice?.( + `Keeping the local ${classification.name} checkout registered at ${classification.path}`, + ); + continue; + } + + if (classification.kind === MANAGED_PACKAGE_ENTRY) { + // Retire stale duplicates, version pins, renamed packages, and paths + // into package-manager output; keep one published-name entry in place. + if (pluginEntrySpecifier(entry) === PACKAGE_NAME && !keptPublishedName) { + keptPublishedName = true; + registered = true; + kept.push(entry); + } + continue; + } + + kept.push(entry); + } + + return registered ? kept : [...kept, PACKAGE_NAME]; } -function mergeTuiConfig(existingConfig) { +function mergeTuiConfig(existingConfig, onNotice) { const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {}; const next = { ...existing }; if (typeof next.$schema !== "string" || !next.$schema.trim()) { next.$schema = "https://opencode.ai/tui.json"; } - next.plugin = normalizePluginList(existing.plugin); + next.plugin = normalizePluginList(existing.plugin, onNotice); return next; } @@ -1370,7 +1508,7 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } existingConfig = existing; const merged = { ...existing }; - merged.plugin = normalizePluginList(existing.plugin); + merged.plugin = normalizePluginList(existing.plugin, log); if (!pluginOnly) { const provider = (existing.provider && typeof existing.provider === "object") ? { ...existing.provider } @@ -1404,7 +1542,7 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { throw new Error("TUI config root must be a JSON object"); } existingTuiConfig = existing; - nextTuiConfig = mergeTuiConfig(existing); + nextTuiConfig = mergeTuiConfig(existing, log); } catch (error) { if (pluginOnly) { throw new Error( @@ -1483,12 +1621,14 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { export const __test = { buildPaths, backupConfig, + classifyPluginEntry, copyFileWithWindowsRetry, formatConfigDiff, formatRedactedConfigDiff, mergeFullTemplate, mergeOpenaiProvider, mergeTuiConfig, + normalizePluginList, parseCliArgs, removeWithWindowsRetry, runStandaloneCommand, diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index 7247e9bb..0f608709 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; type OpenAiTemplate = { provider: { @@ -994,4 +995,150 @@ describe("install-oc-codex-multi-auth script", () => { expect(rmMock).toHaveBeenNthCalledWith(1, firstCachePath, { recursive: true, force: true }); expect(rmMock).toHaveBeenNthCalledWith(2, firstCachePath, { recursive: true, force: true }); }); + + describe("plugin entry registration", () => { + async function createCheckout(root: string, packageName: string, directoryName: string) { + const directory = join(root, directoryName); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ name: packageName, version: "1.0.0" }), + "utf-8", + ); + return directory; + } + + it("keeps a local checkout however it is spelled and does not add the published name", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "oc-codex-multi-auth"); + const buildOutput = join(checkout, "dist"); + await mkdir(buildOutput, { recursive: true }); + const forkInDifferentlyNamedDirectory = await createCheckout( + tempHome, + "oc-codex-multi-auth", + "my-codex-fork", + ); + const monorepoCheckout = await createCheckout( + join(tempHome, "workspace", "packages"), + "oc-codex-multi-auth", + "oc-codex-multi-auth", + ); + + for (const entry of [ + checkout, + pathToFileURL(checkout).href, + buildOutput, + forkInDifferentlyNamedDirectory, + monorepoCheckout, + ]) { + expect(__test.normalizePluginList(["other-plugin", entry])).toEqual([ + "other-plugin", + entry, + ]); + } + }); + + it("retires package-manager references while leaving a published-name entry in place", async () => { + vi.resetModules(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + expect( + __test.normalizePluginList([ + "oc-codex-multi-auth", + "other-plugin", + "oc-chatgpt-multi-auth@1.2.3", + "/absent/node_modules/oc-codex-multi-auth", + "file:///absent/node_modules/oc-chatgpt-multi-auth/dist", + "/absent/.cache/opencode/packages/oc-codex-multi-auth@latest", + ]), + ).toEqual(["oc-codex-multi-auth", "other-plugin"]); + }); + + it("preserves a path it cannot resolve unless the path is package-manager output", async () => { + vi.resetModules(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const unmountedCheckout = "/absent/projects/oc-codex-multi-auth"; + + expect(__test.normalizePluginList([unmountedCheckout])).toEqual([unmountedCheckout]); + }); + + it("never rewrites an entry that carries plugin options", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "oc-codex-multi-auth"); + const configuredCheckout = [checkout, { debug: true }]; + const configuredUnrelated = ["other-plugin", { debug: true }]; + + expect(__test.normalizePluginList([configuredCheckout])).toEqual([configuredCheckout]); + expect(__test.normalizePluginList([configuredUnrelated])).toEqual([ + configuredUnrelated, + "oc-codex-multi-auth", + ]); + }); + + it("registers the published name without touching an unrelated local plugin", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const unrelated = await createCheckout(tempHome, "some-other-plugin", "some-other-plugin"); + + expect(__test.normalizePluginList([unrelated])).toEqual([unrelated, "oc-codex-multi-auth"]); + }); + + it("leaves a config that already registers a local checkout untouched", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "oc-codex-multi-auth"); + const entry = pathToFileURL(checkout).href; + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + const configText = `${JSON.stringify({ plugin: [entry] }, null, 2)}\n`; + const tuiText = `${JSON.stringify( + { $schema: "https://opencode.ai/tui.json", plugin: [entry] }, + null, + 2, + )}\n`; + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, configText, "utf-8"); + await writeFile(tuiConfigPath, tuiText, "utf-8"); + + await expect( + runInstaller(["install", "--plugin-only", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ wrote: false, pluginOnly: true, exitCode: 0 }); + + await expect(readFile(configPath, "utf-8")).resolves.toBe(configText); + await expect(readFile(tuiConfigPath, "utf-8")).resolves.toBe(tuiText); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); + }); + + it("keeps a local checkout when a catalog mode rewrites provider.openai", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "oc-codex-multi-auth"); + const entry = pathToFileURL(checkout).href; + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, JSON.stringify({ plugin: [entry] }, null, 2), "utf-8"); + + await expect( + runInstaller(["--modern", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ action: "install", configMode: "modern", exitCode: 0 }); + + const saved = JSON.parse(await readFile(configPath, "utf-8")) as { plugin: string[] }; + expect(saved.plugin).toEqual([entry]); + }); + }); }); From 656df0fb996f40d6d4d12917b9e3009098e932d0 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:09:45 -0500 Subject: [PATCH 2/7] fix(update): refuse cache eviction outside the OpenCode cache The exit-time cache refresh deleted three paths recursively, each chosen purely because its final segment matched a managed package name. A developer who links a working checkout into the OpenCode cache so OpenCode loads it - `npm link`, or a symlink under `node_modules` - therefore lost that checkout the next time OpenCode exited with an update pending. Every path is now resolved through its symlinks and must still be contained in the OpenCode cache directory before it can be removed. A path that escapes, a path that cannot be resolved, and the cache directory itself are refused and logged rather than deleted. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 531a786 --- lib/auto-update-checker.ts | 44 +++++++++++++++-- test/auto-update-checker.test.ts | 83 ++++++++++++++++++++++++++------ 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/lib/auto-update-checker.ts b/lib/auto-update-checker.ts index 1e91b020..573afa15 100644 --- a/lib/auto-update-checker.ts +++ b/lib/auto-update-checker.ts @@ -1,5 +1,5 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from "node:fs"; +import { isAbsolute, join, relative, resolve } from "node:path"; import { homedir } from "node:os"; import { createLogger } from "./logger.js"; @@ -118,12 +118,50 @@ function getManagedCachePaths(): string[] { ]); } -export function clearManagedOpenCodePluginCache(paths = getManagedCachePaths()): boolean { +function isInsideDirectory(candidate: string, directory: string): boolean { + const relativePath = relative(directory, candidate); + return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); +} + +export interface EvictionScope { + cacheRoot?: string; + resolveRealPath?: (path: string) => string; +} + +/** + * Cache eviction deletes recursively, so it must never act on a path that only + * looks like cache. Resolving symlinks before the containment check is the part + * that matters: a developer who links their working checkout into the cache + * would otherwise have it deleted on exit by a name match alone. + */ +export function isEvictableCachePath(cachePath: string, scope: EvictionScope = {}): boolean { + const { cacheRoot = OPENCODE_CACHE_DIR, resolveRealPath = realpathSync } = scope; + const absolutePath = resolve(cachePath); + const absoluteRoot = resolve(cacheRoot); + if (!isInsideDirectory(absolutePath, absoluteRoot)) return false; + + try { + return isInsideDirectory(resolveRealPath(absolutePath), resolveRealPath(absoluteRoot)); + } catch { + return false; + } +} + +export function clearManagedOpenCodePluginCache( + paths = getManagedCachePaths(), + scope: EvictionScope = {}, +): boolean { let cleared = false; for (const cachePath of paths) { try { if (!existsSync(cachePath)) continue; + if (!isEvictableCachePath(cachePath, scope)) { + log.warn("Refused to clear a plugin cache path that resolves outside the OpenCode cache", { + path: cachePath, + }); + continue; + } rmSync(cachePath, { recursive: true, force: true }); cleared = true; log.info("Cleared OpenCode plugin cache for update", { path: cachePath }); diff --git a/test/auto-update-checker.test.ts b/test/auto-update-checker.test.ts index 6f64aeae..ba721491 100644 --- a/test/auto-update-checker.test.ts +++ b/test/auto-update-checker.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { join } from "node:path"; vi.mock("node:fs", () => ({ readFileSync: vi.fn(), @@ -6,6 +7,7 @@ vi.mock("node:fs", () => ({ existsSync: vi.fn(), mkdirSync: vi.fn(), rmSync: vi.fn(), + realpathSync: vi.fn(), })); describe("auto-update-checker", () => { @@ -329,31 +331,82 @@ describe("auto-update-checker", () => { }); describe("clearManagedOpenCodePluginCache", () => { + const cacheRoot = join("/tmp", "opencode-cache"); + const resolveRealPath = (path: string) => path; + const packagesPath = join(cacheRoot, "packages", "oc-codex-multi-auth@latest"); + const nodeModulesPath = join(cacheRoot, "node_modules", "oc-codex-multi-auth"); + const checkoutPath = join("/home", "dev", "src", "oc-codex-multi-auth"); + it("removes managed OpenCode package cache paths", () => { vi.mocked(fs.existsSync).mockReturnValue(true); - const cleared = clearManagedOpenCodePluginCache([ - "C:\\cache\\packages\\oc-codex-multi-auth@latest", - "C:\\cache\\node_modules\\oc-codex-multi-auth", - ]); + const cleared = clearManagedOpenCodePluginCache([packagesPath, nodeModulesPath], { + cacheRoot, + resolveRealPath, + }); expect(cleared).toBe(true); - expect(fs.rmSync).toHaveBeenCalledWith( - "C:\\cache\\packages\\oc-codex-multi-auth@latest", - { recursive: true, force: true }, - ); - expect(fs.rmSync).toHaveBeenCalledWith( - "C:\\cache\\node_modules\\oc-codex-multi-auth", - { recursive: true, force: true }, - ); + expect(fs.rmSync).toHaveBeenCalledWith(packagesPath, { recursive: true, force: true }); + expect(fs.rmSync).toHaveBeenCalledWith(nodeModulesPath, { recursive: true, force: true }); }); it("returns false when no managed cache paths exist", () => { vi.mocked(fs.existsSync).mockReturnValue(false); - const cleared = clearManagedOpenCodePluginCache([ - "C:\\cache\\packages\\oc-codex-multi-auth@latest", - ]); + const cleared = clearManagedOpenCodePluginCache([packagesPath], { + cacheRoot, + resolveRealPath, + }); + + expect(cleared).toBe(false); + expect(fs.rmSync).not.toHaveBeenCalled(); + }); + + it("refuses a cache entry that resolves onto a linked working checkout", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const cleared = clearManagedOpenCodePluginCache([nodeModulesPath], { + cacheRoot, + resolveRealPath: (path) => (path === nodeModulesPath ? checkoutPath : path), + }); + + expect(cleared).toBe(false); + expect(fs.rmSync).not.toHaveBeenCalled(); + }); + + it("refuses a path outside the OpenCode cache directory", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const cleared = clearManagedOpenCodePluginCache([checkoutPath], { + cacheRoot, + resolveRealPath, + }); + + expect(cleared).toBe(false); + expect(fs.rmSync).not.toHaveBeenCalled(); + }); + + it("refuses the cache directory itself", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const cleared = clearManagedOpenCodePluginCache([cacheRoot], { + cacheRoot, + resolveRealPath, + }); + + expect(cleared).toBe(false); + expect(fs.rmSync).not.toHaveBeenCalled(); + }); + + it("refuses every path when the cache directory cannot be resolved", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const cleared = clearManagedOpenCodePluginCache([packagesPath], { + cacheRoot, + resolveRealPath: () => { + throw new Error("ENOENT"); + }, + }); expect(cleared).toBe(false); expect(fs.rmSync).not.toHaveBeenCalled(); From b35b3b65d15933c59da8a4964837f7d537e416cf Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:10:16 -0500 Subject: [PATCH 3/7] docs: describe running the plugin from a local checkout Developing on this project means pointing OpenCode at a clone rather than at the published package, but nothing said so, and the install commands were presented without noting that they write the reader's real OpenCode config. README gains a section on that setup and on what the installer does with it. AGENTS.md separates the installer from the standalone CLI commands it had been listed beside, since only the installer writes config, and records that a plugin entry is identified by what it resolves to rather than by its last path segment. The setup skill tells an agent to read the existing `plugin` array before installing, and to prefer `update` when the goal is only a stale package cache. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 531a786 --- AGENTS.md | 17 +++++++++++++++-- README.md | 25 +++++++++++++++++++++++-- skills/oc-codex-setup/SKILL.md | 12 +++++++++++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b2981888..5647a753 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,8 @@ Package version: see `package.json` (`version` field). - Do not hardcode ports other than OAuth callback port `1455`; use existing constants/helpers. - Do not remove `store: false` or `reasoning.encrypted_content` from shipped config templates. - Do not treat `oc-chatgpt-multi-auth` as current except in migration/cleanup logic. +- Do not identify a plugin entry by the spelling of its last path segment. Resolve what it points at; a path outside `node_modules` belongs to whoever wrote it and is never rewritten or removed. +- Do not run the installer to repair a developer machine's config. It writes that machine's real OpenCode config; `update` refreshes the package cache without touching either file. - Do not expose account emails, access tokens, refresh tokens, or raw prompt/response bodies in normal diagnostics. - Do not silently delete JSON credentials when keychain operations fail. - Do not document boolean env overrides as truthy for `"true"` or `"yes"`. Only `"1"` is truthy. @@ -91,11 +93,22 @@ npm run test:watch # vitest watch mode npm run lint # eslint ``` +Installer, which writes the real `~/.config/opencode/opencode.json` and +`tui.json` of whoever runs it: + +```bash +npx -y oc-codex-multi-auth@latest # register plugin entries only +npx -y oc-codex-multi-auth@latest --full # also install the explicit model catalog +npx -y oc-codex-multi-auth@latest update # refresh the package cache; never reads or writes config +``` + +A config that already registers this plugin keeps the entry it has, including +one pointing at a working checkout of this repository. The published package +name is added only when nothing in the config resolves to this plugin. + Standalone CLI examples: ```bash -npx -y oc-codex-multi-auth@latest -npx -y oc-codex-multi-auth@latest --full oc-codex-multi-auth warm oc-codex-multi-auth status --json oc-codex-multi-auth doctor diff --git a/README.md b/README.md index 11b4e230..24549710 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,28 @@ opencode debug config opencode auth login ``` -The default installer only normalizes the plugin entry in `~/.config/opencode/opencode.json`, enables the TUI status plugin in `~/.config/opencode/tui.json`, and clears the cached plugin copy. Catalog modes also merge their selected `provider.openai` definitions. Changed config files are backed up before writing. +The default installer only registers the plugin entry in `~/.config/opencode/opencode.json`, enables the TUI status plugin in `~/.config/opencode/tui.json`, and clears the cached plugin copy. Catalog modes also merge their selected `provider.openai` definitions. Changed config files are backed up before writing. + +### Running from a local checkout + +You can point OpenCode at a clone of this repository instead of the published +package, which is how the project is developed: + +```json +{ "plugin": ["file:///path/to/oc-codex-multi-auth"] } +``` + +The installer leaves that entry exactly as written. It identifies an entry by +the package it resolves to rather than by how the path is spelled, so a clone +is recognized under any directory name, whether it is referenced as a path, a +`file://` URL, or its build output. `oc-codex-multi-auth` is appended only when +no entry in the config resolves to this plugin, so the installer never replaces +a checkout with the published package or registers both at once. + +Stale references the installer itself produced are still retired: the bare +package name repeated, version-pinned entries, the former +`oc-chatgpt-multi-auth` name, and paths into `node_modules` or the OpenCode +package cache. ### Standalone CLI (no agent / no token cost) @@ -504,7 +525,7 @@ opencode auth login Common symptoms - Plugin does not load: rerun `npx -y oc-codex-multi-auth@latest`, then restart OpenCode -- Config looks wrong: run `opencode debug config` and confirm `"plugin": ["oc-codex-multi-auth"]` +- Config looks wrong: run `opencode debug config` and confirm `"plugin": ["oc-codex-multi-auth"]`, or the path to your checkout when running one - OAuth callback fails: free port `1455`, then rerun `opencode auth login` - Browser launch is blocked: use the remote/headless login path from [docs/getting-started.md](docs/getting-started.md#remote-or-headless-login) - Wrong account is selected: run `codex-list`, then `codex-switch` diff --git a/skills/oc-codex-setup/SKILL.md b/skills/oc-codex-setup/SKILL.md index ec12df4c..636985a9 100644 --- a/skills/oc-codex-setup/SKILL.md +++ b/skills/oc-codex-setup/SKILL.md @@ -55,6 +55,16 @@ npx -y oc-codex-multi-auth@latest --legacy Use this on older OpenCode versions that do not support variant-based model entries. Installs 59 explicit model IDs only. +## When OpenCode already loads a local checkout + +Check the existing `plugin` array before installing. An entry pointing at a +clone of this repository means the user is running their own build on purpose. + +Every installer mode keeps that entry as written and adds nothing beside it, so +running the installer is safe; it registers the published package only when no +entry resolves to this plugin. Prefer `update` anyway when the goal is just to +refresh a stale package cache, since it never opens either config file. + ## Other installer flags - `--dry-run` — show changed config paths without values or writes @@ -105,7 +115,7 @@ opencode run "Explain this repository" --model=openai/gpt-5.5-medium ## Troubleshooting -- Confirm `"plugin": ["oc-codex-multi-auth"]` is present in the OpenCode config. +- Confirm the OpenCode config registers the plugin, as `"plugin": ["oc-codex-multi-auth"]` or as a path to the user's own checkout. - Re-run `opencode auth login` if tokens expired or the wrong workspace was selected. - Inspect `~/.opencode/logs/codex-plugin/` after a failed request. - Set `ENABLE_PLUGIN_REQUEST_LOGGING=1` for deeper request logging. From a2ce6715ad57ae77aab7ec28b34c695992c954e8 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 13:32:17 -0500 Subject: [PATCH 4/7] fix(installer): register this plugin exactly once, however it is spelled Review of #259 found four ways the new classifier still reached the wrong answer. Each one ends with OpenCode loading two copies of this plugin, or none. A relative entry was never resolved, so its package.json was never read. OpenCode resolves such an entry against the config file that declares it, so `./plugins/my-codex-fork` names a real checkout; the installer saw an unreadable path, called it unrelated, and appended the published name beside it. The declaring config directory is now passed down and used to resolve relative entries FOR INSPECTION ONLY - the entry itself is still written back exactly as the user spelled it. A checkout and the published name could both survive. The published entry was kept whenever it appeared, independently of any checkout, so a config left by an older installer kept the duplicate registration this change exists to repair. Registration is now decided once, across the whole list, before any entry is kept. A checkout of the FORMER package name satisfied that registration. It is user-owned, so it is still never removed, but `oc-chatgpt-multi-auth` is valid for cleanup and not as the registration the installer must ensure - a config holding only a legacy checkout now also gets the current package registered. Windows reaches one directory under many spellings, so a `NODE_MODULES` path there is the same package-manager output as `node_modules` and was being preserved as if a human had chosen it. Segment comparison is now case-insensitive on win32 only; elsewhere the two are different directories and must stay so. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 4e488d6 --- AGENTS.md | 2 +- scripts/install-oc-codex-multi-auth-core.js | 85 ++++++++++----- test/install-oc-codex-multi-auth.test.ts | 110 ++++++++++++++++++++ 3 files changed, 169 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5647a753..9e0c5496 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,7 @@ Package version: see `package.json` (`version` field). - Do not hardcode ports other than OAuth callback port `1455`; use existing constants/helpers. - Do not remove `store: false` or `reasoning.encrypted_content` from shipped config templates. - Do not treat `oc-chatgpt-multi-auth` as current except in migration/cleanup logic. -- Do not identify a plugin entry by the spelling of its last path segment. Resolve what it points at; a path outside `node_modules` belongs to whoever wrote it and is never rewritten or removed. +- Do not identify a plugin entry by the spelling of its last path segment. Resolve what it points at; a path outside package-manager output - `node_modules`, and the versioned directories of the OpenCode package cache - belongs to whoever wrote it and is never rewritten or removed. - Do not run the installer to repair a developer machine's config. It writes that machine's real OpenCode config; `update` refreshes the package cache without touching either file. - Do not expose account emails, access tokens, refresh tokens, or raw prompt/response bodies in normal diagnostics. - Do not silently delete JSON credentials when keychain operations fail. diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index adde259a..59c5e9db 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -212,11 +212,7 @@ function pluginEntrySpecifier(entry) { return null; } -/** - * Relative paths come back unresolved on purpose: OpenCode resolves them - * against the config directory, not the installer's working directory, so - * resolving them here would invent a location that was never registered. - */ +/** The path an entry names, exactly as the config spells it. */ function pluginEntryPath(specifier) { const trimmed = specifier.trim(); if (!trimmed) return null; @@ -235,8 +231,13 @@ function pluginPathSegments(entryPath) { return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean); } -function isPackageManagerPath(entryPath) { - const segments = pluginPathSegments(entryPath); +function isPackageManagerPath(entryPath, platform = process.platform) { + // Windows reaches one directory under many spellings, so `NODE_MODULES` + // there is the same package-manager output as `node_modules`. Elsewhere the + // two are different directories and must stay so. + const segments = pluginPathSegments(entryPath).map((segment) => + platform === "win32" ? segment.toLowerCase() : segment, + ); return segments.some( (segment, index) => segment === "node_modules" || @@ -246,6 +247,18 @@ function isPackageManagerPath(entryPath) { ); } +/** + * Where an entry points, for reading metadata about it only. OpenCode resolves + * a relative entry against the config file that declares it, so that directory + * is what makes such a path mean anything; the installer's working directory + * would name somewhere else entirely. Null when a relative entry arrives with + * no declaring directory to resolve it against. + */ +function resolveInspectionPath(entryPath, baseDirectory) { + if (isAbsolute(entryPath)) return entryPath; + return baseDirectory ? resolve(baseDirectory, entryPath) : null; +} + /** * Last-resort identification for a path that is not present on this machine. * Spelling alone never authorizes deleting an entry; it only names the package a @@ -299,7 +312,12 @@ function resolveDeclaredPackageName(entryPath) { * somewhere a human deliberately pointed OpenCode - a checkout of this package * being developed on, most often - and is never the installer's to remove. */ -function classifyPluginEntry(entry, resolveDeclaredName = resolveDeclaredPackageName) { +function classifyPluginEntry(entry, options = {}) { + const { + resolveDeclaredName = resolveDeclaredPackageName, + baseDirectory, + platform = process.platform, + } = options; const specifier = pluginEntrySpecifier(entry); if (specifier === null) return { kind: UNRELATED_ENTRY, name: null }; @@ -315,7 +333,8 @@ function classifyPluginEntry(entry, resolveDeclaredName = resolveDeclaredPackage : { kind: UNRELATED_ENTRY, name: null }; } - const declaredName = resolveDeclaredName(entryPath); + const inspectionPath = resolveInspectionPath(entryPath, baseDirectory); + const declaredName = inspectionPath ? resolveDeclaredName(inspectionPath) : null; const managedName = declaredName ? getManagedPackageNames().find( (managed) => managed.toLowerCase() === declaredName.toLowerCase(), @@ -324,9 +343,9 @@ function classifyPluginEntry(entry, resolveDeclaredName = resolveDeclaredPackage if (!managedName) return { kind: UNRELATED_ENTRY, name: null }; - return isPackageManagerPath(entryPath) + return isPackageManagerPath(entryPath, platform) ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName } - : { kind: LOCAL_CHECKOUT_ENTRY, name: managedName, path: entryPath }; + : { kind: LOCAL_CHECKOUT_ENTRY, name: managedName, path: inspectionPath ?? entryPath }; } /** @@ -335,50 +354,58 @@ function classifyPluginEntry(entry, resolveDeclaredName = resolveDeclaredPackage * fallback for a config that does not reference the plugin at all, not the * canonical form every config is rewritten into. */ -function normalizePluginList(list, onNotice) { +function normalizePluginList(list, onNotice, options = {}) { const entries = Array.isArray(list) ? list.filter((entry) => entry !== null && entry !== undefined && entry !== "") : []; + const classifications = entries.map((entry) => classifyPluginEntry(entry, options)); + // A checkout of this package already IS the registration, so a published + // entry beside it is a second copy of the same plugin for OpenCode to load. + // Only a checkout of the CURRENT package counts: the former name is valid + // for cleanup, never as the registration the installer exists to ensure. + const checkoutRegistered = classifications.some( + (classification) => + classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME, + ); const kept = []; - let registered = false; let keptPublishedName = false; - for (const entry of entries) { - const classification = classifyPluginEntry(entry); + entries.forEach((entry, index) => { + const classification = classifications[index]; if (classification.kind === LOCAL_CHECKOUT_ENTRY) { kept.push(entry); - registered = true; onNotice?.( `Keeping the local ${classification.name} checkout registered at ${classification.path}`, ); - continue; + return; } if (classification.kind === MANAGED_PACKAGE_ENTRY) { // Retire stale duplicates, version pins, renamed packages, and paths - // into package-manager output; keep one published-name entry in place. - if (pluginEntrySpecifier(entry) === PACKAGE_NAME && !keptPublishedName) { + // into package-manager output; keep one published-name entry in place + // unless a checkout already covers it. + const isPublishedName = pluginEntrySpecifier(entry) === PACKAGE_NAME; + if (isPublishedName && !checkoutRegistered && !keptPublishedName) { keptPublishedName = true; - registered = true; kept.push(entry); } - continue; + return; } kept.push(entry); - } + }); - return registered ? kept : [...kept, PACKAGE_NAME]; + return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME]; } -function mergeTuiConfig(existingConfig, onNotice) { +function mergeTuiConfig(existingConfig, onNotice, options = {}) { const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {}; const next = { ...existing }; if (typeof next.$schema !== "string" || !next.$schema.trim()) { next.$schema = "https://opencode.ai/tui.json"; } - next.plugin = normalizePluginList(existing.plugin, onNotice); + next.plugin = normalizePluginList(existing.plugin, onNotice, options); return next; } @@ -1508,7 +1535,9 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } existingConfig = existing; const merged = { ...existing }; - merged.plugin = normalizePluginList(existing.plugin, log); + merged.plugin = normalizePluginList(existing.plugin, log, { + baseDirectory: paths.configDir, + }); if (!pluginOnly) { const provider = (existing.provider && typeof existing.provider === "object") ? { ...existing.provider } @@ -1542,7 +1571,9 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { throw new Error("TUI config root must be a JSON object"); } existingTuiConfig = existing; - nextTuiConfig = mergeTuiConfig(existing, log); + nextTuiConfig = mergeTuiConfig(existing, log, { + baseDirectory: paths.configDir, + }); } catch (error) { if (pluginOnly) { throw new Error( diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index 0f608709..564200f4 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -1140,5 +1140,115 @@ describe("install-oc-codex-multi-auth script", () => { const saved = JSON.parse(await readFile(configPath, "utf-8")) as { plugin: string[] }; expect(saved.plugin).toEqual([entry]); }); + + it("recognizes a relative checkout through the config directory that declares it", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + await mkdir(configDir, { recursive: true }); + await createCheckout(tempHome, "oc-codex-multi-auth", "my-codex-fork"); + const relativeEntry = "../../my-codex-fork"; + const relativeBuildOutput = "../../my-codex-fork/dist"; + + for (const entry of [relativeEntry, relativeBuildOutput]) { + expect( + __test.normalizePluginList(["other-plugin", entry], undefined, { + baseDirectory: configDir, + }), + ).toEqual(["other-plugin", entry]); + } + + // Without a declaring directory the same spelling names nowhere in + // particular, so it stays put rather than being retired on a guess. + expect(__test.normalizePluginList([relativeEntry])).toEqual([ + relativeEntry, + "oc-codex-multi-auth", + ]); + }); + + it("drops a published entry left beside a registered checkout", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "my-codex-fork"); + + expect( + __test.normalizePluginList(["other-plugin", checkout, "oc-codex-multi-auth"]), + ).toEqual(["other-plugin", checkout]); + expect( + __test.normalizePluginList(["oc-codex-multi-auth", "other-plugin", checkout]), + ).toEqual(["other-plugin", checkout]); + }); + + it("keeps every checkout of this package that a config registers", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const first = await createCheckout(tempHome, "oc-codex-multi-auth", "fork-one"); + const second = await createCheckout(tempHome, "oc-codex-multi-auth", "fork-two"); + + expect(__test.normalizePluginList([first, second])).toEqual([first, second]); + }); + + it("registers the current package beside a checkout of the former one", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const legacyCheckout = await createCheckout( + tempHome, + "oc-chatgpt-multi-auth", + "my-legacy-fork", + ); + + expect(__test.normalizePluginList([legacyCheckout])).toEqual([ + legacyCheckout, + "oc-codex-multi-auth", + ]); + }); + + it("treats package-manager path segments case-insensitively only on Windows", async () => { + vi.resetModules(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const uppercased = "C:/Users/dev/NODE_MODULES/oc-codex-multi-auth"; + + expect( + __test.classifyPluginEntry(uppercased, { platform: "win32" }), + ).toMatchObject({ kind: "managed-package", name: "oc-codex-multi-auth" }); + expect( + __test.classifyPluginEntry(uppercased, { platform: "linux" }), + ).toMatchObject({ kind: "local-checkout", name: "oc-codex-multi-auth" }); + }); + + it("leaves a config registering a relative checkout untouched end to end", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + await createCheckout(tempHome, "oc-codex-multi-auth", "my-codex-fork"); + const entry = "../../my-codex-fork"; + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + const configText = `${JSON.stringify({ plugin: [entry] }, null, 2)}\n`; + const tuiText = `${JSON.stringify( + { $schema: "https://opencode.ai/tui.json", plugin: [entry] }, + null, + 2, + )}\n`; + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, configText, "utf-8"); + await writeFile(tuiConfigPath, tuiText, "utf-8"); + + await expect( + runInstaller(["install", "--plugin-only", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ wrote: false, pluginOnly: true, exitCode: 0 }); + + await expect(readFile(configPath, "utf-8")).resolves.toBe(configText); + await expect(readFile(tuiConfigPath, "utf-8")).resolves.toBe(tuiText); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); + }); }); }); From 799f0fb1faaff08253bb9291d74c70c4759442d1 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 17:17:55 -0500 Subject: [PATCH 5/7] fix(installer): stop keeping a cache copy the same run deletes An entry pointing into OpenCode's package cache at `/packages/oc-codex-multi-auth` survived normalization as a local checkout, because classification recognized only the versioned `packages/@` spelling the cache usually writes. The unversioned directory is a cache layout too, and `clearCache` removes it on the same run, so the installer preserved an entry and then deleted what it pointed at. OpenCode then had a config naming a plugin directory that no longer existed. Spelling could not separate the two: `packages/oc-codex-multi-auth` is also how an ordinary monorepo names its own package, and treating every unversioned `packages/` directory as cache would retire those real checkouts. Ownership can. The installer already knows its cache root and already empties it, so an entry resolving to somewhere inside that root is one the installer wrote and may retire, whatever it is called. A monorepo lives elsewhere and is untouched. Classification takes the cache root the same way it takes the config directory, so the containment question is answered against a path the caller supplies rather than one guessed here. Windows path casing folds, since one directory is reachable there under several spellings. Containment is compared on the path as written, without resolving symlinks, which is the opposite of what the cache eviction path does and is deliberate. Eviction decides whether to delete a directory recursively, so it must refuse anything whose real location escapes the cache. This decides whether an entry names something `clearCache` removes, and `clearCache` removes the paths exactly as it spells them - `rm` unlinks a symlink rather than descending into its target. Resolving here would preserve an entry whose link is about to be unlinked out from under it, which is the failure this commit exists to end. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 59a3271 --- scripts/install-oc-codex-multi-auth-core.js | 50 ++++++++++++--- test/install-oc-codex-multi-auth.test.ts | 70 +++++++++++++++++++++ 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 59c5e9db..28a36c7d 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -1,7 +1,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const PACKAGE_NAME = "oc-codex-multi-auth"; @@ -231,19 +231,48 @@ function pluginPathSegments(entryPath) { return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean); } -function isPackageManagerPath(entryPath, platform = process.platform) { +/** + * Compared as written, without resolving symlinks: this asks whether an entry + * names something `clearCache` removes, and `clearCache` removes the paths + * exactly as it spells them - `rm` unlinks a symlink rather than descending + * into it. Cache eviction resolves symlinks because it decides the opposite + * question, whether a recursive delete is safe. + */ +function isInsideDirectory(candidate, directory, platform) { + const fold = (value) => (platform === "win32" ? value.toLowerCase() : value); + const relativePath = relative(fold(resolve(directory)), fold(resolve(candidate))); + return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath); +} + +function isPackageManagerPath(entryPath, options = {}) { + const { platform = process.platform, cacheDirectory, inspectionPath } = options; // Windows reaches one directory under many spellings, so `NODE_MODULES` // there is the same package-manager output as `node_modules`. Elsewhere the // two are different directories and must stay so. const segments = pluginPathSegments(entryPath).map((segment) => platform === "win32" ? segment.toLowerCase() : segment, ); - return segments.some( - (segment, index) => - segment === "node_modules" || - // OpenCode's plugin cache spells the version into the directory name. - // A `packages/` directory without one is an ordinary monorepo. - (segments[index - 1] === "packages" && segment.includes("@")), + if ( + segments.some( + (segment, index) => + segment === "node_modules" || + // OpenCode's plugin cache spells the version into the directory name. + // A `packages/` directory without one is an ordinary monorepo. + (segments[index - 1] === "packages" && segment.includes("@")), + ) + ) { + return true; + } + // The cache is where this installer puts its own copies, and `clearCache` + // empties it on the same run. Reading spelling alone leaves the cache's + // unversioned `packages/` looking like somebody's monorepo, so the + // entry is kept while the directory under it is deleted - a config left + // pointing at nothing. Whose directory it is settles that; the spelling + // cannot. + return Boolean( + cacheDirectory && + inspectionPath && + isInsideDirectory(inspectionPath, cacheDirectory, platform), ); } @@ -316,6 +345,7 @@ function classifyPluginEntry(entry, options = {}) { const { resolveDeclaredName = resolveDeclaredPackageName, baseDirectory, + cacheDirectory, platform = process.platform, } = options; const specifier = pluginEntrySpecifier(entry); @@ -343,7 +373,7 @@ function classifyPluginEntry(entry, options = {}) { if (!managedName) return { kind: UNRELATED_ENTRY, name: null }; - return isPackageManagerPath(entryPath, platform) + return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath }) ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName } : { kind: LOCAL_CHECKOUT_ENTRY, name: managedName, path: inspectionPath ?? entryPath }; } @@ -1537,6 +1567,7 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { const merged = { ...existing }; merged.plugin = normalizePluginList(existing.plugin, log, { baseDirectory: paths.configDir, + cacheDirectory: paths.cacheDir, }); if (!pluginOnly) { const provider = (existing.provider && typeof existing.provider === "object") @@ -1573,6 +1604,7 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { existingTuiConfig = existing; nextTuiConfig = mergeTuiConfig(existing, log, { baseDirectory: paths.configDir, + cacheDirectory: paths.cacheDir, }); } catch (error) { if (pluginOnly) { diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index 564200f4..eba92c0e 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -1250,5 +1250,75 @@ describe("install-oc-codex-multi-auth script", () => { await expect(readFile(tuiConfigPath, "utf-8")).resolves.toBe(tuiText); await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); }); + + it("retires a cache copy this installer deletes, versioned or not", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const cacheDirectory = join(tempHome, ".cache", "opencode"); + const packagesDirectory = join(cacheDirectory, "packages"); + const unversioned = await createCheckout( + packagesDirectory, + "oc-codex-multi-auth", + "oc-codex-multi-auth", + ); + const versioned = await createCheckout( + packagesDirectory, + "oc-codex-multi-auth", + "oc-codex-multi-auth@latest", + ); + + for (const entry of [unversioned, versioned]) { + expect(__test.normalizePluginList([entry], undefined, { cacheDirectory })).toEqual([ + "oc-codex-multi-auth", + ]); + } + }); + + it("keeps a monorepo checkout that merely spells its directory like the cache", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const cacheDirectory = join(tempHome, ".cache", "opencode"); + const monorepoCheckout = await createCheckout( + join(tempHome, "workspace", "packages"), + "oc-codex-multi-auth", + "oc-codex-multi-auth", + ); + + expect( + __test.normalizePluginList([monorepoCheckout], undefined, { cacheDirectory }), + ).toEqual([monorepoCheckout]); + }); + + it("never leaves the config pointing at the cache copy it just deleted", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const cachePackage = await createCheckout( + join(tempHome, ".cache", "opencode", "packages"), + "oc-codex-multi-auth", + "oc-codex-multi-auth", + ); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ plugin: [pathToFileURL(cachePackage).href] }, null, 2), + "utf-8", + ); + + await expect( + runInstaller([], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ action: "install", exitCode: 0 }); + + const saved = JSON.parse(await readFile(configPath, "utf-8")) as { plugin: string[] }; + expect(saved.plugin).toEqual(["oc-codex-multi-auth"]); + await expect(readdir(cachePackage)).rejects.toMatchObject({ code: "ENOENT" }); + }); }); }); From de2226cb86f7644b1bc01e807a4021ec76f4029e Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 22 Sep 2026 02:05:00 +0800 Subject: [PATCH 6/7] fix(update): refuse eviction when the cache root resolves through a symlink --- lib/auto-update-checker.ts | 12 +++++++++++- test/auto-update-checker.test.ts | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/auto-update-checker.ts b/lib/auto-update-checker.ts index 573afa15..3ced6d10 100644 --- a/lib/auto-update-checker.ts +++ b/lib/auto-update-checker.ts @@ -133,6 +133,10 @@ export interface EvictionScope { * looks like cache. Resolving symlinks before the containment check is the part * that matters: a developer who links their working checkout into the cache * would otherwise have it deleted on exit by a name match alone. + * + * The cache root itself must not resolve through a symlink either. When + * `~/.cache/opencode` is a link to `~`, comparing realpaths makes the whole + * home directory "inside the cache" and containment proves nothing. */ export function isEvictableCachePath(cachePath: string, scope: EvictionScope = {}): boolean { const { cacheRoot = OPENCODE_CACHE_DIR, resolveRealPath = realpathSync } = scope; @@ -141,7 +145,13 @@ export function isEvictableCachePath(cachePath: string, scope: EvictionScope = { if (!isInsideDirectory(absolutePath, absoluteRoot)) return false; try { - return isInsideDirectory(resolveRealPath(absolutePath), resolveRealPath(absoluteRoot)); + const realRoot = resolveRealPath(absoluteRoot); + const rootIsSymlinked = + process.platform === "win32" + ? realRoot.toLowerCase() !== absoluteRoot.toLowerCase() + : realRoot !== absoluteRoot; + if (rootIsSymlinked) return false; + return isInsideDirectory(resolveRealPath(absolutePath), realRoot); } catch { return false; } diff --git a/test/auto-update-checker.test.ts b/test/auto-update-checker.test.ts index ba721491..eb68d2e1 100644 --- a/test/auto-update-checker.test.ts +++ b/test/auto-update-checker.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; vi.mock("node:fs", () => ({ readFileSync: vi.fn(), @@ -374,6 +374,24 @@ describe("auto-update-checker", () => { expect(fs.rmSync).not.toHaveBeenCalled(); }); + it("refuses every path when the cache root itself resolves through a symlink", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + // `~/.cache/opencode -> ~`: the resolved root is the whole home + // directory, so a realpath containment check would call anything under + // ~ "inside the cache". + const linkedRoot = join("/home", "dev", ".cache", "opencode"); + const managedPath = join(linkedRoot, "node_modules", "oc-codex-multi-auth"); + + const cleared = clearManagedOpenCodePluginCache([managedPath], { + cacheRoot: linkedRoot, + resolveRealPath: (path) => + path === resolve(linkedRoot) ? join("/home", "dev") : path, + }); + + expect(cleared).toBe(false); + expect(fs.rmSync).not.toHaveBeenCalled(); + }); + it("refuses a path outside the OpenCode cache directory", () => { vi.mocked(fs.existsSync).mockReturnValue(true); From 21c2b522f1461c0a3496b95b72c9e99e853707d9 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 22 Sep 2026 02:05:03 +0800 Subject: [PATCH 7/7] fix(installer): confine cache deletes to the real cache and classify entries by resolved path --- scripts/install-oc-codex-multi-auth-core.js | 175 +++++++++++++++----- test/install-oc-codex-multi-auth.test.ts | 167 ++++++++++++++++++- 2 files changed, 296 insertions(+), 46 deletions(-) diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 28a36c7d..cc3b9885 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -212,8 +212,28 @@ function pluginEntrySpecifier(entry) { return null; } -/** The path an entry names, exactly as the config spells it. */ -function pluginEntryPath(specifier) { +/** + * Spellings that can only mean a location on disk: absolute paths, `~`, + * `./`/`../`, Windows drive letters, and UNC shares. Anything else that merely + * contains a separator (`@scope/name`, git URLs, `npm:` aliases) is ambiguous + * and counts as a path only when it resolves on this machine. + */ +function isExplicitPathSpecifier(specifier) { + return ( + /^[a-zA-Z]:[\\/]/.test(specifier) || + /^[\\/]/.test(specifier) || + /^~[\\/]/.test(specifier) || + /^\.\.?[\\/]/.test(specifier) + ); +} + +/** + * The path an entry names, exactly as the config spells it - and only when the + * specifier actually is a path. A `/` alone does not make one: registry and + * URL spellings can end in `oc-codex-multi-auth` without naming this package's + * checkout, so an ambiguous specifier counts only when it resolves on disk. + */ +function pluginEntryPath(specifier, baseDirectory) { const trimmed = specifier.trim(); if (!trimmed) return null; if (/^file:\/\//i.test(trimmed)) { @@ -223,8 +243,10 @@ function pluginEntryPath(specifier) { return null; } } + if (isExplicitPathSpecifier(trimmed)) return trimmed; if (!trimmed.includes("/") && !trimmed.includes("\\")) return null; - return trimmed; + const inspectionPath = resolveInspectionPath(trimmed, baseDirectory); + return inspectionPath && existsSync(inspectionPath) ? trimmed : null; } function pluginPathSegments(entryPath) { @@ -351,7 +373,7 @@ function classifyPluginEntry(entry, options = {}) { const specifier = pluginEntrySpecifier(entry); if (specifier === null) return { kind: UNRELATED_ENTRY, name: null }; - const entryPath = pluginEntryPath(specifier); + const entryPath = pluginEntryPath(specifier, baseDirectory); if (entryPath === null) { const bare = specifier.trim().toLowerCase(); const name = getManagedPackageNames().find( @@ -375,7 +397,12 @@ function classifyPluginEntry(entry, options = {}) { return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath }) ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName } - : { kind: LOCAL_CHECKOUT_ENTRY, name: managedName, path: inspectionPath ?? entryPath }; + : { + kind: LOCAL_CHECKOUT_ENTRY, + name: managedName, + path: inspectionPath ?? entryPath, + resolvesOnDisk: Boolean(inspectionPath && existsSync(inspectionPath)), + }; } /** @@ -391,9 +418,12 @@ function normalizePluginList(list, onNotice, options = {}) { const classifications = entries.map((entry) => classifyPluginEntry(entry, options)); // A checkout of this package already IS the registration, so a published // entry beside it is a second copy of the same plugin for OpenCode to load. - // Only a checkout of the CURRENT package counts: the former name is valid - // for cleanup, never as the registration the installer exists to ensure. - const checkoutRegistered = classifications.some( + // `options.checkoutRegistered` carries the same fact across config files: a + // checkout registered only in opencode.json still suppresses the published + // name in tui.json, and vice versa. Only a checkout of the CURRENT package + // counts: the former name is valid for cleanup, never as the registration + // the installer exists to ensure. + const checkoutRegistered = options.checkoutRegistered === true || classifications.some( (classification) => classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME, ); @@ -405,9 +435,16 @@ function normalizePluginList(list, onNotice, options = {}) { if (classification.kind === LOCAL_CHECKOUT_ENTRY) { kept.push(entry); - onNotice?.( - `Keeping the local ${classification.name} checkout registered at ${classification.path}`, - ); + if (classification.resolvesOnDisk === false) { + onNotice?.( + `Warning: keeping ${classification.path} registered, but it does not resolve on disk; ` + + "the plugin may not load until the path exists again.", + ); + } else { + onNotice?.( + `Keeping the local ${classification.name} checkout registered at ${classification.path}`, + ); + } return; } @@ -1421,6 +1458,10 @@ async function removePluginFromCachePackage(paths, dryRun) { if (!existsSync(paths.cachePackageJson)) { return; } + if (!isEvictableCachePath(paths.cachePackageJson, paths.cacheDir)) { + log(`Warning: refusing to update ${paths.cachePackageJson}: it does not resolve inside the OpenCode cache.`); + return; + } let cacheData; try { @@ -1462,6 +1503,29 @@ async function removePluginFromCachePackage(paths, dryRun) { await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData)); } +/** + * Mirror of `isEvictableCachePath` in lib/auto-update-checker.ts. A recursive + * delete must never act on a path that only spells like cache: the cache root + * itself must not resolve through a symlink (`~/.cache/opencode -> ~` would + * otherwise call the whole home directory "inside the cache"), and the + * resolved target must stay inside the resolved root. + */ +function isEvictableCachePath(cachePath, cacheRoot) { + const absolutePath = resolve(cachePath); + const absoluteRoot = resolve(cacheRoot); + if (!isInsideDirectory(absolutePath, absoluteRoot, process.platform)) return false; + try { + const realRoot = realpathSync(absoluteRoot); + const rootIsSymlinked = process.platform === "win32" + ? realRoot.toLowerCase() !== absoluteRoot.toLowerCase() + : realRoot !== absoluteRoot; + if (rootIsSymlinked) return false; + return isInsideDirectory(realpathSync(absolutePath), realRoot, process.platform); + } catch { + return false; + } +} + async function clearCache(paths, dryRun, skipCacheClear) { if (skipCacheClear) { log("Skipping cache clear (--no-cache-clear)."); @@ -1469,6 +1533,12 @@ async function clearCache(paths, dryRun, skipCacheClear) { return; } + const cacheTargets = [ + ...paths.cacheNodeModulesPaths, + ...paths.cachePackagePaths, + paths.cacheBunLock, + ]; + if (dryRun) { for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) { log(`[dry-run] Would remove ${cacheNodeModulesPath}`); @@ -1478,13 +1548,17 @@ async function clearCache(paths, dryRun, skipCacheClear) { } log(`[dry-run] Would remove ${paths.cacheBunLock}`); } else { - for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) { - await removeWithWindowsRetry(cacheNodeModulesPath, { recursive: true, force: true }); - } - for (const cachePackagePath of paths.cachePackagePaths) { - await removeWithWindowsRetry(cachePackagePath, { recursive: true, force: true }); + for (const cacheTarget of cacheTargets) { + if (!existsSync(cacheTarget)) continue; + if (!isEvictableCachePath(cacheTarget, paths.cacheDir)) { + log(`Warning: refusing to remove ${cacheTarget}: it does not resolve inside the OpenCode cache.`); + continue; + } + await removeWithWindowsRetry(cacheTarget, { + recursive: cacheTarget !== paths.cacheBunLock, + force: true, + }); } - await removeWithWindowsRetry(paths.cacheBunLock, { force: true }); } await removePluginFromCachePackage(paths, dryRun); @@ -1553,9 +1627,6 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } } - let nextConfig = pluginOnly - ? { $schema: template.$schema, plugin: [PACKAGE_NAME] } - : template; let existingConfig; if (existsSync(paths.configPath)) { try { @@ -1564,21 +1635,6 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { throw new Error("config root must be a JSON object"); } existingConfig = existing; - const merged = { ...existing }; - merged.plugin = normalizePluginList(existing.plugin, log, { - baseDirectory: paths.configDir, - cacheDirectory: paths.cacheDir, - }); - if (!pluginOnly) { - const provider = (existing.provider && typeof existing.provider === "object") - ? { ...existing.provider } - : {}; - provider.openai = mergeOpenaiProvider(existing.provider?.openai, template.provider?.openai, { - modelKeysToRemove, - }); - merged.provider = provider; - } - nextConfig = merged; } catch (error) { if (pluginOnly) { throw new Error( @@ -1587,13 +1643,11 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`); existingConfig = undefined; - nextConfig = template; } } else { log("No existing config found. Creating new global config."); } - let nextTuiConfig = mergeTuiConfig(undefined); let existingTuiConfig; if (existsSync(paths.tuiConfigPath)) { try { @@ -1602,10 +1656,6 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { throw new Error("TUI config root must be a JSON object"); } existingTuiConfig = existing; - nextTuiConfig = mergeTuiConfig(existing, log, { - baseDirectory: paths.configDir, - cacheDirectory: paths.cacheDir, - }); } catch (error) { if (pluginOnly) { throw new Error( @@ -1614,12 +1664,53 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`); existingTuiConfig = undefined; - nextTuiConfig = mergeTuiConfig(undefined); } } else { log("No existing TUI config found. Creating new global TUI config."); } + // A checkout of this package registered in either file already loads the + // plugin, so the published name must not be written beside it anywhere - + // otherwise the checkout in opencode.json and the published package in + // tui.json both load. + const pluginListOptions = { + baseDirectory: paths.configDir, + cacheDirectory: paths.cacheDir, + }; + const checkoutRegistered = [existingConfig?.plugin, existingTuiConfig?.plugin] + .flatMap((list) => (Array.isArray(list) ? list : [])) + .some((entry) => { + const classification = classifyPluginEntry(entry, pluginListOptions); + return ( + classification.kind === LOCAL_CHECKOUT_ENTRY && + classification.name === PACKAGE_NAME + ); + }); + const normalizeOptions = { ...pluginListOptions, checkoutRegistered }; + + let nextConfig; + if (existingConfig !== undefined) { + const merged = { ...existingConfig }; + merged.plugin = normalizePluginList(existingConfig.plugin, log, normalizeOptions); + if (!pluginOnly) { + const provider = (existingConfig.provider && typeof existingConfig.provider === "object") + ? { ...existingConfig.provider } + : {}; + provider.openai = mergeOpenaiProvider(existingConfig.provider?.openai, template.provider?.openai, { + modelKeysToRemove, + }); + merged.provider = provider; + } + nextConfig = merged; + } else { + nextConfig = pluginOnly + ? { $schema: template.$schema, plugin: [PACKAGE_NAME] } + : template; + nextConfig.plugin = normalizePluginList(nextConfig.plugin, log, normalizeOptions); + } + + const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions); + const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig); const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig); let wrote = false; diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index eba92c0e..b24dce7c 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; type OpenAiTemplate = { @@ -13,7 +14,10 @@ type OpenAiTemplate = { }; async function createTempHome() { - return mkdtemp(join(tmpdir(), "oc-codex-install-")); + // macOS reaches os.tmpdir() through the /var -> /private/var symlink; the + // cache guard refuses a cache root that resolves through a symlink, so the + // fake home has to be canonical for eviction tests to exercise it. + return realpathSync(await mkdtemp(join(tmpdir(), "oc-codex-install-"))); } describe("install-oc-codex-multi-auth script", () => { @@ -895,6 +899,37 @@ describe("install-oc-codex-multi-auth script", () => { expect(cachedPackageJson.dependencies.other).toBe("^1.0.0"); }); + it("refuses to clear cache targets when the OpenCode cache directory resolves through a symlink", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const realCacheDir = join(tempHome, "real-opencode-cache"); + const cacheDirLink = join(tempHome, ".cache", "opencode"); + const managedCache = join(realCacheDir, "node_modules", "oc-codex-multi-auth"); + const realBunLock = join(realCacheDir, "bun.lock"); + + await mkdir(configDir, { recursive: true }); + await mkdir(managedCache, { recursive: true }); + await mkdir(dirname(cacheDirLink), { recursive: true }); + await writeFile(join(managedCache, "keep.txt"), "keep", "utf-8"); + await writeFile(realBunLock, "lockfile", "utf-8"); + await symlink(realCacheDir, cacheDirLink, "dir"); + + await expect( + runInstaller(["update"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ action: "update", exitCode: 0 }); + + // The recursive delete must not follow the link into the real directory. + expect(await readFile(join(managedCache, "keep.txt"), "utf-8")).toBe("keep"); + expect(await readFile(realBunLock, "utf-8")).toBe("lockfile"); + const stdout = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(stdout).toContain("does not resolve inside the OpenCode cache"); + }); + it("rejects full-mode merges when modern and legacy templates overlap", async () => { vi.resetModules(); const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); @@ -972,6 +1007,8 @@ describe("install-oc-codex-multi-auth script", () => { it.each(["EPERM", "EBUSY"])("retries update cache removal after transient Windows %s errors", async (code) => { vi.resetModules(); tempHome = await createTempHome(); + const firstCachePath = join(tempHome, ".cache", "opencode", "node_modules", "oc-codex-multi-auth"); + await mkdir(firstCachePath, { recursive: true }); const rmMock = vi.fn() .mockRejectedValueOnce(Object.assign(new Error("locked"), { code })) .mockResolvedValue(undefined); @@ -991,7 +1028,6 @@ describe("install-oc-codex-multi-auth script", () => { }), ).resolves.toMatchObject({ action: "update", exitCode: 0 }); - const firstCachePath = join(tempHome, ".cache", "opencode", "node_modules", "oc-codex-multi-auth"); expect(rmMock).toHaveBeenNthCalledWith(1, firstCachePath, { recursive: true, force: true }); expect(rmMock).toHaveBeenNthCalledWith(2, firstCachePath, { recursive: true, force: true }); }); @@ -1320,5 +1356,128 @@ describe("install-oc-codex-multi-auth script", () => { expect(saved.plugin).toEqual(["oc-codex-multi-auth"]); await expect(readdir(cachePackage)).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("does not let a foreign specifier spelling this package's name retire the published entry", async () => { + vi.resetModules(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + // Scoped and URL spellings are registry specifiers, not paths: they + // compare by exact canonical name, never by their last segment. + expect( + __test.normalizePluginList(["oc-codex-multi-auth", "@evil/oc-codex-multi-auth"]), + ).toEqual(["oc-codex-multi-auth", "@evil/oc-codex-multi-auth"]); + expect( + __test.normalizePluginList(["https://github.com/example/oc-codex-multi-auth"]), + ).toEqual(["https://github.com/example/oc-codex-multi-auth", "oc-codex-multi-auth"]); + expect( + __test.normalizePluginList(["oc-codex-multi-auth", "npm:oc-codex-multi-auth"]), + ).toEqual(["oc-codex-multi-auth", "npm:oc-codex-multi-auth"]); + }); + + it("treats a slash-bearing specifier as a checkout only when it resolves on disk", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + await mkdir(configDir, { recursive: true }); + await createCheckout(configDir, "oc-codex-multi-auth", join("vendor", "oc-codex-multi-auth")); + const emptyDir = join(tempHome, "elsewhere", "opencode"); + await mkdir(emptyDir, { recursive: true }); + + expect( + __test.normalizePluginList(["vendor/oc-codex-multi-auth"], undefined, { + baseDirectory: configDir, + }), + ).toEqual(["vendor/oc-codex-multi-auth"]); + expect( + __test.normalizePluginList(["vendor/oc-codex-multi-auth"], undefined, { + baseDirectory: emptyDir, + }), + ).toEqual(["vendor/oc-codex-multi-auth", "oc-codex-multi-auth"]); + }); + + it("warns that a preserved checkout path does not resolve on disk", async () => { + vi.resetModules(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const gone = "/definitely-absent/oc-codex-multi-auth"; + const notices: string[] = []; + + expect( + __test.normalizePluginList(["oc-codex-multi-auth", gone], (notice) => + notices.push(notice), + ), + ).toEqual([gone]); + expect(notices.join("\n")).toContain("does not resolve on disk"); + expect(notices.join("\n")).toContain("may not load until the path exists again"); + }); + + it("suppresses the published name in tui.json when only opencode.json registers a checkout", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "my-codex-fork"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ plugin: [checkout] }, null, 2), + "utf-8", + ); + await writeFile( + tuiConfigPath, + JSON.stringify({ plugin: ["oc-codex-multi-auth"] }, null, 2), + "utf-8", + ); + + await expect( + runInstaller(["install", "--plugin-only", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ action: "install", exitCode: 0 }); + + const savedConfig = JSON.parse(await readFile(configPath, "utf-8")) as { + plugin: string[]; + }; + const savedTui = JSON.parse(await readFile(tuiConfigPath, "utf-8")) as { + plugin: string[]; + }; + expect(savedConfig.plugin).toEqual([checkout]); + expect(savedTui.plugin).toEqual([]); + }); + + it("does not add the published name to tui.json when a checkout is registered in opencode.json", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "my-codex-fork"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ plugin: [checkout] }, null, 2), + "utf-8", + ); + + await expect( + runInstaller(["install", "--plugin-only", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ action: "install", exitCode: 0 }); + + const savedConfig = JSON.parse(await readFile(configPath, "utf-8")) as { + plugin: string[]; + }; + const savedTui = JSON.parse(await readFile(tuiConfigPath, "utf-8")) as { + plugin: string[]; + }; + expect(savedConfig.plugin).toEqual([checkout]); + expect(savedTui.plugin).toEqual([]); + }); }); });