From b9bd9bfc91cdbbefd3de010da6c85f7e16279534 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:09:23 -0500 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 c7665af8d0dc85899259a3302e870cf9c834689c Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:51:06 -0500 Subject: [PATCH 04/13] feat(origin): record which build of the plugin OpenCode loaded OpenCode can load this plugin from the published package or from a checkout a developer points it at, and at runtime the two are indistinguishable unless the plugin asks. Several behaviours want the answer: offering an npm update makes no sense for a build that npm did not install, and a developer whose edits silently stopped taking effect has nothing to look at. `lib/plugin-origin.ts` resolves the answer from `import.meta.url` by walking up to the nearest enclosing `package.json`, then classifies the root: one under `node_modules`, or under `packages/` with a version suffix, is package-manager output, and anything else is a location a human chose. The version suffix is what keeps an ordinary monorepo that happens to keep its packages in `packages/` from being mistaken for OpenCode's plugin cache. Each sighting is appended to `~/.opencode/oc-codex-multi-auth-origin.json` rather than overwriting the previous one. A last-writer-wins marker would be useless for the case it exists to explain: once a checkout has been replaced, one load of the replacement would rewrite the record to confirm the new state, erasing the evidence that anything changed. Keeping both lets `findReplacedLocalCheckout` name the checkout that used to be live. The file is written atomically through a temp file and rename, and every read treats malformed or absent history as empty, so a corrupt record degrades to "no history" instead of breaking startup. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 5611cee --- lib/plugin-origin.ts | 227 +++++++++++++++++++++++++++++++++ test/plugin-origin.test.ts | 254 +++++++++++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 lib/plugin-origin.ts create mode 100644 test/plugin-origin.test.ts diff --git a/lib/plugin-origin.ts b/lib/plugin-origin.ts new file mode 100644 index 00000000..1d48e49f --- /dev/null +++ b/lib/plugin-origin.ts @@ -0,0 +1,227 @@ +/** + * Where this plugin is running from, and where it has run from before. + * + * OpenCode can load the plugin from the published package or from a checkout + * a developer points it at, and the two are indistinguishable at runtime + * unless the plugin asks. Knowing which one is live decides whether offering + * an update makes sense, and a record of previous origins is what turns "my + * edits stopped taking effect" into a diagnosable event. + */ + +import { readFileSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { renameWithWindowsRetry } from "./storage/atomic-write.js"; + +const HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json"; +const HISTORY_VERSION = 1; +const MAX_SIGHTINGS = 10; +const PACKAGE_ROOT_LOOKUP_DEPTH = 3; + +export interface PluginOrigin { + name: string; + version: string; + root: string; + isLocalCheckout: boolean; +} + +export interface PluginOriginSighting extends PluginOrigin { + firstSeen: string; + lastSeen: string; +} + +export interface PluginOriginHistory { + version: number; + sightings: PluginOriginSighting[]; +} + +interface PackageManifest { + name?: unknown; + version?: unknown; +} + +function emptyHistory(): PluginOriginHistory { + return { version: HISTORY_VERSION, sightings: [] }; +} + +function readPackageManifest(directory: string): PackageManifest | null { + try { + const parsed: unknown = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + return parsed as PackageManifest; + } catch { + return null; + } +} + +function manifestName(manifest: PackageManifest | null): string | null { + const name = manifest?.name; + return typeof name === "string" && name.trim() ? name.trim() : null; +} + +/** Built output lives inside the package, so the root is at or above it. */ +export function findPackageRoot(startDirectory: string): string | null { + let current = resolve(startDirectory); + for (let depth = 0; depth <= PACKAGE_ROOT_LOOKUP_DEPTH; depth += 1) { + if (manifestName(readPackageManifest(current))) return current; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} + +function pathSegments(path: string): string[] { + return path.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean); +} + +/** + * A root a package manager chose, as opposed to one a human did. The version + * suffix is what separates OpenCode's plugin cache from a monorepo that merely + * keeps its packages in a `packages/` directory. + */ +export function isPackageManagerRoot(root: string): boolean { + const segments = pathSegments(root); + return segments.some( + (segment, index) => + segment === "node_modules" || + (segments[index - 1] === "packages" && segment.includes("@")), + ); +} + +export function resolvePluginOrigin(moduleUrl: string): PluginOrigin | null { + let moduleDirectory: string; + try { + moduleDirectory = dirname(fileURLToPath(moduleUrl)); + } catch { + return null; + } + + const root = findPackageRoot(moduleDirectory); + if (!root) return null; + + const manifest = readPackageManifest(root); + const name = manifestName(manifest); + if (!name) return null; + const version = typeof manifest?.version === "string" ? manifest.version : "0.0.0"; + + return { name, version, root, isLocalCheckout: !isPackageManagerRoot(root) }; +} + +let cachedOrigin: PluginOrigin | null | undefined; + +/** The origin of this running build. Fixed for the life of the process. */ +export function getPluginOrigin(): PluginOrigin | null { + if (cachedOrigin === undefined) cachedOrigin = resolvePluginOrigin(import.meta.url); + return cachedOrigin; +} + +export function getPluginOriginHistoryPath(home: string = homedir()): string { + return join(home, ".opencode", HISTORY_FILE_NAME); +} + +function isSighting(value: unknown): value is PluginOriginSighting { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const candidate = value as Record; + return ( + typeof candidate.name === "string" && + typeof candidate.version === "string" && + typeof candidate.root === "string" && + typeof candidate.isLocalCheckout === "boolean" && + typeof candidate.firstSeen === "string" && + typeof candidate.lastSeen === "string" + ); +} + +export function readPluginOriginHistory( + historyPath: string = getPluginOriginHistoryPath(), +): PluginOriginHistory { + try { + const parsed: unknown = JSON.parse(readFileSync(historyPath, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyHistory(); + const sightings = (parsed as { sightings?: unknown }).sightings; + if (!Array.isArray(sightings)) return emptyHistory(); + return { version: HISTORY_VERSION, sightings: sightings.filter(isSighting) }; + } catch { + return emptyHistory(); + } +} + +function byLastSeen(left: PluginOriginSighting, right: PluginOriginSighting): number { + return (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0); +} + +/** + * History is appended to rather than overwritten, so one origin replacing + * another leaves both on record. A last-writer-wins marker would instead + * confirm whatever state a clobber left behind. + */ +export function withSighting( + history: PluginOriginHistory, + origin: PluginOrigin, + seenAt: string, +): PluginOriginHistory { + const previous = history.sightings.find((sighting) => sighting.root === origin.root); + const others = history.sightings.filter((sighting) => sighting.root !== origin.root); + const current: PluginOriginSighting = { + ...origin, + firstSeen: previous?.firstSeen ?? seenAt, + lastSeen: seenAt, + }; + return { + version: HISTORY_VERSION, + sightings: [...others, current].slice(-MAX_SIGHTINGS), + }; +} + +export async function recordPluginOrigin( + origin: PluginOrigin, + historyPath: string = getPluginOriginHistoryPath(), + now: () => Date = () => new Date(), +): Promise { + const next = withSighting(readPluginOriginHistory(historyPath), origin, now().toISOString()); + const temporaryPath = `${historyPath}.${process.pid}.tmp`; + + await mkdir(dirname(historyPath), { recursive: true }); + await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, "utf-8"); + try { + await renameWithWindowsRetry(temporaryPath, historyPath); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw error; + } + + return next; +} + +/** + * The checkout this plugin used to run from and no longer does. Reported only + * when the current origin is package-manager output, since a move between two + * checkouts is an ordinary thing to do deliberately. + */ +export function findReplacedLocalCheckout( + origin: PluginOrigin, + history: PluginOriginHistory, +): PluginOriginSighting | null { + if (origin.isLocalCheckout) return null; + return ( + history.sightings + .filter( + (sighting) => + sighting.name === origin.name && + sighting.isLocalCheckout && + sighting.root !== origin.root, + ) + .sort(byLastSeen) + .at(-1) ?? null + ); +} + +export function describePluginOrigin(origin: PluginOrigin | null): string { + if (!origin) return "unknown"; + return origin.isLocalCheckout + ? `local checkout at ${origin.root} (v${origin.version})` + : `installed package v${origin.version}`; +} diff --git a/test/plugin-origin.test.ts b/test/plugin-origin.test.ts new file mode 100644 index 00000000..710ddcbf --- /dev/null +++ b/test/plugin-origin.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + describePluginOrigin, + findReplacedLocalCheckout, + getPluginOriginHistoryPath, + isPackageManagerRoot, + readPluginOriginHistory, + recordPluginOrigin, + resolvePluginOrigin, + withSighting, + type PluginOrigin, + type PluginOriginHistory, +} from "../lib/plugin-origin.js"; + +async function createTempRoot() { + return mkdtemp(join(tmpdir(), "oc-codex-origin-")); +} + +async function createPackage(root: string, name: string, version: string) { + await mkdir(root, { recursive: true }); + await writeFile(join(root, "package.json"), JSON.stringify({ name, version }), "utf-8"); + return root; +} + +function localCheckout(root: string): PluginOrigin { + return { name: "oc-codex-multi-auth", version: "1.0.0", root, isLocalCheckout: true }; +} + +function installedPackage(root: string): PluginOrigin { + return { name: "oc-codex-multi-auth", version: "2.0.0", root, isLocalCheckout: false }; +} + +function historyOf(...sightings: PluginOriginHistory["sightings"]): PluginOriginHistory { + return { version: 1, sightings }; +} + +describe("plugin-origin", () => { + let tempRoot: string | null = null; + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + tempRoot = null; + } + }); + + describe("resolvePluginOrigin", () => { + it("reads the name and version off the nearest enclosing package", async () => { + tempRoot = await createTempRoot(); + const packageRoot = await createPackage( + join(tempRoot, "checkout"), + "oc-codex-multi-auth", + "6.21.0", + ); + const moduleUrl = pathToFileURL(join(packageRoot, "dist", "lib", "plugin-origin.js")).href; + + expect(resolvePluginOrigin(moduleUrl)).toEqual({ + name: "oc-codex-multi-auth", + version: "6.21.0", + root: packageRoot, + isLocalCheckout: true, + }); + }); + + it("reports a package-manager root as not a local checkout", async () => { + tempRoot = await createTempRoot(); + const packageRoot = await createPackage( + join(tempRoot, "node_modules", "oc-codex-multi-auth"), + "oc-codex-multi-auth", + "6.21.0", + ); + const moduleUrl = pathToFileURL(join(packageRoot, "dist", "index.js")).href; + + expect(resolvePluginOrigin(moduleUrl)?.isLocalCheckout).toBe(false); + }); + + it("returns null when no enclosing package declares a name", async () => { + tempRoot = await createTempRoot(); + const orphan = join(tempRoot, "a", "b", "c"); + await mkdir(orphan, { recursive: true }); + + expect(resolvePluginOrigin(pathToFileURL(join(orphan, "index.js")).href)).toBeNull(); + expect(resolvePluginOrigin("not-a-url")).toBeNull(); + }); + }); + + describe("isPackageManagerRoot", () => { + it.each([ + ["/home/dev/node_modules/oc-codex-multi-auth", true], + ["/home/dev/.cache/opencode/packages/oc-codex-multi-auth@latest", true], + ["/home/dev/src/oc-codex-multi-auth", false], + ["/home/dev/workspace/packages/oc-codex-multi-auth", false], + ])("classifies %s", (root, expected) => { + expect(isPackageManagerRoot(root)).toBe(expected); + }); + }); + + describe("withSighting", () => { + it("keeps the original firstSeen when the same root is seen again", () => { + const origin = localCheckout("/src/plugin"); + const first = withSighting(historyOf(), origin, "2026-01-01T00:00:00.000Z"); + const second = withSighting(first, origin, "2026-02-01T00:00:00.000Z"); + + expect(second.sightings).toEqual([ + { ...origin, firstSeen: "2026-01-01T00:00:00.000Z", lastSeen: "2026-02-01T00:00:00.000Z" }, + ]); + }); + + it("appends a new root instead of replacing the previous one", () => { + const checkout = localCheckout("/src/plugin"); + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + const history = withSighting( + withSighting(historyOf(), checkout, "2026-01-01T00:00:00.000Z"), + installed, + "2026-03-01T00:00:00.000Z", + ); + + expect(history.sightings.map((sighting) => sighting.root)).toEqual([ + checkout.root, + installed.root, + ]); + }); + + it("caps the recorded history", () => { + let history = historyOf(); + for (let index = 0; index < 25; index += 1) { + history = withSighting( + history, + localCheckout(`/src/plugin-${index}`), + `2026-01-01T00:00:${String(index).padStart(2, "0")}.000Z`, + ); + } + + expect(history.sightings).toHaveLength(10); + expect(history.sightings.at(-1)?.root).toBe("/src/plugin-24"); + }); + }); + + describe("readPluginOriginHistory", () => { + it("returns an empty history for a missing or unreadable file", async () => { + tempRoot = await createTempRoot(); + + expect(readPluginOriginHistory(join(tempRoot, "absent.json")).sightings).toEqual([]); + + const malformed = join(tempRoot, "malformed.json"); + await writeFile(malformed, "{ not json", "utf-8"); + expect(readPluginOriginHistory(malformed).sightings).toEqual([]); + }); + + it("drops entries that do not carry a full sighting", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, "origin.json"); + const valid = { + ...localCheckout("/src/plugin"), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-01-01T00:00:00.000Z", + }; + await writeFile( + historyPath, + JSON.stringify({ version: 1, sightings: [valid, { root: "/src/other" }, null, 42] }), + "utf-8", + ); + + expect(readPluginOriginHistory(historyPath).sightings).toEqual([valid]); + }); + }); + + describe("recordPluginOrigin", () => { + it("persists a sighting that reads back unchanged", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, "state", "origin.json"); + const origin = localCheckout("/src/plugin"); + + const written = await recordPluginOrigin( + origin, + historyPath, + () => new Date("2026-05-05T10:00:00.000Z"), + ); + + expect(written.sightings).toEqual([ + { ...origin, firstSeen: "2026-05-05T10:00:00.000Z", lastSeen: "2026-05-05T10:00:00.000Z" }, + ]); + expect(readPluginOriginHistory(historyPath)).toEqual(written); + await expect(readFile(historyPath, "utf-8")).resolves.toContain("\n"); + }); + + it("leaves no temporary file behind", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, "origin.json"); + + await recordPluginOrigin(localCheckout("/src/plugin"), historyPath); + + await expect(readFile(`${historyPath}.${process.pid}.tmp`, "utf-8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }); + + describe("findReplacedLocalCheckout", () => { + const checkoutSighting = { + ...localCheckout("/src/plugin"), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-02-01T00:00:00.000Z", + }; + + it("reports the checkout that an installed package took over from", () => { + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + + expect(findReplacedLocalCheckout(installed, historyOf(checkoutSighting))).toEqual( + checkoutSighting, + ); + }); + + it("stays silent while a checkout is the live origin", () => { + const other = localCheckout("/src/another-plugin"); + + expect(findReplacedLocalCheckout(other, historyOf(checkoutSighting))).toBeNull(); + }); + + it("ignores a checkout of a different package", () => { + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + const unrelated = { ...checkoutSighting, name: "some-other-plugin" }; + + expect(findReplacedLocalCheckout(installed, historyOf(unrelated))).toBeNull(); + }); + + it("reports the most recently seen checkout when several are on record", () => { + const older = { ...checkoutSighting, root: "/src/old", lastSeen: "2026-01-15T00:00:00.000Z" }; + const newer = { ...checkoutSighting, root: "/src/new", lastSeen: "2026-04-01T00:00:00.000Z" }; + const installed = installedPackage("/cache/node_modules/oc-codex-multi-auth"); + + expect(findReplacedLocalCheckout(installed, historyOf(older, newer))?.root).toBe("/src/new"); + }); + }); + + it("describes each origin in terms a reader can act on", () => { + expect(describePluginOrigin(localCheckout("/src/plugin"))).toBe( + "local checkout at /src/plugin (v1.0.0)", + ); + expect(describePluginOrigin(installedPackage("/cache/pkg"))).toBe("installed package v2.0.0"); + expect(describePluginOrigin(null)).toBe("unknown"); + }); + + it("keeps the history beside the other plugin state", () => { + expect(getPluginOriginHistoryPath("/home/dev")).toBe( + join("/home", "dev", ".opencode", "oc-codex-multi-auth-origin.json"), + ); + }); +}); From 0b77b70a142340fcc70bfabf8176b2a4e7d5a131 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:51:42 -0500 Subject: [PATCH 05/13] fix(update): stop offering updates to a plugin running from a checkout A build loaded from a checkout carries whatever version its `package.json` declares, which is almost always behind the published one. The daily check compared the two, decided an update was available, and said so on every start. Nothing about that was true or actionable: the published version describes a package this process is not running, and the offered remedy - evicting the OpenCode package cache - cannot update a build that does not live there. Worse, the toast points at the installer, and a developer who follows that advice reinstalls over the very checkout they are working in. Silencing the prompt removes the most common route into that mistake. `checkAndNotify` now returns before the registry lookup when the caller reports a local checkout, so no request is made, no toast is shown, and no cache eviction is scheduled. `index.ts` resolves the origin once at startup and passes it, logging the checkout path so it is visible which build is live. The origin is also recorded at that point, skipped under a test runner so suites never write to the history file. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 5611cee --- index.ts | 24 ++++++++++++++++++++---- lib/auto-update-checker.ts | 9 +++++++++ test/auto-update-checker.test.ts | 15 +++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index 1cb71259..f4f3fcd1 100644 --- a/index.ts +++ b/index.ts @@ -124,6 +124,7 @@ import { } from "./lib/logger.js"; import { createQuotaMonitor } from "./lib/quota-notifications.js"; import { checkAndNotify } from "./lib/auto-update-checker.js"; +import { describePluginOrigin, getPluginOrigin, recordPluginOrigin } from "./lib/plugin-origin.js"; import { handleContextOverflow } from "./lib/context-overflow.js"; import { AccountManager, @@ -391,6 +392,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { let accountManagerPromise: Promise | null = null; let loaderMutex: Promise | null = null; let startupPrewarmTriggered = false; + let startupOriginRecorded = false; let startupPreflightShown = false; let beginnerSafeModeEnabled = false; const MIN_BACKOFF_MS = 100; @@ -2153,10 +2155,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { }); } + const underTestRunner = + process.env.VITEST === "true" || process.env.NODE_ENV === "test"; const prewarmEnabled = - process.env.CODEX_AUTH_PREWARM !== "0" && - process.env.VITEST !== "true" && - process.env.NODE_ENV !== "test"; + process.env.CODEX_AUTH_PREWARM !== "0" && !underTestRunner; if (!startupPrewarmTriggered && prewarmEnabled && getRequestTransformMode(pluginConfig) === "legacy") { startupPrewarmTriggered = true; @@ -2174,9 +2176,23 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { ) : null; + const pluginOrigin = getPluginOrigin(); + if (pluginOrigin?.isLocalCheckout) { + logInfo(`Running from ${describePluginOrigin(pluginOrigin)}`); + } + if (pluginOrigin && !startupOriginRecorded && !underTestRunner) { + startupOriginRecorded = true; + recordPluginOrigin(pluginOrigin).catch((err) => { + logDebug(`Failed to record plugin origin: ${err instanceof Error ? err.message : String(err)}`); + }); + } + checkAndNotify(async (message, variant) => { await showToast(message, variant); - }, { autoUpdate: autoUpdateEnabled }).catch((err) => { + }, { + autoUpdate: autoUpdateEnabled, + localCheckout: pluginOrigin?.isLocalCheckout ?? false, + }).catch((err) => { logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`); }); await runStartupPreflight(); diff --git a/lib/auto-update-checker.ts b/lib/auto-update-checker.ts index 573afa15..31a47a16 100644 --- a/lib/auto-update-checker.ts +++ b/lib/auto-update-checker.ts @@ -104,6 +104,7 @@ export interface UpdateCheckResult { export interface CheckAndNotifyOptions { autoUpdate?: boolean; scheduleCacheClear?: () => boolean; + localCheckout?: boolean; } function getManagedPackageNames(): string[] { @@ -224,6 +225,14 @@ export async function checkAndNotify( options: CheckAndNotifyOptions = {}, ): Promise { try { + // The published version says nothing about a build loaded from a checkout, + // and evicting the cache would not update it. Offering either is noise at + // best and an invitation to overwrite the checkout at worst. + if (options.localCheckout) { + log.debug("Skipping the update check for a plugin loaded from a local checkout"); + return; + } + const result = await checkForUpdates(); if (result.hasUpdate && result.latestVersion) { diff --git a/test/auto-update-checker.test.ts b/test/auto-update-checker.test.ts index ba721491..c19865a4 100644 --- a/test/auto-update-checker.test.ts +++ b/test/auto-update-checker.test.ts @@ -304,6 +304,21 @@ describe("auto-update-checker", () => { await expect(checkAndNotify(showToast)).resolves.toBeUndefined(); expect(showToast).not.toHaveBeenCalled(); }); + + it("offers nothing for a build loaded from a local checkout", async () => { + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ version: "5.0.0" }), + } as Response); + const showToast = vi.fn().mockResolvedValue(undefined); + const scheduleCacheClear = vi.fn(() => true); + + await checkAndNotify(showToast, { localCheckout: true, scheduleCacheClear }); + + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(showToast).not.toHaveBeenCalled(); + expect(scheduleCacheClear).not.toHaveBeenCalled(); + }); }); describe("clearUpdateCache", () => { From e706c50243112bce8fa70f87f7ad55c25aac03fa Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:52:17 -0500 Subject: [PATCH 06/13] feat(diagnostics): report which build of the plugin is running Which build OpenCode loaded was invisible from inside the plugin, so a developer whose edits had stopped taking effect had no way to tell whether their checkout was still live. The config entry could have been replaced weeks earlier and every diagnostic would keep looking healthy, because the installed package is healthy - it is simply not the code being worked on. `codex-status` now names the origin in every output mode, next to the storage path that already answers the equivalent question about accounts. `codex-doctor` reports it under the deep technical snapshot and, when the origin history shows the plugin used to run from a checkout and now runs from the installed package, raises a warning naming that checkout and when it was last loaded. The warning fires only in that direction. Moving between two checkouts is an ordinary thing to do deliberately, and a developer who has gone back to the published package on purpose is told once, with the path, rather than being corrected. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 5611cee --- lib/tools/codex-doctor.ts | 28 ++++++++++++++++++++++++++++ lib/tools/codex-status.ts | 9 +++++++++ 2 files changed, 37 insertions(+) diff --git a/lib/tools/codex-doctor.ts b/lib/tools/codex-doctor.ts index 082d4c9e..83ca9660 100644 --- a/lib/tools/codex-doctor.ts +++ b/lib/tools/codex-doctor.ts @@ -37,6 +37,12 @@ import { renderJsonOutput, type RoutingVisibilitySnapshot, } from "../runtime.js"; +import { + describePluginOrigin, + findReplacedLocalCheckout, + getPluginOrigin, + readPluginOriginHistory, +} from "../plugin-origin.js"; import { findAccountIndexByIdentity, type RefreshAccountIdentity, @@ -153,6 +159,18 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { .join(", ")}).`, }); } + const origin = getPluginOrigin(); + const replacedCheckout = origin + ? findReplacedLocalCheckout(origin, readPluginOriginHistory()) + : null; + if (replacedCheckout) { + findings.push({ + severity: "warning", + code: "plugin-origin-replaced", + summary: `This plugin now loads from the installed package, but ran from ${replacedCheckout.root} until ${replacedCheckout.lastSeen}.`, + action: `Point the OpenCode plugin entry back at ${replacedCheckout.root} if your own build should still be loaded.`, + }); + } findings.push(...extraFindings); return { storage, activeIndex, snapshots, runtime, summary, findings, nextAction }; @@ -367,6 +385,7 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { technicalSnapshot: deep ? { storagePath: getStoragePath(), + pluginOrigin: getPluginOrigin(), runtimeFailures: { failedRequests: runtime.failedRequests, rateLimitedResponses: runtime.rateLimitedResponses, @@ -460,6 +479,14 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { lines.push( formatUiKeyValue(ui, "Storage", getStoragePath(), "muted"), ); + lines.push( + formatUiKeyValue( + ui, + "Running from", + describePluginOrigin(getPluginOrigin()), + "muted", + ), + ); lines.push( formatUiKeyValue( ui, @@ -520,6 +547,7 @@ export function createCodexDoctorTool(ctx: ToolContext): ToolDefinition { lines.push(""); lines.push("Technical snapshot:"); lines.push(` Storage: ${getStoragePath()}`); + lines.push(` Running from: ${describePluginOrigin(getPluginOrigin())}`); lines.push( ` Runtime failures: failed=${runtime.failedRequests}, rateLimited=${runtime.rateLimitedResponses}, authRefreshFailed=${runtime.authRefreshFailures}, server=${runtime.serverErrors}, network=${runtime.networkErrors}`, ); diff --git a/lib/tools/codex-status.ts b/lib/tools/codex-status.ts index e2b680c7..cd6f7fde 100644 --- a/lib/tools/codex-status.ts +++ b/lib/tools/codex-status.ts @@ -21,6 +21,7 @@ import { formatUiSection, } from "../ui/format.js"; import { normalizeToolOutputFormat, renderJsonOutput } from "../runtime.js"; +import { describePluginOrigin, getPluginOrigin } from "../plugin-origin.js"; import { formatPlanType } from "../auth/plan-tier.js"; import type { ToolContext } from "./index.js"; @@ -130,6 +131,7 @@ export function createCodexStatusTool(ctx: ToolContext): ToolDefinition { if (outputFormat === "json") { return renderJsonOutput({ totalAccounts: storage.accounts.length, + pluginOrigin: getPluginOrigin(), selectionView: { modelFamily: explainabilityFamily, effectiveModel: explainabilityModel ?? null, @@ -194,6 +196,12 @@ export function createCodexStatusTool(ctx: ToolContext): ToolDefinition { const lines: string[] = [ ...formatUiHeader(ui, "Account status"), formatUiKeyValue(ui, "Total", String(storage.accounts.length)), + formatUiKeyValue( + ui, + "Running from", + describePluginOrigin(getPluginOrigin()), + "muted", + ), formatUiKeyValue( ui, "Selection view", @@ -314,6 +322,7 @@ export function createCodexStatusTool(ctx: ToolContext): ToolDefinition { const lines: string[] = [ `Account Status (${storage.accounts.length} total):`, + `Running from: ${describePluginOrigin(getPluginOrigin())}`, "", ...buildTableHeader(statusTableOptions), ]; From 90769caea1fde1e432be87a9c59d6d01a22a037b Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 03:53:04 -0500 Subject: [PATCH 07/13] feat(installer): name a checkout the config no longer registers Preserving a checkout entry only helps while the entry is still there. Once a config has lost it - to an older installer, a hand edit, a restored backup - the remaining entry is an ordinary published-package reference, and nothing about it suggests the user ever wanted anything else. The installer would go on registering the published package, correctly and unhelpfully, every time. The origin history the plugin keeps is the one record that knows better. When the finished plugin list registers no checkout but the history shows this plugin running from one, the installer names that path and the date it was last loaded, and says how to point the entry back at it. It reports rather than restores. History is evidence of what happened, not authority over what should be registered now, and a user who deliberately moved back to the published package would not thank an installer that kept undoing it. A recorded path that no longer resolves to this package is skipped, so a deleted or renamed checkout produces silence instead of advice to point OpenCode at a directory that is gone. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 5611cee --- scripts/install-oc-codex-multi-auth-core.js | 47 +++++++++++++ test/install-oc-codex-multi-auth.test.ts | 78 +++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index adde259a..18e2e740 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const PACKAGE_NAME = "oc-codex-multi-auth"; const LEGACY_PACKAGE_NAMES = ["oc-chatgpt-multi-auth"]; +const ORIGIN_HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json"; const WINDOWS_RENAME_RETRY_ATTEMPTS = 5; const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10; const STALE_MANAGED_MODEL_KEYS = new Set([ @@ -150,6 +151,7 @@ function buildPaths(homeDir) { ]), cacheBunLock: join(cacheDir, "bun.lock"), cachePackageJson: join(cacheDir, "package.json"), + originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME), modernTemplatePath, legacyTemplatePath, }; @@ -372,6 +374,42 @@ function normalizePluginList(list, onNotice) { return registered ? kept : [...kept, PACKAGE_NAME]; } +function readLocalCheckoutSightings(historyPath) { + try { + const parsed = JSON.parse(readFileSync(historyPath, "utf8")); + const sightings = parsed?.sightings; + if (!Array.isArray(sightings)) return []; + return sightings.filter( + (sighting) => + sighting && + typeof sighting === "object" && + sighting.isLocalCheckout === true && + typeof sighting.root === "string" && + typeof sighting.lastSeen === "string" && + getManagedPackageNames().includes(sighting.name), + ); + } catch { + return []; + } +} + +/** + * A checkout the plugin has run from that the finished config does not + * register. Reported rather than restored: config history is evidence of what + * happened, not authority over what the user wants registered now. + */ +function findUnregisteredLocalCheckout(pluginList, historyPath) { + const entries = Array.isArray(pluginList) ? pluginList : []; + if (entries.some((entry) => classifyPluginEntry(entry).kind === LOCAL_CHECKOUT_ENTRY)) { + return null; + } + const latest = readLocalCheckoutSightings(historyPath) + .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0)) + .at(-1); + if (!latest) return null; + return resolveDeclaredPackageName(latest.root) ? latest : null; +} + function mergeTuiConfig(existingConfig, onNotice) { const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {}; const next = { ...existing }; @@ -1557,6 +1595,14 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { log("No existing TUI config found. Creating new global TUI config."); } + const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath); + if (unregisteredCheckout) { + log( + `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` + + `which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`, + ); + } + const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig); const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig); let wrote = false; @@ -1623,6 +1669,7 @@ export const __test = { backupConfig, classifyPluginEntry, copyFileWithWindowsRetry, + findUnregisteredLocalCheckout, formatConfigDiff, formatRedactedConfigDiff, mergeFullTemplate, diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index 0f608709..6210d933 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -1119,6 +1119,84 @@ describe("install-oc-codex-multi-auth script", () => { await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); }); + async function writeOriginHistory(home: string, sightings: unknown[]) { + const historyPath = join(home, ".opencode", "oc-codex-multi-auth-origin.json"); + await mkdir(join(home, ".opencode"), { recursive: true }); + await writeFile(historyPath, JSON.stringify({ version: 1, sightings }), "utf-8"); + return historyPath; + } + + function sightingFor(root: string) { + return { + name: "oc-codex-multi-auth", + version: "6.21.0", + root, + isLocalCheckout: true, + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-02-01T00:00:00.000Z", + }; + } + + it("reports a checkout the plugin ran from that the config no longer registers", 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 historyPath = await writeOriginHistory(tempHome, [sightingFor(checkout)]); + + expect(__test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath)).toMatchObject({ + root: checkout, + }); + expect(__test.findUnregisteredLocalCheckout([checkout], historyPath)).toBeNull(); + }); + + it("stays silent when the recorded checkout is gone or was never recorded", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const absent = join(tempHome, "deleted-checkout"); + const historyPath = await writeOriginHistory(tempHome, [sightingFor(absent)]); + + expect(__test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath)).toBeNull(); + expect( + __test.findUnregisteredLocalCheckout( + ["oc-codex-multi-auth"], + join(tempHome, ".opencode", "absent.json"), + ), + ).toBeNull(); + }); + + it("names the replaced checkout without restoring it to the config", 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 checkout = await createCheckout(tempHome, "oc-codex-multi-auth", "oc-codex-multi-auth"); + await writeOriginHistory(tempHome, [sightingFor(checkout)]); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + 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({ exitCode: 0 }); + + const stdout = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(stdout).toContain(checkout); + expect(stdout).toContain("last loaded from a checkout"); + + const saved = JSON.parse(await readFile(configPath, "utf-8")) as { plugin: string[] }; + expect(saved.plugin).toEqual(["oc-codex-multi-auth"]); + }); + it("keeps a local checkout when a catalog mode rewrites provider.openai", async () => { vi.resetModules(); tempHome = await createTempHome(); From a2ce6715ad57ae77aab7ec28b34c695992c954e8 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 13:32:17 -0500 Subject: [PATCH 08/13] 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 a396af512b74e8c06268e53763963e036d5798cc Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 13:47:24 -0500 Subject: [PATCH 09/13] fix(origin): keep every concurrently recorded origin Review of #260 found the history file could lose the very sighting it exists to preserve. Every OpenCode process records its origin at startup, and this machine routinely starts dozens at once, so the read-modify-write was a race in practice rather than in theory: two processes read the same history and each wrote back its own snapshot, and whichever landed second erased the other's origin. The sighting that goes missing is the one worth having. A checkout being replaced by the installed package is precisely two different origins written close together, so the case the file is meant to report is the case most likely to be lost. Recording now takes the same kind of short lease the storage layer already uses, and re-reads the history inside it, so each writer merges into what is on disk rather than into what it read moments earlier. A process that cannot take the lease records nothing and returns what is already there: it will be started again, and a sighting arriving one startup later costs less than a sighting overwritten. The installer also classified entries without the config directory when deciding whether a recorded checkout is still registered, so a checkout registered by a relative path was reported as unregistered. It now uses the same base directory as the rest of the installer's entry classification. The history file name is spelled in two places, because the installer runs before anything is built and cannot import the runtime module. A test now pins the two spellings together; a silent disagreement would leave each side reporting confidently about a file the other never writes. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 4e488d6 --- lib/plugin-origin.ts | 80 ++++++++++++++++++--- scripts/install-oc-codex-multi-auth-core.js | 9 ++- test/plugin-origin.test.ts | 28 ++++++++ 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/lib/plugin-origin.ts b/lib/plugin-origin.ts index 1d48e49f..eb67a2cf 100644 --- a/lib/plugin-origin.ts +++ b/lib/plugin-origin.ts @@ -13,13 +13,36 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { lock } from "proper-lockfile"; +import { createLogger } from "./logger.js"; import { renameWithWindowsRetry } from "./storage/atomic-write.js"; -const HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json"; +const log = createLogger("plugin-origin"); + +/** + * The origin history file name, shared with the installer. + * + * The installer is plain JS that must run before anything is built, so it + * cannot import this module and declares the same name itself. A test pins the + * two together, because a silent disagreement would leave each side reporting + * confidently about a file the other never writes. + */ +export const HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json"; const HISTORY_VERSION = 1; const MAX_SIGHTINGS = 10; const PACKAGE_ROOT_LOOKUP_DEPTH = 3; +/** The lease covers one small read and one small write, so it is short. */ +const HISTORY_LOCK_STALE_MS = 10_000; +const HISTORY_LOCK_UPDATE_MS = 2_000; +const HISTORY_LOCK_RETRIES = { + retries: 6, + factor: 1.6, + minTimeout: 25, + maxTimeout: 400, + randomize: true, +} as const; + export interface PluginOrigin { name: string; version: string; @@ -176,24 +199,63 @@ export function withSighting( }; } +async function writeHistory(historyPath: string, history: PluginOriginHistory): Promise { + const temporaryPath = `${historyPath}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(history, null, 2)}\n`, "utf-8"); + try { + await renameWithWindowsRetry(temporaryPath, historyPath); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw error; + } +} + +/** + * Records this origin without dropping anybody else's. + * + * Every OpenCode process records at startup, and a machine running many of + * them starts several at once, so read-modify-write on a shared file is a real + * race rather than a theoretical one. The reader that matters here asks which + * origins have been seen, so a lost sighting is a lost answer: the very + * handover this file exists to report - a checkout replaced by the installed + * package - is two different origins written at close to the same time. + * + * The history is therefore re-read INSIDE the lease, so each writer merges + * into what is actually on disk. A writer that cannot take the lease records + * nothing rather than overwriting blind; it is about to be started again, and + * a missing sighting costs a later startup while a clobbered one costs the + * only evidence there was. + */ export async function recordPluginOrigin( origin: PluginOrigin, historyPath: string = getPluginOriginHistoryPath(), now: () => Date = () => new Date(), ): Promise { - const next = withSighting(readPluginOriginHistory(historyPath), origin, now().toISOString()); - const temporaryPath = `${historyPath}.${process.pid}.tmp`; - await mkdir(dirname(historyPath), { recursive: true }); - await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, "utf-8"); + + let release: (() => Promise) | null = null; try { - await renameWithWindowsRetry(temporaryPath, historyPath); + release = await lock(historyPath, { + realpath: false, + lockfilePath: `${historyPath}.lock`, + stale: HISTORY_LOCK_STALE_MS, + update: HISTORY_LOCK_UPDATE_MS, + retries: HISTORY_LOCK_RETRIES, + }); } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined); - throw error; + log.debug("Skipped recording the plugin origin; another process holds the history", { + error: error instanceof Error ? error.message : String(error), + }); + return readPluginOriginHistory(historyPath); } - return next; + try { + const next = withSighting(readPluginOriginHistory(historyPath), origin, now().toISOString()); + await writeHistory(historyPath, next); + return next; + } finally { + await release().catch(() => undefined); + } } /** diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 34e28b98..8aa8fab5 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -425,9 +425,9 @@ function readLocalCheckoutSightings(historyPath) { * register. Reported rather than restored: config history is evidence of what * happened, not authority over what the user wants registered now. */ -function findUnregisteredLocalCheckout(pluginList, historyPath) { +function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) { const entries = Array.isArray(pluginList) ? pluginList : []; - if (entries.some((entry) => classifyPluginEntry(entry).kind === LOCAL_CHECKOUT_ENTRY)) { + if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) { return null; } const latest = readLocalCheckoutSightings(historyPath) @@ -1626,7 +1626,9 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { log("No existing TUI config found. Creating new global TUI config."); } - const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath); + const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, { + baseDirectory: paths.configDir, + }); if (unregisteredCheckout) { log( `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` + @@ -1696,6 +1698,7 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } export const __test = { + ORIGIN_HISTORY_FILE_NAME, buildPaths, backupConfig, classifyPluginEntry, diff --git a/test/plugin-origin.test.ts b/test/plugin-origin.test.ts index 710ddcbf..23d5f02f 100644 --- a/test/plugin-origin.test.ts +++ b/test/plugin-origin.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { + HISTORY_FILE_NAME, describePluginOrigin, findReplacedLocalCheckout, getPluginOriginHistoryPath, @@ -251,4 +252,31 @@ describe("plugin-origin", () => { join("/home", "dev", ".opencode", "oc-codex-multi-auth-origin.json"), ); }); + + // The installer runs before anything is built and so cannot import this + // module; it spells the same file name itself. Each side would otherwise go + // on reporting confidently about a file the other never writes. + it("agrees with the installer about where the history lives", async () => { + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + + expect(__test.ORIGIN_HISTORY_FILE_NAME).toBe(HISTORY_FILE_NAME); + expect(__test.buildPaths("/home/dev").originHistoryPath).toBe( + getPluginOriginHistoryPath("/home/dev"), + ); + }); + + it("keeps every concurrently recorded origin", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, ".opencode", "oc-codex-multi-auth-origin.json"); + const origins = Array.from({ length: 8 }, (_unused, index) => + localCheckout(join(tempRoot ?? "", `checkout-${index}`)), + ); + + await Promise.all(origins.map((origin) => recordPluginOrigin(origin, historyPath))); + + const recorded = readPluginOriginHistory(historyPath).sightings.map( + (sighting) => sighting.root, + ); + expect(new Set(recorded)).toEqual(new Set(origins.map((origin) => origin.root))); + }); }); From a502700bba94c490c5a412e0cd9a875e1a244e72 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 14:15:01 -0500 Subject: [PATCH 10/13] fix(origin): stop a reclaimed lease from ending the session Recording where this plugin was loaded from takes a lockfile lease, and proper-lockfile's default answer to losing one is `(err) => { throw err }` (lockfile.js:213). That throw is raised from the timer that refreshes the lease, so it lands outside every promise chain and no `.catch()` on the caller can reach it: the OpenCode process hosting the plugin dies. A stalled event loop past the ten-second stale window is enough to trigger it, which on a machine running dozens of sessions is an ordinary Tuesday rather than a fault. Writing one line of diagnostic history is not worth an editor closing. The lease now supplies its own handler, which records the loss and warns, matching what `lib/storage/transaction-lock.ts` already does for the storage and refresh leases. The write is then skipped, because whoever reclaimed the lease owns the file now and writing the merged history read before they arrived would drop their sighting - the clobber the lease exists to prevent. This is the same answer already given to a lease that cannot be acquired at all, arriving later. The regression test drives the path directly by standing in for proper-lockfile: it asserts a handler is supplied, that calling it does not throw, and that nothing is written afterwards. Against the previous revision it fails on both counts. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 4e488d6 --- lib/plugin-origin.ts | 20 +++++++++++++++++++- test/plugin-origin.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/plugin-origin.ts b/lib/plugin-origin.ts index eb67a2cf..d55f8758 100644 --- a/lib/plugin-origin.ts +++ b/lib/plugin-origin.ts @@ -224,7 +224,8 @@ async function writeHistory(historyPath: string, history: PluginOriginHistory): * into what is actually on disk. A writer that cannot take the lease records * nothing rather than overwriting blind; it is about to be started again, and * a missing sighting costs a later startup while a clobbered one costs the - * only evidence there was. + * only evidence there was. A lease reclaimed mid-write is the same situation + * arriving later, and is answered the same way. */ export async function recordPluginOrigin( origin: PluginOrigin, @@ -233,6 +234,7 @@ export async function recordPluginOrigin( ): Promise { await mkdir(dirname(historyPath), { recursive: true }); + let compromised: Error | undefined; let release: (() => Promise) | null = null; try { release = await lock(historyPath, { @@ -241,6 +243,18 @@ export async function recordPluginOrigin( stale: HISTORY_LOCK_STALE_MS, update: HISTORY_LOCK_UPDATE_MS, retries: HISTORY_LOCK_RETRIES, + // proper-lockfile's default rethrows from the timer that refreshes the + // lease, so it lands outside every promise chain and ends the process + // hosting this plugin. A stalled event loop is enough to trigger it. + // Nothing here is worth an editor closing, so the loss is recorded and + // the write is abandoned instead. + onCompromised: (error: Error) => { + compromised = error; + log.warn("The plugin origin history lease was reclaimed", { + path: `${historyPath}.lock`, + error: error.message, + }); + }, }); } catch (error) { log.debug("Skipped recording the plugin origin; another process holds the history", { @@ -251,6 +265,10 @@ export async function recordPluginOrigin( try { const next = withSighting(readPluginOriginHistory(historyPath), origin, now().toISOString()); + // Checked here rather than before the merge: whoever reclaimed the lease + // owns the file now, so writing what we read before they did would drop + // their sighting - the clobber the lease exists to prevent. + if (compromised) return readPluginOriginHistory(historyPath); await writeHistory(historyPath, next); return next; } finally { diff --git a/test/plugin-origin.test.ts b/test/plugin-origin.test.ts index 23d5f02f..52f0a995 100644 --- a/test/plugin-origin.test.ts +++ b/test/plugin-origin.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -44,6 +44,8 @@ describe("plugin-origin", () => { let tempRoot: string | null = null; afterEach(async () => { + vi.doUnmock("proper-lockfile"); + vi.resetModules(); if (tempRoot) { await rm(tempRoot, { recursive: true, force: true }); tempRoot = null; @@ -279,4 +281,32 @@ describe("plugin-origin", () => { ); expect(new Set(recorded)).toEqual(new Set(origins.map((origin) => origin.root))); }); + + it("gives up the write when the lease is reclaimed, rather than ending the process", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, ".opencode", "oc-codex-multi-auth-origin.json"); + let leaseOptions: { onCompromised?: (error: Error) => void } | undefined; + + vi.resetModules(); + vi.doMock("proper-lockfile", () => ({ + lock: async (_target: string, options: { onCompromised?: (error: Error) => void }) => { + leaseOptions = options; + options.onCompromised?.(new Error("lock file was removed")); + return async () => { + throw new Error("Lock is already released"); + }; + }, + })); + const origin = localCheckout(join(tempRoot, "checkout")); + const module = await import("../lib/plugin-origin.js"); + + await expect(module.recordPluginOrigin(origin, historyPath)).resolves.toEqual(historyOf()); + expect(module.readPluginOriginHistory(historyPath).sightings).toEqual([]); + // Supplying a handler at all is the fix: proper-lockfile's own default is + // `(err) => { throw err }`, raised from the timer that refreshes the lease + // and therefore reachable by no caller and fatal to the host process. + const onCompromised = leaseOptions?.onCompromised; + expect(typeof onCompromised).toBe("function"); + expect(() => onCompromised?.(new Error("reclaimed again"))).not.toThrow(); + }); }); From 799f0fb1faaff08253bb9291d74c70c4759442d1 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 17:17:55 -0500 Subject: [PATCH 11/13] 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 40fb5b4f174d803f0a5270812144ff6ca0462667 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 17:29:13 -0500 Subject: [PATCH 12/13] fix(installer): only name a recorded checkout that still holds this package The note pointing a user back at a checkout OpenCode has run from accepted the recorded path as long as any `package.json` could be found at it. Paths get reused - a checkout deleted and an unrelated project cloned into the same directory - and the installer would then recommend that directory as somewhere to point OpenCode, naming this plugin while describing somebody else's project. The recorded sighting already carries the package name that was seen there, so the two are compared. A directory that has become something else produces no note at all, which is the right answer: there is nothing to go back to. The same call now also receives the cache root it was missing, so it reads a plugin list by the same rules normalization just applied to it. Without it a cache copy counted as a registered checkout here while being retired there, and the two disagreed about the list they had both just been handed. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 59a3271 --- scripts/install-oc-codex-multi-auth-core.js | 11 +++++++++- test/install-oc-codex-multi-auth.test.ts | 23 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 4e67535e..b2651928 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -464,7 +464,15 @@ function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) { .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0)) .at(-1); if (!latest) return null; - return resolveDeclaredPackageName(latest.root) ? latest : null; + // The directory has to still hold the package that was recorded there. A + // path gets reused - a checkout deleted and something else cloned into its + // place - and a recorded path that now declares another project would + // otherwise be offered as somewhere to point OpenCode back at. + const declaredName = resolveDeclaredPackageName(latest.root); + if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) { + return null; + } + return latest; } function mergeTuiConfig(existingConfig, onNotice, options = {}) { @@ -1660,6 +1668,7 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, { baseDirectory: paths.configDir, + cacheDirectory: paths.cacheDir, }); if (unregisteredCheckout) { log( diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index e386409f..3e51d014 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -1166,6 +1166,29 @@ describe("install-oc-codex-multi-auth script", () => { ).toBeNull(); }); + it("stays silent when the recorded directory now holds a different package", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const reusedRoot = await createCheckout(tempHome, "oc-codex-multi-auth", "reused-root"); + const historyPath = await writeOriginHistory(tempHome, [sightingFor(reusedRoot)]); + + expect( + __test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath)?.root, + ).toBe(reusedRoot); + + // Same path, cloned over with something else since it was recorded. + await writeFile( + join(reusedRoot, "package.json"), + JSON.stringify({ name: "some-unrelated-project", version: "1.0.0" }), + "utf-8", + ); + + expect( + __test.findUnregisteredLocalCheckout(["oc-codex-multi-auth"], historyPath), + ).toBeNull(); + }); + it("names the replaced checkout without restoring it to the config", async () => { vi.resetModules(); tempHome = await createTempHome(); From b792cab13070e7fee6e641bcfeaaa4275d7818b6 Mon Sep 17 00:00:00 2001 From: Nowaker Date: Thu, 17 Sep 2026 17:29:52 -0500 Subject: [PATCH 13/13] fix(origin): decide ownership at the rename and root identity per platform Two ways the history could still lose a sighting it was supposed to keep. Both are about the same guarantee - that every origin seen on a machine stays on record - so they are answered together. Ownership was checked once, before the replacement copy was written. `writeFile` is the long part of that sequence, so a lease reclaimed during it was reclaimed after the only check: the losing writer then renamed its older snapshot over history the new owner had already written, dropping exactly the sighting the lease exists to protect. The question is now asked again immediately before the rename, which is the only moment the replacement becomes visible to anyone else, and the temporary file is discarded instead when the answer is no. `writeHistory` reports whether it replaced anything, so a caller that lost the race returns what is on disk rather than what it hoped to write. Roots were compared verbatim. On Windows one directory is reachable under several spellings, so `C:\Repo` and `c:\repo` took two slots in a history bounded at ten and could evict a genuinely different origin between them, and `findReplacedLocalCheckout` would read a re-cased spelling of the current root as a checkout that had been replaced. Comparison now runs through a key that folds case and separators on Windows only - elsewhere those are different directories and must stay so. The key decides matching alone: sightings keep the path as the run that recorded them spelled it, so a reader is shown somewhere real. `isPackageManagerRoot` folds segment casing the same way, so a `NODE_MODULES` path is recognized as package-manager output rather than being taken for a checkout and silently exempted from update checks. The installer settled this for its own classifier already; the runtime cannot import that plain-JS script, so the rule is stated once more here rather than shared. Each rule takes the platform as an argument so both sides of it can be tested from either host. AI-Tool: opencode AI-Model: anthropic/claude-opus-5 AI-Platform: linux AI-Harness: Vibeterm 59a3271 --- lib/plugin-origin.ts | 71 ++++++++++++++++++++++------ test/plugin-origin.test.ts | 95 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 15 deletions(-) diff --git a/lib/plugin-origin.ts b/lib/plugin-origin.ts index d55f8758..fe1ba385 100644 --- a/lib/plugin-origin.ts +++ b/lib/plugin-origin.ts @@ -8,7 +8,7 @@ * edits stopped taking effect" into a diagnosable event. */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -100,13 +100,29 @@ function pathSegments(path: string): string[] { return path.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean); } +/** + * How two spellings of a root are told apart. Windows reaches one directory + * under several of them, so comparing verbatim there would let `C:\Repo` and + * `c:\repo` occupy two slots in a bounded history and evict a genuinely + * different origin between them. + */ +function rootComparisonKey(root: string, platform: NodeJS.Platform = process.platform): string { + const normalized = root.replaceAll("\\", "/").replace(/\/+$/, ""); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + /** * A root a package manager chose, as opposed to one a human did. The version * suffix is what separates OpenCode's plugin cache from a monorepo that merely * keeps its packages in a `packages/` directory. */ -export function isPackageManagerRoot(root: string): boolean { - const segments = pathSegments(root); +export function isPackageManagerRoot( + root: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const segments = pathSegments(root).map((segment) => + platform === "win32" ? segment.toLowerCase() : segment, + ); return segments.some( (segment, index) => segment === "node_modules" || @@ -185,9 +201,15 @@ export function withSighting( history: PluginOriginHistory, origin: PluginOrigin, seenAt: string, + platform: NodeJS.Platform = process.platform, ): PluginOriginHistory { - const previous = history.sightings.find((sighting) => sighting.root === origin.root); - const others = history.sightings.filter((sighting) => sighting.root !== origin.root); + // Matched on the comparison key, recorded as the origin spells it: a reader + // is shown the path they configured, not a folded version of it. + const key = rootComparisonKey(origin.root, platform); + const sameRoot = (sighting: PluginOriginSighting) => + rootComparisonKey(sighting.root, platform) === key; + const previous = history.sightings.find(sameRoot); + const others = history.sightings.filter((sighting) => !sameRoot(sighting)); const current: PluginOriginSighting = { ...origin, firstSeen: previous?.firstSeen ?? seenAt, @@ -199,14 +221,30 @@ export function withSighting( }; } -async function writeHistory(historyPath: string, history: PluginOriginHistory): Promise { +/** + * Replaces the history, unless ownership was lost while the new copy was being + * written. Only the rename is visible to anybody else, so that is where the + * question has to be asked: checking earlier leaves the whole of `writeFile` + * as a window in which the lease can be reclaimed and newer history written, + * which this rename would then replace with an older snapshot. + * + * Returns whether the replacement happened. + */ +async function writeHistory( + historyPath: string, + history: PluginOriginHistory, + stillOwned: () => boolean, +): Promise { const temporaryPath = `${historyPath}.${process.pid}.tmp`; await writeFile(temporaryPath, `${JSON.stringify(history, null, 2)}\n`, "utf-8"); try { + if (!stillOwned()) return false; await renameWithWindowsRetry(temporaryPath, historyPath); - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined); - throw error; + return true; + } finally { + if (existsSync(temporaryPath)) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + } } } @@ -265,12 +303,13 @@ export async function recordPluginOrigin( try { const next = withSighting(readPluginOriginHistory(historyPath), origin, now().toISOString()); - // Checked here rather than before the merge: whoever reclaimed the lease - // owns the file now, so writing what we read before they did would drop - // their sighting - the clobber the lease exists to prevent. + // Whoever reclaimed the lease owns the file now, so writing what we read + // before they did would drop their sighting - the clobber the lease + // exists to prevent. Asked again at the rename, since the lease can be + // lost at any point up to it. if (compromised) return readPluginOriginHistory(historyPath); - await writeHistory(historyPath, next); - return next; + const written = await writeHistory(historyPath, next, () => !compromised); + return written ? next : readPluginOriginHistory(historyPath); } finally { await release().catch(() => undefined); } @@ -284,15 +323,17 @@ export async function recordPluginOrigin( export function findReplacedLocalCheckout( origin: PluginOrigin, history: PluginOriginHistory, + platform: NodeJS.Platform = process.platform, ): PluginOriginSighting | null { if (origin.isLocalCheckout) return null; + const currentKey = rootComparisonKey(origin.root, platform); return ( history.sightings .filter( (sighting) => sighting.name === origin.name && sighting.isLocalCheckout && - sighting.root !== origin.root, + rootComparisonKey(sighting.root, platform) !== currentKey, ) .sort(byLastSeen) .at(-1) ?? null diff --git a/test/plugin-origin.test.ts b/test/plugin-origin.test.ts index 52f0a995..58d888b5 100644 --- a/test/plugin-origin.test.ts +++ b/test/plugin-origin.test.ts @@ -45,6 +45,7 @@ describe("plugin-origin", () => { afterEach(async () => { vi.doUnmock("proper-lockfile"); + vi.doUnmock("node:fs/promises"); vi.resetModules(); if (tempRoot) { await rm(tempRoot, { recursive: true, force: true }); @@ -309,4 +310,98 @@ describe("plugin-origin", () => { expect(typeof onCompromised).toBe("function"); expect(() => onCompromised?.(new Error("reclaimed again"))).not.toThrow(); }); + + it("leaves newer history alone when the lease is lost while the copy is written", async () => { + tempRoot = await createTempRoot(); + const historyPath = join(tempRoot, ".opencode", "oc-codex-multi-auth-origin.json"); + const newOwner = { + ...localCheckout(join(tempRoot, "written-by-the-new-owner")), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-01-01T00:00:00.000Z", + }; + await mkdir(join(tempRoot, ".opencode"), { recursive: true }); + await writeFile(historyPath, JSON.stringify(historyOf(newOwner)), "utf-8"); + + let leaseOptions: { onCompromised?: (error: Error) => void } | undefined; + vi.resetModules(); + vi.doMock("proper-lockfile", () => ({ + lock: async (_target: string, options: { onCompromised?: (error: Error) => void }) => { + leaseOptions = options; + return async () => {}; + }, + })); + // Reclaimed precisely inside the window the fix closes: after the merge + // has been decided, while the replacement copy is still being written. + vi.doMock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + writeFile: async (...args: Parameters) => { + leaseOptions?.onCompromised?.(new Error("lease reclaimed mid-write")); + return actual.writeFile(...args); + }, + }; + }); + const module = await import("../lib/plugin-origin.js"); + const origin = localCheckout(join(tempRoot, "losing-writer")); + + await module.recordPluginOrigin(origin, historyPath); + + const roots = module.readPluginOriginHistory(historyPath).sightings.map((s) => s.root); + expect(roots).toEqual([newOwner.root]); + expect(roots).not.toContain(origin.root); + }); + + describe("Windows path casing", () => { + it("recognizes package-manager output under any casing, but only on Windows", () => { + const cacheRoot = "C:/Users/dev/.cache/opencode/NODE_MODULES/oc-codex-multi-auth"; + + expect(isPackageManagerRoot(cacheRoot, "win32")).toBe(true); + expect(isPackageManagerRoot(cacheRoot, "linux")).toBe(false); + expect(isPackageManagerRoot("/home/dev/src/oc-codex-multi-auth", "win32")).toBe(false); + }); + + it("keeps one sighting for one directory spelled two ways", () => { + const first = withSighting( + historyOf(), + localCheckout("C:\\Repo\\plugin"), + "2026-01-01T00:00:00.000Z", + "win32", + ); + const second = withSighting( + first, + localCheckout("c:\\repo\\plugin"), + "2026-02-01T00:00:00.000Z", + "win32", + ); + + expect(second.sightings).toHaveLength(1); + expect(second.sightings[0]?.firstSeen).toBe("2026-01-01T00:00:00.000Z"); + expect(second.sightings[0]?.lastSeen).toBe("2026-02-01T00:00:00.000Z"); + // Recorded as this run spelled it, so a reader is shown a real path. + expect(second.sightings[0]?.root).toBe("c:\\repo\\plugin"); + + expect( + withSighting(first, localCheckout("c:\\repo\\plugin"), "2026-02-01T00:00:00.000Z", "linux") + .sightings, + ).toHaveLength(2); + }); + + it("does not call a differently cased spelling of the same root a replacement", () => { + const checkout = { + ...localCheckout("C:\\Repo\\plugin"), + firstSeen: "2026-01-01T00:00:00.000Z", + lastSeen: "2026-01-01T00:00:00.000Z", + }; + const installed = { + ...installedPackage("c:\\repo\\plugin"), + isLocalCheckout: false, + }; + + expect(findReplacedLocalCheckout(installed, historyOf(checkout), "win32")).toBeNull(); + expect(findReplacedLocalCheckout(installed, historyOf(checkout), "linux")?.root).toBe( + "C:\\Repo\\plugin", + ); + }); + }); });