mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 16:14:31 +00:00
1 parent
7fb56d62e1
commit
6841025bb4
14 files changed
+1011
-70
No files matched your search
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
|
||||||
"syncFs": {
|
"syncFs": {
|
||||||
"totalOccurrences": 2427,
|
"totalOccurrences": 2446,
|
||||||
"filesAffected": 258,
|
"filesAffected": 260,
|
||||||
"hotpathOccurrences": 1142,
|
"hotpathOccurrences": 1161,
|
||||||
"hotpathFilesAffected": 152,
|
"hotpathFilesAffected": 154,
|
||||||
"topHotpathFiles": [
|
"topHotpathFiles": [
|
||||||
{
|
{
|
||||||
"file": "src/management/shared-manager/diverged-file-adopter.ts",
|
"file": "src/management/shared-manager/diverged-file-adopter.ts",
|
||||||
@@ -498,10 +498,10 @@
|
|||||||
"maintainability": {
|
"maintainability": {
|
||||||
"typedErrors": {
|
"typedErrors": {
|
||||||
"totalThrows": 452,
|
"totalThrows": 452,
|
||||||
"typedThrows": 80,
|
"typedThrows": 81,
|
||||||
"plainThrows": 312,
|
"plainThrows": 311,
|
||||||
"otherThrows": 60,
|
"otherThrows": 60,
|
||||||
"adoptionRatio": 0.177,
|
"adoptionRatio": 0.1792,
|
||||||
"topSubdomainsByThrows": [
|
"topSubdomainsByThrows": [
|
||||||
{
|
{
|
||||||
"subdomain": "web-server",
|
"subdomain": "web-server",
|
||||||
@@ -579,8 +579,8 @@
|
|||||||
},
|
},
|
||||||
"loggerCoverage": {
|
"loggerCoverage": {
|
||||||
"filesWithCreateLogger": 65,
|
"filesWithCreateLogger": 65,
|
||||||
"totalSourceFiles": 759,
|
"totalSourceFiles": 761,
|
||||||
"coverageRatio": 0.0856,
|
"coverageRatio": 0.0854,
|
||||||
"subdomainsWithZeroCreateLogger": [
|
"subdomainsWithZeroCreateLogger": [
|
||||||
"api",
|
"api",
|
||||||
"bin",
|
"bin",
|
||||||
@@ -616,7 +616,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"subdomain": "management",
|
"subdomain": "management",
|
||||||
"count": 33,
|
"count": 35,
|
||||||
"withLogger": 1
|
"withLogger": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| Sync fs occurrences (all) | 2427 |
|
| Sync fs occurrences (all) | 2446 |
|
||||||
| Sync fs files affected (all) | 258 |
|
| Sync fs files affected (all) | 260 |
|
||||||
| Sync fs occurrences (runtime hotpaths) | 1142 |
|
| Sync fs occurrences (runtime hotpaths) | 1161 |
|
||||||
| Sync fs files affected (runtime hotpaths) | 152 |
|
| Sync fs files affected (runtime hotpaths) | 154 |
|
||||||
| Legacy shim markers | 458 |
|
| Legacy shim markers | 458 |
|
||||||
| Legacy shim files affected | 173 |
|
| Legacy shim files affected | 173 |
|
||||||
|
|
||||||
@@ -56,11 +56,11 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|
|||||||
|
|
||||||
| Metric | Value |
|
| Metric | Value |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
| typed-error adoption (typed/total throws) | 17.7% (80/452) |
|
| typed-error adoption (typed/total throws) | 17.9% (81/452) |
|
||||||
| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% |
|
| typed-error adoption (P4 locked subdomains) | 93.3% (28/30), target 40% |
|
||||||
| hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) |
|
| hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) |
|
||||||
| hotpath console.error/warn files | 82 |
|
| hotpath console.error/warn files | 82 |
|
||||||
| files with createLogger | 65/759 |
|
| files with createLogger | 65/761 |
|
||||||
| subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
|
| subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
|
||||||
| files > 400 LOC | 91 |
|
| files > 400 LOC | 91 |
|
||||||
| files > 600 LOC | 42 |
|
| files > 600 LOC | 42 |
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class AuthCommands {
|
|||||||
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
|
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)`
|
` ${color('--bare', 'command')} Create clean profile without shared settings/instructions/resources`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` ${color('--mode <mode>', 'command')} Shared resource mode for resources: shared|profile-local`
|
` ${color('--mode <mode>', 'command')} Shared resource mode for resources: shared|profile-local`
|
||||||
@@ -169,10 +169,10 @@ class AuthCommands {
|
|||||||
);
|
);
|
||||||
console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`);
|
console.log(` Account logins, tokens, and .anthropic stay isolated for every profile.`);
|
||||||
console.log(
|
console.log(
|
||||||
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
|
` Non-bare account profiles share ${color('settings.json', 'path')} and ${color('CLAUDE.md', 'path')} from ${color('~/.claude/', 'path')}; ${color('ccs auth show <profile>', 'command')} shows resource mode and settings link state.`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.`
|
` Shared Resources control plugins/commands/skills/agents/settings.json/CLAUDE.md; History Sync controls project/session continuity only.`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ export interface AccountConfig {
|
|||||||
context_group?: string;
|
context_group?: string;
|
||||||
/** Shared continuity depth when context_mode='shared' */
|
/** Shared continuity depth when context_mode='shared' */
|
||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
/** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */
|
||||||
shared_resource_mode?: 'shared' | 'profile-local';
|
shared_resource_mode?: 'shared' | 'profile-local';
|
||||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,116 @@ export function isAccountInstanceName(name: string): boolean {
|
|||||||
return !name.startsWith('.');
|
return !name.startsWith('.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeAccountInstanceName(name: string): string {
|
||||||
|
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsafeInstancePath(targetPath: string): never {
|
||||||
|
throw Object.assign(new TypeError(`Unsafe account instance path: ${targetPath}`), {
|
||||||
|
code: 'EINVAL',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryIdentity {
|
||||||
|
device: number;
|
||||||
|
inode: number;
|
||||||
|
realPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDirectoryIdentity(directoryPath: string): DirectoryIdentity {
|
||||||
|
const stats = fs.lstatSync(directoryPath);
|
||||||
|
if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(directoryPath);
|
||||||
|
return {
|
||||||
|
device: stats.dev,
|
||||||
|
inode: stats.ino,
|
||||||
|
realPath: fs.realpathSync.native(directoryPath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function identitiesMatch(left: DirectoryIdentity, right: DirectoryIdentity): boolean {
|
||||||
|
return (
|
||||||
|
left.device === right.device && left.inode === right.inode && left.realPath === right.realPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDirectoryUnderStableParent(directoryPath: string): void {
|
||||||
|
const parentPath = path.dirname(directoryPath);
|
||||||
|
const parentBefore = readDirectoryIdentity(parentPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(directoryPath, { mode: 0o700 });
|
||||||
|
readDirectoryIdentity(directoryPath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentAfter: DirectoryIdentity;
|
||||||
|
try {
|
||||||
|
parentAfter = readDirectoryIdentity(parentPath);
|
||||||
|
} catch {
|
||||||
|
unsafeInstancePath(parentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!identitiesMatch(parentBefore, parentAfter)) {
|
||||||
|
unsafeInstancePath(parentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
readDirectoryIdentity(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathWithinDirectory(candidatePath: string, directoryPath: string): boolean {
|
||||||
|
const relative = path.relative(directoryPath, candidatePath);
|
||||||
|
return relative.length > 0 && relative !== '..' && !relative.startsWith(`..${path.sep}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject symlink roots/entries and require the real instance path to stay under the real root. */
|
||||||
|
export function isSafeAccountInstancePath(instancesDir: string, instancePath: string): boolean {
|
||||||
|
try {
|
||||||
|
const rootStats = fs.lstatSync(instancesDir);
|
||||||
|
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) return false;
|
||||||
|
|
||||||
|
const instanceStats = fs.lstatSync(instancePath);
|
||||||
|
if (instanceStats.isSymbolicLink() || !instanceStats.isDirectory()) return false;
|
||||||
|
|
||||||
|
const realRoot = fs.realpathSync.native(instancesDir);
|
||||||
|
const realInstance = fs.realpathSync.native(instancePath);
|
||||||
|
return isPathWithinDirectory(realInstance, realRoot);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a missing managed instances root without traversing symlink parents. */
|
||||||
|
export function ensureSafeAccountInstancesDirectory(instancesDir: string): void {
|
||||||
|
const missingPaths: string[] = [];
|
||||||
|
let currentPath = instancesDir;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const stats = fs.lstatSync(currentPath);
|
||||||
|
if (stats.isSymbolicLink() || !stats.isDirectory()) unsafeInstancePath(currentPath);
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
missingPaths.push(currentPath);
|
||||||
|
const parentPath = path.dirname(currentPath);
|
||||||
|
if (parentPath === currentPath) unsafeInstancePath(instancesDir);
|
||||||
|
currentPath = parentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directoryPath of missingPaths.reverse()) {
|
||||||
|
createDirectoryUnderStableParent(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootStats = fs.lstatSync(instancesDir);
|
||||||
|
if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) unsafeInstancePath(instancesDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSafeAccountInstancePath(instancesDir: string, instancePath: string): void {
|
||||||
|
if (!isSafeAccountInstancePath(instancesDir, instancePath)) unsafeInstancePath(instancePath);
|
||||||
|
}
|
||||||
|
|
||||||
export function listAccountInstanceNames(instancesDir: string): string[] {
|
export function listAccountInstanceNames(instancesDir: string): string[] {
|
||||||
if (!fs.existsSync(instancesDir)) {
|
if (!fs.existsSync(instancesDir)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -15,11 +125,7 @@ export function listAccountInstanceNames(instancesDir: string): string[] {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return isSafeAccountInstancePath(instancesDir, path.join(instancesDir, name));
|
||||||
return fs.statSync(path.join(instancesDir, name)).isDirectory();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ import type { AccountContextPolicy } from '../auth/account-context';
|
|||||||
import { getCcsHome } from '../utils/config-manager';
|
import { getCcsHome } from '../utils/config-manager';
|
||||||
import { createLogger } from '../services/logging';
|
import { createLogger } from '../services/logging';
|
||||||
import { getCcsDir } from '../config/config-loader-facade';
|
import { getCcsDir } from '../config/config-loader-facade';
|
||||||
import { listAccountInstanceNames } from './instance-directory';
|
import { ProfileError } from '../errors/error-types';
|
||||||
|
import {
|
||||||
|
assertSafeAccountInstancePath,
|
||||||
|
ensureSafeAccountInstancesDirectory,
|
||||||
|
listAccountInstanceNames,
|
||||||
|
normalizeAccountInstanceName,
|
||||||
|
} from './instance-directory';
|
||||||
|
|
||||||
const logger = createLogger('management:instance-manager');
|
const logger = createLogger('management:instance-manager');
|
||||||
|
|
||||||
@@ -23,7 +29,7 @@ const MANAGED_MCP_SERVER_NAMES = new Set(['ccs-websearch', 'ccs-image-analysis',
|
|||||||
|
|
||||||
/** Options for instance creation */
|
/** Options for instance creation */
|
||||||
export interface InstanceOptions {
|
export interface InstanceOptions {
|
||||||
/** Skip shared symlinks (commands, skills, agents, settings.json) */
|
/** Skip shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +58,18 @@ class InstanceManager {
|
|||||||
options: InstanceOptions = {}
|
options: InstanceOptions = {}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const instancePath = this.getInstancePath(profileName);
|
const instancePath = this.getInstancePath(profileName);
|
||||||
|
const initialInstanceStats = this.getInstanceStats(instancePath);
|
||||||
|
if (initialInstanceStats) {
|
||||||
|
assertSafeAccountInstancePath(this.instancesDir, instancePath);
|
||||||
|
} else {
|
||||||
|
ensureSafeAccountInstancesDirectory(this.instancesDir);
|
||||||
|
}
|
||||||
|
|
||||||
// Serialize context sync operations per profile across processes.
|
// Serialize context sync operations per profile across processes.
|
||||||
await this.contextSyncLock.withLock(profileName, async () => {
|
await this.contextSyncLock.withLock(profileName, async () => {
|
||||||
// Lazy initialization
|
// Lazy initialization
|
||||||
if (!fs.existsSync(instancePath)) {
|
if (!this.getInstanceStats(instancePath)) {
|
||||||
|
ensureSafeAccountInstancesDirectory(this.instancesDir);
|
||||||
logger.stage('route', 'instance.init', 'Initializing new profile instance', {
|
logger.stage('route', 'instance.init', 'Initializing new profile instance', {
|
||||||
profile: profileName,
|
profile: profileName,
|
||||||
bare: options.bare === true,
|
bare: options.bare === true,
|
||||||
@@ -64,6 +77,9 @@ class InstanceManager {
|
|||||||
this.initializeInstance(profileName, instancePath, options);
|
this.initializeInstance(profileName, instancePath, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-check after locking/creation before any operation can mutate the instance.
|
||||||
|
assertSafeAccountInstancePath(this.instancesDir, instancePath);
|
||||||
|
|
||||||
// Validate structure (auto-fix missing dirs)
|
// Validate structure (auto-fix missing dirs)
|
||||||
this.validateInstance(instancePath);
|
this.validateInstance(instancePath);
|
||||||
|
|
||||||
@@ -94,7 +110,7 @@ class InstanceManager {
|
|||||||
* Get instance path for profile
|
* Get instance path for profile
|
||||||
*/
|
*/
|
||||||
getInstancePath(profileName: string): string {
|
getInstancePath(profileName: string): string {
|
||||||
const safeName = this.sanitizeName(profileName);
|
const safeName = normalizeAccountInstanceName(profileName);
|
||||||
return path.join(this.instancesDir, safeName);
|
return path.join(this.instancesDir, safeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,30 +124,14 @@ class InstanceManager {
|
|||||||
): void {
|
): void {
|
||||||
try {
|
try {
|
||||||
// Create base directory
|
// Create base directory
|
||||||
fs.mkdirSync(instancePath, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(instancePath, { mode: 0o700 });
|
||||||
|
|
||||||
// Create Claude-expected subdirectories (profile-specific only)
|
// Expected subdirectories and shared links are created only after the
|
||||||
const subdirs = [
|
// caller validates that this new root stayed inside the managed parent.
|
||||||
'session-env',
|
|
||||||
'todos',
|
|
||||||
'logs',
|
|
||||||
'file-history',
|
|
||||||
'shell-snapshots',
|
|
||||||
'debug',
|
|
||||||
'.anthropic',
|
|
||||||
];
|
|
||||||
|
|
||||||
subdirs.forEach((dir) => {
|
|
||||||
const dirPath = path.join(instancePath, dir);
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Shared links are created during ensureInstance() under the plugin layout lock.
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new ProfileError(
|
||||||
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`
|
`Failed to initialize instance for ${profileName}: ${(error as Error).message}`,
|
||||||
|
profileName
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,12 +268,13 @@ class InstanceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private getInstanceStats(instancePath: string): fs.Stats | null {
|
||||||
* Sanitize profile name for filesystem
|
try {
|
||||||
*/
|
return fs.lstatSync(instancePath);
|
||||||
private sanitizeName(name: string): string {
|
} catch (error) {
|
||||||
// Replace unsafe characters with dash
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { warn } from '../../utils/ui';
|
||||||
|
import { getLstatSync } from './fs-helpers';
|
||||||
|
|
||||||
|
let claimSequence = 0;
|
||||||
|
|
||||||
|
export function readRegularFileContent(filePath: string): Buffer | null {
|
||||||
|
const stats = getLstatSync(filePath);
|
||||||
|
return stats?.isFile() ? fs.readFileSync(filePath) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function preserveClaim(filePath: string, claimPath: string, reason: string): string {
|
||||||
|
const basePath = `${filePath}.ccs-shared-conflict`;
|
||||||
|
let sequence = 0;
|
||||||
|
while (true) {
|
||||||
|
const recoveryPath = sequence === 0 ? basePath : `${basePath}-${sequence}`;
|
||||||
|
try {
|
||||||
|
fs.linkSync(claimPath, recoveryPath);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
} catch {
|
||||||
|
// Both names preserve the same inode, so leaving the claim is safe.
|
||||||
|
}
|
||||||
|
console.log(warn(`${reason}; preserved content at ${recoveryPath}`));
|
||||||
|
return recoveryPath;
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
|
if (code === 'EEXIST') {
|
||||||
|
sequence++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.copyFileSync(claimPath, recoveryPath, fs.constants.COPYFILE_EXCL);
|
||||||
|
fs.chmodSync(recoveryPath, fs.statSync(claimPath).mode & 0o777);
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
console.log(
|
||||||
|
warn(`${reason}; copied content to ${recoveryPath} (${code ?? 'link failed'})`)
|
||||||
|
);
|
||||||
|
return recoveryPath;
|
||||||
|
} catch (copyError) {
|
||||||
|
if ((copyError as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||||
|
sequence++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getLstatSync(filePath)) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(claimPath, filePath);
|
||||||
|
} catch {
|
||||||
|
// The claim remains the only recoverable copy if restoration loses a race.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(warn(`${reason}; unable to publish recovery, restored original when possible`));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interruptedClaimPaths(filePath: string): string[] {
|
||||||
|
const directory = path.dirname(filePath);
|
||||||
|
const prefix = `${path.basename(filePath)}.ccs-shared-claim-`;
|
||||||
|
try {
|
||||||
|
return fs
|
||||||
|
.readdirSync(directory)
|
||||||
|
.filter((entry) => entry.startsWith(prefix))
|
||||||
|
.map((entry) => path.join(directory, entry))
|
||||||
|
.filter((entryPath) => fs.lstatSync(entryPath).isFile())
|
||||||
|
.sort();
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recoverInterruptedClaims(filePath: string): void {
|
||||||
|
const claims = interruptedClaimPaths(filePath);
|
||||||
|
if (claims.length === 0) return;
|
||||||
|
|
||||||
|
if (claims.length === 1 && !getLstatSync(filePath)) {
|
||||||
|
fs.renameSync(claims[0], filePath);
|
||||||
|
console.log(warn(`Recovered interrupted shared-file claim at ${filePath}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const claimPath of claims) {
|
||||||
|
preserveClaim(filePath, claimPath, `Quarantined interrupted shared-file claim for ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimCanonicalFirstCopy(
|
||||||
|
filePath: string,
|
||||||
|
canonicalContent: Buffer | null,
|
||||||
|
reason: string
|
||||||
|
): boolean {
|
||||||
|
const initialStats = getLstatSync(filePath);
|
||||||
|
if (!initialStats?.isFile()) return false;
|
||||||
|
|
||||||
|
const claimPath = `${filePath}.ccs-shared-claim-${process.pid}-${Date.now()}-${claimSequence++}`;
|
||||||
|
fs.renameSync(filePath, claimPath);
|
||||||
|
if (getLstatSync(filePath)) {
|
||||||
|
preserveClaim(filePath, claimPath, `Concurrent replacement detected at ${filePath}`);
|
||||||
|
throw Object.assign(new TypeError(`Concurrent replacement detected at ${filePath}`), {
|
||||||
|
code: 'EEXIST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const claimedContent = fs.readFileSync(claimPath);
|
||||||
|
if (canonicalContent?.equals(claimedContent)) {
|
||||||
|
fs.unlinkSync(claimPath);
|
||||||
|
} else {
|
||||||
|
preserveClaim(filePath, claimPath, reason);
|
||||||
|
}
|
||||||
|
if (getLstatSync(filePath)) {
|
||||||
|
throw Object.assign(new TypeError(`Path reappeared during reconciliation: ${filePath}`), {
|
||||||
|
code: 'EEXIST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import { info, warn } from '../../utils/ui';
|
||||||
|
import ProfileRegistry from '../../auth/profile-registry';
|
||||||
|
import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy';
|
||||||
|
import {
|
||||||
|
isSafeAccountInstancePath,
|
||||||
|
listAccountInstancePaths,
|
||||||
|
normalizeAccountInstanceName,
|
||||||
|
} from '../instance-directory';
|
||||||
|
import { adoptDivergedFileContent } from './diverged-file-adopter';
|
||||||
|
import { getLstatSync } from './fs-helpers';
|
||||||
|
import {
|
||||||
|
claimCanonicalFirstCopy,
|
||||||
|
readRegularFileContent,
|
||||||
|
recoverInterruptedClaims,
|
||||||
|
} from './canonical-first-file-claims';
|
||||||
|
|
||||||
|
interface CanonicalFirstRoots {
|
||||||
|
claudeDir: string;
|
||||||
|
sharedDir: string;
|
||||||
|
instancesDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidatePaths(roots: CanonicalFirstRoots, fileName: string): string[] {
|
||||||
|
const profiles = new ProfileRegistry().getAllProfilesMerged();
|
||||||
|
const profilesByInstanceName = new Map<string, Array<(typeof profiles)[string]>>();
|
||||||
|
for (const [profileName, profile] of Object.entries(profiles)) {
|
||||||
|
const instanceName = normalizeAccountInstanceName(profileName);
|
||||||
|
const matches = profilesByInstanceName.get(instanceName) ?? [];
|
||||||
|
matches.push(profile);
|
||||||
|
profilesByInstanceName.set(instanceName, matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
path.join(roots.sharedDir, fileName),
|
||||||
|
...listAccountInstancePaths(roots.instancesDir)
|
||||||
|
.filter((instancePath) => {
|
||||||
|
const instanceName = normalizeAccountInstanceName(path.basename(instancePath));
|
||||||
|
const matches = profilesByInstanceName.get(instanceName) ?? [];
|
||||||
|
if (matches.length > 1) return false;
|
||||||
|
return matches.length === 0 || !isProfileLocalSharedResourceMode(matches[0]);
|
||||||
|
})
|
||||||
|
.map((instancePath) => path.join(instancePath, fileName)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileCandidatesAgainstCanonical(
|
||||||
|
canonicalPath: string,
|
||||||
|
candidates: readonly string[]
|
||||||
|
): void {
|
||||||
|
let canonicalContent: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
canonicalContent = fs.readFileSync(canonicalPath);
|
||||||
|
} catch {
|
||||||
|
// A present but unreadable or dangling canonical path still owns precedence.
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidatePath of candidates) {
|
||||||
|
claimCanonicalFirstCopy(
|
||||||
|
candidatePath,
|
||||||
|
canonicalContent,
|
||||||
|
`Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${candidatePath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile a canonical-first shared file before any seed or symlink mutation.
|
||||||
|
* Existing canonical content always wins. With no canonical, one unique byte
|
||||||
|
* variant is adopted; conflicting variants are all quarantined for recovery.
|
||||||
|
*/
|
||||||
|
export function reconcileCanonicalFirstFile(roots: CanonicalFirstRoots, fileName: string): void {
|
||||||
|
const canonicalPath = path.join(roots.claudeDir, fileName);
|
||||||
|
const candidates = candidatePaths(roots, fileName);
|
||||||
|
for (const candidatePath of candidates) {
|
||||||
|
if (candidatePath !== path.join(roots.sharedDir, fileName)) {
|
||||||
|
const instancePath = path.dirname(candidatePath);
|
||||||
|
if (!isSafeAccountInstancePath(roots.instancesDir, instancePath)) continue;
|
||||||
|
}
|
||||||
|
recoverInterruptedClaims(candidatePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getLstatSync(canonicalPath)) {
|
||||||
|
reconcileCandidatesAgainstCanonical(canonicalPath, candidates);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants: Array<{ path: string; content: Buffer }> = [];
|
||||||
|
for (const candidatePath of candidates) {
|
||||||
|
const content = readRegularFileContent(candidatePath);
|
||||||
|
if (content) variants.push({ path: candidatePath, content });
|
||||||
|
}
|
||||||
|
if (variants.length === 0) return;
|
||||||
|
|
||||||
|
const uniqueContents: Buffer[] = [];
|
||||||
|
for (const variant of variants) {
|
||||||
|
if (!uniqueContents.some((content) => content.equals(variant.content))) {
|
||||||
|
uniqueContents.push(variant.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueContents.length === 1) {
|
||||||
|
adoptDivergedFileContent(variants[0].path, canonicalPath);
|
||||||
|
reconcileCandidatesAgainstCanonical(canonicalPath, candidates);
|
||||||
|
console.log(info(`Adopted the only existing ${fileName} variant as canonical content`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
claimCanonicalFirstCopy(
|
||||||
|
variant.path,
|
||||||
|
null,
|
||||||
|
`Conflicting ${fileName} variants found while no canonical file exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
warn(
|
||||||
|
`No ${fileName} variant was selected; recover one of the .ccs-shared-conflict files explicitly`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preserve a later divergent copy without allowing it to replace canonical content. */
|
||||||
|
export function preserveCanonicalFirstDivergence(
|
||||||
|
divergedPath: string,
|
||||||
|
canonicalPath: string
|
||||||
|
): boolean {
|
||||||
|
let canonicalContent: Buffer | null = null;
|
||||||
|
try {
|
||||||
|
canonicalContent = fs.readFileSync(canonicalPath);
|
||||||
|
} catch {
|
||||||
|
// Preserve the divergence when canonical cannot be compared safely.
|
||||||
|
}
|
||||||
|
|
||||||
|
return claimCanonicalFirstCopy(
|
||||||
|
divergedPath,
|
||||||
|
canonicalContent,
|
||||||
|
`Canonical ${path.basename(canonicalPath)} wins over divergent copy at ${divergedPath}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { info, warn } from '../../utils/ui';
|
import { info, warn } from '../../utils/ui';
|
||||||
|
import { isSafeAccountInstancePath } from '../instance-directory';
|
||||||
import {
|
import {
|
||||||
adoptDivergedFileContent,
|
adoptDivergedFileContent,
|
||||||
assertAdoptionPathAbsent,
|
assertAdoptionPathAbsent,
|
||||||
@@ -41,6 +42,10 @@ import {
|
|||||||
ensureSharedPluginLayoutDefaults,
|
ensureSharedPluginLayoutDefaults,
|
||||||
linkInstancePlugins,
|
linkInstancePlugins,
|
||||||
} from './plugin-layout-internals';
|
} from './plugin-layout-internals';
|
||||||
|
import {
|
||||||
|
preserveCanonicalFirstDivergence,
|
||||||
|
reconcileCanonicalFirstFile,
|
||||||
|
} from './canonical-first-file-reconciler';
|
||||||
import { SHARED_ITEMS } from './types';
|
import { SHARED_ITEMS } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,11 +125,15 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
|||||||
recoverOrphanedCanonicalClaim(claudePath);
|
recoverOrphanedCanonicalClaim(claudePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
|
||||||
|
reconcileCanonicalFirstFile(roots, item.name);
|
||||||
|
}
|
||||||
|
|
||||||
if (!getLstatSync(claudePath)) {
|
if (!getLstatSync(claudePath)) {
|
||||||
if (item.type === 'directory') {
|
if (item.type === 'directory') {
|
||||||
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(claudePath, { recursive: true, mode: 0o700 });
|
||||||
} else if (item.type === 'file') {
|
} else if (item.type === 'file') {
|
||||||
fs.writeFileSync(claudePath, JSON.stringify({}, null, 2), 'utf8');
|
fs.writeFileSync(claudePath, item.seedContent, 'utf8');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +158,9 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
|||||||
// Continue to recreate
|
// Continue to recreate
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'file') {
|
if (item.type === 'file' && item.divergencePolicy === 'canonical-first') {
|
||||||
|
removeExisting = !preserveCanonicalFirstDivergence(sharedPath, claudePath);
|
||||||
|
} else if (item.type === 'file') {
|
||||||
sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath);
|
sharedAdoption = adoptDivergedFileContent(sharedPath, claudePath);
|
||||||
removeExisting = sharedAdoption === 'not-claimed';
|
removeExisting = sharedAdoption === 'not-claimed';
|
||||||
}
|
}
|
||||||
@@ -196,6 +207,11 @@ export function ensureSharedDirectories(roots: LinkerRoots): void {
|
|||||||
* Link shared directories into a specific instance path.
|
* Link shared directories into a specific instance path.
|
||||||
*/
|
*/
|
||||||
export function linkSharedDirectories(roots: LinkerRoots, instancePath: string): void {
|
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);
|
ensureSharedDirectories(roots);
|
||||||
|
|
||||||
const sharedDir = roots.sharedDir;
|
const sharedDir = roots.sharedDir;
|
||||||
@@ -210,7 +226,11 @@ export function linkSharedDirectories(roots: LinkerRoots, instancePath: string):
|
|||||||
const targetPath = path.join(sharedDir, item.name);
|
const targetPath = path.join(sharedDir, item.name);
|
||||||
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
|
let adoption: ReturnType<typeof adoptDivergedFileContent> = 'not-claimed';
|
||||||
|
|
||||||
if (item.type === 'file') {
|
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));
|
adoption = adoptDivergedFileContent(linkPath, path.join(roots.claudeDir, item.name));
|
||||||
if (adoption === 'not-claimed') {
|
if (adoption === 'not-claimed') {
|
||||||
removeExistingPath(linkPath, item.type);
|
removeExistingPath(linkPath, item.type);
|
||||||
|
|||||||
@@ -16,6 +16,20 @@ export interface SharedItem {
|
|||||||
type: 'directory' | 'file';
|
type: 'directory' | 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ManagedSharedDirectoryItem extends SharedItem {
|
||||||
|
type: 'directory';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedSharedFileItem extends SharedItem {
|
||||||
|
type: 'file';
|
||||||
|
/** Exact bytes used when no canonical or adoptable source file exists. */
|
||||||
|
seedContent: string;
|
||||||
|
/** Controls whether an instance may replace an existing canonical file. */
|
||||||
|
divergencePolicy: 'newer-wins' | 'canonical-first';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManagedSharedItem = ManagedSharedDirectoryItem | ManagedSharedFileItem;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default content for a freshly provisioned installed_plugins.json registry.
|
* Default content for a freshly provisioned installed_plugins.json registry.
|
||||||
* Version 2 schema with an empty plugins map.
|
* Version 2 schema with an empty plugins map.
|
||||||
@@ -36,12 +50,23 @@ export const DEFAULT_INSTALLED_PLUGIN_REGISTRY = JSON.stringify(
|
|||||||
* Order matters: consumers rely on a stable iteration order when reconciling
|
* Order matters: consumers rely on a stable iteration order when reconciling
|
||||||
* symlinks, and 'plugins' is special-cased by the linker.
|
* symlinks, and 'plugins' is special-cased by the linker.
|
||||||
*/
|
*/
|
||||||
export const SHARED_ITEMS: readonly SharedItem[] = [
|
export const SHARED_ITEMS: readonly ManagedSharedItem[] = [
|
||||||
{ name: 'commands', type: 'directory' },
|
{ name: 'commands', type: 'directory' },
|
||||||
{ name: 'skills', type: 'directory' },
|
{ name: 'skills', type: 'directory' },
|
||||||
{ name: 'agents', type: 'directory' },
|
{ name: 'agents', type: 'directory' },
|
||||||
{ name: 'plugins', type: 'directory' },
|
{ name: 'plugins', type: 'directory' },
|
||||||
{ name: 'settings.json', type: 'file' },
|
{
|
||||||
|
name: 'settings.json',
|
||||||
|
type: 'file',
|
||||||
|
seedContent: JSON.stringify({}, null, 2),
|
||||||
|
divergencePolicy: 'newer-wins',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'CLAUDE.md',
|
||||||
|
type: 'file',
|
||||||
|
seedContent: '',
|
||||||
|
divergencePolicy: 'canonical-first',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+2
-2
@@ -121,9 +121,9 @@ export interface ProfileMetadata {
|
|||||||
context_group?: string;
|
context_group?: string;
|
||||||
/** Shared continuity depth when context_mode='shared' */
|
/** Shared continuity depth when context_mode='shared' */
|
||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
/** Account-level shared resource behavior for plugins, commands, skills, agents, settings.json, and CLAUDE.md */
|
||||||
shared_resource_mode?: 'shared' | 'profile-local';
|
shared_resource_mode?: 'shared' | 'profile-local';
|
||||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json, CLAUDE.md) */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -284,6 +284,168 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
expect(manager.listInstances()).toEqual(['work']);
|
expect(manager.listInstances()).toEqual(['work']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const testCase of [
|
||||||
|
{
|
||||||
|
label: 'isolated bare',
|
||||||
|
policy: { mode: 'isolated' as const },
|
||||||
|
options: { bare: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'shared context',
|
||||||
|
policy: { mode: 'shared' as const, group: 'review-safety' },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
it(`rejects an existing symlink instance root before ${testCase.label} mutation`, async () => {
|
||||||
|
const externalInstance = path.join(tempRoot, `external-${testCase.label.replace(' ', '-')}`);
|
||||||
|
const instancesDir = path.join(tempRoot, '.ccs', 'instances');
|
||||||
|
fs.mkdirSync(externalInstance, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8');
|
||||||
|
fs.mkdirSync(instancesDir, { recursive: true });
|
||||||
|
fs.symlinkSync(externalInstance, path.join(instancesDir, 'work'), 'dir');
|
||||||
|
|
||||||
|
const manager = new InstanceManager();
|
||||||
|
await expect(
|
||||||
|
manager.ensureInstance('work', testCase.policy, testCase.options)
|
||||||
|
).rejects.toThrow('Unsafe account instance path');
|
||||||
|
|
||||||
|
expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']);
|
||||||
|
expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe(
|
||||||
|
'unchanged'
|
||||||
|
);
|
||||||
|
expect(fs.readdirSync(instancesDir)).toEqual(['work']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects a symlinked instances parent before creating a new instance or lock', async () => {
|
||||||
|
const externalInstances = path.join(tempRoot, 'external-instances');
|
||||||
|
const managedInstances = path.join(tempRoot, '.ccs', 'instances');
|
||||||
|
fs.mkdirSync(externalInstances, { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(managedInstances), { recursive: true });
|
||||||
|
fs.symlinkSync(externalInstances, managedInstances, 'dir');
|
||||||
|
|
||||||
|
const manager = new InstanceManager();
|
||||||
|
await expect(
|
||||||
|
manager.ensureInstance('new-profile', { mode: 'shared', group: 'review-safety' })
|
||||||
|
).rejects.toThrow('Unsafe account instance path');
|
||||||
|
|
||||||
|
expect(fs.readdirSync(externalInstances)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects parent replacement and leaves only an empty redirected artifact', async () => {
|
||||||
|
const managedCcsDir = path.join(tempRoot, '.ccs');
|
||||||
|
const managedInstances = path.join(managedCcsDir, 'instances');
|
||||||
|
const displacedCcsDir = path.join(tempRoot, 'displaced-ccs');
|
||||||
|
const externalTarget = path.join(tempRoot, 'parent-race-target');
|
||||||
|
fs.mkdirSync(managedCcsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(externalTarget, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(externalTarget, 'sentinel.txt'), 'unchanged', 'utf8');
|
||||||
|
|
||||||
|
const originalMkdirSync = fs.mkdirSync;
|
||||||
|
let replacedParent = false;
|
||||||
|
spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => {
|
||||||
|
if (directoryPath === managedInstances && !replacedParent) {
|
||||||
|
replacedParent = true;
|
||||||
|
fs.renameSync(managedCcsDir, displacedCcsDir);
|
||||||
|
fs.symlinkSync(externalTarget, managedCcsDir, 'dir');
|
||||||
|
}
|
||||||
|
return originalMkdirSync(directoryPath, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new InstanceManager();
|
||||||
|
await expect(
|
||||||
|
manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true })
|
||||||
|
).rejects.toThrow('Unsafe account instance path');
|
||||||
|
|
||||||
|
expect(replacedParent).toBe(true);
|
||||||
|
expect(fs.readdirSync(externalTarget).sort()).toEqual(['instances', 'sentinel.txt']);
|
||||||
|
expect(fs.readdirSync(path.join(externalTarget, 'instances'))).toEqual([]);
|
||||||
|
expect(fs.existsSync(path.join(externalTarget, 'instances', '.locks'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(externalTarget, 'instances', 'new-profile'))).toBe(false);
|
||||||
|
expect(fs.readdirSync(displacedCcsDir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves created and unrelated directories when the redirected path is replaced', async () => {
|
||||||
|
const managedCcsDir = path.join(tempRoot, '.ccs');
|
||||||
|
const managedInstances = path.join(managedCcsDir, 'instances');
|
||||||
|
const displacedCcsDir = path.join(tempRoot, 'displaced-ccs-cleanup-race');
|
||||||
|
const externalTarget = path.join(tempRoot, 'cleanup-race-target');
|
||||||
|
const createdDirectory = path.join(externalTarget, 'created-instances');
|
||||||
|
const redirectedInstances = path.join(externalTarget, 'instances');
|
||||||
|
fs.mkdirSync(managedCcsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(externalTarget, { recursive: true });
|
||||||
|
|
||||||
|
const originalMkdirSync = fs.mkdirSync;
|
||||||
|
const originalLstatSync = fs.lstatSync;
|
||||||
|
let replacedParent = false;
|
||||||
|
let createdIdentityObserved = false;
|
||||||
|
let substitutedUnrelatedDirectory = false;
|
||||||
|
|
||||||
|
spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => {
|
||||||
|
if (directoryPath === managedInstances && !replacedParent) {
|
||||||
|
replacedParent = true;
|
||||||
|
fs.renameSync(managedCcsDir, displacedCcsDir);
|
||||||
|
fs.symlinkSync(externalTarget, managedCcsDir, 'dir');
|
||||||
|
}
|
||||||
|
return originalMkdirSync(directoryPath, options);
|
||||||
|
});
|
||||||
|
spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => {
|
||||||
|
if (
|
||||||
|
targetPath === managedCcsDir &&
|
||||||
|
createdIdentityObserved &&
|
||||||
|
!substitutedUnrelatedDirectory
|
||||||
|
) {
|
||||||
|
substitutedUnrelatedDirectory = true;
|
||||||
|
fs.renameSync(redirectedInstances, createdDirectory);
|
||||||
|
originalMkdirSync(redirectedInstances);
|
||||||
|
}
|
||||||
|
const stats = originalLstatSync(targetPath, options);
|
||||||
|
if (targetPath === managedInstances && replacedParent && stats.isDirectory()) {
|
||||||
|
createdIdentityObserved = true;
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new InstanceManager();
|
||||||
|
await expect(
|
||||||
|
manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true })
|
||||||
|
).rejects.toThrow('Unsafe account instance path');
|
||||||
|
|
||||||
|
expect(substitutedUnrelatedDirectory).toBe(true);
|
||||||
|
expect(fs.existsSync(redirectedInstances)).toBe(true);
|
||||||
|
expect(fs.readdirSync(redirectedInstances)).toEqual([]);
|
||||||
|
expect(fs.existsSync(createdDirectory)).toBe(true);
|
||||||
|
expect(fs.readdirSync(createdDirectory)).toEqual([]);
|
||||||
|
expect(fs.existsSync(path.join(redirectedInstances, '.locks'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(redirectedInstances, 'new-profile'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(createdDirectory, '.locks'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(createdDirectory, 'new-profile'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revalidates a newly created instance root before initializing its contents', async () => {
|
||||||
|
const externalInstance = path.join(tempRoot, 'post-create-race-target');
|
||||||
|
fs.mkdirSync(externalInstance, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(externalInstance, 'sentinel.txt'), 'unchanged', 'utf8');
|
||||||
|
|
||||||
|
const manager = new InstanceManager();
|
||||||
|
const instancePath = manager.getInstancePath('new-profile');
|
||||||
|
const originalMkdirSync = fs.mkdirSync;
|
||||||
|
spyOn(fs, 'mkdirSync').mockImplementation((directoryPath, options) => {
|
||||||
|
if (directoryPath === instancePath) {
|
||||||
|
fs.symlinkSync(externalInstance, instancePath, 'dir');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return originalMkdirSync(directoryPath, options);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.ensureInstance('new-profile', { mode: 'isolated' }, { bare: true })
|
||||||
|
).rejects.toThrow('Unsafe account instance path');
|
||||||
|
|
||||||
|
expect(fs.readdirSync(externalInstance)).toEqual(['sentinel.txt']);
|
||||||
|
expect(fs.readFileSync(path.join(externalInstance, 'sentinel.txt'), 'utf8')).toBe('unchanged');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
|
||||||
const linkSharedSpy = spyOn(
|
const linkSharedSpy = spyOn(
|
||||||
SharedManager.prototype,
|
SharedManager.prototype,
|
||||||
@@ -491,7 +653,9 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
const personalRegistry = readJson(path.join(personalPath, 'plugins', 'known_marketplaces.json')) as Record<
|
const personalRegistry = readJson(
|
||||||
|
path.join(personalPath, 'plugins', 'known_marketplaces.json')
|
||||||
|
) as Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
installLocation?: string;
|
installLocation?: string;
|
||||||
@@ -552,10 +716,7 @@ describe('InstanceManager MCP sync', () => {
|
|||||||
|
|
||||||
const legacyRegistry = readJson(
|
const legacyRegistry = readJson(
|
||||||
path.join(legacyPath, 'plugins', 'known_marketplaces.json')
|
path.join(legacyPath, 'plugins', 'known_marketplaces.json')
|
||||||
) as Record<
|
) as Record<string, { installLocation?: string; label?: string; refreshToken?: string }>;
|
||||||
string,
|
|
||||||
{ installLocation?: string; label?: string; refreshToken?: string }
|
|
||||||
>;
|
|
||||||
expect(legacyRegistry['claude-code-plugins']).toMatchObject({
|
expect(legacyRegistry['claude-code-plugins']).toMatchObject({
|
||||||
installLocation: marketplacePath(legacyPath),
|
installLocation: marketplacePath(legacyPath),
|
||||||
label: 'Legacy marketplace',
|
label: 'Legacy marketplace',
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ describe('account instance directory enumeration', () => {
|
|||||||
expect(listAccountInstanceNames(instancesDir())).toEqual(['work']);
|
expect(listAccountInstanceNames(instancesDir())).toEqual(['work']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects instance entries that symlink outside the managed root', () => {
|
||||||
|
const externalInstance = path.join(tempRoot, 'external-instance');
|
||||||
|
fs.mkdirSync(externalInstance, { recursive: true });
|
||||||
|
fs.mkdirSync(instancesDir(), { recursive: true });
|
||||||
|
fs.symlinkSync(externalInstance, path.join(instancesDir(), 'escaped'), 'dir');
|
||||||
|
|
||||||
|
expect(listAccountInstanceNames(instancesDir())).toEqual([]);
|
||||||
|
expect(listAccountInstancePaths(instancesDir())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an instances root that is itself a symlink', () => {
|
||||||
|
const externalInstances = path.join(tempRoot, 'external-instances');
|
||||||
|
fs.mkdirSync(path.join(externalInstances, 'work'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(instancesDir()), { recursive: true });
|
||||||
|
fs.symlinkSync(externalInstances, instancesDir(), 'dir');
|
||||||
|
|
||||||
|
expect(listAccountInstanceNames(instancesDir())).toEqual([]);
|
||||||
|
expect(listAccountInstancePaths(instancesDir())).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps ccs doctor settings symlinks healthy when .locks exists', () => {
|
it('keeps ccs doctor settings symlinks healthy when .locks exists', () => {
|
||||||
createValidSettingsLayout();
|
createValidSettingsLayout();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import SharedManager from '../../src/management/shared-manager';
|
||||||
|
|
||||||
|
describe('shared CLAUDE.md memory', () => {
|
||||||
|
let tempHome = '';
|
||||||
|
let originalHome: string | undefined;
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalCcsDir: string | undefined;
|
||||||
|
|
||||||
|
const claudeFile = () => path.join(tempHome, '.claude', 'CLAUDE.md');
|
||||||
|
const ccsDir = () => path.join(tempHome, '.ccs');
|
||||||
|
const sharedFile = () => path.join(ccsDir(), 'shared', 'CLAUDE.md');
|
||||||
|
const instanceDir = (name: string) => path.join(ccsDir(), 'instances', name);
|
||||||
|
const instanceFile = (name: string) => path.join(instanceDir(name), 'CLAUDE.md');
|
||||||
|
|
||||||
|
function writeFile(filePath: string, content: string): void {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictFiles(filePath: string): string[] {
|
||||||
|
const prefix = `${path.basename(filePath)}.ccs-shared-conflict`;
|
||||||
|
return fs
|
||||||
|
.readdirSync(path.dirname(filePath))
|
||||||
|
.filter((entry) => entry === prefix || entry.startsWith(`${prefix}-`))
|
||||||
|
.map((entry) => path.join(path.dirname(filePath), entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLegacyProfiles(
|
||||||
|
profiles: Record<string, { shared_resource_mode?: string; bare?: boolean }>
|
||||||
|
): void {
|
||||||
|
writeFile(
|
||||||
|
path.join(ccsDir(), 'profiles.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: '2.0.0',
|
||||||
|
default: null,
|
||||||
|
profiles: Object.fromEntries(
|
||||||
|
Object.entries(profiles).map(([name, metadata]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
type: 'account',
|
||||||
|
created: '2026-08-08T00:00:00.000Z',
|
||||||
|
last_used: null,
|
||||||
|
...metadata,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectLinkTo(filePath: string, targetPath: string): void {
|
||||||
|
expect(fs.lstatSync(filePath).isSymbolicLink()).toBe(true);
|
||||||
|
expect(path.resolve(path.dirname(filePath), fs.readlinkSync(filePath))).toBe(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-claude-memory-'));
|
||||||
|
originalHome = process.env.HOME;
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
originalCcsDir = process.env.CCS_DIR;
|
||||||
|
|
||||||
|
spyOn(os, 'homedir').mockReturnValue(tempHome);
|
||||||
|
process.env.HOME = tempHome;
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
delete process.env.CCS_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.restore();
|
||||||
|
|
||||||
|
if (originalHome === undefined) delete process.env.HOME;
|
||||||
|
else process.env.HOME = originalHome;
|
||||||
|
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
|
||||||
|
else process.env.CCS_HOME = originalCcsHome;
|
||||||
|
if (originalCcsDir === undefined) delete process.env.CCS_DIR;
|
||||||
|
else process.env.CCS_DIR = originalCcsDir;
|
||||||
|
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds missing Markdown as empty text and links it through the shared root', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const workInstance = instanceDir('work');
|
||||||
|
fs.mkdirSync(workInstance, { recursive: true });
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(workInstance);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('');
|
||||||
|
expectLinkTo(sharedFile(), claudeFile());
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves canonical instructions and quarantines an older instance variant', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
writeFile(claudeFile(), '# Canonical\n');
|
||||||
|
writeFile(instanceFile('work'), '# Stale local copy\n');
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
const recoveries = conflictFiles(instanceFile('work'));
|
||||||
|
expect(recoveries).toHaveLength(1);
|
||||||
|
expect(fs.readFileSync(recoveries[0], 'utf8')).toBe('# Stale local copy\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts the only instance variant when the canonical file is absent', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
writeFile(instanceFile('work'), '# Adopt me\n');
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Adopt me\n');
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never inventories or mutates profile-local CLAUDE.md content', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
writeLegacyProfiles({
|
||||||
|
work: { shared_resource_mode: 'shared' },
|
||||||
|
sandbox: { shared_resource_mode: 'profile-local', bare: true },
|
||||||
|
});
|
||||||
|
writeFile(instanceFile('work'), '# Shared account\n');
|
||||||
|
writeFile(instanceFile('sandbox'), '# Isolated account\n');
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n');
|
||||||
|
expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Isolated account\n');
|
||||||
|
expect(conflictFiles(instanceFile('sandbox'))).toEqual([]);
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches uppercase profile metadata to its normalized instance name', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
writeLegacyProfiles({
|
||||||
|
work: { shared_resource_mode: 'shared' },
|
||||||
|
Sandbox: { shared_resource_mode: 'profile-local', bare: true },
|
||||||
|
});
|
||||||
|
writeFile(instanceFile('work'), '# Shared account\n');
|
||||||
|
writeFile(instanceFile('sandbox'), '# Uppercase isolated account\n');
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n');
|
||||||
|
expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Uppercase isolated account\n');
|
||||||
|
expect(conflictFiles(instanceFile('sandbox'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes legacy profile-name normalization collisions from inventory', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
writeLegacyProfiles({
|
||||||
|
work: { shared_resource_mode: 'shared' },
|
||||||
|
Sandbox: { shared_resource_mode: 'profile-local', bare: true },
|
||||||
|
sandbox: { shared_resource_mode: 'shared' },
|
||||||
|
});
|
||||||
|
writeFile(instanceFile('work'), '# Shared account\n');
|
||||||
|
writeFile(instanceFile('sandbox'), '# Ambiguous legacy account\n');
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Shared account\n');
|
||||||
|
expect(fs.readFileSync(instanceFile('sandbox'), 'utf8')).toBe('# Ambiguous legacy account\n');
|
||||||
|
expect(conflictFiles(instanceFile('sandbox'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quarantines every distinct variant instead of selecting by mtime', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
writeFile(instanceFile('work'), '# Work\n');
|
||||||
|
writeFile(instanceFile('personal'), '# Personal\n');
|
||||||
|
const now = Date.now();
|
||||||
|
fs.utimesSync(instanceFile('work'), new Date(now - 60_000), new Date(now - 60_000));
|
||||||
|
fs.utimesSync(instanceFile('personal'), new Date(now), new Date(now));
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('');
|
||||||
|
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Work\n');
|
||||||
|
expect(fs.readFileSync(conflictFiles(instanceFile('personal'))[0], 'utf8')).toBe(
|
||||||
|
'# Personal\n'
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
logSpy.mock.calls.some(([message]) =>
|
||||||
|
String(message).includes('No CLAUDE.md variant was selected')
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent after canonical-first reconciliation', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
writeFile(claudeFile(), '# Canonical\n');
|
||||||
|
writeFile(instanceFile('work'), '# Diverged\n');
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
const firstRecoveries = conflictFiles(instanceFile('work'));
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(conflictFiles(instanceFile('work'))).toEqual(firstRecoveries);
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never lets a later diverged instance overwrite canonical instructions', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
writeFile(claudeFile(), '# Canonical\n');
|
||||||
|
fs.mkdirSync(instanceDir('work'), { recursive: true });
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
fs.unlinkSync(instanceFile('work'));
|
||||||
|
writeFile(instanceFile('work'), '# Newer but local\n');
|
||||||
|
const future = new Date(Date.now() + 60_000);
|
||||||
|
fs.utimesSync(instanceFile('work'), future, future);
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||||
|
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe(
|
||||||
|
'# Newer but local\n'
|
||||||
|
);
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const linkErrorCode of ['EPERM', 'EACCES', 'ENOTSUP']) {
|
||||||
|
it(`copies conflict recovery when hard links fail with ${linkErrorCode}`, () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const originalLinkSync = fs.linkSync;
|
||||||
|
writeFile(claudeFile(), '# Canonical\n');
|
||||||
|
writeFile(instanceFile('work'), '# Diverged\n');
|
||||||
|
spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => {
|
||||||
|
if (String(newPath).includes('.ccs-shared-conflict')) {
|
||||||
|
throw Object.assign(new Error('simulated hard-link failure'), { code: linkErrorCode });
|
||||||
|
}
|
||||||
|
return originalLinkSync(existingPath, newPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Diverged\n');
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('restores the original divergence when hard-link and copy recovery both fail', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const originalCopyFileSync = fs.copyFileSync;
|
||||||
|
writeFile(claudeFile(), '# Canonical\n');
|
||||||
|
writeFile(instanceFile('work'), '# Diverged\n');
|
||||||
|
spyOn(fs, 'linkSync').mockImplementation(() => {
|
||||||
|
throw Object.assign(new Error('simulated hard-link failure'), { code: 'EPERM' });
|
||||||
|
});
|
||||||
|
spyOn(fs, 'copyFileSync').mockImplementation((source, destination, mode) => {
|
||||||
|
if (String(destination).includes('.ccs-shared-conflict')) {
|
||||||
|
throw Object.assign(new Error('simulated copy failure'), { code: 'EACCES' });
|
||||||
|
}
|
||||||
|
return originalCopyFileSync(source, destination, mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow(
|
||||||
|
'simulated hard-link failure'
|
||||||
|
);
|
||||||
|
expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Diverged\n');
|
||||||
|
expect(
|
||||||
|
fs.readdirSync(instanceDir('work')).some((entry) => entry.includes('.ccs-shared-claim-'))
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers an interrupted claim before inventory and adoption', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const claimPath = `${instanceFile('work')}.ccs-shared-claim-interrupted`;
|
||||||
|
writeFile(claimPath, '# Interrupted\n');
|
||||||
|
|
||||||
|
manager.linkSharedDirectories(instanceDir('work'));
|
||||||
|
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Interrupted\n');
|
||||||
|
expect(fs.existsSync(claimPath)).toBe(false);
|
||||||
|
expectLinkTo(instanceFile('work'), sharedFile());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves both copies and aborts when a concurrent writer replaces the claimed path', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const originalRenameSync = fs.renameSync;
|
||||||
|
writeFile(claudeFile(), '# Canonical\n');
|
||||||
|
writeFile(instanceFile('work'), '# Claimed\n');
|
||||||
|
spyOn(fs, 'renameSync').mockImplementation((oldPath, newPath) => {
|
||||||
|
originalRenameSync(oldPath, newPath);
|
||||||
|
if (oldPath === instanceFile('work') && String(newPath).includes('.ccs-shared-claim-')) {
|
||||||
|
writeFile(instanceFile('work'), '# Concurrent writer\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow(
|
||||||
|
'Concurrent replacement detected'
|
||||||
|
);
|
||||||
|
expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Concurrent writer\n');
|
||||||
|
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n');
|
||||||
|
expect(fs.readFileSync(claudeFile(), 'utf8')).toBe('# Canonical\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts when a late writer recreates the path during recovery publication', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const originalLinkSync = fs.linkSync;
|
||||||
|
writeFile(claudeFile(), '# Canonical\n');
|
||||||
|
writeFile(instanceFile('work'), '# Claimed\n');
|
||||||
|
spyOn(fs, 'linkSync').mockImplementation((existingPath, newPath) => {
|
||||||
|
const result = originalLinkSync(existingPath, newPath);
|
||||||
|
if (String(newPath).includes('.ccs-shared-conflict')) {
|
||||||
|
writeFile(instanceFile('work'), '# Late writer\n');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(instanceDir('work'))).toThrow(
|
||||||
|
'Path reappeared during reconciliation'
|
||||||
|
);
|
||||||
|
expect(fs.readFileSync(instanceFile('work'), 'utf8')).toBe('# Late writer\n');
|
||||||
|
expect(fs.readFileSync(conflictFiles(instanceFile('work'))[0], 'utf8')).toBe('# Claimed\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects linking through a symlinked instances root without touching its target', () => {
|
||||||
|
const manager = new SharedManager();
|
||||||
|
const managedInstances = path.join(ccsDir(), 'instances');
|
||||||
|
const externalInstances = path.join(tempHome, 'external-instances');
|
||||||
|
const externalWork = path.join(externalInstances, 'work');
|
||||||
|
writeFile(path.join(externalWork, 'CLAUDE.md'), '# External\n');
|
||||||
|
fs.mkdirSync(path.dirname(managedInstances), { recursive: true });
|
||||||
|
fs.symlinkSync(externalInstances, managedInstances, 'dir');
|
||||||
|
|
||||||
|
expect(() => manager.linkSharedDirectories(path.join(managedInstances, 'work'))).toThrow(
|
||||||
|
'Unsafe account instance path'
|
||||||
|
);
|
||||||
|
expect(fs.readFileSync(path.join(externalWork, 'CLAUDE.md'), 'utf8')).toBe('# External\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user