mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 14:13:18 +00:00
fix: degrade WebSearch launch provisioning failures (#1571)
This commit is contained in:
1 parent
813a7a043a
commit
1a0398579a
13 files changed
+225
-33
No files matched your search
@@ -53,7 +53,7 @@
|
||||
"src/cliproxy/executor/auth-coordinator.ts:176",
|
||||
"src/cliproxy/executor/auth-coordinator.ts:317",
|
||||
"src/cliproxy/executor/browser-launch-setup.ts:120",
|
||||
"src/cliproxy/executor/index.ts:378",
|
||||
"src/cliproxy/executor/index.ts:391",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:129",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:162",
|
||||
"src/cliproxy/executor/lifecycle-manager.ts:59",
|
||||
@@ -174,7 +174,7 @@
|
||||
"src/dispatcher/cli-argument-parser.ts:324",
|
||||
"src/dispatcher/cli-argument-parser.ts:328",
|
||||
"src/dispatcher/flows/default-flow.ts:76",
|
||||
"src/dispatcher/flows/settings-flow.ts:132",
|
||||
"src/dispatcher/flows/settings-flow.ts:135",
|
||||
"src/docker/docker-assets.ts:35",
|
||||
"src/docker/docker-executor.ts:154",
|
||||
"src/docker/docker-executor.ts:435",
|
||||
|
||||
@@ -71,6 +71,17 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
function makeWebSearchProvisioningFail(): void {
|
||||
const ccsDir = path.join(tmpHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
'version: 13\nwebsearch:\n enabled: true\n providers:\n duckduckgo:\n enabled: true\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
@@ -81,6 +92,120 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('keeps WebSearch provisioning strict for --config settings writes', async () => {
|
||||
makeWebSearchProvisioningFail();
|
||||
|
||||
let requestCount = 0;
|
||||
const server = http.createServer((_req, res) => {
|
||||
requestCount += 1;
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
throw new Error('Test server did not bind to a TCP port');
|
||||
}
|
||||
|
||||
try {
|
||||
await expect(
|
||||
execClaudeWithCLIProxy(
|
||||
fakeClaudePath,
|
||||
'gemini',
|
||||
[
|
||||
'--proxy-host',
|
||||
'127.0.0.1',
|
||||
'--proxy-port',
|
||||
String(address.port),
|
||||
'--proxy-auth-token',
|
||||
'SECRET_TOKEN_FOR_VALIDATION',
|
||||
'--remote-only',
|
||||
'--config',
|
||||
],
|
||||
{}
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'WebSearch is enabled, but CCS could not prepare the local WebSearch tool.'
|
||||
);
|
||||
|
||||
expect(requestCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades WebSearch provisioning failures for CLIProxy launches', async () => {
|
||||
makeWebSearchProvisioningFail();
|
||||
|
||||
const markerPath = path.join(tmpHome, 'fake-claude-launched');
|
||||
fs.writeFileSync(
|
||||
fakeClaudePath,
|
||||
`#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
|
||||
let requestCount = 0;
|
||||
const server = http.createServer((_req, res) => {
|
||||
requestCount += 1;
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
throw new Error('Test server did not bind to a TCP port');
|
||||
}
|
||||
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await execClaudeWithCLIProxy(
|
||||
fakeClaudePath,
|
||||
'gemini',
|
||||
[
|
||||
'--proxy-host',
|
||||
'127.0.0.1',
|
||||
'--proxy-port',
|
||||
String(address.port),
|
||||
'--proxy-auth-token',
|
||||
'SECRET_TOKEN_FOR_VALIDATION',
|
||||
'--remote-only',
|
||||
'--print',
|
||||
'hello',
|
||||
],
|
||||
{}
|
||||
);
|
||||
|
||||
expect(await waitForFile(markerPath)).toBe(true);
|
||||
expect(requestCount).toBeGreaterThan(0);
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('validates conflicting browser launch flags before remote proxy checks', async () => {
|
||||
let requestCount = 0;
|
||||
const server = http.createServer((_req, res) => {
|
||||
|
||||
@@ -26,7 +26,11 @@ import { supportsModelConfig } from '../model-catalog';
|
||||
import { CLIProxyProvider, ExecutorConfig } from '../types';
|
||||
import { CodexReasoningProxy } from '../ai-providers/codex-reasoning-proxy';
|
||||
import { ToolSanitizationProxy } from '../proxy/tool-sanitization-proxy';
|
||||
import { ensureWebSearchMcpOrThrow, displayWebSearchStatus } from '../../utils/websearch-manager';
|
||||
import {
|
||||
ensureWebSearchMcpForLaunch,
|
||||
ensureWebSearchMcpOrThrow,
|
||||
displayWebSearchStatus,
|
||||
} from '../../utils/websearch-manager';
|
||||
import {
|
||||
ensureImageAnalysisMcpOrThrow,
|
||||
syncImageAnalysisMcpToConfigDir,
|
||||
@@ -157,10 +161,7 @@ export async function execClaudeWithCLIProxy(
|
||||
log,
|
||||
});
|
||||
|
||||
// Setup first-class CCS WebSearch runtime
|
||||
ensureWebSearchMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
displayWebSearchStatus();
|
||||
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
log(`Provider: ${providerConfig.displayName}`);
|
||||
@@ -191,6 +192,7 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
const {
|
||||
forceConfig,
|
||||
forceImport,
|
||||
addAccount,
|
||||
showAccounts,
|
||||
useAccount,
|
||||
@@ -224,6 +226,8 @@ export async function execClaudeWithCLIProxy(
|
||||
|
||||
// Handle --config
|
||||
if (forceConfig && supportsModelConfig(provider)) {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
|
||||
// Block --config for composite variants (per-tier models in config.yaml)
|
||||
if (cfg.isComposite) {
|
||||
const variantName = cfg.profileName || provider;
|
||||
@@ -266,8 +270,17 @@ export async function execClaudeWithCLIProxy(
|
||||
await handleLogout(authCtx);
|
||||
|
||||
// Handle --import (early exit, Kiro only)
|
||||
if (forceImport) {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
}
|
||||
await handleImport(authCtx);
|
||||
|
||||
// Setup first-class CCS WebSearch runtime for non-strict user launches.
|
||||
const shouldDisplayWebSearchStatus = ensureWebSearchMcpForLaunch();
|
||||
if (shouldDisplayWebSearchStatus) {
|
||||
displayWebSearchStatus();
|
||||
}
|
||||
|
||||
// 3. Ensure OAuth completed (if provider requires it)
|
||||
const remoteAuthToken = proxyConfig.authToken?.trim();
|
||||
const skipLocalAuth = resolveSkipLocalAuth(remoteAuthToken, useRemoteProxy);
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
appendThirdPartyWebSearchToolArgs,
|
||||
appendWebSearchTrace,
|
||||
createWebSearchTraceContext,
|
||||
ensureWebSearchMcpOrThrow,
|
||||
ensureWebSearchMcpForLaunch,
|
||||
getWebSearchHookEnv,
|
||||
readWebSearchTraceRecords,
|
||||
syncWebSearchMcpToConfigDir,
|
||||
@@ -159,7 +159,7 @@ export class HeadlessExecutor {
|
||||
);
|
||||
}
|
||||
|
||||
ensureWebSearchMcpOrThrow();
|
||||
ensureWebSearchMcpForLaunch();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '../../cliproxy';
|
||||
import { getEffectiveEnvVars, getCompositeEnvVars } from '../../cliproxy/config/env-builder';
|
||||
import { resolveLifecyclePort } from '../../cliproxy/config/port-manager';
|
||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
||||
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
||||
import {
|
||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||
@@ -38,9 +37,6 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
|
||||
const imageAnalysisMcpReady =
|
||||
resolvedTarget === 'claude' ? ensureImageAnalysisMcpOrThrow() : true;
|
||||
if (resolvedTarget === 'claude') {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
}
|
||||
const provider = profileInfo.provider || (profileInfo.name as CLIProxyProvider);
|
||||
const expandedCliproxySettingsPath = profileInfo.settingsPath
|
||||
? expandPath(profileInfo.settingsPath)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { fail, info } from '../../utils/ui';
|
||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
||||
import { ensureWebSearchMcpForLaunch } from '../../utils/websearch-manager';
|
||||
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
||||
import {
|
||||
ensureProfileHooks as ensureImageAnalyzerHooks,
|
||||
@@ -23,7 +23,7 @@ export async function runCopilotFlow(ctx: ProfileDispatchContext): Promise<void>
|
||||
resolveProfileContinuityInheritance,
|
||||
} = ctx;
|
||||
|
||||
ensureWebSearchMcpOrThrow();
|
||||
ensureWebSearchMcpForLaunch();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
if (resolvedTarget === 'claude') {
|
||||
if (imageAnalysisMcpReady) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { fail, info } from '../../utils/ui';
|
||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
||||
import { ensureWebSearchMcpForLaunch } from '../../utils/websearch-manager';
|
||||
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
|
||||
import { installImageAnalyzerHook } from '../../utils/hooks';
|
||||
import type { ProfileDispatchContext } from '../dispatcher-context';
|
||||
@@ -19,7 +19,7 @@ export async function runCursorFlow(ctx: ProfileDispatchContext): Promise<void>
|
||||
resolveProfileContinuityInheritance,
|
||||
} = ctx;
|
||||
|
||||
ensureWebSearchMcpOrThrow();
|
||||
ensureWebSearchMcpForLaunch();
|
||||
installImageAnalyzerHook();
|
||||
ensureImageAnalyzerHooks({
|
||||
profileName: profileInfo.name,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
validateAnthropicKey,
|
||||
} from '../../utils/api-key-validator';
|
||||
import {
|
||||
ensureWebSearchMcpOrThrow,
|
||||
ensureWebSearchMcpForLaunch,
|
||||
displayWebSearchStatus,
|
||||
getWebSearchHookEnv,
|
||||
syncWebSearchMcpToConfigDir,
|
||||
@@ -94,15 +94,18 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
|
||||
if (browserAttachRuntime?.warning) {
|
||||
process.stderr.write(`${warn(browserAttachRuntime.warning)}\n`);
|
||||
}
|
||||
let shouldDisplayWebSearchStatus = true;
|
||||
if (resolvedTarget === 'claude') {
|
||||
ensureWebSearchMcpOrThrow();
|
||||
shouldDisplayWebSearchStatus = ensureWebSearchMcpForLaunch();
|
||||
if (browserRuntimeEnv) {
|
||||
ensureBrowserMcpOrThrow();
|
||||
}
|
||||
}
|
||||
|
||||
// Display WebSearch status (single line, equilibrium UX)
|
||||
displayWebSearchStatus();
|
||||
if (shouldDisplayWebSearchStatus) {
|
||||
displayWebSearchStatus();
|
||||
}
|
||||
|
||||
const continuityInheritance =
|
||||
resolvedTarget === 'claude'
|
||||
|
||||
@@ -64,6 +64,7 @@ export {
|
||||
removeWebSearchMcpConfig,
|
||||
uninstallWebSearchMcp,
|
||||
syncWebSearchMcpToConfigDir,
|
||||
ensureWebSearchMcpForLaunch,
|
||||
ensureWebSearchMcpOrThrow,
|
||||
} from './websearch/mcp-installer';
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export {
|
||||
removeWebSearchMcpConfig,
|
||||
uninstallWebSearchMcp,
|
||||
syncWebSearchMcpToConfigDir,
|
||||
ensureWebSearchMcpForLaunch,
|
||||
ensureWebSearchMcpOrThrow,
|
||||
} from './mcp-installer';
|
||||
|
||||
|
||||
@@ -438,3 +438,30 @@ export function ensureWebSearchMcpOrThrow(): void {
|
||||
throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare WebSearch for a user launch without blocking Claude startup.
|
||||
*
|
||||
* Returns true when the normal WebSearch status line is still accurate. A
|
||||
* failed MCP prepare already prints a degraded-path warning, so callers should
|
||||
* skip the ready/status line when this returns false.
|
||||
*/
|
||||
export function ensureWebSearchMcpForLaunch(): boolean {
|
||||
const wsConfig = getWebSearchConfig();
|
||||
if (!wsConfig.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ready = ensureWebSearchMcp();
|
||||
if (!ready) {
|
||||
process.stderr.write(
|
||||
String(
|
||||
warn(
|
||||
'WebSearch is enabled, but CCS could not prepare the local WebSearch tool. This session will continue without local WebSearch.'
|
||||
)
|
||||
) + '\n'
|
||||
);
|
||||
}
|
||||
|
||||
return ready;
|
||||
}
|
||||
@@ -61,7 +61,10 @@ function startMockUpstream(port: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(args: string[], env: Record<string, string>): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
function runCli(
|
||||
args: string[],
|
||||
env: Record<string, string>
|
||||
): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [DIST_ENTRY, ...args], {
|
||||
env: {
|
||||
@@ -101,6 +104,11 @@ describe('openai provider routing e2e', () => {
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
const outputPath = path.join(tempDir, 'claude-output.json');
|
||||
const fakeClaudeRuntimePath = path.join(binDir, 'fake-claude.cjs');
|
||||
const fakeClaudePath = path.join(
|
||||
binDir,
|
||||
process.platform === 'win32' ? 'claude.cmd' : 'claude'
|
||||
);
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
@@ -123,8 +131,8 @@ describe('openai provider routing e2e', () => {
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(binDir, 'claude'),
|
||||
`#!/usr/bin/env node
|
||||
fakeClaudeRuntimePath,
|
||||
`
|
||||
const fs = require('fs');
|
||||
(async () => {
|
||||
const response = await fetch(\`\${process.env.ANTHROPIC_BASE_URL}/v1/messages\`, {
|
||||
@@ -153,6 +161,13 @@ const fs = require('fs');
|
||||
process.exit(1);
|
||||
});
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
fakeClaudePath,
|
||||
process.platform === 'win32'
|
||||
? '@echo off\r\nnode "%~dp0fake-claude.cjs" %*\r\n'
|
||||
: '#!/bin/sh\nexec node "$(dirname "$0")/fake-claude.cjs" "$@"\n',
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
|
||||
@@ -160,7 +175,7 @@ const fs = require('fs');
|
||||
...process.env,
|
||||
CCS_HOME: tempDir,
|
||||
CCS_E2E_OUTPUT: outputPath,
|
||||
PATH: `${binDir}:${process.env.PATH || ''}`,
|
||||
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`,
|
||||
});
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
@@ -4,7 +4,8 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
||||
const STEERING_PROMPT_SNIPPET =
|
||||
'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
@@ -103,28 +104,40 @@ exit 0
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fails before Claude launch when the local WebSearch tool runtime cannot be prepared', () => {
|
||||
it('continues without local WebSearch when the tool runtime cannot be prepared', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('could not prepare the local WebSearch tool');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(false);
|
||||
expect(result.stderr).toContain('This session will continue without local WebSearch');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--disallowedTools');
|
||||
expect(launchedArgs).toContain('WebSearch');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('fails before delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => {
|
||||
it('continues delegated headless launch when the local WebSearch tool runtime cannot be prepared', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8');
|
||||
|
||||
const result = runCcs(['glm', '-p', 'smoke'], baseEnv);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).toContain('could not prepare the local WebSearch tool');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(false);
|
||||
expect(result.stderr).toContain('This session will continue without local WebSearch');
|
||||
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
expect(launchedArgs).toContain('--disallowedTools');
|
||||
expect(launchedArgs).toContain('WebSearch');
|
||||
expect(launchedArgs).toContain('--append-system-prompt');
|
||||
expect(launchedArgs).toContain(STEERING_PROMPT_SNIPPET);
|
||||
});
|
||||
|
||||
it('keeps launch non-fatal when WebSearch is disabled', () => {
|
||||
@@ -162,9 +175,7 @@ exit 0
|
||||
expect(fs.existsSync(tracePath)).toBe(true);
|
||||
|
||||
const traceEvents = readTraceEvents(tracePath);
|
||||
const launchEvent = traceEvents.find(
|
||||
(event) => event.event === 'ccs_websearch_launch'
|
||||
) as
|
||||
const launchEvent = traceEvents.find((event) => event.event === 'ccs_websearch_launch') as
|
||||
| {
|
||||
launcher?: string;
|
||||
nativeWebSearchDisallowed?: boolean;
|
||||
|
||||
Reference in new issue
Block a user