mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 14:19:56 +00:00
fix(settings): confine dashboard settings paths (#1231)
This commit is contained in:
@@ -63,13 +63,90 @@ const { logRouteError, respondInternalError } = createRouteErrorHelpers('setting
|
|||||||
|
|
||||||
function resolvePathWithin(basePath: string, targetPath: string): string {
|
function resolvePathWithin(basePath: string, targetPath: string): string {
|
||||||
const resolvedBase = path.resolve(basePath);
|
const resolvedBase = path.resolve(basePath);
|
||||||
const resolvedTarget = path.resolve(targetPath);
|
const resolvedTarget = path.resolve(
|
||||||
if (resolvedTarget !== resolvedBase && !resolvedTarget.startsWith(`${resolvedBase}${path.sep}`)) {
|
path.isAbsolute(targetPath) ? targetPath : path.join(resolvedBase, targetPath)
|
||||||
|
);
|
||||||
|
if (!isPathWithin(resolvedBase, resolvedTarget)) {
|
||||||
throw new Error('Invalid settings path');
|
throw new Error('Invalid settings path');
|
||||||
}
|
}
|
||||||
return resolvedTarget;
|
return resolvedTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePathForComparison(targetPath: string): string {
|
||||||
|
const resolvedPath = path.resolve(targetPath);
|
||||||
|
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathWithin(basePath: string, targetPath: string): boolean {
|
||||||
|
const normalizedBase = normalizePathForComparison(basePath);
|
||||||
|
const normalizedTarget = normalizePathForComparison(targetPath);
|
||||||
|
const relative = path.relative(normalizedBase, normalizedTarget);
|
||||||
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRealPath(targetPath: string): string | null {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync.native(targetPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRealCcsDir(): string {
|
||||||
|
return safeRealPath(getCcsDir()) ?? path.resolve(getCcsDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePathWithinRealCcsDir(targetPath: string): void {
|
||||||
|
if (!isPathWithin(getRealCcsDir(), targetPath)) {
|
||||||
|
throw new Error('Invalid settings path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireExistingSettingsPathWithinCcs(settingsPath: string): boolean {
|
||||||
|
const realSettingsPath = safeRealPath(settingsPath);
|
||||||
|
if (!realSettingsPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
requirePathWithinRealCcsDir(realSettingsPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExistingParentRealPath(targetPath: string): string | null {
|
||||||
|
let currentPath = path.dirname(targetPath);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const realParentPath = safeRealPath(currentPath);
|
||||||
|
if (realParentPath) {
|
||||||
|
return realParentPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = path.dirname(currentPath);
|
||||||
|
if (parentPath === currentPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
currentPath = parentPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWritableSettingsPathWithinCcs(settingsPath: string): void {
|
||||||
|
const realSettingsPath = safeRealPath(settingsPath);
|
||||||
|
if (realSettingsPath) {
|
||||||
|
requirePathWithinRealCcsDir(realSettingsPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realParentPath = findExistingParentRealPath(settingsPath);
|
||||||
|
if (!realParentPath) {
|
||||||
|
throw new Error('Invalid settings path');
|
||||||
|
}
|
||||||
|
requirePathWithinRealCcsDir(realParentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSettingsCandidatePath(ccsDir: string, settingsPath: string): string {
|
||||||
|
return resolvePathWithin(ccsDir, expandPath(settingsPath));
|
||||||
|
}
|
||||||
|
|
||||||
function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
|
function requireSensitiveLocalAccess(req: Request, res: Response): boolean {
|
||||||
return requireLocalAccessWhenAuthDisabled(
|
return requireLocalAccessWhenAuthDisabled(
|
||||||
req,
|
req,
|
||||||
@@ -114,24 +191,25 @@ function resolveSettingsPath(profileOrVariant: string): string {
|
|||||||
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
|
path.join(resolvedCcsDir, `${profileOrVariant}.settings.json`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return path.resolve(resolveProviderSettingsPath(directProvider));
|
return resolvePathWithin(resolvedCcsDir, resolveProviderSettingsPath(directProvider));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a variant
|
// Check if this is a variant
|
||||||
const variants = listVariants();
|
const variants = listVariants();
|
||||||
const variant = variants[profileOrVariant];
|
const variant = variants[profileOrVariant];
|
||||||
if (variant?.settings) {
|
if (variant?.settings) {
|
||||||
return path.resolve(expandPath(variant.settings));
|
return resolveSettingsCandidatePath(resolvedCcsDir, variant.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let configuredSettingsPath: string | undefined;
|
||||||
try {
|
try {
|
||||||
const configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant];
|
configuredSettingsPath = loadConfigSafe().profiles[profileOrVariant];
|
||||||
if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) {
|
|
||||||
return path.resolve(expandPath(configuredSettingsPath));
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to the conventional ~/.ccs/<profile>.settings.json path below.
|
// Fall back to the conventional ~/.ccs/<profile>.settings.json path below.
|
||||||
}
|
}
|
||||||
|
if (typeof configuredSettingsPath === 'string' && configuredSettingsPath.trim().length > 0) {
|
||||||
|
return resolveSettingsCandidatePath(resolvedCcsDir, configuredSettingsPath);
|
||||||
|
}
|
||||||
|
|
||||||
// Regular profile settings
|
// Regular profile settings
|
||||||
return resolvePathWithin(
|
return resolvePathWithin(
|
||||||
@@ -328,13 +406,21 @@ async function resolvePreviewImageAnalysisStatus(profileOrVariant: string, setti
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
function writeSettingsAtomically(settingsPath: string, settings: Settings): void {
|
||||||
|
requireWritableSettingsPathWithinCcs(settingsPath);
|
||||||
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
||||||
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
|
requireWritableSettingsPathWithinCcs(tempPath);
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n', { flag: 'wx' });
|
||||||
fs.renameSync(tempPath, settingsPath);
|
fs.renameSync(tempPath, settingsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function withSettingsFileLock<T>(settingsPath: string, callback: () => T): T {
|
function withSettingsFileLock<T>(settingsPath: string, callback: () => T): T {
|
||||||
const lockTarget = fs.existsSync(settingsPath) ? settingsPath : path.dirname(settingsPath);
|
requireWritableSettingsPathWithinCcs(settingsPath);
|
||||||
|
const lockTarget = safeRealPath(settingsPath)
|
||||||
|
? settingsPath
|
||||||
|
: findExistingParentRealPath(settingsPath);
|
||||||
|
if (!lockTarget) {
|
||||||
|
throw new Error('Invalid settings path');
|
||||||
|
}
|
||||||
let release: (() => void) | undefined;
|
let release: (() => void) | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -357,6 +443,10 @@ function loadCanonicalProfileSettings(
|
|||||||
persist = false,
|
persist = false,
|
||||||
strictPersist = false
|
strictPersist = false
|
||||||
): Settings {
|
): Settings {
|
||||||
|
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
|
||||||
|
throw new Error('Settings not found');
|
||||||
|
}
|
||||||
|
|
||||||
const loaded = loadSettings(settingsPath);
|
const loaded = loadSettings(settingsPath);
|
||||||
const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded);
|
const canonicalized = canonicalizeProfileSettings(profileOrVariant, loaded);
|
||||||
|
|
||||||
@@ -399,7 +489,7 @@ router.get('/:profile', async (req: Request, res: Response): Promise<void> => {
|
|||||||
const { profile } = req.params;
|
const { profile } = req.params;
|
||||||
const settingsPath = resolveSettingsPath(profile);
|
const settingsPath = resolveSettingsPath(profile);
|
||||||
|
|
||||||
if (!fs.existsSync(settingsPath)) {
|
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
|
||||||
res.status(404).json({ error: 'Settings not found' });
|
res.status(404).json({ error: 'Settings not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -435,7 +525,7 @@ router.get('/:profile/raw', async (req: Request, res: Response): Promise<void> =
|
|||||||
const { profile } = req.params;
|
const { profile } = req.params;
|
||||||
const settingsPath = resolveSettingsPath(profile);
|
const settingsPath = resolveSettingsPath(profile);
|
||||||
|
|
||||||
if (!fs.existsSync(settingsPath)) {
|
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
|
||||||
res.status(404).json({ error: 'Settings not found' });
|
res.status(404).json({ error: 'Settings not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -556,11 +646,13 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
|||||||
const existingContent = fs.readFileSync(settingsPath, 'utf8');
|
const existingContent = fs.readFileSync(settingsPath, 'utf8');
|
||||||
if (existingContent !== newContent) {
|
if (existingContent !== newContent) {
|
||||||
const backupDir = path.join(ccsDir, 'backups');
|
const backupDir = path.join(ccsDir, 'backups');
|
||||||
|
requireWritableSettingsPathWithinCcs(path.join(backupDir, '.settings-backup-probe'));
|
||||||
if (!fs.existsSync(backupDir)) {
|
if (!fs.existsSync(backupDir)) {
|
||||||
fs.mkdirSync(backupDir, { recursive: true });
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
}
|
}
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
|
backupPath = path.join(backupDir, `${profile}.${timestamp}.settings.json`);
|
||||||
|
requireWritableSettingsPathWithinCcs(backupPath);
|
||||||
fs.copyFileSync(settingsPath, backupPath);
|
fs.copyFileSync(settingsPath, backupPath);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -569,7 +661,8 @@ router.put('/:profile', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
const tempPath = `${settingsPath}.tmp.${process.pid}`;
|
||||||
fs.writeFileSync(tempPath, newContent);
|
requireWritableSettingsPathWithinCcs(tempPath);
|
||||||
|
fs.writeFileSync(tempPath, newContent, { flag: 'wx' });
|
||||||
fs.renameSync(tempPath, settingsPath);
|
fs.renameSync(tempPath, settingsPath);
|
||||||
newMtime = fs.statSync(settingsPath).mtime.getTime();
|
newMtime = fs.statSync(settingsPath).mtime.getTime();
|
||||||
});
|
});
|
||||||
@@ -604,7 +697,7 @@ router.get('/:profile/presets', (req: Request, res: Response): void => {
|
|||||||
const { profile } = req.params;
|
const { profile } = req.params;
|
||||||
const settingsPath = resolveSettingsPath(profile);
|
const settingsPath = resolveSettingsPath(profile);
|
||||||
|
|
||||||
if (!fs.existsSync(settingsPath)) {
|
if (!requireExistingSettingsPathWithinCcs(settingsPath)) {
|
||||||
res.json({ presets: [] });
|
res.json({ presets: [] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ function writeProfileSettings(
|
|||||||
return settingsPath;
|
return settingsPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSymlinkIfAvailable(
|
||||||
|
targetPath: string,
|
||||||
|
linkPath: string,
|
||||||
|
type?: fs.symlink.Type
|
||||||
|
): boolean {
|
||||||
|
try {
|
||||||
|
fs.symlinkSync(targetPath, linkPath, type);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as { code?: string }).code;
|
||||||
|
if (process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES')) {
|
||||||
|
console.warn('Skipping symlink escape regression: symlink creation unavailable on Windows.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('settings-routes image-analysis status', () => {
|
describe('settings-routes image-analysis status', () => {
|
||||||
let server: Server;
|
let server: Server;
|
||||||
let baseUrl = '';
|
let baseUrl = '';
|
||||||
@@ -199,7 +217,7 @@ describe('settings-routes image-analysis status', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses the configured custom settings path for status and persistence diagnostics', async () => {
|
it('uses the configured custom settings path for status and persistence diagnostics', async () => {
|
||||||
const customSettingsPath = path.join(tempHome, 'profiles', 'foo.bar.settings.json');
|
const customSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'foo.bar.settings.json');
|
||||||
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
|
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
|
||||||
profiles: {
|
profiles: {
|
||||||
'foo.bar': customSettingsPath,
|
'foo.bar': customSettingsPath,
|
||||||
@@ -231,6 +249,196 @@ describe('settings-routes image-analysis status', () => {
|
|||||||
expect(body.imageAnalysisStatus.hookInstalled).toBe(true);
|
expect(body.imageAnalysisStatus.hookInstalled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves configured profile and variant relative settings paths under the CCS directory', async () => {
|
||||||
|
const profileSettingsPath = path.join(tempHome, '.ccs', 'profiles', 'relative.settings.json');
|
||||||
|
const variantSettingsPath = path.join(
|
||||||
|
tempHome,
|
||||||
|
'.ccs',
|
||||||
|
'variants',
|
||||||
|
'relative-var.settings.json'
|
||||||
|
);
|
||||||
|
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
|
||||||
|
profiles: {
|
||||||
|
relative: 'profiles/relative.settings.json',
|
||||||
|
},
|
||||||
|
cliproxy: {
|
||||||
|
'relative-var': {
|
||||||
|
provider: 'gemini',
|
||||||
|
settings: 'variants/relative-var.settings.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeProfileSettings(
|
||||||
|
tempHome,
|
||||||
|
'relative',
|
||||||
|
{
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
ANTHROPIC_API_KEY: 'relative-profile-key',
|
||||||
|
},
|
||||||
|
profileSettingsPath
|
||||||
|
);
|
||||||
|
writeProfileSettings(
|
||||||
|
tempHome,
|
||||||
|
'relative-var',
|
||||||
|
{
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
ANTHROPIC_API_KEY: 'relative-variant-key',
|
||||||
|
},
|
||||||
|
variantSettingsPath
|
||||||
|
);
|
||||||
|
|
||||||
|
const profileResponse = await fetch(`${baseUrl}/api/settings/relative/raw`);
|
||||||
|
expect(profileResponse.status).toBe(200);
|
||||||
|
const profileBody = (await profileResponse.json()) as { path: string };
|
||||||
|
expect(profileBody.path).toBe(profileSettingsPath);
|
||||||
|
|
||||||
|
const variantResponse = await fetch(`${baseUrl}/api/settings/relative-var/raw`);
|
||||||
|
expect(variantResponse.status).toBe(200);
|
||||||
|
const variantBody = (await variantResponse.json()) as { path: string };
|
||||||
|
expect(variantBody.path).toBe(variantSettingsPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects configured profile settings paths outside the CCS directory', async () => {
|
||||||
|
const outsideSettingsPath = path.join(tempHome, 'outside', 'escaped.settings.json');
|
||||||
|
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
|
||||||
|
profiles: {
|
||||||
|
escaped: outsideSettingsPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeJson(outsideSettingsPath, {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'outside-secret',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const readResponse = await fetch(`${baseUrl}/api/settings/escaped/raw`);
|
||||||
|
expect(readResponse.status).toBe(500);
|
||||||
|
|
||||||
|
const writeResponse = await fetch(`${baseUrl}/api/settings/escaped`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'overwritten-secret',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(writeResponse.status).toBe(500);
|
||||||
|
|
||||||
|
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
|
||||||
|
env: { ANTHROPIC_AUTH_TOKEN: string };
|
||||||
|
};
|
||||||
|
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('outside-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects raw reads through settings-file symlink escapes', async () => {
|
||||||
|
const outsideSettingsPath = path.join(tempHome, 'outside-raw.settings.json');
|
||||||
|
const linkedSettingsPath = path.join(tempHome, '.ccs', 'linked.settings.json');
|
||||||
|
writeJson(outsideSettingsPath, {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'raw-outside-secret',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.dirname(linkedSettingsPath), { recursive: true });
|
||||||
|
if (!createSymlinkIfAvailable(outsideSettingsPath, linkedSettingsPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/linked/raw`);
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
|
||||||
|
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
|
||||||
|
env: { ANTHROPIC_AUTH_TOKEN: string };
|
||||||
|
};
|
||||||
|
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('raw-outside-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects variant settings paths outside the CCS directory', async () => {
|
||||||
|
const outsideSettingsPath = path.join(tempHome, 'outside-variant', 'escaped.settings.json');
|
||||||
|
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
|
||||||
|
profiles: {},
|
||||||
|
cliproxy: {
|
||||||
|
evilvar: {
|
||||||
|
provider: 'gemini',
|
||||||
|
settings: outsideSettingsPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeJson(outsideSettingsPath, {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'variant-outside-secret',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/evilvar/raw`);
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
|
||||||
|
const writeResponse = await fetch(`${baseUrl}/api/settings/evilvar`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'variant-overwritten-secret',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(writeResponse.status).toBe(500);
|
||||||
|
|
||||||
|
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
|
||||||
|
env: { ANTHROPIC_AUTH_TOKEN: string };
|
||||||
|
};
|
||||||
|
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-outside-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects variant PUT settings paths that escape through symlinked parents', async () => {
|
||||||
|
const outsideDir = path.join(tempHome, 'outside-variant-link');
|
||||||
|
const outsideSettingsPath = path.join(outsideDir, 'escaped.settings.json');
|
||||||
|
const linkDir = path.join(tempHome, '.ccs', 'variant-link');
|
||||||
|
const linkedSettingsPath = path.join(linkDir, 'escaped.settings.json');
|
||||||
|
fs.mkdirSync(outsideDir, { recursive: true });
|
||||||
|
writeJson(outsideSettingsPath, {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'variant-link-original',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fs.mkdirSync(path.dirname(linkDir), { recursive: true });
|
||||||
|
if (!createSymlinkIfAvailable(outsideDir, linkDir, 'dir')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJson(path.join(tempHome, '.ccs', 'config.json'), {
|
||||||
|
profiles: {},
|
||||||
|
cliproxy: {
|
||||||
|
'linked-var': {
|
||||||
|
provider: 'gemini',
|
||||||
|
settings: linkedSettingsPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api/settings/linked-var`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'variant-link-overwritten',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
|
||||||
|
const outsideSettings = JSON.parse(fs.readFileSync(outsideSettingsPath, 'utf8')) as {
|
||||||
|
env: { ANTHROPIC_AUTH_TOKEN: string };
|
||||||
|
};
|
||||||
|
expect(outsideSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('variant-link-original');
|
||||||
|
});
|
||||||
|
|
||||||
it('previews image-analysis status from unsaved editor settings', async () => {
|
it('previews image-analysis status from unsaved editor settings', async () => {
|
||||||
writeProfileSettings(tempHome, 'glm', {
|
writeProfileSettings(tempHome, 'glm', {
|
||||||
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
ANTHROPIC_BASE_URL: 'https://api.z.ai/v1',
|
||||||
|
|||||||
Reference in New Issue
Block a user