mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 00:14:58 +00:00
Merge pull request #1200 from kaitranntt/kai/feat/1199-shared-resource-controls
feat(auth): add shared resource controls
This commit is contained in:
46 files changed
+2721
-144
No files matched your search
@@ -63,6 +63,35 @@ describe('auth command args parsing', () => {
|
||||
expect(parsed.contextGroup).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('parses shared resource mode value for resources command', () => {
|
||||
const parsed = parseArgs(['work', '--mode', 'profile-local'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('parses inline shared resource mode value', () => {
|
||||
const parsed = parseArgs(['work', '--mode=shared'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('shared');
|
||||
});
|
||||
|
||||
it('flags missing shared resource mode value as empty string', () => {
|
||||
const parsed = parseArgs(['work', '--mode'], { allowMode: true });
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBe('');
|
||||
});
|
||||
|
||||
it('treats shared resource mode as unknown unless the command opts in', () => {
|
||||
const parsed = parseArgs(['work', '--mode', 'shared']);
|
||||
|
||||
expect(parsed.profileName).toBe('work');
|
||||
expect(parsed.mode).toBeUndefined();
|
||||
expect(parsed.unknownFlags).toEqual(['--mode']);
|
||||
});
|
||||
|
||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import ProfileRegistry from '../../src/auth/profile-registry';
|
||||
import InstanceManager from '../../src/management/instance-manager';
|
||||
import { handleResources } from '../../src/auth/commands/resources-command';
|
||||
import { handleCreate } from '../../src/auth/commands/create-command';
|
||||
import { handleList } from '../../src/auth/commands/list-command';
|
||||
import { handleShow } from '../../src/auth/commands/show-command';
|
||||
import { handleBackup } from '../../src/auth/commands/backup-command';
|
||||
import { handleRemove } from '../../src/auth/commands/remove-command';
|
||||
import { handleDefault, handleResetDefault } from '../../src/auth/commands/default-command';
|
||||
|
||||
async function expectProfileExit(action: () => Promise<void>): Promise<string> {
|
||||
const originalExit = process.exit;
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const lines: string[] = [];
|
||||
|
||||
process.exit = ((code?: number) => {
|
||||
throw new Error(`process.exit(${code ?? 0})`);
|
||||
}) as typeof process.exit;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
console.error = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(action()).rejects.toThrow('process.exit(7)');
|
||||
} finally {
|
||||
process.exit = originalExit;
|
||||
console.log = originalLog;
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
describe('auth resources command', () => {
|
||||
let tempRoot = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalUnifiedMode: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-resources-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
process.env.CCS_HOME = tempRoot;
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||
else delete process.env.CCS_HOME;
|
||||
|
||||
if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
|
||||
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||
|
||||
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('updates an existing account shared resource mode without changing context metadata', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
path.join(ccsDir, 'instances', 'work')
|
||||
);
|
||||
const lines: string[] = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
lines.push(args.map(String).join(' '));
|
||||
};
|
||||
|
||||
try {
|
||||
await handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local', '--json']
|
||||
);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const output = JSON.parse(lines.join('\n')) as {
|
||||
shared_resource_mode: string;
|
||||
bare?: boolean;
|
||||
};
|
||||
expect(output.shared_resource_mode).toBe('profile-local');
|
||||
expect(output.bare).toBe(true);
|
||||
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBe('profile-local');
|
||||
expect(account.bare).toBe(true);
|
||||
expect(account.context_mode).toBe('shared');
|
||||
expect(account.context_group).toBe('sprint-a');
|
||||
});
|
||||
|
||||
it('rolls back inferred shared metadata when instance reconciliation fails', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue(
|
||||
new Error('reconcile failed')
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local']
|
||||
)
|
||||
).rejects.toThrow('reconcile failed');
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBeUndefined();
|
||||
expect(account.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects resources --mode without a value instead of showing current mode', async () => {
|
||||
const ccsDir = path.join(tempRoot, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
const output = await expectProfileExit(() =>
|
||||
handleResources(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode']
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).toContain('Missing value for --mode: expected shared|profile-local');
|
||||
});
|
||||
|
||||
it('rejects --mode on auth create instead of silently ignoring it', async () => {
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
const output = await expectProfileExit(() =>
|
||||
handleCreate(
|
||||
{
|
||||
registry,
|
||||
instanceMgr,
|
||||
version: 'test',
|
||||
},
|
||||
['work', '--mode', 'profile-local']
|
||||
)
|
||||
);
|
||||
|
||||
expect(output).toContain('Unknown option(s): "--mode"');
|
||||
});
|
||||
|
||||
it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => {
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
const ctx = { registry, instanceMgr, version: 'test' };
|
||||
const cases: Array<[string, () => Promise<void>]> = [
|
||||
['list', () => handleList(ctx, ['--mode', 'shared'])],
|
||||
['show', () => handleShow(ctx, ['work', '--mode=shared'])],
|
||||
['backup', () => handleBackup(ctx, ['work', '--mode', 'profile-local'])],
|
||||
['remove', () => handleRemove(ctx, ['work', '--mode', 'shared'])],
|
||||
['default', () => handleDefault(ctx, ['work', '--mode', 'shared'])],
|
||||
['reset-default', () => handleResetDefault(ctx, ['--mode', 'shared'])],
|
||||
];
|
||||
|
||||
for (const [commandName, action] of cases) {
|
||||
const output = await expectProfileExit(action);
|
||||
expect(`${commandName}\n${output}`).toContain('Unknown option(s): "--mode"');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,7 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.bare).toBe(true);
|
||||
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('persists bare flag for unified accounts and merged projection', () => {
|
||||
@@ -126,8 +127,52 @@ describe('profile-registry context normalization', () => {
|
||||
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
expect(accounts.work.bare).toBe(true);
|
||||
expect(accounts.work.shared_resource_mode).toBe('profile-local');
|
||||
|
||||
const merged = registry.getAllProfilesMerged();
|
||||
expect(merged.work.bare).toBe(true);
|
||||
expect(merged.work.shared_resource_mode).toBe('profile-local');
|
||||
});
|
||||
|
||||
it('lets explicit shared resource mode override stale unified bare metadata', () => {
|
||||
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-03-05T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' shared_resource_mode: shared',
|
||||
' bare: true',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const accounts = registry.getAllAccountsUnified();
|
||||
|
||||
expect(accounts.work.shared_resource_mode).toBe('shared');
|
||||
expect(accounts.work.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists explicit profile-local mode for legacy profiles', () => {
|
||||
const registry = new ProfileRegistry();
|
||||
registry.createProfile('work', {
|
||||
type: 'account',
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
|
||||
const profile = registry.getProfile('work');
|
||||
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||
expect(profile.bare).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
normalizeSharedResourceMetadata,
|
||||
resolveSharedResourcePolicy,
|
||||
sharedResourceModeToMetadata,
|
||||
} from '../../../src/auth/shared-resource-policy';
|
||||
|
||||
describe('shared resource policy', () => {
|
||||
it('defaults missing metadata to shared resources', () => {
|
||||
const policy = resolveSharedResourcePolicy(undefined);
|
||||
|
||||
expect(policy.mode).toBe('shared');
|
||||
expect(policy.inferred).toBe(true);
|
||||
expect(policy.profileLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves legacy bare metadata as profile-local', () => {
|
||||
const policy = resolveSharedResourcePolicy({ bare: true });
|
||||
|
||||
expect(policy.mode).toBe('profile-local');
|
||||
expect(policy.inferred).toBe(true);
|
||||
expect(policy.profileLocal).toBe(true);
|
||||
});
|
||||
|
||||
it('lets explicit shared mode override stale bare metadata', () => {
|
||||
const normalized = normalizeSharedResourceMetadata({
|
||||
shared_resource_mode: 'shared',
|
||||
bare: true,
|
||||
});
|
||||
|
||||
expect(normalized.shared_resource_mode).toBe('shared');
|
||||
expect(normalized.bare).toBeUndefined();
|
||||
expect(resolveSharedResourcePolicy(normalized).profileLocal).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes explicit profile-local mode to legacy-compatible bare metadata', () => {
|
||||
const normalized = normalizeSharedResourceMetadata({
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
|
||||
expect(normalized.shared_resource_mode).toBe('profile-local');
|
||||
expect(normalized.bare).toBe(true);
|
||||
});
|
||||
|
||||
it('builds write metadata for both supported modes', () => {
|
||||
expect(sharedResourceModeToMetadata('profile-local')).toEqual({
|
||||
shared_resource_mode: 'profile-local',
|
||||
bare: true,
|
||||
});
|
||||
expect(sharedResourceModeToMetadata('shared')).toEqual({
|
||||
shared_resource_mode: 'shared',
|
||||
bare: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('sync command MCP sync behavior', () => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('syncs MCP servers only to non-bare profiles', async () => {
|
||||
it('syncs MCP servers only to shared-resource profiles', async () => {
|
||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||
success: true,
|
||||
@@ -42,6 +42,8 @@ describe('sync command MCP sync behavior', () => {
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
work: profile(),
|
||||
sandbox: profile({ bare: true }),
|
||||
local: profile({ shared_resource_mode: 'profile-local' }),
|
||||
restored: profile({ bare: true, shared_resource_mode: 'shared' }),
|
||||
personal: profile(),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
@@ -55,8 +57,16 @@ describe('sync command MCP sync behavior', () => {
|
||||
|
||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']);
|
||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
'work',
|
||||
'restored',
|
||||
'personal',
|
||||
]);
|
||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/tmp/work',
|
||||
'/tmp/restored',
|
||||
'/tmp/personal',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips MCP sync when all profiles are bare', async () => {
|
||||
@@ -69,7 +79,7 @@ describe('sync command MCP sync behavior', () => {
|
||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||
sandbox: profile({ bare: true }),
|
||||
experiment: profile({ bare: true }),
|
||||
experiment: profile({ shared_resource_mode: 'profile-local' }),
|
||||
});
|
||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||
|
||||
|
||||
@@ -177,6 +177,43 @@ describe('migration-manager legacy kimi compatibility', () => {
|
||||
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('migrates legacy bare accounts to profile-local shared resource mode', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'profiles.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
default: 'sandbox',
|
||||
profiles: {
|
||||
sandbox: {
|
||||
type: 'account',
|
||||
created: '2026-02-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
bare: true,
|
||||
},
|
||||
restored: {
|
||||
type: 'account',
|
||||
created: '2026-02-02T00:00:00.000Z',
|
||||
last_used: null,
|
||||
shared_resource_mode: 'shared',
|
||||
bare: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await migrate(false);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const unified = loadUnifiedConfig();
|
||||
expect(unified?.accounts.sandbox.shared_resource_mode).toBe('profile-local');
|
||||
expect(unified?.accounts.sandbox.bare).toBe(true);
|
||||
expect(unified?.accounts.restored.shared_resource_mode).toBe('shared');
|
||||
expect(unified?.accounts.restored.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies safe browser defaults when migrating legacy config files', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
@@ -262,6 +262,51 @@ describe('web-server account-routes context normalization', () => {
|
||||
expect(fs.existsSync(instancePath)).toBe(false);
|
||||
});
|
||||
|
||||
it('reports effective shared resource mode without initializing an account instance', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 12',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' shared_resource_mode: profile-local',
|
||||
' restored:',
|
||||
' created: "2026-02-02T00:00:00.000Z"',
|
||||
' shared_resource_mode: shared',
|
||||
' bare: true',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const payload = await getJson<{
|
||||
accounts: Array<{
|
||||
name: string;
|
||||
shared_resource_mode?: string;
|
||||
shared_resource_inferred?: boolean;
|
||||
bare?: boolean;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
const work = payload.accounts.find((account) => account.name === 'work');
|
||||
const restored = payload.accounts.find((account) => account.name === 'restored');
|
||||
expect(work?.shared_resource_mode).toBe('profile-local');
|
||||
expect(work?.shared_resource_inferred).toBe(false);
|
||||
expect(work?.bare).toBe(true);
|
||||
expect(restored?.shared_resource_mode).toBe('shared');
|
||||
expect(restored?.shared_resource_inferred).toBe(false);
|
||||
expect(restored?.bare).toBeUndefined();
|
||||
expect(fs.existsSync(path.join(ccsDir, 'instances', 'work'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not delete metadata when instance deletion fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
@@ -354,6 +399,148 @@ describe('web-server account-routes context normalization', () => {
|
||||
expect(work?.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('updates existing account shared resource mode without changing history sync metadata', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
' context_mode: shared',
|
||||
' context_group: sprint-a',
|
||||
' continuity_mode: deeper',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||
path.join(ccsDir, 'instances', 'work')
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await response.json()) as {
|
||||
shared_resource_mode: string;
|
||||
shared_resource_inferred?: boolean;
|
||||
};
|
||||
expect(payload.shared_resource_mode).toBe('profile-local');
|
||||
expect(payload.shared_resource_inferred).toBe(false);
|
||||
expect(ensureSpy).toHaveBeenCalledWith(
|
||||
'work',
|
||||
{
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'deeper',
|
||||
},
|
||||
{ bare: true }
|
||||
);
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBe('profile-local');
|
||||
expect(account.bare).toBe(true);
|
||||
expect(account.context_mode).toBe('shared');
|
||||
expect(account.context_group).toBe('sprint-a');
|
||||
expect(account.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('rolls back inferred shared resource metadata when instance reconciliation fails', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue(
|
||||
new Error('reconcile failed')
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('reconcile failed');
|
||||
} finally {
|
||||
ensureSpy.mockRestore();
|
||||
}
|
||||
|
||||
const registry = new ProfileRegistry();
|
||||
const account = registry.getAllAccountsUnified().work;
|
||||
expect(account.shared_resource_mode).toBeUndefined();
|
||||
expect(account.bare).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid shared resource mode updates', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||
shared_resource_mode: 'plugins-only',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('shared_resource_mode');
|
||||
});
|
||||
|
||||
it('rejects shared resource updates for CLIProxy account identifiers', async () => {
|
||||
const response = await putJson(baseUrl, '/api/accounts/gemini:test/shared-resources', {
|
||||
shared_resource_mode: 'profile-local',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('CLIProxy');
|
||||
});
|
||||
|
||||
it('rejects shared mode updates without context_group', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
@@ -285,6 +285,218 @@ describe('web-server shared-routes', () => {
|
||||
expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md')));
|
||||
});
|
||||
|
||||
it('hides plugin infrastructure directories when no plugins are installed', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
const cacheDir = path.join(pluginsDir, 'cache');
|
||||
fs.mkdirSync(path.join(cacheDir, 'payloads'), { recursive: true });
|
||||
fs.writeFileSync(path.join(cacheDir, 'plugin-index.json'), '{}');
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ version: 2, plugins: {} }, null, 2)
|
||||
);
|
||||
|
||||
const listPayload = await getJson<{
|
||||
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||
}>(baseUrl, '/api/shared/plugins');
|
||||
|
||||
expect(listPayload.items).toEqual([]);
|
||||
|
||||
const contentResponse = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}`
|
||||
);
|
||||
|
||||
expect(contentResponse.status).toBe(404);
|
||||
const contentPayload = (await contentResponse.json()) as { error: string };
|
||||
expect(contentPayload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('lists installed plugins from the shared plugin registry', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'installed_plugins.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
plugins: {
|
||||
'discord@claude-plugins-official': [
|
||||
{
|
||||
installPath: path.join(pluginsDir, 'cache', 'discord'),
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'known_marketplaces.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
'claude-plugins-official': {
|
||||
source: { type: 'github', repo: 'anthropics/claude-plugins' },
|
||||
installLocation: path.join(pluginsDir, 'marketplaces', 'claude-plugins-official'),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const listPayload = await getJson<{
|
||||
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||
}>(baseUrl, '/api/shared/plugins');
|
||||
|
||||
expect(listPayload.items).toEqual([
|
||||
{
|
||||
name: 'discord@claude-plugins-official',
|
||||
type: 'plugin',
|
||||
description: 'Installed from claude-plugins-official; 1 record in shared registry',
|
||||
path: 'plugin-registry:discord%40claude-plugins-official',
|
||||
},
|
||||
]);
|
||||
|
||||
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=plugins&path=${encodeURIComponent(listPayload.items[0].path)}`
|
||||
);
|
||||
|
||||
expect(contentPayload.content).toContain('# discord@claude-plugins-official');
|
||||
expect(contentPayload.content).toContain('## Marketplace Registry Entry');
|
||||
expect(contentPayload.content).toContain('## Plugin Registry Entry');
|
||||
expect(contentPayload.contentPath).toBe(
|
||||
fs.realpathSync(path.join(pluginsDir, 'installed_plugins.json'))
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects plugin registry prototype keys as plugin names', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
fs.mkdirSync(pluginsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginsDir, 'installed_plugins.json'),
|
||||
JSON.stringify({ version: 2, plugins: {} }, null, 2)
|
||||
);
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent('plugin-registry:toString')}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('returns real shared settings content when settings.json is present', async () => {
|
||||
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: { ANTHROPIC_MODEL: 'claude-sonnet-4-5' },
|
||||
permissions: { allow: ['Bash(git status)'] },
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const summaryPayload = await getJson<{ settings: boolean; total: number }>(
|
||||
baseUrl,
|
||||
'/api/shared/summary'
|
||||
);
|
||||
expect(summaryPayload.settings).toBe(true);
|
||||
expect(summaryPayload.total).toBe(1);
|
||||
|
||||
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(contentPayload.content).toContain('"ANTHROPIC_MODEL": "claude-sonnet-4-5"');
|
||||
expect(contentPayload.content).toContain('"allow"');
|
||||
expect(contentPayload.contentPath).toBe(fs.realpathSync(settingsPath));
|
||||
});
|
||||
|
||||
it('returns shared settings content when settings.json links to the Claude config file', async () => {
|
||||
const claudeDir = path.join(tempHome, '.claude');
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
const claudeSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
fs.writeFileSync(
|
||||
claudeSettingsPath,
|
||||
JSON.stringify({ hooks: { PreToolUse: [] }, env: { ANTHROPIC_MODEL: 'claude-opus-4-1' } })
|
||||
);
|
||||
|
||||
const sharedSettingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
createFileSymlink(claudeSettingsPath, sharedSettingsPath);
|
||||
|
||||
const summaryPayload = await getJson<{ settings: boolean; total: number }>(
|
||||
baseUrl,
|
||||
'/api/shared/summary'
|
||||
);
|
||||
expect(summaryPayload.settings).toBe(true);
|
||||
expect(summaryPayload.total).toBe(1);
|
||||
|
||||
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(contentPayload.content).toContain('"ANTHROPIC_MODEL": "claude-opus-4-1"');
|
||||
expect(contentPayload.contentPath).toBe(fs.realpathSync(claudeSettingsPath));
|
||||
});
|
||||
|
||||
it('rejects shared settings content when settings.json resolves outside the shared directory', async () => {
|
||||
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||
const outsideSettingsPath = path.join(tempHome, 'outside-settings.json');
|
||||
fs.writeFileSync(outsideSettingsPath, '{"env":{"SECRET":"do-not-read"}}');
|
||||
createFileSymlink(outsideSettingsPath, settingsPath);
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('rejects shared settings symlinks to non-settings files inside the Claude config directory', async () => {
|
||||
const claudeDir = path.join(tempHome, '.claude');
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeDir;
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
|
||||
const otherClaudeFile = path.join(claudeDir, 'credentials.json');
|
||||
fs.writeFileSync(otherClaudeFile, '{"SECRET":"do-not-read"}');
|
||||
createFileSymlink(otherClaudeFile, path.join(ccsDir, 'shared', 'settings.json'));
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}`
|
||||
);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toBe('Shared content not found');
|
||||
});
|
||||
|
||||
it('returns README content for a shared plugin directory when available', async () => {
|
||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||
const pluginDir = path.join(pluginsDir, 'legacy-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs');
|
||||
|
||||
const payload = await getJson<{ content: string; contentPath: string }>(
|
||||
baseUrl,
|
||||
`/api/shared/content?type=plugins&path=${encodeURIComponent(pluginDir)}`
|
||||
);
|
||||
|
||||
expect(payload.content).toContain('Shared docs');
|
||||
expect(payload.contentPath).toBe(fs.realpathSync(path.join(pluginDir, 'README.md')));
|
||||
});
|
||||
|
||||
it('does not use markdown frontmatter fences as descriptions when frontmatter is malformed', async () => {
|
||||
const commandsDir = path.join(ccsDir, 'shared', 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
|
||||
Reference in new issue
Block a user