fix(auth): guard shared linking against instance replacement

This commit is contained in:
Tam Nhu Tran committed 2026-08-08 23:06:29 -04:00
1 parent 5f9db033e7
commit 54efb82055
4 files changed
+342 -138

No files matched your search

+34 -1
View File
@@ -15,7 +15,7 @@ function unsafeInstancePath(targetPath: string): never {
});
}
interface DirectoryIdentity {
export interface DirectoryIdentity {
device: number;
inode: number;
realPath: string;
@@ -115,6 +115,39 @@ export function assertSafeAccountInstancePath(instancesDir: string, instancePath
if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath);
}
/**
* Capture the validated directory identity used by later mutation guards.
* This is defense-in-depth for static and ordinary concurrent replacement,
* not a hostile same-UID race boundary; that would require dirfd/openat-style APIs.
*/
export function captureSafeAccountInstanceIdentity(
instancesDir: string,
instancePath: string
): DirectoryIdentity {
assertSafeAccountInstancePath(instancesDir, instancePath);
const identity = readDirectoryIdentity(instancePath);
assertAccountInstanceIdentity(instancesDir, instancePath, identity);
return identity;
}
/** Require the lexical instance path to still name the validated directory. */
export function assertAccountInstanceIdentity(
instancesDir: string,
instancePath: string,
expectedIdentity: DirectoryIdentity
): void {
if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath);
let currentIdentity: DirectoryIdentity;
try {
currentIdentity = readDirectoryIdentity(instancePath);
} catch {
unsafeInstancePath(instancePath);
}
if (!identitiesMatch(expectedIdentity, currentIdentity)) unsafeInstancePath(instancePath);
}
export function listAccountInstanceNames(instancesDir: string): string[] {
if (!fs.existsSync(instancesDir)) {
return [];
@@ -39,6 +39,18 @@ import type { SharedItem } from './types';
*/
export type PluginLayoutRoots = PluginMetadataRoots;
function runGuardedInstanceMutation<T>(
assertInstanceIdentity: (() => void) | undefined,
operation: () => T
): T {
assertInstanceIdentity?.();
try {
return operation();
} finally {
assertInstanceIdentity?.();
}
}
/**
* Ensure the plugin layout default directories (cache, marketplaces) and
* registry files (installed_plugins.json, known_marketplaces.json) exist
@@ -75,7 +87,11 @@ export function ensureSharedPluginLayoutDefaults(claudeDir: string): void {
* Link shared plugins directory entries into an instance, creating the
* instance plugins directory first if needed.
*/
export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: string): void {
export function linkInstancePlugins(
roots: PluginLayoutRoots,
instancePath: string,
assertInstanceIdentity?: () => void
): void {
const linkPath = path.join(instancePath, 'plugins');
const targetPath = path.join(roots.sharedDir, 'plugins');
let linkStats: fs.Stats | null = null;
@@ -87,30 +103,42 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
throw err;
}
}
assertInstanceIdentity?.();
if (linkStats?.isSymbolicLink() || (linkStats && !linkStats.isDirectory())) {
removeExistingPath(linkPath, linkStats.isDirectory() ? 'directory' : 'file');
const existingType = linkStats.isDirectory() ? 'directory' : 'file';
runGuardedInstanceMutation(assertInstanceIdentity, () =>
removeExistingPath(linkPath, existingType)
);
}
if (!linkStats || !linkStats.isDirectory()) {
fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 });
runGuardedInstanceMutation(assertInstanceIdentity, () =>
fs.mkdirSync(linkPath, { recursive: true, mode: 0o700 })
);
}
for (const item of getSharedPluginLinkItems(roots.sharedDir)) {
const linkItems = getSharedPluginLinkItems(roots.sharedDir);
assertInstanceIdentity?.();
for (const item of linkItems) {
const targetEntryPath = path.join(targetPath, item.name);
const linkEntryPath = path.join(linkPath, item.name);
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
if (item.type === 'file') {
adoption = adoptDivergedFileContent(
linkEntryPath,
path.join(roots.claudeDir, 'plugins', item.name)
adoption = runGuardedInstanceMutation(assertInstanceIdentity, () =>
adoptDivergedFileContent(linkEntryPath, path.join(roots.claudeDir, 'plugins', item.name))
);
if (adoption === 'not-claimed') {
removeExistingPath(linkEntryPath, item.type);
runGuardedInstanceMutation(assertInstanceIdentity, () =>
removeExistingPath(linkEntryPath, item.type)
);
}
} else {
removeExistingPath(linkEntryPath, item.type);
runGuardedInstanceMutation(assertInstanceIdentity, () =>
removeExistingPath(linkEntryPath, item.type)
);
}
assertAdoptionPathAbsent(linkEntryPath, adoption);
@@ -119,10 +147,12 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
continue;
}
assertInstanceIdentity?.();
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetEntryPath, linkEntryPath, symlinkType);
} catch (_err) {
assertInstanceIdentity?.();
assertAdoptionPathAbsent(linkEntryPath, adoption);
if (getLstatSync(linkEntryPath)) {
console.log(warn(`Skipping plugins/${item.name}: path reappeared during reconciliation`));
@@ -130,9 +160,13 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
}
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(targetEntryPath, linkEntryPath);
runGuardedInstanceMutation(assertInstanceIdentity, () =>
copyDirectoryFallback(targetEntryPath, linkEntryPath)
);
} else {
fs.copyFileSync(targetEntryPath, linkEntryPath, fs.constants.COPYFILE_EXCL);
runGuardedInstanceMutation(assertInstanceIdentity, () =>
fs.copyFileSync(targetEntryPath, linkEntryPath, fs.constants.COPYFILE_EXCL)
);
}
console.log(
warn(`Symlink failed for plugins/${item.name}, copied instead (enable Developer Mode)`)
@@ -141,6 +175,7 @@ export function linkInstancePlugins(roots: PluginLayoutRoots, instancePath: stri
throw _err;
}
}
assertInstanceIdentity?.();
}
}
+158 -126
View File
@@ -18,7 +18,10 @@ import * as fs from 'fs';
import * as path from 'path';
import { info, warn } from '../../utils/ui';
import { isSafeAccountInstancePath } from '../instance-directory';
import {
assertAccountInstanceIdentity,
captureSafeAccountInstanceIdentity,
} from '../instance-directory';
import {
adoptDivergedFileContent,
assertAdoptionPathAbsent,
@@ -54,6 +57,18 @@ import { SHARED_ITEMS } from './types';
*/
export type LinkerRoots = PluginMetadataRoots;
function runInstanceMutationStage<T>(
assertInstanceIdentity: (() => void) | undefined,
operation: () => T
): T {
assertInstanceIdentity?.();
try {
return operation();
} finally {
assertInstanceIdentity?.();
}
}
/**
* Detect a circular symlink before creation. A symlink is circular when its
* target (raw or canonical) points back inside the shared root.
@@ -102,104 +117,115 @@ export function detectCircularSymlink(target: string, sharedDir: string): boolea
* Ensure shared directories exist as symlinks to ~/.claude/ and that the
* plugin layout default directories and registry files are present.
*/
export function ensureSharedDirectories(roots: LinkerRoots): void {
export function ensureSharedDirectories(
roots: LinkerRoots,
assertInstanceIdentity?: () => void
): void {
const claudeDir = roots.claudeDir;
const sharedDir = roots.sharedDir;
if (!getLstatSync(claudeDir)) {
console.log(info('Creating ~/.claude/ directory structure'));
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
}
runInstanceMutationStage(assertInstanceIdentity, () => {
if (!getLstatSync(claudeDir)) {
console.log(info('Creating ~/.claude/ directory structure'));
fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
}
});
if (!getLstatSync(sharedDir)) {
fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 });
}
runInstanceMutationStage(assertInstanceIdentity, () => {
if (!getLstatSync(sharedDir)) {
fs.mkdirSync(sharedDir, { recursive: true, mode: 0o700 });
}
});
ensureSharedPluginLayoutDefaults(claudeDir);
runInstanceMutationStage(assertInstanceIdentity, () =>
ensureSharedPluginLayoutDefaults(claudeDir)
);
for (const item of SHARED_ITEMS) {
const claudePath = path.join(claudeDir, item.name);
const sharedPath = path.join(sharedDir, item.name);
runInstanceMutationStage(assertInstanceIdentity, () => {
const claudePath = path.join(claudeDir, item.name);
const sharedPath = path.join(sharedDir, item.name);
if (item.type === 'file') {
recoverOrphanedCanonicalClaim(claudePath);
}
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
reconcileCanonicalFirstFile(roots, item.name);
}
if (!getLstatSync(claudePath)) {
if (item.type === 'directory') {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
} else if (item.type === 'file') {
fs.writeFileSync(claudePath, item.seedContent, 'utf8');
}
}
if (detectCircularSymlink(claudePath, sharedDir)) {
console.log(warn(`Skipping ${item.name}: circular symlink detected`));
continue;
}
let sharedAdoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
if (getLstatSync(sharedPath)) {
let removeExisting = true;
try {
const stats = fs.lstatSync(sharedPath);
if (stats.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(sharedPath);
const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget);
if (resolvedTarget === claudePath) {
continue;
}
}
} catch (_err) {
// Continue to recreate
if (item.type === 'file') {
recoverOrphanedCanonicalClaim(claudePath);
}
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
removeExisting = !preserveCanonicalFirstDivergence(sharedPath, claudePath);
} else if (item.type === 'file') {
sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath);
removeExisting = sharedAdoption === 'not-claimed';
reconcileCanonicalFirstFile(roots, item.name);
}
if (item.type === 'directory' && removeExisting) {
fs.rmSync(sharedPath, { recursive: true, force: true });
} else if (removeExisting) {
fs.unlinkSync(sharedPath);
if (!getLstatSync(claudePath)) {
if (item.type === 'directory') {
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
} else if (item.type === 'file') {
fs.writeFileSync(claudePath, item.seedContent, 'utf8');
}
}
assertAdoptionPathAbsent(sharedPath, sharedAdoption);
}
if (getLstatSync(sharedPath)) {
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
continue;
}
if (detectCircularSymlink(claudePath, sharedDir)) {
console.log(warn(`Skipping ${item.name}: circular symlink detected`));
return;
}
let sharedAdoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
if (getLstatSync(sharedPath)) {
let removeExisting = true;
try {
const stats = fs.lstatSync(sharedPath);
if (stats.isSymbolicLink()) {
const currentTarget = fs.readlinkSync(sharedPath);
const resolvedTarget = path.resolve(path.dirname(sharedPath), currentTarget);
if (resolvedTarget === claudePath) {
return;
}
}
} catch (_err) {
// Continue to recreate
}
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
removeExisting = !preserveCanonicalFirstDivergence(sharedPath, claudePath);
} else if (item.type === 'file') {
sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath);
removeExisting = sharedAdoption === 'not-claimed';
}
if (item.type === 'directory' && removeExisting) {
fs.rmSync(sharedPath, { recursive: true, force: true });
} else if (removeExisting) {
fs.unlinkSync(sharedPath);
}
assertAdoptionPathAbsent(sharedPath, sharedAdoption);
}
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(claudePath, sharedPath, symlinkType);
} catch (_err) {
assertAdoptionPathAbsent(sharedPath, sharedAdoption);
if (getLstatSync(sharedPath)) {
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
continue;
return;
}
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(claudePath, sharedPath);
} else if (item.type === 'file') {
fs.copyFileSync(claudePath, sharedPath, fs.constants.COPYFILE_EXCL);
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(claudePath, sharedPath, symlinkType);
} catch (_err) {
assertAdoptionPathAbsent(sharedPath, sharedAdoption);
if (getLstatSync(sharedPath)) {
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
return;
}
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(claudePath, sharedPath);
} else if (item.type === 'file') {
fs.copyFileSync(claudePath, sharedPath, fs.constants.COPYFILE_EXCL);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
});
}
}
@@ -207,73 +233,79 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
* Link shared directories into a specific instance path.
*/
export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void {
if (!isSafeAccountInstancePath(roots.instancesDir, instancePath)) {
throw Object.assign(new TypeError(`Unsafe account instance path: ${instancePath}`), {
code: 'EINVAL',
});
}
ensureSharedDirectories(roots);
const instanceIdentity = captureSafeAccountInstanceIdentity(roots.instancesDir, instancePath);
const assertInstanceIdentity = () =>
assertAccountInstanceIdentity(roots.instancesDir, instancePath, instanceIdentity);
ensureSharedDirectories(roots, assertInstanceIdentity);
assertInstanceIdentity();
const sharedDir = roots.sharedDir;
for (const item of SHARED_ITEMS) {
if (item.name === 'plugins') {
linkInstancePlugins(roots, instancePath);
continue;
}
runInstanceMutationStage(assertInstanceIdentity, () => {
if (item.name === 'plugins') {
linkInstancePlugins(roots, instancePath, assertInstanceIdentity);
return;
}
const linkPath = path.join(instancePath, item.name);
const targetPath = path.join(sharedDir, item.name);
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
const linkPath = path.join(instancePath, item.name);
const targetPath = path.join(sharedDir, item.name);
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
if (!preserveCanonicalFirstDivergence(linkPath, path.join(roots.claudeDir, item.name))) {
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
if (!preserveCanonicalFirstDivergence(linkPath, path.join(roots.claudeDir, item.name))) {
removeExistingPath(linkPath, item.type);
}
} else if (item.type === 'file') {
adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
if (adoption === 'not-claimed') {
removeExistingPath(linkPath, item.type);
}
} else {
removeExistingPath(linkPath, item.type);
}
} else if (item.type === 'file') {
adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
if (adoption === 'not-claimed') {
removeExistingPath(linkPath, item.type);
}
} else {
removeExistingPath(linkPath, item.type);
}
assertAdoptionPathAbsent(linkPath, adoption);
if (getLstatSync(linkPath)) {
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
continue;
}
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetPath, linkPath, symlinkType);
} catch (_err) {
assertAdoptionPathAbsent(linkPath, adoption);
if (getLstatSync(linkPath)) {
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
continue;
return;
}
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(targetPath, linkPath);
} else if (item.type === 'file') {
fs.copyFileSync(targetPath, linkPath, fs.constants.COPYFILE_EXCL);
try {
const symlinkType = item.type === 'directory' ? 'dir' : 'file';
fs.symlinkSync(targetPath, linkPath, symlinkType);
} catch (_err) {
assertAdoptionPathAbsent(linkPath, adoption);
if (getLstatSync(linkPath)) {
console.log(warn(`Skipping ${item.name}: path reappeared during reconciliation`));
return;
}
if (process.platform === 'win32') {
if (item.type === 'directory') {
copyDirectoryFallback(targetPath, linkPath);
} else if (item.type === 'file') {
fs.copyFileSync(targetPath, linkPath, fs.constants.COPYFILE_EXCL);
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
console.log(
warn(`Symlink failed for ${item.name}, copied instead (enable Developer Mode)`)
);
} else {
throw _err;
}
}
});
}
// Preserve original behavior: linkSharedDirectories always concludes by
// normalizing plugin + marketplace metadata for the freshly linked
// instance. migrateFromV311 relies on this side effect.
normalizePluginRegistryPaths(roots, instancePath);
normalizeMarketplaceRegistryPaths(roots, instancePath);
runInstanceMutationStage(assertInstanceIdentity, () =>
normalizePluginRegistryPaths(roots, instancePath)
);
runInstanceMutationStage(assertInstanceIdentity, () =>
normalizeMarketplaceRegistryPaths(roots, instancePath)
);
}
/**
+104
View File
@@ -62,6 +62,18 @@ describe('shared CLAUDE.md memory', () => {
expect(path.resolve(path.dirname(filePath), fs.readlinkSync(filePath))).toBe(targetPath);
}
function expectInvalidInstancePath(operation: () => void): void {
let thrown: unknown;
try {
operation();
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(TypeError);
expect((thrown as NodeJS.ErrnoException).code).toBe('EINVAL');
}
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-claude-memory-'));
originalHome = process.env.HOME;
@@ -326,6 +338,98 @@ describe('shared CLAUDE.md memory', () => {
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n');
});
it('aborts when the instance root is replaced during shared-root setup', () => {
const manager = new SharedManager();
const workInstance = instanceDir('work');
const originalInstance = instanceDir('work-original');
const externalInstance = path.join(tempHome, 'external-work');
const sentinelPath = path.join(externalInstance, 'sentinel.bin');
const sentinel = Buffer.from([0x00, 0x7f, 0x80, 0xff]);
fs.mkdirSync(workInstance, { recursive: true });
fs.mkdirSync(externalInstance, { recursive: true });
fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true });
fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true });
fs.writeFileSync(sentinelPath, sentinel);
const originalMkdirSync = fs.mkdirSync;
let replaced = false;
spyOn(fs, 'mkdirSync').mockImplementation(((targetPath, options) => {
if (!replaced && String(targetPath) === path.join(tempHome, '.claude', 'plugins')) {
replaced = true;
fs.renameSync(workInstance, originalInstance);
fs.symlinkSync(externalInstance, workInstance, 'dir');
}
return originalMkdirSync(targetPath, options);
}) as typeof fs.mkdirSync);
expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance));
expect(fs.readFileSync(sentinelPath)).toEqual(sentinel);
expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.bin']);
});
it('does not reconcile a regular replacement directory during shared-root setup', () => {
const manager = new SharedManager();
const workInstance = instanceDir('work');
const originalInstance = instanceDir('work-original');
const replacementFixture = path.join(tempHome, 'replacement-work');
const replacementMemory = path.join(replacementFixture, 'CLAUDE.md');
const sentinelPath = path.join(replacementFixture, 'sentinel.bin');
const memoryBytes = Buffer.from([0x23, 0x20, 0x80, 0xff, 0x0a]);
const sentinel = Buffer.from([0x11, 0x22, 0x33, 0x44]);
fs.mkdirSync(workInstance, { recursive: true });
fs.mkdirSync(replacementFixture, { recursive: true });
fs.mkdirSync(path.join(tempHome, '.claude'), { recursive: true });
fs.mkdirSync(path.join(ccsDir(), 'shared'), { recursive: true });
fs.writeFileSync(replacementMemory, memoryBytes);
fs.writeFileSync(sentinelPath, sentinel);
const originalMkdirSync = fs.mkdirSync;
let replaced = false;
spyOn(fs, 'mkdirSync').mockImplementation(((targetPath, options) => {
if (!replaced && String(targetPath) === path.join(tempHome, '.claude', 'plugins')) {
replaced = true;
fs.renameSync(workInstance, originalInstance);
fs.renameSync(replacementFixture, workInstance);
}
return originalMkdirSync(targetPath, options);
}) as typeof fs.mkdirSync);
expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance));
expect(fs.readFileSync(path.join(workInstance, 'CLAUDE.md'))).toEqual(memoryBytes);
expect(fs.readFileSync(path.join(workInstance, 'sentinel.bin'))).toEqual(sentinel);
expect(fs.readdirSync(workInstance).sort()).toEqual(['CLAUDE.md', 'sentinel.bin']);
});
it('aborts when the instance root is replaced during plugin linking', () => {
const manager = new SharedManager();
const workInstance = instanceDir('work');
const originalInstance = instanceDir('work-original');
const externalInstance = path.join(tempHome, 'external-work');
const externalPlugins = path.join(externalInstance, 'plugins');
const sentinelPath = path.join(externalPlugins, 'sentinel.bin');
const sentinel = Buffer.from([0x10, 0x20, 0x30, 0x40]);
fs.mkdirSync(path.join(workInstance, 'plugins'), { recursive: true });
fs.mkdirSync(externalPlugins, { recursive: true });
fs.writeFileSync(sentinelPath, sentinel);
const originalLstatSync = fs.lstatSync;
let replaced = false;
spyOn(fs, 'lstatSync').mockImplementation(((targetPath, options) => {
const result = originalLstatSync(targetPath, options);
if (!replaced && String(targetPath) === path.join(workInstance, 'plugins')) {
replaced = true;
fs.renameSync(workInstance, originalInstance);
fs.symlinkSync(externalInstance, workInstance, 'dir');
}
return result;
}) as typeof fs.lstatSync);
expectInvalidInstancePath(() => manager.linkSharedDirectories(workInstance));
expect(fs.readFileSync(sentinelPath)).toEqual(sentinel);
expect(fs.readdirSync(externalPlugins)).toEqual(['sentinel.bin']);
expect(fs.readdirSync(externalInstance)).toEqual(['plugins']);
});
it('rejects linking through a symlinked instances root without touching its target', () => {
const manager = new SharedManager();
const managedInstances = path.join(ccsDir(), 'instances');