fix: normalize plugin metadata across shared launch flows

This commit is contained in:
Tam Nhu Tran committed 2026-03-17 09:56:31 -04:00
1 parent 2cb5b4ebec
commit 1043a433f6
8 files changed
+431 -40

No files matched your search

+114 -15
View File
@@ -7,14 +7,35 @@ import SharedManager from '../../src/management/shared-manager';
describe('InstanceManager MCP sync', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
function writeMarketplaceRegistry(registryPath: string, installLocation: string): void {
fs.mkdirSync(path.dirname(registryPath), { recursive: true });
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation,
},
},
null,
2
),
'utf8'
);
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-mcp-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
spyOn(os, 'homedir').mockReturnValue(tempRoot);
process.env.HOME = tempRoot;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
@@ -22,6 +43,9 @@ describe('InstanceManager MCP sync', () => {
afterEach(() => {
mock.restore();
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
@@ -71,7 +95,9 @@ describe('InstanceManager MCP sync', () => {
const synced = manager.syncMcpServers(instancePath);
expect(synced).toBe(true);
const instanceContent = JSON.parse(fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8'));
const instanceContent = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
);
expect(instanceContent.otherKey).toBe('keep-me');
expect(instanceContent.mcpServers).toEqual({
globalOnly: { command: 'global-cmd' },
@@ -96,45 +122,118 @@ describe('InstanceManager MCP sync', () => {
});
it('skips shared symlinks and MCP sync for bare instance creation', async () => {
const linkSharedSpy = spyOn(SharedManager.prototype, 'linkSharedDirectories').mockImplementation(
() => {}
);
const linkSharedSpy = spyOn(
SharedManager.prototype,
'linkSharedDirectories'
).mockImplementation(() => {});
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const normalizeSharedPluginMetadataSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('sandbox');
const sharedRegistryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
writeMarketplaceRegistry(
sharedRegistryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
await manager.ensureInstance('sandbox', { mode: 'isolated' }, { bare: true });
const normalized = JSON.parse(fs.readFileSync(sharedRegistryPath, 'utf8'));
expect(linkSharedSpy).not.toHaveBeenCalled();
expect(normalizeSharedPluginMetadataSpy).not.toHaveBeenCalled();
expect(fs.existsSync(instancePath)).toBe(true);
expect(normalized['claude-code-plugins'].installLocation).toBe(
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
);
expect(fs.existsSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'))).toBe(
false
);
expect(syncMcpSpy).not.toHaveBeenCalled();
});
it('normalizes shared plugin metadata for existing non-bare instances', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const normalizeSharedPluginMetadataSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
writeMarketplaceRegistry(
path.join(instancePath, 'plugins', 'known_marketplaces.json'),
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
await manager.ensureInstance('work', { mode: 'isolated' });
expect(normalizeSharedPluginMetadataSpy).toHaveBeenCalledTimes(1);
const normalized = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(normalized['claude-code-plugins'].installLocation).toBe(
path.join(tempRoot, '.claude', 'plugins', 'marketplaces', 'claude-code-plugins')
);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
it('normalizes shared plugin metadata during new non-bare instance creation', async () => {
spyOn(SharedManager.prototype, 'syncProjectContext').mockResolvedValue(undefined);
spyOn(SharedManager.prototype, 'syncAdvancedContinuityArtifacts').mockResolvedValue(undefined);
const syncMcpSpy = spyOn(InstanceManager.prototype, 'syncMcpServers').mockImplementation(
() => false
);
const registryPath = path.join(tempRoot, '.claude', 'plugins', 'known_marketplaces.json');
writeMarketplaceRegistry(
registryPath,
path.join(
tempRoot,
'.ccs',
'instances',
'work',
'plugins',
'marketplaces',
'claude-code-plugins'
)
);
const manager = new InstanceManager();
const instancePath = await manager.ensureInstance('work', { mode: 'isolated' });
const expected = path.join(
tempRoot,
'.claude',
'plugins',
'marketplaces',
'claude-code-plugins'
);
const normalizedShared = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
const normalizedInstance = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(normalizedShared['claude-code-plugins'].installLocation).toBe(expected);
expect(normalizedInstance['claude-code-plugins'].installLocation).toBe(expected);
expect(syncMcpSpy).toHaveBeenCalledWith(instancePath);
});
});
+87 -4
View File
@@ -5,11 +5,13 @@ import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:te
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import SharedManager from '../../src/management/shared-manager';
import SharedManager, {
normalizePluginMetadataPathString,
} from '../../src/management/shared-manager';
// Test the normalization regex pattern directly
const normalizePluginPaths = (content: string): string => {
return content.replace(/\/\.ccs\/instances\/[^/]+\//g, '/.claude/');
return normalizePluginMetadataPathString(content);
};
describe('SharedManager', () => {
@@ -17,12 +19,14 @@ describe('SharedManager', () => {
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
let originalPlatform: PropertyDescriptor | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-shared-manager-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
spyOn(os, 'homedir').mockReturnValue(tempRoot);
process.env.HOME = tempRoot;
@@ -42,6 +46,10 @@ describe('SharedManager', () => {
if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir;
else delete process.env.CCS_DIR;
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
if (tempRoot && fs.existsSync(tempRoot)) {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
@@ -149,9 +157,8 @@ describe('SharedManager', () => {
});
it('should handle Windows-style paths (backslash)', () => {
// Windows paths use backslashes, regex should not match
const input = 'C:\\Users\\user\\.ccs\\instances\\ck\\plugins\\cache';
expect(normalizePluginPaths(input)).toBe(input);
expect(normalizePluginPaths(input)).toBe('C:\\Users\\user\\.claude\\plugins\\cache');
});
});
});
@@ -185,5 +192,81 @@ describe('SharedManager', () => {
'/home/kai/.claude/plugins/marketplaces/claude-code-plugins'
);
});
it('rewrites Windows-style known_marketplaces.json paths on disk', () => {
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'C:\\Users\\kai\\.ccs\\instances\\work\\plugins\\marketplaces\\claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
manager.normalizeMarketplaceRegistryPaths();
const normalized = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
expect(normalized['claude-code-plugins'].installLocation).toBe(
'C:\\Users\\kai\\.claude\\plugins\\marketplaces\\claude-code-plugins'
);
});
it('normalizes copied shared and instance metadata under Windows fallback', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
spyOn(fs, 'symlinkSync').mockImplementation(() => {
throw Object.assign(new Error('simulated symlink failure'), { code: 'EPERM' });
});
const pluginsDir = path.join(tempRoot, '.claude', 'plugins');
fs.mkdirSync(pluginsDir, { recursive: true });
const registryPath = path.join(pluginsDir, 'known_marketplaces.json');
fs.writeFileSync(
registryPath,
JSON.stringify(
{
'claude-code-plugins': {
installLocation:
'/home/kai/.ccs/instances/work/plugins/marketplaces/claude-code-plugins',
},
},
null,
2
),
'utf8'
);
const manager = new SharedManager();
const instancePath = path.join(tempRoot, '.ccs', 'instances', 'personal');
fs.mkdirSync(instancePath, { recursive: true });
manager.linkSharedDirectories(instancePath);
const expected = '/home/kai/.claude/plugins/marketplaces/claude-code-plugins';
const claudeRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
const sharedRegistry = JSON.parse(
fs.readFileSync(
path.join(tempRoot, '.ccs', 'shared', 'plugins', 'known_marketplaces.json'),
'utf8'
)
);
const instanceRegistry = JSON.parse(
fs.readFileSync(path.join(instancePath, 'plugins', 'known_marketplaces.json'), 'utf8')
);
expect(claudeRegistry['claude-code-plugins'].installLocation).toBe(expected);
expect(sharedRegistry['claude-code-plugins'].installLocation).toBe(expected);
expect(instanceRegistry['claude-code-plugins'].installLocation).toBe(expected);
});
});
});
@@ -1,4 +1,14 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test';
import { EventEmitter } from 'events';
import * as childProcess from 'child_process';
import * as fs from 'fs';
@@ -115,6 +125,7 @@ preferences:
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
let SharedManager: typeof import('../../../src/management/shared-manager').default;
beforeAll(async () => {
registerChildProcessMock();
@@ -123,6 +134,9 @@ beforeAll(async () => {
execClaude = shellExecutor.execClaude;
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
const sharedManagerModule = await import('../../../src/management/shared-manager');
SharedManager = sharedManagerModule.default;
const headless = await import('../../../src/delegation/headless-executor');
HeadlessExecutor = headless.HeadlessExecutor;
});
@@ -242,6 +256,32 @@ describe('CLAUDECODE environment stripping', () => {
expect(env.DISABLE_AUTOUPDATER).toBeUndefined();
});
it('execClaude normalizes shared plugin metadata before default-profile launch', () => {
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' });
expect(normalizeSpy).toHaveBeenCalledWith(undefined);
});
it('execClaude normalizes shared plugin metadata using CLAUDE_CONFIG_DIR when provided', () => {
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
const instancePath = path.join(os.tmpdir(), 'ccs-shell-executor-instance');
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CLAUDE_CONFIG_DIR: instancePath,
});
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
});
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
writeConfigWithAutoUpdatePreference(false);
process.env.CLAUDECODE = 'nested';
@@ -1,10 +1,21 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
mock,
spyOn,
} from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes';
import SharedManager from '../../../src/management/shared-manager';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
@@ -98,6 +109,8 @@ describe('web-server claude-extension-routes', () => {
});
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
@@ -143,6 +156,61 @@ describe('web-server claude-extension-routes', () => {
expect(payload.sharedSettings.json).toContain('"env"');
});
it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => {
const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm');
const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json');
const normalizeSpy = spyOn(
SharedManager.prototype,
'normalizeSharedPluginMetadataPaths'
).mockImplementation(() => {});
fs.writeFileSync(
glmSettingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.example.test',
ANTHROPIC_API_KEY: 'sk-ant-test-123456',
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
CLAUDE_CONFIG_DIR: explicitConfigDir,
},
},
null,
2
) + '\n'
);
const config = createEmptyUnifiedConfig();
config.profiles.glm = {
type: 'api',
settings: glmSettingsPath,
};
config.accounts.work = {
created: '2026-03-15T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
};
config.default = 'work';
config.continuity = {
inherit_from_account: {
glm: 'work',
},
};
saveUnifiedConfig(config);
const response = await fetch(`${baseUrl}/api/claude-extension/setup?profile=glm&host=vscode`);
expect(response.status).toBe(200);
const payload = (await response.json()) as {
ideSettings: { json: string };
};
expect(payload.ideSettings.json).toContain(explicitConfigDir);
expect(normalizeSpy.mock.calls.some(([configDir]) => configDir === explicitConfigDir)).toBe(
true
);
});
it('renders Windsurf setup for default account resolution via CLAUDE_CONFIG_DIR', async () => {
const response = await fetch(
`${baseUrl}/api/claude-extension/setup?profile=default&host=windsurf`
@@ -179,7 +247,11 @@ describe('web-server claude-extension-routes', () => {
expect(createResponse.status).toBe(201);
const created = (await createResponse.json()) as {
binding: { id: string; effectiveIdeSettingsPath: string; usesDefaultIdeSettingsPath: boolean };
binding: {
id: string;
effectiveIdeSettingsPath: string;
usesDefaultIdeSettingsPath: boolean;
};
};
expect(created.binding.effectiveIdeSettingsPath).toBe(ideSettingsPath);
expect(created.binding.usesDefaultIdeSettingsPath).toBe(false);
@@ -418,7 +490,10 @@ describe('web-server claude-extension-routes', () => {
);
expect(applyResponse.status).toBe(200);
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
let ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<
string,
unknown
>;
const appliedEnv = ideSettings['claudeCode.environmentVariables'] as Array<{
name: string;
value: string;
@@ -426,7 +501,9 @@ describe('web-server claude-extension-routes', () => {
expect(appliedEnv.some((entry) => entry.name === 'KEEP_ME' && entry.value === '1')).toBe(true);
expect(
appliedEnv.some((entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456')
appliedEnv.some(
(entry) => entry.name === 'ANTHROPIC_API_KEY' && entry.value === 'sk-ant-test-123456'
)
).toBe(true);
const verifyAppliedResponse = await fetch(
@@ -452,7 +529,9 @@ describe('web-server claude-extension-routes', () => {
ideSettings = JSON.parse(fs.readFileSync(ideSettingsPath, 'utf8')) as Record<string, unknown>;
expect(ideSettings['editor.tabSize']).toBe(2);
expect(ideSettings['claudeCode.disableLoginPrompt']).toBeUndefined();
expect(ideSettings['claudeCode.environmentVariables']).toEqual([{ name: 'KEEP_ME', value: '1' }]);
expect(ideSettings['claudeCode.environmentVariables']).toEqual([
{ name: 'KEEP_ME', value: '1' },
]);
const verifyResetResponse = await fetch(
`${baseUrl}/api/claude-extension/bindings/${created.binding.id}/verify`