From ea437745a312f84a5a415dff8b959dd6e65d863d Mon Sep 17 00:00:00 2001
From: Parsifa1
Date: Thu, 30 Jul 2026 06:21:16 +0000
Subject: [PATCH] fix(xdg): move secret-placeholder.key, marketplaces.json, and
run/ out of config root
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
These four paths bypassed DirResolver's XDG-aware rootSubdir/agentSubdir
hooks, resolving directly against getConfigRootDir()/getAgentDir() and
ignoring XDG state/data layout. Add XDG-aware path helpers in dirs.ts
and route all four through them:
- secret-placeholder.key → $XDG_STATE_HOME/omp/ (state, agent flattened)
- marketplaces.json → $XDG_DATA_HOME/omp/ (data)
- run/daemons// → $XDG_STATE_HOME/omp/run/ (state)
- run/provider-inflight/ → $XDG_STATE_HOME/omp/run/ (state)
omp config init-xdg migrates secret-placeholder.key and marketplaces.json
from their legacy locations; run/ is ephemeral and rebuilds on restart.
---
packages/ai/src/stream.ts | 4 +--
packages/coding-agent/CHANGELOG.md | 6 ++++
.../plugins/marketplace/registry.ts | 10 ++----
packages/coding-agent/src/launch/paths.ts | 7 ++--
packages/coding-agent/src/sdk.ts | 10 ++++--
packages/coding-agent/src/secrets/index.ts | 32 +++++++++----------
packages/utils/CHANGELOG.md | 4 +++
packages/utils/src/dirs.ts | 21 ++++++++++++
8 files changed, 60 insertions(+), 34 deletions(-)
diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts
index 36213199b..1ad774e90 100644
--- a/packages/ai/src/stream.ts
+++ b/packages/ai/src/stream.ts
@@ -15,7 +15,7 @@ import {
} from "@oh-my-pi/pi-catalog/model-thinking";
import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models";
import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex";
-import { $env, $pickenv, getConfigRootDir, isEnoent, logger, withExtraCaFetch } from "@oh-my-pi/pi-utils";
+import { $env, $pickenv, getProviderInFlightRoot, isEnoent, logger, withExtraCaFetch } from "@oh-my-pi/pi-utils";
import { getCustomApi } from "./api-registry";
import { createAuthRetryKeyState, isApiKeyResolver, resolveNextAuthRetryKey } from "./auth-retry";
import * as AIError from "./error";
@@ -189,7 +189,7 @@ function resolveProviderInFlightLimit(
function providerInFlightRoot(): string {
if (providerInFlightRootOverride) return providerInFlightRootOverride;
- return path.join(getConfigRootDir(), "run", "provider-inflight");
+ return getProviderInFlightRoot();
}
function providerInFlightSegment(provider: string): string {
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 1b632e9c5..295af56d6 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -2,6 +2,12 @@
## [Unreleased]
+### 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
### Changed
diff --git a/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts b/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts
index b48a08e24..3e193fce9 100644
--- a/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts
+++ b/packages/coding-agent/src/extensibility/plugins/marketplace/registry.ts
@@ -15,7 +15,9 @@
import * as fs from "node:fs/promises";
import * as path from "node:path";
-import { getConfigRootDir, getPluginsDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
+import { getPluginsDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
+
+export { getMarketplacesRegistryPath } from "@oh-my-pi/pi-utils";
import type {
InstalledPluginEntry,
@@ -24,12 +26,6 @@ import type {
MarketplacesRegistry,
} from "./types";
-// ── Path helpers ─────────────────────────────────────────────────────
-
-export function getMarketplacesRegistryPath(): string {
- return path.join(getConfigRootDir(), "marketplaces.json");
-}
-
export function getInstalledPluginsRegistryPath(): string {
return path.join(getPluginsDir(), "installed_plugins.json");
}
diff --git a/packages/coding-agent/src/launch/paths.ts b/packages/coding-agent/src/launch/paths.ts
index 2426da642..00951ef6c 100644
--- a/packages/coding-agent/src/launch/paths.ts
+++ b/packages/coding-agent/src/launch/paths.ts
@@ -1,11 +1,8 @@
import * as path from "node:path";
-import { getConfigRootDir } from "@oh-my-pi/pi-utils";
+import { getDaemonRuntimeDir } from "@oh-my-pi/pi-utils";
/** Resolve the private runtime directory shared by omp processes in one project directory. */
-export function daemonRuntimeDir(projectDir: string, configRoot: string = getConfigRootDir()): string {
- const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
- return path.join(configRoot, "run", "daemons", key);
-}
+export { getDaemonRuntimeDir as daemonRuntimeDir };
/** Resolve the Unix socket or Windows named pipe used by one project broker. */
export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string {
diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts
index 393044ead..66e73260c 100644
--- a/packages/coding-agent/src/sdk.ts
+++ b/packages/coding-agent/src/sdk.ts
@@ -1377,11 +1377,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// headless run on an unwritable default config root pays for a feature it
// does not use.
const needsPlaceholderKey = secretEntriesNeedPlaceholderKey([...envEntries, ...fileEntries]);
+ const explicitAgentDir = options.agentDir;
const placeholderKey = needsPlaceholderKey
- ? await getSecretPlaceholderKey(agentDir)
- : await getExistingSecretPlaceholderKey(agentDir);
+ ? await getSecretPlaceholderKey(explicitAgentDir)
+ : await getExistingSecretPlaceholderKey(explicitAgentDir);
if (allEntries.length > 0) {
- obfuscator = new SecretObfuscator(allEntries, placeholderKey ?? (() => getSecretPlaceholderKeySync(agentDir)));
+ obfuscator = new SecretObfuscator(
+ allEntries,
+ placeholderKey ?? (() => getSecretPlaceholderKeySync(explicitAgentDir)),
+ );
}
if (obfuscator?.hasSecrets() !== true && placeholderKey !== undefined) {
// No configured entry produced an active secret (e.g. only ignored short
diff --git a/packages/coding-agent/src/secrets/index.ts b/packages/coding-agent/src/secrets/index.ts
index e94eba24e..2ce028751 100644
--- a/packages/coding-agent/src/secrets/index.ts
+++ b/packages/coding-agent/src/secrets/index.ts
@@ -2,7 +2,7 @@ import * as crypto from "node:crypto";
import * as fs from "node:fs";
import * as path from "node:path";
import { SENSITIVE_TOKEN_RE } from "@oh-my-pi/pi-ai/providers/transform-messages";
-import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
+import { getSecretPlaceholderKeyPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { regexHasUnresolvableShortMatchFallback, type SecretEntry, sanitizeSecretFriendlyName } from "./obfuscator";
import { compileSecretRegex } from "./regex";
@@ -11,17 +11,15 @@ const PLACEHOLDER_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
const cachedPlaceholderKeys = new Map();
/**
- * Per-install secret key for the placeholder digest. Persisted under the agent
- * config directory and never sent to a provider, so model-visible placeholders
- * cannot be reversed by dictionary-hashing candidate secrets. Stable across
- * sessions so persisted transcripts deobfuscate consistently. Defaults to
- * `getAgentDir()` — the same directory `createAgentSession()` passes as
- * `agentDir` — so a caller relying on the default reads/writes the identical
- * key file live sessions use, per `~/.omp/agent/secret-placeholder.key` in
- * docs/secrets.md.
+ * Per-install secret key for the placeholder digest. Persisted under XDG state
+ * and never sent to a provider, so model-visible placeholders cannot be reversed
+ * by dictionary-hashing candidate secrets. Stable across sessions so persisted
+ * transcripts deobfuscate consistently. Defaults to `getSecretPlaceholderKeyPath()`
+ * — `$XDG_STATE_HOME/omp/secret-placeholder.key` (or `~/.omp/agent/secret-placeholder.key`
+ * without XDG), per docs/secrets.md.
*/
-export async function getSecretPlaceholderKey(keyDir: string = getAgentDir()): Promise {
- const keyPath = path.join(keyDir, "secret-placeholder.key");
+export async function getSecretPlaceholderKey(keyDir?: string): Promise {
+ const keyPath = keyDir ? path.join(keyDir, "secret-placeholder.key") : getSecretPlaceholderKeyPath();
const cached = cachedPlaceholderKeys.get(keyPath);
if (cached !== undefined) return cached;
@@ -32,7 +30,7 @@ export async function getSecretPlaceholderKey(keyDir: string = getAgentDir()): P
}
const generated = crypto.randomBytes(32).toString("base64url");
- await fs.promises.mkdir(keyDir, { recursive: true });
+ await fs.promises.mkdir(path.dirname(keyPath), { recursive: true });
try {
await fs.promises.writeFile(keyPath, generated, { flag: "wx", mode: 0o600 });
cachedPlaceholderKeys.set(keyPath, generated);
@@ -53,8 +51,8 @@ export async function getSecretPlaceholderKey(keyDir: string = getAgentDir()): P
}
/** Return an existing placeholder key for redaction without creating a new key file. */
-export async function getExistingSecretPlaceholderKey(keyDir: string = getAgentDir()): Promise {
- const keyPath = path.join(keyDir, "secret-placeholder.key");
+export async function getExistingSecretPlaceholderKey(keyDir?: string): Promise {
+ const keyPath = keyDir ? path.join(keyDir, "secret-placeholder.key") : getSecretPlaceholderKeyPath();
const cached = cachedPlaceholderKeys.get(keyPath);
if (cached !== undefined) return cached;
// Redaction-only: this key is loaded solely to redact an existing key file from
@@ -85,8 +83,8 @@ let ephemeralSyncPlaceholderKey: string | undefined;
* throws: an unreadable or unwritable key file degrades to a process-ephemeral
* key (with a warning) instead of breaking the session.
*/
-export function getSecretPlaceholderKeySync(keyDir: string = getAgentDir()): string {
- const keyPath = path.join(keyDir, "secret-placeholder.key");
+export function getSecretPlaceholderKeySync(keyDir?: string): string {
+ const keyPath = keyDir ? path.join(keyDir, "secret-placeholder.key") : getSecretPlaceholderKeyPath();
const cached = cachedPlaceholderKeys.get(keyPath);
if (cached !== undefined) return cached;
try {
@@ -100,7 +98,7 @@ export function getSecretPlaceholderKeySync(keyDir: string = getAgentDir()): str
}
const generated = crypto.randomBytes(32).toString("base64url");
try {
- fs.mkdirSync(keyDir, { recursive: true });
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
fs.writeFileSync(keyPath, generated, { flag: "wx", mode: 0o600 });
cachedPlaceholderKeys.set(keyPath, generated);
return generated;
diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md
index 27466e466..6740bb20c 100644
--- a/packages/utils/CHANGELOG.md
+++ b/packages/utils/CHANGELOG.md
@@ -2,6 +2,10 @@
## [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.
+
## [17.2.1] - 2026-07-30
### Added
diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts
index b3474ccca..9463f1271 100644
--- a/packages/utils/src/dirs.ts
+++ b/packages/utils/src/dirs.ts
@@ -820,6 +820,27 @@ 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). */
+export function getSecretPlaceholderKeyPath(): string {
+ return dirs.agentSubdir(undefined, "secret-placeholder.key", "state");
+}
+
+/** Get the daemon runtime directory for a project (~/.omp/run/daemons/; XDG default: $XDG_STATE_HOME/omp/run/daemons/). */
+export function getDaemonRuntimeDir(projectDir: string): string {
+ const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
+ return dirs.rootSubdir(path.join("run", "daemons", key), "state");
+}
+
+/** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
+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). */
+export function getMarketplacesRegistryPath(): string {
+ return dirs.rootSubdir("marketplaces.json", "data");
+}
+
// =============================================================================
// Project subdirectories (.omp/*)
// =============================================================================