mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 16:19:27 +00:00
fix(proxy): harden stale daemon ownership checks
This commit is contained in:
@@ -103,36 +103,43 @@ export function removeLegacyOpenAICompatProxySession(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listOpenAICompatProxyProfileNames(): string[] {
|
function decodeProfileKey(profileKey: string): string[] {
|
||||||
|
try {
|
||||||
|
return [decodeURIComponent(profileKey)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listProfileNamesForSuffix(suffix: string, legacyName?: string): string[] {
|
||||||
try {
|
try {
|
||||||
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
|
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
|
||||||
const profileKeys = new Set<string>();
|
const profileKeys = new Set<string>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (
|
if (!entry.isFile() || entry.name === legacyName || !entry.name.endsWith(suffix)) {
|
||||||
!entry.isFile() ||
|
|
||||||
entry.name === 'session.json' ||
|
|
||||||
!entry.name.endsWith('.session.json')
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const profileKey = entry.name.slice(0, -'.session.json'.length);
|
const profileKey = entry.name.slice(0, -suffix.length);
|
||||||
if (!profileKey) {
|
if (!profileKey) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
profileKeys.add(profileKey);
|
profileKeys.add(profileKey);
|
||||||
}
|
}
|
||||||
return [...profileKeys].flatMap((profileKey) => {
|
return [...profileKeys].flatMap(decodeProfileKey);
|
||||||
try {
|
|
||||||
return [decodeURIComponent(profileKey)];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listOpenAICompatProxyProfileNames(): string[] {
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
...listProfileNamesForSuffix('.session.json', 'session.json'),
|
||||||
|
...listProfileNamesForSuffix('.daemon.pid', 'daemon.pid'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
||||||
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
||||||
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
||||||
|
|||||||
+51
-19
@@ -47,6 +47,31 @@ interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult {
|
|||||||
bindConflict?: boolean;
|
bindConflict?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OpenAICompatProxyHealthPayload {
|
||||||
|
service?: string;
|
||||||
|
profile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyProxyProcessOwnership(
|
||||||
|
pid: number,
|
||||||
|
profileName?: string
|
||||||
|
): ReturnType<typeof verifyProcessOwnership> {
|
||||||
|
const profilePattern = profileName
|
||||||
|
? new RegExp(`(^|\\s)--profile(?:\\s+|=)${escapeRegExp(profileName)}(?=\\s|$)`)
|
||||||
|
: null;
|
||||||
|
return verifyProcessOwnership(
|
||||||
|
pid,
|
||||||
|
(commandLine) =>
|
||||||
|
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||||
|
commandLine.includes('proxy-daemon-entry') &&
|
||||||
|
(!profilePattern || profilePattern.test(commandLine))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function generateProxyAuthToken(): string {
|
function generateProxyAuthToken(): string {
|
||||||
return crypto.randomBytes(24).toString('hex');
|
return crypto.randomBytes(24).toString('hex');
|
||||||
}
|
}
|
||||||
@@ -162,7 +187,10 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isOpenAICompatProxyRunning(port: number): Promise<boolean> {
|
export async function isOpenAICompatProxyRunning(
|
||||||
|
port: number,
|
||||||
|
expectedProfileName?: string
|
||||||
|
): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const req = http.request(
|
const req = http.request(
|
||||||
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
|
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
|
||||||
@@ -176,8 +204,11 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(body) as { service?: string };
|
const payload = JSON.parse(body) as OpenAICompatProxyHealthPayload;
|
||||||
resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME);
|
resolve(
|
||||||
|
payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME &&
|
||||||
|
(!expectedProfileName || payload.profile === expectedProfileName)
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
resolve(false);
|
resolve(false);
|
||||||
}
|
}
|
||||||
@@ -199,11 +230,11 @@ async function getOpenAICompatProxyStatusForProfile(
|
|||||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||||
const session = state.session;
|
const session = state.session;
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return { running: false, profileName };
|
return { running: false, profileName, pid: state.pid || undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = session.port;
|
const port = session.port;
|
||||||
const running = await isOpenAICompatProxyRunning(port);
|
const running = await isOpenAICompatProxyRunning(port, profileName);
|
||||||
return {
|
return {
|
||||||
running,
|
running,
|
||||||
pid: running ? state.pid || undefined : undefined,
|
pid: running ? state.pid || undefined : undefined,
|
||||||
@@ -219,7 +250,8 @@ async function getLegacyOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStat
|
|||||||
}
|
}
|
||||||
|
|
||||||
const port = session?.port;
|
const port = session?.port;
|
||||||
const running = typeof port === 'number' ? await isOpenAICompatProxyRunning(port) : false;
|
const running =
|
||||||
|
typeof port === 'number' ? await isOpenAICompatProxyRunning(port, session?.profileName) : false;
|
||||||
return {
|
return {
|
||||||
running,
|
running,
|
||||||
pid: running ? pid || undefined : pid || undefined,
|
pid: running ? pid || undefined : pid || undefined,
|
||||||
@@ -297,12 +329,7 @@ async function stopOpenAICompatProxyUnlocked(
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownership = verifyProcessOwnership(
|
const ownership = verifyProxyProcessOwnership(pid, profileName);
|
||||||
pid,
|
|
||||||
(commandLine) =>
|
|
||||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
|
||||||
commandLine.includes('proxy-daemon-entry')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ownership === 'not-owned') {
|
if (ownership === 'not-owned') {
|
||||||
removeOpenAICompatProxyState(state, profileName);
|
removeOpenAICompatProxyState(state, profileName);
|
||||||
@@ -368,12 +395,7 @@ async function stopLegacyOpenAICompatProxyUnlocked(): Promise<{
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const ownership = verifyProcessOwnership(
|
const ownership = verifyProxyProcessOwnership(pid);
|
||||||
pid,
|
|
||||||
(commandLine) =>
|
|
||||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
|
||||||
commandLine.includes('proxy-daemon-entry')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (ownership === 'not-owned' || ownership === 'not-running') {
|
if (ownership === 'not-owned' || ownership === 'not-running') {
|
||||||
removeLegacyOpenAICompatProxyPid();
|
removeLegacyOpenAICompatProxyPid();
|
||||||
@@ -490,6 +512,16 @@ export async function startOpenAICompatProxy(
|
|||||||
if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
|
if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
|
||||||
return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` };
|
return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` };
|
||||||
}
|
}
|
||||||
|
if (status.pid && !status.port) {
|
||||||
|
const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName);
|
||||||
|
if (!stopped.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
port: preferredPort,
|
||||||
|
error: stopped.error || 'Failed to clear stale proxy state',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const daemonEntry = await resolveDaemonEntrypoint();
|
const daemonEntry = await resolveDaemonEntrypoint();
|
||||||
if (!daemonEntry) {
|
if (!daemonEntry) {
|
||||||
@@ -565,7 +597,7 @@ export async function startOpenAICompatProxy(
|
|||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if (await isOpenAICompatProxyRunning(port)) {
|
if (await isOpenAICompatProxyRunning(port, profile.profileName)) {
|
||||||
if (persistState) {
|
if (persistState) {
|
||||||
commitState();
|
commitState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-rou
|
|||||||
import {
|
import {
|
||||||
getLegacyOpenAICompatProxyPidPath,
|
getLegacyOpenAICompatProxyPidPath,
|
||||||
getLegacyOpenAICompatProxySessionPath,
|
getLegacyOpenAICompatProxySessionPath,
|
||||||
|
getOpenAICompatProxyPidPath,
|
||||||
getOpenAICompatProxySessionPath,
|
getOpenAICompatProxySessionPath,
|
||||||
} from '../../../src/proxy/proxy-daemon-paths';
|
} from '../../../src/proxy/proxy-daemon-paths';
|
||||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||||
@@ -470,4 +471,181 @@ describe('openai proxy daemon lifecycle', () => {
|
|||||||
expect(status.running).toBe(false);
|
expect(status.running).toBe(false);
|
||||||
expect(status.profileName).toBe('never-started');
|
expect(status.profileName).toBe('never-started');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not treat another profile on the same port as already running', async () => {
|
||||||
|
const sharedPort = await getPort();
|
||||||
|
const firstSettingsPath = path.join(tempDir, 'profile-b.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
firstSettingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstProfile = resolveOpenAICompatProfileConfig('profile-b', firstSettingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
if (!firstProfile) {
|
||||||
|
throw new Error('Expected first OpenAI-compatible profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstStart = await startOpenAICompatProxy(firstProfile, { port: sharedPort });
|
||||||
|
expect(firstStart.success).toBe(true);
|
||||||
|
|
||||||
|
const secondSettingsPath = path.join(tempDir, 'profile-a.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
secondSettingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondProfile = resolveOpenAICompatProfileConfig('profile-a', secondSettingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
if (!secondProfile) {
|
||||||
|
throw new Error('Expected second OpenAI-compatible profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
getOpenAICompatProxySessionPath('profile-a'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
profileName: 'profile-a',
|
||||||
|
settingsPath: secondProfile.settingsPath,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: sharedPort,
|
||||||
|
baseUrl: secondProfile.baseUrl,
|
||||||
|
authToken: 'stale-token-a',
|
||||||
|
model: secondProfile.model,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondStart = await startOpenAICompatProxy(secondProfile);
|
||||||
|
expect(secondStart.success).toBe(true);
|
||||||
|
expect(secondStart.alreadyRunning).not.toBe(true);
|
||||||
|
expect(secondStart.authToken).not.toBe('stale-token-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces pid-only proxy state before starting a new daemon', async () => {
|
||||||
|
const firstPort = await getPort();
|
||||||
|
const replacementPort = await getPort();
|
||||||
|
const settingsPath = path.join(tempDir, 'pid-only.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
settingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = resolveOpenAICompatProfileConfig('pid-only', settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
if (!profile) {
|
||||||
|
throw new Error('Expected pid-only OpenAI-compatible profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstStart = await startOpenAICompatProxy(profile, { port: firstPort });
|
||||||
|
expect(firstStart.success).toBe(true);
|
||||||
|
expect(firstStart.pid).toBeDefined();
|
||||||
|
|
||||||
|
fs.unlinkSync(getOpenAICompatProxySessionPath('pid-only'));
|
||||||
|
|
||||||
|
const replacement = await startOpenAICompatProxy(profile, { port: replacementPort });
|
||||||
|
expect(replacement.success).toBe(true);
|
||||||
|
expect(replacement.port).toBe(replacementPort);
|
||||||
|
expect(replacement.pid).toBeDefined();
|
||||||
|
expect(replacement.pid).not.toBe(firstStart.pid);
|
||||||
|
|
||||||
|
const stalePidPath = getOpenAICompatProxyPidPath('pid-only');
|
||||||
|
expect(fs.readFileSync(stalePidPath, 'utf8').trim()).toBe(String(replacement.pid));
|
||||||
|
expect((await fetch(`http://127.0.0.1:${replacementPort}/health`)).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stop another profile when a stale pid file points at its daemon', async () => {
|
||||||
|
const firstPort = await getPort();
|
||||||
|
const secondPort = await getPort();
|
||||||
|
const firstSettingsPath = path.join(tempDir, 'profile-b-stale-pid.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
firstSettingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const firstProfile = resolveOpenAICompatProfileConfig('profile-b-stale-pid', firstSettingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
if (!firstProfile) {
|
||||||
|
throw new Error('Expected first OpenAI-compatible profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort });
|
||||||
|
expect(firstStart.success).toBe(true);
|
||||||
|
expect(firstStart.pid).toBeDefined();
|
||||||
|
|
||||||
|
const secondSettingsPath = path.join(tempDir, 'profile-a-stale-pid.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
secondSettingsPath,
|
||||||
|
JSON.stringify({
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const secondProfile = resolveOpenAICompatProfileConfig('profile-a-stale-pid', secondSettingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid',
|
||||||
|
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
if (!secondProfile) {
|
||||||
|
throw new Error('Expected second OpenAI-compatible profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
getOpenAICompatProxyPidPath('profile-a-stale-pid'),
|
||||||
|
String(firstStart.pid),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondStart = await startOpenAICompatProxy(secondProfile, { port: secondPort });
|
||||||
|
expect(secondStart.success).toBe(true);
|
||||||
|
expect(secondStart.port).toBe(secondPort);
|
||||||
|
expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200);
|
||||||
|
expect((await fetch(`http://127.0.0.1:${secondPort}/health`)).status).toBe(200);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import {
|
import {
|
||||||
getLegacyOpenAICompatProxySessionPath,
|
getLegacyOpenAICompatProxySessionPath,
|
||||||
|
getOpenAICompatProxyPidPath,
|
||||||
getOpenAICompatProxySessionPath,
|
getOpenAICompatProxySessionPath,
|
||||||
} from '../../../src/proxy/proxy-daemon-paths';
|
} from '../../../src/proxy/proxy-daemon-paths';
|
||||||
import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state';
|
import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state';
|
||||||
@@ -42,4 +43,11 @@ describe('listOpenAICompatProxyProfileNames', () => {
|
|||||||
|
|
||||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes pid-only profile state in the discovered profile list', () => {
|
||||||
|
fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true });
|
||||||
|
fs.writeFileSync(getOpenAICompatProxyPidPath('ccg'), '123\n', 'utf8');
|
||||||
|
|
||||||
|
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user