From 72c66c87c119aaaa490f67c4352040e7f97e3e6e Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 1 Aug 2026 20:46:25 +0200 Subject: [PATCH] fix(xdg): adopted legacy secret-placeholder.key and marketplaces.json at XDG paths --- docs/secrets.md | 2 +- packages/coding-agent/CHANGELOG.md | 9 +-- .../plugins/marketplace/registry.ts | 2 +- packages/utils/CHANGELOG.md | 8 +- packages/utils/src/dirs.ts | 31 +++++++- packages/utils/test/dirs-cache.test.ts | 79 ++++++++++++++++++- 6 files changed, 116 insertions(+), 15 deletions(-) diff --git a/docs/secrets.md b/docs/secrets.md index faf349e1e..6e4726aed 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -80,7 +80,7 @@ Each entry in the array has these fields: This produces placeholders shaped like `#GITHUBTOKEN_AB12:L#`. The friendly name is sanitized to uppercase letters and digits, capped at 32 characters, and omitted if it sanitizes to an empty value. Invalid optional `friendlyName` metadata does not disable the secret entry; the secret still obfuscates with an unlabeled placeholder. -The hash base is an HMAC of the secret under a private per-install key (stored at `~/.omp/agent/secret-placeholder.key`, never sent to a model), so a transcript reader cannot dictionary the placeholder back to the secret. The base is keyed on the exact secret value, so two secrets that differ only by case get independent bases and a provider that sees one placeholder cannot synthesize another secret's token by swapping the hint. A case hint suffix labels the casing of the redacted value for the model: +The hash base is an HMAC of the secret under a private per-install key (stored at `~/.omp/agent/secret-placeholder.key`, or `$XDG_STATE_HOME/omp/secret-placeholder.key` on XDG-enabled installs, never sent to a model), so a transcript reader cannot dictionary the placeholder back to the secret. The base is keyed on the exact secret value, so two secrets that differ only by case get independent bases and a provider that sees one placeholder cannot synthesize another secret's token by swapping the hint. A case hint suffix labels the casing of the redacted value for the model: | Hint | Meaning | | ---- | -------------------------------------------- | diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 65c674923..d5b80b58b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,10 @@ - Headless hosts (print/RPC/ACP/eval/SDK) now use a 1s SQLite `busy_timeout` for the session-critical databases (agent.db, history.db, stats.db), so lock contention no longer freezes the protocol loop for the full interactive 5s timeout; interactive hosts keep the 5s timeout. The interactive-host flag is now declared before settings load so the first database opens see the correct timeout. - MCP JSON-RPC request ids now default to per-connection sequential integers instead of snowflake strings, matching the wider MCP ecosystem and making integer-only decoders like Apple's `xcrun mcpbridge` work without configuration; set `requestIdFormat: "string"` per server to restore collision-resistant string ids ([#7053](https://github.com/can1357/oh-my-pi/issues/7053)). +- `secret-placeholder.key` now resolves under XDG state (`$XDG_STATE_HOME/omp/secret-placeholder.key`) instead of the agent config directory, so it follows the same XDG layout as other state files. +- Daemon runtime directories (`run/daemons/`) and provider in-flight tracking (`run/provider-inflight`) now resolve under XDG state (`$XDG_STATE_HOME/omp/run/`) instead of the config root, keeping ephemeral runtime state out of `~/.config`. +- `marketplaces.json` now resolves under XDG data (`$XDG_DATA_HOME/omp/marketplaces.json`) instead of the config root, aligning with the XDG data category for user-scoped registry files. +- Existing XDG installs keep their placeholder key and marketplace registry: the legacy `~/.omp/agent/secret-placeholder.key` and `~/.omp/marketplaces.json` are copied to their XDG locations on first resolution. ### Fixed @@ -31,11 +35,6 @@ - Preserved explicit `-e`/`--extension` and `--hook` packages under `--no-extensions` while excluding ambient extension factories and sibling capabilities from settings or installed OMP packages. -### Changed - -- `secret-placeholder.key` now resolves under XDG state (`$XDG_STATE_HOME/omp/secret-placeholder.key`) instead of the agent config directory, so it follows the same XDG layout as other state files. -- Daemon runtime directories (`run/daemons/`) and provider in-flight tracking (`run/provider-inflight`) now resolve under XDG state (`$XDG_STATE_HOME/omp/run/`) instead of the config root, keeping ephemeral runtime state out of `~/.config`. -- `marketplaces.json` now resolves under XDG data (`$XDG_DATA_HOME/omp/marketplaces.json`) instead of the config root, aligning with the XDG data category for user-scoped registry files. ## [17.2.3] - 2026-08-01 diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts index 3e193fce9..ec6787627 100644 --- a/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts +++ b/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts @@ -2,7 +2,7 @@ * Registry read/write operations for the marketplace plugin system. * * Two registries: - * - marketplaces.json under getConfigRootDir() — which catalogs the user has added + * - marketplaces.json at getMarketplacesRegistryPath() — which catalogs the user has added * - installed_plugins.json under getPluginsDir() — which plugins are installed * * Read/write functions accept explicit file paths so callers control the diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 5d49bb6d1..b2ece73cb 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added `getSecretPlaceholderKeyPath()`, `getDaemonRuntimeDir()`, `getProviderInFlightRoot()`, and `getMarketplacesRegistryPath()` to resolve secret key, daemon runtime, provider in-flight, and marketplace registry paths under their respective XDG categories (state, data) instead of the config root. +- Existing installs enabling XDG keep their data: a legacy `~/.omp/agent/secret-placeholder.key` or `~/.omp/marketplaces.json` is copied to its XDG location on first resolution, so persisted transcripts still deobfuscate and added marketplaces survive the move. + ### Changed - Headless hosts (print/RPC/ACP/eval/SDK) now use a 1s SQLite `busy_timeout` for the session-critical databases (agent.db, history.db, stats.db) via `getDbBusyTimeoutMs()`, so lock contention no longer freezes the protocol loop for the full interactive 5s timeout; interactive hosts keep the 5s timeout. @@ -9,9 +14,6 @@ ### Fixed - Fixed Bun test-runtime detection treating application-owned `NODE_ENV=test` and `BUN_ENV=test` values as test-runner signals ([#7261](https://github.com/can1357/oh-my-pi/issues/7261)). -### Added - -- Added `getSecretPlaceholderKeyPath()`, `getDaemonRuntimeDir()`, `getProviderInFlightRoot()`, and `getMarketplacesRegistryPath()` to resolve secret key, daemon runtime, provider in-flight, and marketplace registry paths under their respective XDG categories (state, data) instead of the config root. ## [17.2.1] - 2026-07-30 diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 9463f1271..23888de1b 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -820,9 +820,30 @@ export function getDebugLogPath(agentDir?: string): string { return dirs.agentSubdir(agentDir, `${APP_NAME}-debug.log`, "state"); } -/** Get the secret placeholder key path (~/.omp/agent/secret-placeholder.key; XDG default: $XDG_STATE_HOME/omp/secret-placeholder.key). */ +/** + * Best-effort one-time copy of a legacy config-root file to its redirected XDG + * location. Existing installs that enable XDG after the file was created keep + * their data (e.g. a placeholder key whose loss would break deobfuscation of + * persisted transcripts). The legacy file is left in place for older omp + * versions sharing the profile. + */ +function adoptLegacyFile(legacyPath: string, targetPath: string): void { + if (targetPath === legacyPath) return; + try { + if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) return; + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.copyFileSync(legacyPath, targetPath, fs.constants.COPYFILE_EXCL); + } catch { + // Opportunistic: a copy race or unwritable XDG dir falls back to a fresh + // file at the new path — the pre-adoption behavior. + } +} + +/** Get the secret placeholder key path (~/.omp/agent/secret-placeholder.key; XDG default: $XDG_STATE_HOME/omp/secret-placeholder.key). Adopts a legacy key on first XDG resolution. */ export function getSecretPlaceholderKeyPath(): string { - return dirs.agentSubdir(undefined, "secret-placeholder.key", "state"); + const keyPath = dirs.agentSubdir(undefined, "secret-placeholder.key", "state"); + adoptLegacyFile(path.join(dirs.agentDir, "secret-placeholder.key"), keyPath); + return keyPath; } /** Get the daemon runtime directory for a project (~/.omp/run/daemons/; XDG default: $XDG_STATE_HOME/omp/run/daemons/). */ @@ -836,9 +857,11 @@ export function getProviderInFlightRoot(): string { return dirs.rootSubdir(path.join("run", "provider-inflight"), "state"); } -/** Get the marketplaces registry path (~/.omp/marketplaces.json; XDG default: $XDG_DATA_HOME/omp/marketplaces.json). */ +/** Get the marketplaces registry path (~/.omp/marketplaces.json; XDG default: $XDG_DATA_HOME/omp/marketplaces.json). Adopts a legacy registry on first XDG resolution. */ export function getMarketplacesRegistryPath(): string { - return dirs.rootSubdir("marketplaces.json", "data"); + const registryPath = dirs.rootSubdir("marketplaces.json", "data"); + adoptLegacyFile(path.join(dirs.configRoot, "marketplaces.json"), registryPath); + return registryPath; } // ============================================================================= diff --git a/packages/utils/test/dirs-cache.test.ts b/packages/utils/test/dirs-cache.test.ts index c84a5be5d..63c6324e0 100644 --- a/packages/utils/test/dirs-cache.test.ts +++ b/packages/utils/test/dirs-cache.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, type Mock, spyOn } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -7,7 +7,9 @@ import { getActiveProfile, getConfigDirName, getDocumentConversionCacheDir, + getMarketplacesRegistryPath, getProfileRootDir, + getSecretPlaceholderKeyPath, setAgentDir, } from "@oh-my-pi/pi-utils/dirs"; import { Snowflake } from "@oh-my-pi/pi-utils/snowflake"; @@ -102,3 +104,78 @@ describe("test directory state cleanup", () => { } }); }); + +describe("legacy file adoption on XDG paths", () => { + let tempRoot = ""; + let originalPiCodingAgentDir: string | undefined; + let originalOmpProfile: string | undefined; + let originalPiProfile: string | undefined; + let originalXdgStateHome: string | undefined; + let originalXdgDataHome: string | undefined; + let homedirSpy: Mock<() => string> | undefined; + + beforeEach(async () => { + originalPiCodingAgentDir = process.env.PI_CODING_AGENT_DIR; + originalOmpProfile = process.env.OMP_PROFILE; + originalPiProfile = process.env.PI_PROFILE; + originalXdgStateHome = process.env.XDG_STATE_HOME; + originalXdgDataHome = process.env.XDG_DATA_HOME; + tempRoot = path.join(os.tmpdir(), "pi-utils-xdg-adoption", Snowflake.next()); + await fs.mkdir(tempRoot, { recursive: true }); + }); + + afterEach(async () => { + homedirSpy?.mockRestore(); + homedirSpy = undefined; + restoreEnv("PI_CODING_AGENT_DIR", originalPiCodingAgentDir); + restoreEnv("OMP_PROFILE", originalOmpProfile); + restoreEnv("PI_PROFILE", originalPiProfile); + restoreEnv("XDG_STATE_HOME", originalXdgStateHome); + restoreEnv("XDG_DATA_HOME", originalXdgDataHome); + __resetDirsFromEnvForTests(); + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + /** Rebuild the resolver with home at tempRoot, the default agent dir, and the given XDG env. */ + function activateTempHome(xdgEnv: Record): void { + homedirSpy = spyOn(os, "homedir").mockReturnValue(tempRoot); + delete process.env.PI_CODING_AGENT_DIR; + delete process.env.OMP_PROFILE; + delete process.env.PI_PROFILE; + delete process.env.XDG_STATE_HOME; + delete process.env.XDG_DATA_HOME; + for (const key in xdgEnv) { + process.env[key] = xdgEnv[key]; + } + __resetDirsFromEnvForTests(); + } + + it("adopts legacy files at the XDG paths without clobbering existing XDG files", async () => { + if (process.platform === "win32") return; + const xdgState = path.join(tempRoot, "xdg-state"); + const xdgData = path.join(tempRoot, "xdg-data"); + await fs.mkdir(path.join(xdgState, "omp"), { recursive: true }); + await fs.mkdir(path.join(xdgData, "omp"), { recursive: true }); + // Legacy layout: key under ~/.omp/agent, registry under ~/.omp. + await fs.mkdir(path.join(tempRoot, ".omp", "agent"), { recursive: true }); + await fs.writeFile(path.join(tempRoot, ".omp", "agent", "secret-placeholder.key"), "legacy-key"); + await fs.writeFile(path.join(tempRoot, ".omp", "marketplaces.json"), '{"legacy":true}'); + // The XDG registry is already populated: adoption must not overwrite it. + await fs.writeFile(path.join(xdgData, "omp", "marketplaces.json"), '{"xdg":true}'); + activateTempHome({ XDG_STATE_HOME: xdgState, XDG_DATA_HOME: xdgData }); + + const key = getSecretPlaceholderKeyPath(); + const registry = getMarketplacesRegistryPath(); + expect(key).toBe(path.join(xdgState, "omp", "secret-placeholder.key")); + expect(registry).toBe(path.join(xdgData, "omp", "marketplaces.json")); + expect(await fs.readFile(key, "utf8")).toBe("legacy-key"); + expect(await fs.readFile(registry, "utf8")).toBe('{"xdg":true}'); + }); + + it("keeps the legacy paths canonical when XDG is inactive", async () => { + if (process.platform === "win32") return; + activateTempHome({}); + expect(getSecretPlaceholderKeyPath()).toBe(path.join(tempRoot, ".omp", "agent", "secret-placeholder.key")); + expect(getMarketplacesRegistryPath()).toBe(path.join(tempRoot, ".omp", "marketplaces.json")); + }); +});