mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Respect terminalWindowsShell setting for local Windows agent launches (#7526)
Ensure agent CLI startup and draft launch commands use the correct quoting format based on the user's configured local Windows shell (e.g., cmd.exe). This avoids using host settings for remote/SSH targets where local shell preferences do not apply.
This commit is contained in:
@@ -3538,6 +3538,7 @@ describe('OrcaRuntimeService', () => {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
terminalWindowsShell: 'cmd.exe',
|
||||
agentCmdOverrides: {}
|
||||
}),
|
||||
getRepos: () => [remoteRepo],
|
||||
@@ -7264,6 +7265,94 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows bare agent command defaults for cmd.exe terminal creates', async () => {
|
||||
setPlatform('win32')
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
terminalWindowsShell: 'cmd.exe',
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { claude: '--dangerously-skip-permissions' },
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'claude',
|
||||
title: 'worker'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined
|
||||
expect(spawnCall?.command).toBe('claude "--dangerously-skip-permissions"')
|
||||
})
|
||||
|
||||
it('does not use the local Windows shell setting for remote Windows bare agent creates', async () => {
|
||||
const remoteRepo = {
|
||||
id: TEST_REPO_ID,
|
||||
path: 'C:/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const remoteStore = {
|
||||
...store,
|
||||
getRepos: () => [remoteRepo],
|
||||
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined),
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
terminalWindowsShell: 'cmd.exe',
|
||||
agentCmdOverrides: {},
|
||||
agentDefaultArgs: { claude: '--dangerously-skip-permissions' },
|
||||
agentDefaultEnv: {}
|
||||
})
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: 'C:/remote/repo',
|
||||
head: 'abc',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
}
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-remote-windows-bare' })
|
||||
registerSshGitProvider('ssh-1', provider as never)
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
try {
|
||||
await runtime.createTerminal('path:C:/remote/repo', {
|
||||
command: 'claude',
|
||||
title: 'worker'
|
||||
})
|
||||
|
||||
const spawnCall = spawn.mock.calls[0]?.[0] as { command?: string } | undefined
|
||||
expect(spawnCall?.command).toBe("claude '--dangerously-skip-permissions'")
|
||||
} finally {
|
||||
unregisterSshGitProvider('ssh-1')
|
||||
}
|
||||
})
|
||||
|
||||
it('matches canonical bare agent commands when a command override is configured', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' })
|
||||
const runtimeStore = {
|
||||
@@ -17184,6 +17273,53 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses cmd.exe quoting for mobile agent launch commands in local Windows host runtimes', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-cmd' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
...store,
|
||||
getProjects: () => [
|
||||
{
|
||||
id: 'project-1',
|
||||
displayName: 'repo',
|
||||
badgeColor: 'blue',
|
||||
sourceRepoIds: [TEST_REPO_ID],
|
||||
localWindowsRuntimePreference: { kind: 'windows-host' },
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
],
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
disabledTuiAgents: [],
|
||||
agentCmdOverrides: { 'command-code': 'command-code --profile mobile' },
|
||||
agentDefaultArgs: { 'command-code': '--note "can\'t"' },
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' },
|
||||
terminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
} as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
|
||||
|
||||
await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
agent: 'command-code'
|
||||
})
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'command-code --profile mobile "--note" "can\'t"',
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes headless mobile session agent identity with synthesized PTY status', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
|
||||
@@ -188,6 +188,7 @@ import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
|
||||
import {
|
||||
getTuiAgentLaunchCommand,
|
||||
isTuiAgent,
|
||||
@@ -787,6 +788,7 @@ type RuntimeStore = {
|
||||
agentCmdOverrides?: GlobalSettings['agentCmdOverrides']
|
||||
agentDefaultArgs?: GlobalSettings['agentDefaultArgs']
|
||||
agentDefaultEnv?: GlobalSettings['agentDefaultEnv']
|
||||
terminalWindowsShell?: GlobalSettings['terminalWindowsShell']
|
||||
agentStatusHooksEnabled?: GlobalSettings['agentStatusHooksEnabled']
|
||||
defaultTaskSource?: GlobalSettings['defaultTaskSource']
|
||||
defaultTaskViewPreset?: GlobalSettings['defaultTaskViewPreset']
|
||||
@@ -12353,6 +12355,11 @@ export class OrcaRuntimeService {
|
||||
// Linux over SSH. Startup command quoting must target the shell that runs it.
|
||||
const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo)
|
||||
const isRemote = repoIsRemote(repo)
|
||||
const queuedShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform: agentLaunchPlatform,
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const draftLaunchPlan = buildAgentDraftLaunchPlan({
|
||||
agent,
|
||||
draft: content,
|
||||
@@ -12360,6 +12367,7 @@ export class OrcaRuntimeService {
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
platform: agentLaunchPlatform,
|
||||
shell: queuedShell,
|
||||
isRemote
|
||||
})
|
||||
if (draftLaunchPlan) {
|
||||
@@ -12383,6 +12391,7 @@ export class OrcaRuntimeService {
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
platform: agentLaunchPlatform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
@@ -12418,6 +12427,12 @@ export class OrcaRuntimeService {
|
||||
// Why: CLI clients may target SSH runtimes from macOS/Windows, so quote for
|
||||
// the workspace shell rather than the client shell.
|
||||
const agentLaunchPlatform = this.getAgentLaunchPlatformForRepo(repo)
|
||||
const isRemote = repoIsRemote(repo)
|
||||
const queuedShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform: agentLaunchPlatform,
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
prompt: prompt ?? '',
|
||||
@@ -12425,7 +12440,8 @@ export class OrcaRuntimeService {
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
platform: agentLaunchPlatform,
|
||||
isRemote: repoIsRemote(repo),
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
if (!startupPlan) {
|
||||
@@ -15632,6 +15648,11 @@ export class OrcaRuntimeService {
|
||||
const settings = this.store.getSettings()
|
||||
const platform = this.getAgentLaunchPlatformForWorkspace(workspace)
|
||||
const isRemote = repoIsRemote(workspace.repo)
|
||||
const queuedShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const agent = resolveBareAgentLaunchCommand({
|
||||
command: opts.command,
|
||||
settings,
|
||||
@@ -15649,6 +15670,7 @@ export class OrcaRuntimeService {
|
||||
agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv),
|
||||
platform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
@@ -16193,6 +16215,11 @@ export class OrcaRuntimeService {
|
||||
// Why: an SSH workspace runs the CLI through the relay shim (plain `orca`),
|
||||
// so the Linux-only `orca-ide` rename must not be applied.
|
||||
const isRemote = workspace.repo ? repoIsRemote(workspace.repo) : repoIsRemote(workspace)
|
||||
const queuedShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform,
|
||||
isRemote,
|
||||
terminalWindowsShell: settings.terminalWindowsShell
|
||||
})
|
||||
const startupPlan = buildAgentStartupPlan({
|
||||
agent: opts.agent,
|
||||
prompt: '',
|
||||
@@ -16200,6 +16227,7 @@ export class OrcaRuntimeService {
|
||||
agentArgs: resolveTuiAgentLaunchArgs(opts.agent, settings.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(opts.agent, settings.agentDefaultEnv),
|
||||
platform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
|
||||
@@ -25,6 +25,7 @@ const store = {
|
||||
agentDefaultArgs: Record<string, string>
|
||||
agentDefaultEnv: Record<string, Record<string, string>>
|
||||
activeRuntimeEnvironmentId: string | null
|
||||
terminalWindowsShell?: string
|
||||
experimentalNativeChat?: boolean
|
||||
openAgentTabsInChatByDefault?: boolean
|
||||
},
|
||||
@@ -409,7 +410,102 @@ describe('launchAgentInNewTab', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows default agent args for cmd.exe empty launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'claude "--dangerously-skip-permissions"'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell quoting for local Windows default agent args', async () => {
|
||||
store.settings.terminalWindowsShell = 'powershell.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows explicit agent args for cmd.exe prompt launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'codex',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'fix the spinner',
|
||||
agentArgs: '--model gpt-5',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: 'codex "--model" "gpt-5" "fix the spinner"'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('quotes local Windows draft launches for Git Bash', async () => {
|
||||
store.settings.terminalWindowsShell = 'git-bash'
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: "review Bob's change",
|
||||
promptDelivery: 'draft',
|
||||
launchPlatform: 'win32'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use the local Windows shell setting for remote Windows launches', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: 'C:\\remote\\repo' }]
|
||||
const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab')
|
||||
|
||||
launchAgentInNewTab({
|
||||
agent: 'claude',
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
|
||||
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: "claude '--dangerously-skip-permissions'"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses WSL launch quoting by default for Windows-path projects forced to WSL', async () => {
|
||||
store.settings.terminalWindowsShell = 'cmd.exe'
|
||||
store.projects = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell'
|
||||
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
|
||||
import { repoIsRemote } from '../../../shared/agent-launch-remote'
|
||||
import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt-status-seed'
|
||||
@@ -119,12 +120,26 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
// Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only
|
||||
// `orca-ide` rename must not be applied for remote launches.
|
||||
const isRemote = repo ? repoIsRemote(repo) : false
|
||||
const queuedShell = resolveLocalWindowsAgentStartupShell({
|
||||
platform: resolvedLaunchPlatform,
|
||||
isRemote,
|
||||
terminalWindowsShell: store.settings?.terminalWindowsShell
|
||||
})
|
||||
const cmdOverrides = store.settings?.agentCmdOverrides ?? {}
|
||||
const effectiveAgentArgs =
|
||||
agentArgs !== undefined
|
||||
? agentArgs
|
||||
: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs)
|
||||
const agentEnv = resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv)
|
||||
const startupPlanBase = {
|
||||
agent,
|
||||
cmdOverrides,
|
||||
platform: resolvedLaunchPlatform,
|
||||
shell: queuedShell,
|
||||
isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv
|
||||
}
|
||||
const trimmedPrompt = prompt?.trim() ?? ''
|
||||
const hasPrompt = trimmedPrompt.length > 0
|
||||
const isFollowupPath = TUI_AGENT_CONFIG[agent].promptInjectionMode === 'stdin-after-start'
|
||||
@@ -143,13 +158,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
// Why: generated multi-line prompts are too large to echo through a shell
|
||||
// argv/prefill command. Launch cleanly, then paste+submit inside the TUI.
|
||||
startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
...startupPlanBase,
|
||||
prompt: '',
|
||||
cmdOverrides,
|
||||
platform: resolvedLaunchPlatform,
|
||||
isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
pasteDraftAfterLaunch = trimmedPrompt
|
||||
@@ -157,13 +167,8 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
forcePasteAfterLaunch = true
|
||||
} else if (hasPrompt && promptDelivery === 'draft') {
|
||||
const draftLaunchPlan = buildAgentDraftLaunchPlan({
|
||||
agent,
|
||||
draft: trimmedPrompt,
|
||||
cmdOverrides,
|
||||
platform: resolvedLaunchPlatform,
|
||||
isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv
|
||||
...startupPlanBase,
|
||||
draft: trimmedPrompt
|
||||
})
|
||||
if (draftLaunchPlan) {
|
||||
startupPlan = {
|
||||
@@ -179,38 +184,23 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI
|
||||
}
|
||||
} else {
|
||||
startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
...startupPlanBase,
|
||||
prompt: '',
|
||||
cmdOverrides,
|
||||
platform: resolvedLaunchPlatform,
|
||||
isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
pasteDraftAfterLaunch = trimmedPrompt
|
||||
}
|
||||
} else if (hasPrompt && isFollowupPath) {
|
||||
startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
...startupPlanBase,
|
||||
prompt: '',
|
||||
cmdOverrides,
|
||||
platform: resolvedLaunchPlatform,
|
||||
isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv,
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
pasteDraftAfterLaunch = trimmedPrompt
|
||||
} else {
|
||||
startupPlan = buildAgentStartupPlan({
|
||||
agent,
|
||||
...startupPlanBase,
|
||||
prompt: hasPrompt ? trimmedPrompt : '',
|
||||
cmdOverrides,
|
||||
platform: resolvedLaunchPlatform,
|
||||
isRemote,
|
||||
agentArgs: effectiveAgentArgs,
|
||||
agentEnv,
|
||||
allowEmptyPromptLaunch: !hasPrompt
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,3 +35,16 @@ export function resolveWindowsShellStartupFamily(
|
||||
}
|
||||
return 'powershell'
|
||||
}
|
||||
|
||||
export function resolveLocalWindowsAgentStartupShell(args: {
|
||||
platform: NodeJS.Platform
|
||||
isRemote: boolean
|
||||
terminalWindowsShell?: string | null
|
||||
}): AgentStartupShell | undefined {
|
||||
// Why: terminalWindowsShell describes the local host shell; SSH/remote
|
||||
// targets need their own shell signal before we can safely override quoting.
|
||||
if (args.platform !== 'win32' || args.isRemote) {
|
||||
return undefined
|
||||
}
|
||||
return resolveWindowsShellStartupFamily(args.terminalWindowsShell)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user