mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix: stabilize Computer Use dependency installs
Computer Use now rejects Python runtimes below 3.9 before running pip, keeps Pillow on the Python 3.9-compatible 11.x line, and falls back from the configured mirror to the default PyPI index when dependency installs fail. Constraint: Pillow 11.3 requires Python 3.9+, while user machines may resolve a different Python than expected Rejected: Downgrade Pillow to the Python 3.8-compatible 10.x line | current product direction assumes modern Python installs Confidence: high Scope-risk: narrow Directive: Keep runtime package ranges aligned with the setup Python minimum before changing either side Tested: bun test src/server/__tests__/computer-use-api.test.ts src/server/__tests__/computer-use-requirements.test.ts src/server/__tests__/computer-use-python.test.ts src/utils/computerUse/pipInstall.test.ts Tested: bun run check:server Tested: bun run check:coverage Tested: bun run check:policy Not-tested: Real Windows 10 machine dependency install against the reported user environment
This commit is contained in:
parent
6b62b6c633
commit
d9bd75b5e5
@ -1,5 +1,5 @@
|
||||
mss>=9.0.2,<10
|
||||
Pillow>=11.3.0
|
||||
Pillow>=11.3.0,<12
|
||||
pyautogui>=0.9.54
|
||||
pywin32>=306
|
||||
psutil>=5.9.0
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
mss>=9.0.2,<10
|
||||
Pillow>=11.3.0
|
||||
Pillow>=11.3.0,<12
|
||||
pyautogui>=0.9.54
|
||||
pyobjc-core>=11.1
|
||||
pyobjc-framework-Cocoa>=11.1
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { handleComputerUseApi } from '../api/computer-use.js'
|
||||
|
||||
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
let configDir: string | null = null
|
||||
let computerUseApi: typeof import('../api/computer-use.js') | null = null
|
||||
|
||||
async function importComputerUseApi() {
|
||||
if (!computerUseApi) throw new Error('Computer Use API module was not initialized')
|
||||
return computerUseApi
|
||||
}
|
||||
|
||||
function makeRequest(method: string, body?: unknown): Request {
|
||||
return new Request('http://localhost/api/computer-use/authorized-apps', {
|
||||
@ -16,6 +20,7 @@ function makeRequest(method: string, body?: unknown): Request {
|
||||
}
|
||||
|
||||
async function callAuthorizedApps(method: string, body?: unknown): Promise<Response> {
|
||||
const { handleComputerUseApi } = await importComputerUseApi()
|
||||
return handleComputerUseApi(
|
||||
makeRequest(method, body),
|
||||
new URL('http://localhost/api/computer-use/authorized-apps'),
|
||||
@ -23,12 +28,20 @@ async function callAuthorizedApps(method: string, body?: unknown): Promise<Respo
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeAll(async () => {
|
||||
configDir = await mkdtemp(join(tmpdir(), 'cc-haha-computer-use-api-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
computerUseApi = await import('../api/computer-use.js')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
beforeEach(async () => {
|
||||
if (!configDir) throw new Error('configDir was not initialized')
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
await rm(join(configDir, 'cc-haha'), { recursive: true, force: true })
|
||||
await rm(join(configDir, '.runtime'), { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
@ -83,3 +96,75 @@ describe('Computer Use API authorized app config', () => {
|
||||
expect(await resetGetRes.json()).toMatchObject({ pythonPath: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runPipInstallWithFallback', () => {
|
||||
it('builds a clear unsupported Python version step for setup', async () => {
|
||||
const { getUnsupportedPythonVersionStep } = await importComputerUseApi()
|
||||
|
||||
expect(getUnsupportedPythonVersionStep('3.8.18')).toEqual({
|
||||
name: 'python_version',
|
||||
ok: false,
|
||||
message: 'Computer Use 需要 Python >= 3.9,当前版本为 3.8.18',
|
||||
})
|
||||
expect(getUnsupportedPythonVersionStep('3.9.19')).toBeNull()
|
||||
})
|
||||
|
||||
it('installs setup dependencies by upgrading pip before requirements', async () => {
|
||||
const { installSetupDependencies } = await importComputerUseApi()
|
||||
const calls: string[] = []
|
||||
|
||||
const result = await installSetupDependencies(
|
||||
'python',
|
||||
'/tmp/requirements.txt',
|
||||
async (cmd, args) => {
|
||||
calls.push(`${cmd} ${args.join(' ')}`)
|
||||
return { ok: true, stdout: args.includes('-r') ? 'deps' : 'pip', stderr: '', code: 0 }
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.stdout).toBe('deps')
|
||||
expect(calls).toEqual([
|
||||
'python -m pip install --upgrade pip',
|
||||
'python -m pip install -r /tmp/requirements.txt',
|
||||
])
|
||||
})
|
||||
|
||||
it('tries the mirror first and falls back to the default PyPI index', async () => {
|
||||
const { runPipInstallWithFallback } = await importComputerUseApi()
|
||||
const calls: string[] = []
|
||||
const result = await runPipInstallWithFallback(
|
||||
'python',
|
||||
['-m', 'pip', 'install', '-r', 'requirements.txt'],
|
||||
async (cmd, args) => {
|
||||
calls.push(`${cmd} ${args.join(' ')}`)
|
||||
if (args.includes('-i')) {
|
||||
return { ok: false, stdout: '', stderr: 'mirror unavailable', code: 1 }
|
||||
}
|
||||
return { ok: true, stdout: 'installed', stderr: '', code: 0 }
|
||||
},
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.stdout).toBe('installed')
|
||||
expect(calls).toEqual([
|
||||
'python -m pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ --trusted-host pypi.tuna.tsinghua.edu.cn',
|
||||
'python -m pip install -r requirements.txt',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the first failure when every pip index attempt fails', async () => {
|
||||
const { runPipInstallWithFallback } = await importComputerUseApi()
|
||||
const result = await runPipInstallWithFallback(
|
||||
'python',
|
||||
['-m', 'pip', 'install', '-r', 'requirements.txt'],
|
||||
async (_cmd, args) => ({
|
||||
ok: false,
|
||||
stdout: '',
|
||||
stderr: args.includes('-i') ? 'mirror failed' : 'default failed',
|
||||
code: args.includes('-i') ? 1 : 2,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result).toEqual({ ok: false, stdout: '', stderr: 'mirror failed', code: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,18 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { detectPythonRuntime } from '../api/computer-use-python.js'
|
||||
import { detectPythonRuntime, isPythonVersionAtLeast } from '../api/computer-use-python.js'
|
||||
|
||||
describe('isPythonVersionAtLeast', () => {
|
||||
test('accepts supported Python 3.9+ versions', () => {
|
||||
expect(isPythonVersionAtLeast('3.9.0', 3, 9)).toBe(true)
|
||||
expect(isPythonVersionAtLeast('3.12.11', 3, 9)).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects missing or older Python versions', () => {
|
||||
expect(isPythonVersionAtLeast(null, 3, 9)).toBe(false)
|
||||
expect(isPythonVersionAtLeast('3.8.18', 3, 9)).toBe(false)
|
||||
expect(isPythonVersionAtLeast('2.7.18', 3, 9)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectPythonRuntime', () => {
|
||||
test('prefers python3 on Windows when available', async () => {
|
||||
|
||||
@ -15,4 +15,9 @@ describe('computer use requirements', () => {
|
||||
expect(findRequirement(darwinRequirements, 'mss')).toBe('mss>=9.0.2,<10')
|
||||
expect(findRequirement(win32Requirements, 'mss')).toBe('mss>=9.0.2,<10')
|
||||
})
|
||||
|
||||
test('pins Pillow to the Python 3.9-compatible 11.x major line', () => {
|
||||
expect(findRequirement(darwinRequirements, 'Pillow')).toBe('Pillow>=11.3.0,<12')
|
||||
expect(findRequirement(win32Requirements, 'Pillow')).toBe('Pillow>=11.3.0,<12')
|
||||
})
|
||||
})
|
||||
|
||||
@ -69,6 +69,19 @@ function extractPythonVersion(output: string): string | null {
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
export function isPythonVersionAtLeast(
|
||||
version: string | null,
|
||||
major: number,
|
||||
minor: number,
|
||||
): boolean {
|
||||
if (!version) return false
|
||||
const match = version.match(/^(\d+)\.(\d+)/)
|
||||
if (!match) return false
|
||||
const currentMajor = Number(match[1])
|
||||
const currentMinor = Number(match[2])
|
||||
return currentMajor > major || (currentMajor === major && currentMinor >= minor)
|
||||
}
|
||||
|
||||
function firstOutputLine(output: string): string | null {
|
||||
const line = output
|
||||
.split(/\r?\n/)
|
||||
|
||||
@ -14,7 +14,8 @@ import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import type { CuPermissionRequest } from '../../vendor/computer-use-mcp/types.js'
|
||||
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
|
||||
import { detectPythonRuntime } from './computer-use-python.js'
|
||||
import { detectPythonRuntime, isPythonVersionAtLeast } from './computer-use-python.js'
|
||||
import { buildPipInstallAttempts } from '../../utils/computerUse/pipInstall.js'
|
||||
import {
|
||||
DEFAULT_DESKTOP_GRANT_FLAGS,
|
||||
loadStoredComputerUseConfig,
|
||||
@ -41,6 +42,8 @@ const installStampPath = join(runtimeStateRoot, 'requirements.sha256')
|
||||
// 记录上次创建 venv 时所用的 config.pythonPath 原值。读取该文件来判断当前
|
||||
// venv 是否仍与最新的自定义路径配置一致。
|
||||
const baseInterpreterMarkerPath = join(runtimeStateRoot, 'venv-base-interpreter.txt')
|
||||
const MIN_PYTHON_MAJOR = 3
|
||||
const MIN_PYTHON_MINOR = 9
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
const REQUIREMENTS_CONTENT = isWindows ? REQUIREMENTS_WIN32 : REQUIREMENTS_DARWIN
|
||||
@ -54,10 +57,6 @@ function getPythonCommandEnv(): Record<string, string> | undefined {
|
||||
} as Record<string, string>
|
||||
}
|
||||
|
||||
// 清华大学 PyPI 镜像,国内安装速度更快
|
||||
const PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple/'
|
||||
const PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn'
|
||||
|
||||
// Paths that resolve correctly in both dev and bundled modes
|
||||
function getRequirementsPath(): string {
|
||||
return join(runtimeStateRoot, 'requirements.txt')
|
||||
@ -127,6 +126,20 @@ async function runCommand(
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPipInstallWithFallback(
|
||||
pythonCmd: string,
|
||||
baseArgs: string[],
|
||||
run: typeof runCommand = runCommand,
|
||||
): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> {
|
||||
let firstFailure: { ok: boolean; stdout: string; stderr: string; code: number } | null = null
|
||||
for (const args of buildPipInstallAttempts(baseArgs)) {
|
||||
const result = await run(pythonCmd, args)
|
||||
if (result.ok) return result
|
||||
firstFailure ??= result
|
||||
}
|
||||
return firstFailure ?? { ok: false, stdout: '', stderr: 'pip install failed', code: -1 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure runtime source files (requirements.txt, mac_helper.py) exist in
|
||||
* ~/.claude/.runtime/. In dev mode they are copied from the project's
|
||||
@ -256,6 +269,26 @@ type SetupResult = {
|
||||
steps: { name: string; ok: boolean; message: string }[]
|
||||
}
|
||||
|
||||
export function getUnsupportedPythonVersionStep(
|
||||
version: string | null,
|
||||
): SetupResult['steps'][number] | null {
|
||||
if (isPythonVersionAtLeast(version, MIN_PYTHON_MAJOR, MIN_PYTHON_MINOR)) return null
|
||||
return {
|
||||
name: 'python_version',
|
||||
ok: false,
|
||||
message: `Computer Use 需要 Python >= ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR},当前版本为 ${version ?? 'unknown'}`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSetupDependencies(
|
||||
venvPython: string,
|
||||
reqPath: string,
|
||||
install: typeof runPipInstallWithFallback = runPipInstallWithFallback,
|
||||
): Promise<{ ok: boolean; stdout: string; stderr: string; code: number }> {
|
||||
await install(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
return install(venvPython, ['-m', 'pip', 'install', '-r', reqPath])
|
||||
}
|
||||
|
||||
async function runSetup(): Promise<SetupResult> {
|
||||
const steps: SetupResult['steps'] = []
|
||||
|
||||
@ -318,6 +351,9 @@ async function runSetup(): Promise<SetupResult> {
|
||||
: `Python ${pythonRuntime.version}`,
|
||||
})
|
||||
|
||||
const unsupportedVersionStep = getUnsupportedPythonVersionStep(pythonRuntime.version)
|
||||
if (unsupportedVersionStep) return { success: false, steps: [...steps, unsupportedVersionStep] }
|
||||
|
||||
// Step 2: Extract runtime files to ~/.claude/.runtime/
|
||||
try {
|
||||
await ensureRuntimeFiles()
|
||||
@ -403,18 +439,7 @@ async function runSetup(): Promise<SetupResult> {
|
||||
} catch {}
|
||||
|
||||
if (installedDigest !== digest) {
|
||||
// Upgrade pip first (using China mirror)
|
||||
await runCommand(venvPython, [
|
||||
'-m', 'pip', 'install', '--upgrade', 'pip',
|
||||
'-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST,
|
||||
])
|
||||
|
||||
// Install deps (using China mirror)
|
||||
const installResult = await runCommand(venvPython, [
|
||||
'-m', 'pip', 'install',
|
||||
'-r', reqPath,
|
||||
'-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST,
|
||||
])
|
||||
const installResult = await installSetupDependencies(venvPython, reqPath)
|
||||
if (!installResult.ok) {
|
||||
steps.push({
|
||||
name: 'deps',
|
||||
|
||||
82
src/utils/computerUse/pipInstall.test.ts
Normal file
82
src/utils/computerUse/pipInstall.test.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { buildPipInstallAttempts } from './pipInstall.js'
|
||||
import { installRuntimeDependencies, runPipInstallWithFallback } from './pythonBridge.js'
|
||||
|
||||
describe('buildPipInstallAttempts', () => {
|
||||
test('tries the configured mirror before falling back to the default index', () => {
|
||||
expect(buildPipInstallAttempts(['install', '-r', 'requirements.txt'])).toEqual([
|
||||
[
|
||||
'install',
|
||||
'-r',
|
||||
'requirements.txt',
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple/',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
],
|
||||
['install', '-r', 'requirements.txt'],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pythonBridge runPipInstallWithFallback', () => {
|
||||
test('falls back to the default PyPI index after the mirror fails', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await runPipInstallWithFallback(
|
||||
['-m', 'pip', 'install', '--upgrade', 'pip'],
|
||||
'pip upgrade',
|
||||
async args => {
|
||||
calls.push(args)
|
||||
return {
|
||||
code: args.includes('-i') ? 1 : 0,
|
||||
stdout: args.includes('-i') ? '' : 'ok',
|
||||
stderr: args.includes('-i') ? 'mirror unavailable' : '',
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
expect(calls).toEqual([
|
||||
[
|
||||
'-m',
|
||||
'pip',
|
||||
'install',
|
||||
'--upgrade',
|
||||
'pip',
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple/',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
],
|
||||
['-m', 'pip', 'install', '--upgrade', 'pip'],
|
||||
])
|
||||
})
|
||||
|
||||
test('throws the first pip failure when both indexes fail', async () => {
|
||||
await expect(runPipInstallWithFallback(
|
||||
['-m', 'pip', 'install', '-r', 'requirements.txt'],
|
||||
'python dependency install',
|
||||
async args => ({
|
||||
code: 1,
|
||||
stdout: '',
|
||||
stderr: args.includes('-i') ? 'mirror unavailable' : 'official unavailable',
|
||||
}),
|
||||
)).rejects.toThrow('python dependency install failed with code 1: mirror unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installRuntimeDependencies', () => {
|
||||
test('upgrades pip before installing requirements', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
await installRuntimeDependencies('/tmp/requirements.txt', async (args, label) => {
|
||||
calls.push(`${label}: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
'pip upgrade: -m pip install --upgrade pip',
|
||||
'python dependency install: -m pip install -r /tmp/requirements.txt',
|
||||
])
|
||||
})
|
||||
})
|
||||
13
src/utils/computerUse/pipInstall.ts
Normal file
13
src/utils/computerUse/pipInstall.ts
Normal file
@ -0,0 +1,13 @@
|
||||
const PIP_MIRROR_ARGS = [
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple/',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
]
|
||||
|
||||
export function buildPipInstallAttempts(baseArgs: string[]): string[][] {
|
||||
return [
|
||||
[...baseArgs, ...PIP_MIRROR_ARGS],
|
||||
baseArgs,
|
||||
]
|
||||
}
|
||||
@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { execFileNoThrow } from '../execFileNoThrow.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
||||
import { buildPipInstallAttempts } from './pipInstall.js'
|
||||
import { loadStoredComputerUseConfig } from './preauthorizedConfig.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
@ -17,9 +18,6 @@ const runtimeStateRoot = path.join(getClaudeConfigHomeDir(), '.runtime')
|
||||
const venvRoot = path.join(runtimeStateRoot, 'venv')
|
||||
const installStampPath = path.join(runtimeStateRoot, 'requirements.sha256')
|
||||
|
||||
const PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple/'
|
||||
const PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn'
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
// Always read from ~/.claude/.runtime/ — works in both dev and bundled mode.
|
||||
@ -61,6 +59,31 @@ async function runOrThrow(file: string, args: string[], label: string): Promise<
|
||||
return stdout
|
||||
}
|
||||
|
||||
export async function runPipInstallWithFallback(
|
||||
baseArgs: string[],
|
||||
label: string,
|
||||
run: (args: string[]) => Promise<{ code: number; stdout: string; stderr: string }> = args =>
|
||||
execFileNoThrow(pythonBinPath(), args, { useCwd: false }),
|
||||
): Promise<void> {
|
||||
let firstFailure = ''
|
||||
for (const args of buildPipInstallAttempts(baseArgs)) {
|
||||
const { code, stdout, stderr } = await run(args)
|
||||
if (code === 0) return
|
||||
if (!firstFailure) {
|
||||
firstFailure = `${label} failed with code ${code}: ${stderr || stdout || 'unknown error'}`
|
||||
}
|
||||
}
|
||||
throw new Error(firstFailure || `${label} failed`)
|
||||
}
|
||||
|
||||
export async function installRuntimeDependencies(
|
||||
requirementsPath: string,
|
||||
install: typeof runPipInstallWithFallback = runPipInstallWithFallback,
|
||||
): Promise<void> {
|
||||
await install(['-m', 'pip', 'install', '--upgrade', 'pip'], 'pip upgrade')
|
||||
await install(['-m', 'pip', 'install', '-r', requirementsPath], 'python dependency install')
|
||||
}
|
||||
|
||||
async function getVenvCreationPythonCommand(): Promise<string> {
|
||||
const config = await loadStoredComputerUseConfig()
|
||||
if (config.pythonPath) return config.pythonPath
|
||||
@ -120,16 +143,7 @@ export async function ensureBootstrapped(): Promise<void> {
|
||||
|
||||
if (installedDigest !== digest) {
|
||||
logForDebugging('installing python runtime dependencies', { level: 'debug' })
|
||||
await runOrThrow(pythonBinPath(), [
|
||||
'-m', 'pip', 'install', '--upgrade', 'pip',
|
||||
'-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST,
|
||||
], 'pip upgrade')
|
||||
await runOrThrow(
|
||||
pythonBinPath(),
|
||||
['-m', 'pip', 'install', '-r', requirementsPath,
|
||||
'-i', PIP_INDEX_URL, '--trusted-host', PIP_TRUSTED_HOST],
|
||||
'python dependency install',
|
||||
)
|
||||
await installRuntimeDependencies(requirementsPath)
|
||||
await writeFile(installStampPath, `${digest}\n`, 'utf8')
|
||||
}
|
||||
})()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user