fix(xdg): adopted legacy secret-placeholder.key and marketplaces.json at XDG paths
This commit is contained in:
+1
-1
@@ -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 |
|
||||
| ---- | -------------------------------------------- |
|
||||
|
||||
@@ -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/<hash>`) 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/<hash>`) 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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<string, string>): 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"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user