oh-my-codex 0.21.31 → 0.21.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/setup.js CHANGED
@@ -207,9 +207,14 @@ function getBackupContext(scope, projectRoot) {
207
207
  baseRoot: projectRoot,
208
208
  };
209
209
  }
210
+ // For user scope, keep backups under ~/.omx/backups (not under CODEX_HOME).
211
+ // The baseRoot is used for validation scope determination per destination:
212
+ // - Destinations under CODEX_HOME are validated against CODEX_HOME
213
+ // - Destinations under ~/.omx are validated against homedir() (or OMX_ROOT if set)
214
+ const homeDir = homedir();
210
215
  return {
211
- backupRoot: join(homedir(), ".omx", "backups", "setup", timestamp),
212
- baseRoot: homedir(),
216
+ backupRoot: join(homeDir, ".omx", "backups", "setup", timestamp),
217
+ baseRoot: homeDir,
213
218
  };
214
219
  }
215
220
  function logManagedCodexHooksPlanDiagnostics(diagnostics, options) {
@@ -1100,12 +1105,39 @@ async function cleanupNativeHookTransactionStagedDeletions(applied, ancestorPrec
1100
1105
  }
1101
1106
  }
1102
1107
  }
1108
+ /**
1109
+ * Sanitize an absolute path to be a valid relative path component for backups.
1110
+ * Handles Windows paths by removing drive letters, colons, and backslashes.
1111
+ * On Windows: C:\custom-codex\config.toml -> custom-codex_config.toml
1112
+ * On Unix: /custom-codex/config.toml -> custom-codex_config.toml
1113
+ */
1114
+ function sanitizePathForBackup(absPath) {
1115
+ // Remove the Windows drive letter (e.g., "C:"), then leading slashes and backslashes
1116
+ let sanitized = absPath.replace(/^[A-Za-z]:/, "").replace(/^[\\/]+/, "");
1117
+ // Remove any remaining backslashes and colons, replacing with underscores
1118
+ sanitized = sanitized.replace(/[\\/\\:]/g, "_");
1119
+ // Collapse multiple underscores
1120
+ sanitized = sanitized.replace(/_+/g, "_");
1121
+ return sanitized;
1122
+ }
1123
+ /**
1124
+ * Relative location of `targetPath` inside a backup root. Falls back to a sanitized
1125
+ * absolute path when the target is outside `baseRoot`, including Windows cross-drive
1126
+ * targets where `relative()` returns an absolute path instead of a `..` path.
1127
+ */
1128
+ export function backupRelativePath(baseRoot, targetPath, pathApi = { relative, isAbsolute }) {
1129
+ const relativePath = pathApi.relative(baseRoot, targetPath);
1130
+ if (relativePath === "" ||
1131
+ relativePath === ".." ||
1132
+ relativePath.startsWith("../") ||
1133
+ relativePath.startsWith("..\\") ||
1134
+ pathApi.isAbsolute(relativePath)) {
1135
+ return sanitizePathForBackup(targetPath);
1136
+ }
1137
+ return relativePath;
1138
+ }
1103
1139
  function nativeHookTransactionBackupPath(artifactPath, backupContext) {
1104
- const relativePath = relative(backupContext.baseRoot, artifactPath);
1105
- const safeRelativePath = relativePath.startsWith("..") || relativePath === ""
1106
- ? artifactPath.replace(/^[/]+/, "")
1107
- : relativePath;
1108
- return join(backupContext.backupRoot, safeRelativePath);
1140
+ return join(backupContext.backupRoot, backupRelativePath(backupContext.baseRoot, artifactPath));
1109
1141
  }
1110
1142
  async function ensureSnapshotBackup(artifact, backupContext, options, tracker) {
1111
1143
  const bytes = artifact.before.bytes;
@@ -1241,15 +1273,113 @@ async function commitNativeHookTransaction(artifacts, preconditions, ancestorPre
1241
1273
  throw new Error(`Native hook transaction failed and was rolled back: ${message}`);
1242
1274
  }
1243
1275
  }
1276
+ /**
1277
+ * Canonicalize a path to its real form using the deepest-existing-ancestor pattern.
1278
+ * Uses fs.realpath (follows symlinks) on the deepest existing component, then
1279
+ * re-appends any non-existent tail. This ensures consistent handling of OS-level
1280
+ * symlinks in parent directories (e.g., /tmp -> /private/tmp on macOS).
1281
+ * #4352912480: Handles MISSING scope roots by canonicalizing through deepest existing ancestor.
1282
+ */
1283
+ async function canonicalizePathToRealForm(absolutePath) {
1284
+ let realPath = absolutePath;
1285
+ let currentPath = absolutePath;
1286
+ while (currentPath && currentPath !== dirname(currentPath)) {
1287
+ try {
1288
+ // Check with lstat first to detect dangling symlinks before realpath (#4353639282)
1289
+ // If this component is a symlink (even dangling), try its parent instead of following it
1290
+ try {
1291
+ const stats = await lstat(currentPath);
1292
+ if (stats.isSymbolicLink()) {
1293
+ // This is a symlink; don't follow it, try the parent instead
1294
+ currentPath = dirname(currentPath);
1295
+ continue;
1296
+ }
1297
+ }
1298
+ catch (lstatError) {
1299
+ // lstat failed; this might be ENOENT, continue to realpath attempt
1300
+ }
1301
+ // Try to realpath the current component (follows all symlinks)
1302
+ realPath = await realpath(currentPath);
1303
+ // Successfully realpathhed; now append the remaining tail
1304
+ const tail = absolutePath.slice(currentPath.length);
1305
+ realPath = tail ? join(realPath, tail) : realPath;
1306
+ break;
1307
+ }
1308
+ catch (error) {
1309
+ if (isMissingPathError(error)) {
1310
+ // This component doesn't exist; try its parent
1311
+ currentPath = dirname(currentPath);
1312
+ if (currentPath === dirname(currentPath)) {
1313
+ // Reached the filesystem root; use absolute path as-is
1314
+ realPath = absolutePath;
1315
+ break;
1316
+ }
1317
+ }
1318
+ else {
1319
+ throw error;
1320
+ }
1321
+ }
1322
+ }
1323
+ return realPath;
1324
+ }
1325
+ /**
1326
+ * Try to find an alternative symlinked path that resolves to the canonical path.
1327
+ * Used for error messages to show symlinked paths when possible.
1328
+ */
1329
+ async function findSymlinkedAlternativePath(canonicalPath) {
1330
+ try {
1331
+ const parts = canonicalPath.split(sep).filter(Boolean);
1332
+ if (parts.length < 2)
1333
+ return null;
1334
+ // Walk up the canonical path, checking each ancestor for symlinks
1335
+ for (let i = 1; i < parts.length; i++) {
1336
+ const canonicalPrefix = sep + parts.slice(0, i).join(sep);
1337
+ const suffix = parts.slice(i).join(sep);
1338
+ try {
1339
+ const parentDir = dirname(canonicalPrefix);
1340
+ const baseName = basename(canonicalPrefix);
1341
+ // Check if there's a symlink with the same name at the same level
1342
+ try {
1343
+ const symlinkPath = join(parentDir, baseName);
1344
+ const stat = await lstat(symlinkPath);
1345
+ if (stat.isSymbolicLink()) {
1346
+ // Verify it points to the right place
1347
+ const target = await canonicalizePathToRealForm(symlinkPath);
1348
+ if (target === canonicalPrefix) {
1349
+ return join(symlinkPath, suffix);
1350
+ }
1351
+ }
1352
+ }
1353
+ catch {
1354
+ // Continue if this check fails
1355
+ }
1356
+ }
1357
+ catch {
1358
+ // Continue searching
1359
+ }
1360
+ }
1361
+ }
1362
+ catch {
1363
+ // If anything goes wrong, return null and use the canonical path
1364
+ }
1365
+ return null;
1366
+ }
1244
1367
  /**
1245
1368
  * Validates that a destination path is safe for managed setup operations (install, read, compare).
1246
1369
  * Refuses if the destination is a symlink or lies outside the specified scope root,
1247
1370
  * or if any ancestor component within the scope is a symlink.
1248
1371
  *
1249
1372
  * This guard prevents symlink attacks that would redirect reads, backups, and writes outside
1250
- * the managed tree. It validates every path component without following symlinks, but only
1251
- * checks ancestors within the scope root—never rejects OS-level symlinks above the managed
1252
- * scope (e.g., /var, /tmp on macOS which may be systemwide symlinks).
1373
+ * the managed tree. Uses a single algorithm that:
1374
+ * 1. Canonicalizes the scope root via deepest-existing-ancestor realpath (follows ALL symlinks)
1375
+ * 2. Walks prefixes of the LEXICAL absolute destination from shortest to longest
1376
+ * 3. Finds the FIRST prefix P whose deepest-existing-ancestor canonical form equals canonicalScope
1377
+ * 4. For each lexical component after P down to destination, lstat it:
1378
+ * - symlink (including dangling) → throw error
1379
+ * - ENOENT → stop (rest is missing, fine)
1380
+ *
1381
+ * This handles cases like /tmp -> /private/tmp on macOS where scopeRoot and destination
1382
+ * may be in different lexical forms but resolve to the same canonical path.
1253
1383
  *
1254
1384
  * MUST be called BEFORE reading/comparing contents and BEFORE mkdir of any intermediate dir.
1255
1385
  * It is the single authoritative source of symlink validation for all managed scope writes.
@@ -1260,78 +1390,134 @@ async function commitNativeHookTransaction(artifacts, preconditions, ancestorPre
1260
1390
  * - 4351560195: respects CODEX_HOME via codexHome() ✓
1261
1391
  * - 4351560207: validate before compare ✓
1262
1392
  * - 4351560219: validate before mkdir ✓
1393
+ * - 4352912480: canonicalizes through OS-level symlinks in ancestors ✓
1394
+ * - 4352912502: validates against override root ✓
1263
1395
  */
1264
1396
  async function assertManagedDestination(destinationPath, scopeRoot) {
1265
1397
  // Resolve paths to absolute forms
1266
1398
  const absolutePath = isAbsolute(destinationPath) ? destinationPath : join(process.cwd(), destinationPath);
1267
1399
  const absoluteScopeRoot = isAbsolute(scopeRoot) ? scopeRoot : join(process.cwd(), scopeRoot);
1268
- // Resolve scope root to its real path (following all symlinks) to properly handle
1269
- // OS-level symlinks like /tmp -> /private/tmp on macOS. This allows us to detect when
1270
- // a destination lies outside the real managed scope.
1271
- let realScopeRoot;
1272
- try {
1273
- realScopeRoot = await realpath(absoluteScopeRoot);
1274
- }
1275
- catch (error) {
1276
- if (isMissingPathError(error)) {
1277
- // Scope root doesn't exist yet; use absolute path
1278
- realScopeRoot = absoluteScopeRoot;
1279
- }
1280
- else {
1281
- throw error;
1400
+ // Step 1: Canonicalize the scope root via deepest-existing-ancestor realpath.
1401
+ const canonicalScope = await canonicalizePathToRealForm(absoluteScopeRoot);
1402
+ // Step 2 & 3: Walk lexical prefixes of destination to find the matching scope point.
1403
+ // Build the list of prefixes from root downward.
1404
+ const prefixes = [];
1405
+ let currentPrefix = absolutePath;
1406
+ while (true) {
1407
+ prefixes.unshift(currentPrefix);
1408
+ if (currentPrefix === sep)
1409
+ break;
1410
+ const parent = dirname(currentPrefix);
1411
+ if (parent === currentPrefix) {
1412
+ // Shouldn't happen, but break to avoid infinite loop
1413
+ break;
1282
1414
  }
1415
+ currentPrefix = parent;
1283
1416
  }
1284
- // Verify destination is within the managed scope by checking relative path.
1285
- // Using realScopeRoot ensures we detect when symlinks in ancestors would escape the scope.
1286
- const relFromScope = relative(realScopeRoot, absolutePath);
1287
- if (relFromScope === '..' || relFromScope.startsWith(`..${sep}`)) {
1288
- throw new Error(`Destination ${absolutePath} is outside managed scope ${realScopeRoot}.`);
1289
- }
1290
- // Walk from destination up to and including the scope root, checking each component
1291
- // for symlinks. We do NOT resolve symlinks while walking (to catch them).
1292
- // Stop at the scope root (don't check parents of the scope root).
1293
- let currentPath = absolutePath;
1294
- const visited = new Set();
1295
- while (currentPath && currentPath !== dirname(currentPath)) {
1296
- if (visited.has(currentPath))
1297
- break; // Avoid infinite loops
1298
- visited.add(currentPath);
1299
- // Stop before checking ancestors beyond the scope root
1300
- if (currentPath === realScopeRoot)
1417
+ let matchingPrefixPath = '';
1418
+ for (const prefix of prefixes) {
1419
+ const canonicalPrefix = await canonicalizePathToRealForm(prefix);
1420
+ if (canonicalPrefix === canonicalScope) {
1421
+ matchingPrefixPath = prefix;
1301
1422
  break;
1302
- // Check if this path component is a symlink (using lstat, not stat)
1303
- let stats;
1304
- try {
1305
- stats = await lstat(currentPath);
1306
1423
  }
1307
- catch (error) {
1308
- if (isMissingPathError(error)) {
1309
- // Path doesn't exist yet, move to parent
1310
- currentPath = dirname(currentPath);
1311
- continue;
1424
+ }
1425
+ if (!matchingPrefixPath) {
1426
+ // No prefix matched the scope; destination is outside managed scope
1427
+ throw new Error(`Destination ${absolutePath} is outside managed scope ${absoluteScopeRoot}.`);
1428
+ }
1429
+ // Step 4: For each component lexically after the matching prefix, check with lstat.
1430
+ // Extract the remaining path components after the matching prefix.
1431
+ let current = matchingPrefixPath;
1432
+ const remaining = relative(current, absolutePath);
1433
+ if (remaining && remaining !== '.') {
1434
+ const remainingComponents = remaining.split(sep);
1435
+ let currentForDisplay = matchingPrefixPath;
1436
+ for (const component of remainingComponents) {
1437
+ current = join(current, component);
1438
+ currentForDisplay = join(currentForDisplay, component);
1439
+ try {
1440
+ const stats = await lstat(current);
1441
+ if (stats.isSymbolicLink()) {
1442
+ // Try to find a symlinked alternative path for the error message
1443
+ let displayPath = current;
1444
+ const altPath = await findSymlinkedAlternativePath(current);
1445
+ if (altPath) {
1446
+ displayPath = altPath;
1447
+ }
1448
+ const msg = `Refusing to install through symlink ${displayPath}. ` +
1449
+ `Either remove the symlink (rm ${displayPath}), ` +
1450
+ `or move the target to the expected location before running setup again.`;
1451
+ throw new Error(msg);
1452
+ }
1453
+ }
1454
+ catch (error) {
1455
+ // If component doesn't exist yet, that's fine; we'll create it during mkdir
1456
+ if (isMissingPathError(error)) {
1457
+ // Stop checking further components; rest of path is missing
1458
+ break;
1459
+ }
1460
+ // Re-throw symlink errors and other unexpected errors
1461
+ if (error instanceof Error && error.message.includes('Refusing to install through symlink')) {
1462
+ throw error;
1463
+ }
1464
+ throw error;
1312
1465
  }
1313
- throw error;
1314
- }
1315
- if (stats.isSymbolicLink()) {
1316
- const msg = `Refusing to install through symlink ${currentPath}. ` +
1317
- `Either remove the symlink (rm ${currentPath}), ` +
1318
- `or move the target to the expected location before running setup again.`;
1319
- throw new Error(msg);
1320
1466
  }
1321
- currentPath = dirname(currentPath);
1322
1467
  }
1323
1468
  }
1469
+ /**
1470
+ * Determine the validation scope root for a destination based on its location.
1471
+ * Canonicalizes paths to handle OS-level symlinks (e.g., /tmp -> /private/tmp on macOS).
1472
+ * For project scope: validates against projectRoot (#4352912487)
1473
+ * Destinations under CODEX_HOME validate against CODEX_HOME;
1474
+ * Destinations under OMX_ROOT (if set) validate against OMX_ROOT (#4352912502);
1475
+ * other paths (like ~/.omx, ~/.claude) validate against the user's home.
1476
+ */
1477
+ async function getValidationScopeRoot(destinationPath) {
1478
+ const absPath = isAbsolute(destinationPath) ? destinationPath : join(process.cwd(), destinationPath);
1479
+ const projectRoot = process.cwd();
1480
+ const codexHomeDir = codexHome();
1481
+ const projectCodexHome = join(projectRoot, ".codex");
1482
+ // Canonicalize both paths to handle symlinks consistently
1483
+ const realDestPath = await canonicalizePathToRealForm(absPath);
1484
+ const realCodexHome = await canonicalizePathToRealForm(isAbsolute(codexHomeDir) ? codexHomeDir : join(process.cwd(), codexHomeDir));
1485
+ const realProjectCodexHome = await canonicalizePathToRealForm(projectCodexHome);
1486
+ const realProjectRoot = await canonicalizePathToRealForm(projectRoot);
1487
+ // Check OMX_ROOT override first (#4352912502)
1488
+ const omxRoot = process.env.OMX_ROOT;
1489
+ if (omxRoot) {
1490
+ const realOmxRoot = await canonicalizePathToRealForm(isAbsolute(omxRoot) ? omxRoot : join(process.cwd(), omxRoot));
1491
+ if (realDestPath.startsWith(realOmxRoot + sep) || realDestPath === realOmxRoot) {
1492
+ return realOmxRoot; // Return the canonical OMX_ROOT for consistent scope comparison
1493
+ }
1494
+ }
1495
+ // Check if destination is under project root or project .codex (#4352912487)
1496
+ // This indicates project scope files that should validate against projectRoot
1497
+ if (realDestPath.startsWith(realProjectRoot + sep) || realDestPath === realProjectRoot ||
1498
+ realDestPath.startsWith(realProjectCodexHome + sep) || realDestPath === realProjectCodexHome) {
1499
+ // If the destination is under the project root but NOT under user's CODEX_HOME,
1500
+ // it's a project-scoped file that should validate against projectRoot
1501
+ if (!realDestPath.startsWith(realCodexHome + sep) && realDestPath !== realCodexHome) {
1502
+ return realProjectRoot; // Return canonical project root
1503
+ }
1504
+ }
1505
+ // Check if the destination is under CODEX_HOME (using real paths for comparison)
1506
+ if (realDestPath.startsWith(realCodexHome + sep) || realDestPath === realCodexHome) {
1507
+ return realCodexHome; // Return the canonical CODEX_HOME for consistent scope comparison
1508
+ }
1509
+ // Otherwise, it's validated against the home directory
1510
+ return await canonicalizePathToRealForm(homedir());
1511
+ }
1324
1512
  async function ensureBackup(destinationPath, contentChanged, backupContext, options) {
1325
- await assertManagedDestination(destinationPath, backupContext.baseRoot);
1513
+ const scopeRoot = await getValidationScopeRoot(destinationPath);
1514
+ await assertManagedDestination(destinationPath, scopeRoot);
1326
1515
  if (!contentChanged || !existsSync(destinationPath))
1327
1516
  return false;
1328
- const relativePath = relative(backupContext.baseRoot, destinationPath);
1329
- const safeRelativePath = relativePath.startsWith("..") || relativePath === ""
1330
- ? destinationPath.replace(/^[/]+/, "")
1331
- : relativePath;
1332
- const backupPath = join(backupContext.backupRoot, safeRelativePath);
1517
+ const backupPath = join(backupContext.backupRoot, backupRelativePath(backupContext.baseRoot, destinationPath));
1333
1518
  if (!options.dryRun) {
1334
- await assertManagedDestination(backupPath, backupContext.baseRoot);
1519
+ const backupScopeRoot = await getValidationScopeRoot(backupPath);
1520
+ await assertManagedDestination(backupPath, backupScopeRoot);
1335
1521
  await mkdir(dirname(backupPath), { recursive: true });
1336
1522
  await copyFile(destinationPath, backupPath);
1337
1523
  }
@@ -1436,28 +1622,55 @@ export async function validateSkillFile(skillMdPath) {
1436
1622
  }
1437
1623
  const INSTALLED_SKILL_BADGE_PREFIX = "[OMX] ";
1438
1624
  function installedSkillReceiptPath(scope, projectRoot) {
1439
- const authorityRoot = scope === "project" ? projectRoot : homedir();
1625
+ // For user scope, use CODEX_HOME as the managed authority root, not the user's home directory.
1626
+ const authorityRoot = scope === "project" ? projectRoot : codexHome();
1440
1627
  return join(authorityRoot, ".omx", "state", "setup", "installed-skills.json");
1441
1628
  }
1442
- async function readInstalledSkillReceipt(receiptPath) {
1443
- try {
1444
- const parsed = JSON.parse(await readFile(receiptPath, "utf-8"));
1445
- if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
1446
- // Re-key through a prototype-less record so a `__proto__` entry in a persisted receipt is a
1447
- // real own property on both sides of the comparison.
1448
- const skills = Object.create(null);
1449
- for (const [name, entry] of Object.entries(parsed.skills)) {
1450
- const files = Object.create(null);
1451
- for (const [file, digest] of Object.entries(entry?.files ?? {}))
1452
- files[file] = String(digest);
1453
- skills[name] = { files };
1629
+ async function readInstalledSkillReceipt(receiptPath, legacyReceiptPath) {
1630
+ const parseReceipt = (content) => {
1631
+ try {
1632
+ const parsed = JSON.parse(content);
1633
+ if (parsed?.version === 1 && parsed.skills && typeof parsed.skills === "object") {
1634
+ // Re-key through a prototype-less record so a `__proto__` entry in a persisted receipt is a
1635
+ // real own property on both sides of the comparison.
1636
+ const skills = Object.create(null);
1637
+ for (const [name, entry] of Object.entries(parsed.skills)) {
1638
+ const files = Object.create(null);
1639
+ for (const [file, digest] of Object.entries(entry?.files ?? {}))
1640
+ files[file] = String(digest);
1641
+ skills[name] = { files };
1642
+ }
1643
+ return { version: 1, skills };
1454
1644
  }
1455
- return { version: 1, skills };
1456
1645
  }
1646
+ catch {
1647
+ // Invalid or malformed receipt
1648
+ }
1649
+ return null;
1650
+ };
1651
+ // Try reading from the new receipt path first
1652
+ try {
1653
+ const content = await readFile(receiptPath, "utf-8");
1654
+ const parsed = parseReceipt(content);
1655
+ if (parsed)
1656
+ return parsed;
1457
1657
  }
1458
1658
  catch {
1459
- // A missing or unreadable receipt means "no proof of ownership", which retains conservatively.
1659
+ // New receipt does not exist or is unreadable; try legacy path
1460
1660
  }
1661
+ // Try reading from the legacy receipt path if provided
1662
+ if (legacyReceiptPath) {
1663
+ try {
1664
+ const content = await readFile(legacyReceiptPath, "utf-8");
1665
+ const parsed = parseReceipt(content);
1666
+ if (parsed)
1667
+ return parsed;
1668
+ }
1669
+ catch {
1670
+ // Legacy receipt does not exist or is unreadable
1671
+ }
1672
+ }
1673
+ // No valid receipt found; return empty receipt
1461
1674
  return { version: 1, skills: Object.create(null) };
1462
1675
  }
1463
1676
  /**
@@ -1491,8 +1704,8 @@ async function digestSkillDirectory(skillDir) {
1491
1704
  return files;
1492
1705
  }
1493
1706
  /** True only when every current file matches the digest OMX recorded at install time. */
1494
- async function isUnmodifiedRecordedInstall(receiptPath, skillName, skillDir) {
1495
- const receipt = await readInstalledSkillReceipt(receiptPath);
1707
+ async function isUnmodifiedRecordedInstall(receiptPath, skillName, skillDir, legacyReceiptPath) {
1708
+ const receipt = await readInstalledSkillReceipt(receiptPath, legacyReceiptPath);
1496
1709
  const recorded = receipt.skills[skillName]?.files;
1497
1710
  if (!recorded || Object.keys(recorded).length === 0)
1498
1711
  return false;
@@ -2649,7 +2862,8 @@ export async function setup(options = {}) {
2649
2862
  if (!dryRun) {
2650
2863
  const omxPath = join(projectRoot, ".omx");
2651
2864
  try {
2652
- await assertManagedDestination(omxPath, projectRoot);
2865
+ const omxScopeRoot = await getValidationScopeRoot(omxPath);
2866
+ await assertManagedDestination(omxPath, omxScopeRoot);
2653
2867
  }
2654
2868
  catch (error) {
2655
2869
  // Missing .omx is fine, but symlinks must be refused
@@ -2770,6 +2984,8 @@ export async function setup(options = {}) {
2770
2984
  const scopeSourceMessage = resolvedScope.source === "persisted" ? " (from .omx/setup-scope.json)" : "";
2771
2985
  const backupContext = getBackupContext(resolvedScope.scope, projectRoot);
2772
2986
  const skillReceiptPath = installedSkillReceiptPath(resolvedScope.scope, projectRoot);
2987
+ // For user scope, compute the legacy receipt path at $HOME/.omx/state/setup/installed-skills.json
2988
+ const legacySkillReceiptPath = resolvedScope.scope === "user" ? join(homedir(), ".omx", "state", "setup", "installed-skills.json") : undefined;
2773
2989
  const isPluginInstallMode = resolvedInstallMode?.installMode === "plugin";
2774
2990
  if (repairPluginCache && !isPluginInstallMode) {
2775
2991
  throw new Error("--repair-plugin-cache requires plugin setup mode; add --plugin.");
@@ -2895,19 +3111,23 @@ export async function setup(options = {}) {
2895
3111
  ? [scopeDirs.codexHomeDir, scopeDirs.nativeAgentsDir]
2896
3112
  : [scopeDirs.codexHomeDir, scopeDirs.promptsDir, scopeDirs.skillsDir, scopeDirs.nativeAgentsDir];
2897
3113
  const projectScopeDirs = [omxStateDir(projectRoot), omxPlansDir(projectRoot), omxLogsDir(projectRoot)];
2898
- // Validate and create user-scope directories against user authority root
3114
+ // Validate and create user-scope directories against appropriate authority root
3115
+ // (CODEX_HOME for files under CODEX_HOME, homedir() for ~/.omx and other paths)
2899
3116
  for (const dir of userScopeDirs) {
2900
3117
  if (!dryRun) {
2901
- await assertManagedDestination(dir, backupContext.baseRoot);
3118
+ const dirScopeRoot = await getValidationScopeRoot(dir);
3119
+ await assertManagedDestination(dir, dirScopeRoot);
2902
3120
  await mkdir(dir, { recursive: true });
2903
3121
  }
2904
3122
  if (verbose)
2905
3123
  console.log(` mkdir ${dir}`);
2906
3124
  }
2907
- // Validate and create project-scope directories against project authority root
3125
+ // Validate and create project-scope directories against appropriate authority root
3126
+ // (projectRoot for normal files, OMX_ROOT if set for .omx/* paths)
2908
3127
  for (const dir of projectScopeDirs) {
2909
3128
  if (!dryRun) {
2910
- await assertManagedDestination(dir, projectRoot);
3129
+ const dirScopeRoot = await getValidationScopeRoot(dir);
3130
+ await assertManagedDestination(dir, dirScopeRoot);
2911
3131
  await mkdir(dir, { recursive: true });
2912
3132
  }
2913
3133
  if (verbose)
@@ -2978,7 +3198,7 @@ export async function setup(options = {}) {
2978
3198
  const skillsDst = scopeDirs.skillsDir;
2979
3199
  if (isPluginInstallMode) {
2980
3200
  summary.skills = createEmptyCategorySummary();
2981
- const cleanup = await cleanupLegacyManagedSkills(skillsSrc, skillsDst, backupContext, { dryRun, verbose, skillReceiptPath });
3201
+ const cleanup = await cleanupLegacyManagedSkills(skillsSrc, skillsDst, backupContext, { dryRun, verbose, skillReceiptPath, legacySkillReceiptPath });
2982
3202
  summary.skills.backedUp += cleanup.backedUp;
2983
3203
  summary.skills.removed += cleanup.removedSkillNames.length;
2984
3204
  summary.skills.skipped += cleanup.skippedSkillNames.length;
@@ -3413,6 +3633,7 @@ export async function setup(options = {}) {
3413
3633
  if (force || !existsSync(hudConfigPath)) {
3414
3634
  if (!dryRun) {
3415
3635
  const defaultHudConfig = { preset: "focused" };
3636
+ await mkdir(dirname(hudConfigPath), { recursive: true });
3416
3637
  await writeFile(hudConfigPath, JSON.stringify(defaultHudConfig, null, 2));
3417
3638
  }
3418
3639
  if (verbose)
@@ -3520,7 +3741,8 @@ function isGitHubCliConfigured() {
3520
3741
  return cachedGitHubCliConfigured;
3521
3742
  }
3522
3743
  async function syncManagedFileFromDisk(srcPath, dstPath, summary, backupContext, options, verboseLabel) {
3523
- await assertManagedDestination(dstPath, backupContext.baseRoot);
3744
+ const dstScopeRoot = await getValidationScopeRoot(dstPath);
3745
+ await assertManagedDestination(dstPath, dstScopeRoot);
3524
3746
  const destinationExists = existsSync(dstPath);
3525
3747
  const changed = !destinationExists || (await filesDiffer(srcPath, dstPath));
3526
3748
  if (!changed) {
@@ -3540,7 +3762,8 @@ async function syncManagedFileFromDisk(srcPath, dstPath, summary, backupContext,
3540
3762
  }
3541
3763
  }
3542
3764
  async function syncManagedContent(content, dstPath, summary, backupContext, options, verboseLabel) {
3543
- await assertManagedDestination(dstPath, backupContext.baseRoot);
3765
+ const dstScopeRoot = await getValidationScopeRoot(dstPath);
3766
+ await assertManagedDestination(dstPath, dstScopeRoot);
3544
3767
  const destinationExists = existsSync(dstPath);
3545
3768
  let changed = true;
3546
3769
  if (destinationExists) {
@@ -3607,7 +3830,8 @@ async function writeNativeAgentInstallManifest(agentsDir, manifest, scopeRoot) {
3607
3830
  async function syncNativeAgentToml(content, dstPath, summary, backupContext, options, verboseLabel, manifest) {
3608
3831
  const fileName = basename(dstPath);
3609
3832
  const nextHash = hashContent(content);
3610
- await assertManagedDestination(dstPath, backupContext.baseRoot);
3833
+ const dstScopeRoot = await getValidationScopeRoot(dstPath);
3834
+ await assertManagedDestination(dstPath, dstScopeRoot);
3611
3835
  const destinationExists = existsSync(dstPath);
3612
3836
  if (!destinationExists) {
3613
3837
  if (!options.dryRun) {
@@ -3827,7 +4051,8 @@ async function cleanupGeneratedNonInstallableNativeAgents(agentsDir, manifest, b
3827
4051
  async function refreshNativeAgentConfigs(pkgRoot, agentsDir, backupContext, options) {
3828
4052
  const summary = createEmptyCategorySummary();
3829
4053
  if (!options.dryRun) {
3830
- await assertManagedDestination(agentsDir, backupContext.baseRoot);
4054
+ const agentsDirScopeRoot = await getValidationScopeRoot(agentsDir);
4055
+ await assertManagedDestination(agentsDir, agentsDirScopeRoot);
3831
4056
  await mkdir(agentsDir, { recursive: true });
3832
4057
  }
3833
4058
  const nativeAgentManifest = await readNativeAgentInstallManifest(agentsDir);
@@ -3910,7 +4135,8 @@ async function refreshNativeAgentConfigs(pkgRoot, agentsDir, backupContext, opti
3910
4135
  }
3911
4136
  }
3912
4137
  if (!options.dryRun) {
3913
- await writeNativeAgentInstallManifest(agentsDir, nativeAgentManifest, backupContext.baseRoot);
4138
+ const manifestScopeRoot = await getValidationScopeRoot(join(agentsDir, "..", ".omx", "native-agents.json"));
4139
+ await writeNativeAgentInstallManifest(agentsDir, nativeAgentManifest, manifestScopeRoot);
3914
4140
  }
3915
4141
  return summary;
3916
4142
  }
@@ -3962,7 +4188,8 @@ export async function installSkills(srcDir, dstDir, backupContext, options) {
3962
4188
  return summary;
3963
4189
  // Validate the skills directory before creating any skill subdirectories
3964
4190
  if (!options.dryRun) {
3965
- await assertManagedDestination(dstDir, backupContext.baseRoot);
4191
+ const dstDirScopeRoot = await getValidationScopeRoot(dstDir);
4192
+ await assertManagedDestination(dstDir, dstDirScopeRoot);
3966
4193
  }
3967
4194
  const installableSkillNames = getSetupInstallableSkillNames();
3968
4195
  const installableSkills = [];
@@ -4020,7 +4247,8 @@ export async function installSkills(srcDir, dstDir, backupContext, options) {
4020
4247
  const skillSrc = skill.sourceDir;
4021
4248
  const skillDst = skill.destinationDir;
4022
4249
  if (!options.dryRun) {
4023
- await assertManagedDestination(skillDst, backupContext.baseRoot);
4250
+ const skillDstScopeRoot = await getValidationScopeRoot(skillDst);
4251
+ await assertManagedDestination(skillDst, skillDstScopeRoot);
4024
4252
  await mkdir(skillDst, { recursive: true });
4025
4253
  }
4026
4254
  const skillFiles = await readdir(skillSrc);
@@ -4044,7 +4272,8 @@ export async function installSkills(srcDir, dstDir, backupContext, options) {
4044
4272
  // Without an authority path, refuse to create a receipt rather than placing deletion authority
4045
4273
  // in the user-writable skills directory.
4046
4274
  if (options.skillReceiptPath) {
4047
- await writeInstalledSkillReceipt(options.skillReceiptPath, dstDir, writtenFilesBySkill, options, backupContext.baseRoot);
4275
+ const receiptScopeRoot = await getValidationScopeRoot(options.skillReceiptPath);
4276
+ await writeInstalledSkillReceipt(options.skillReceiptPath, dstDir, writtenFilesBySkill, options, receiptScopeRoot);
4048
4277
  }
4049
4278
  if (manifest && existsSync(dstDir)) {
4050
4279
  for (const staleSkill of staleCandidateSkillNames) {
@@ -4098,11 +4327,7 @@ async function removeDirectoryCopyAware(sourceDir, backupContext, options) {
4098
4327
  const destinationExists = existsSync(sourceDir);
4099
4328
  if (!destinationExists)
4100
4329
  return false;
4101
- const relativePath = relative(backupContext.baseRoot, sourceDir);
4102
- const safeRelativePath = relativePath.startsWith("..") || relativePath === ""
4103
- ? sourceDir.replace(/^[/]+/, "")
4104
- : relativePath;
4105
- const backupPath = join(backupContext.backupRoot, safeRelativePath);
4330
+ const backupPath = join(backupContext.backupRoot, backupRelativePath(backupContext.baseRoot, sourceDir));
4106
4331
  if (!options.dryRun) {
4107
4332
  await mkdir(dirname(backupPath), { recursive: true });
4108
4333
  await cp(sourceDir, backupPath, { recursive: true });
@@ -4148,7 +4373,7 @@ async function cleanupLegacyManagedSkills(srcDir, dstDir, backupContext, options
4148
4373
  // A matching SKILL.md does not prove the whole directory is ours: a user file sitting beside it
4149
4374
  // would be deleted with the directory. Require the same unmodified-install proof the ordinary
4150
4375
  // retirement sweep uses.
4151
- if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, skillName, installedSkillDir))) {
4376
+ if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, skillName, installedSkillDir, options.legacySkillReceiptPath))) {
4152
4377
  const warning = `Skipping legacy skill cleanup for ${skillName}: directory contents are not a receipted unmodified OMX install.`;
4153
4378
  result.skippedSkillNames.push(skillName);
4154
4379
  result.warnings.push(warning);
@@ -4171,7 +4396,7 @@ async function cleanupLegacyManagedSkills(srcDir, dstDir, backupContext, options
4171
4396
  if (!(await isOmxManagedInstalledSkillDir(installedSkillDir)))
4172
4397
  continue;
4173
4398
  // The badge alone is not proof of ownership for deletion; a user-edited body keeps it.
4174
- if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, entry.name, installedSkillDir))) {
4399
+ if (!options.skillReceiptPath || !(await isUnmodifiedRecordedInstall(options.skillReceiptPath, entry.name, installedSkillDir, options.legacySkillReceiptPath))) {
4175
4400
  result.skippedSkillNames.push(entry.name);
4176
4401
  result.warnings.push(`Skipping retired skill ${entry.name}: modified since install, or installed before receipts existed.`);
4177
4402
  continue;