mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
M7: pass-1 quickfixes (PATH, SFTP tilde, SOUL.md, ScarfGo bundle id)
Four fixes surfaced during the 2026-04-24 pass-1 smoke test of the iOS companion against a local Hermes host. All discovered while collaboratively driving the Simulator + tailing os.Logger. 1. ACPClient+iOS.swift — ACP exec command prepends common install paths to PATH. SSH RFC 4254 exec uses a non-interactive shell whose PATH is sshd's default (`/usr/bin:/bin:/usr/sbin:/sbin`); `.zshrc` doesn't source, so `~/.local/bin/hermes` (pipx default) was invisible and the agent died with "command not found: hermes". Mirrors HermesPathSet.hermesBinaryCandidates (the Mac-side local probe list) inline in the exec command. 2. CitadelServerTransport.swift — SFTP tilde expansion. Every Memory/Cron/Skills/Settings read used paths like `~/.hermes/memories/MEMORY.md`. SFTP treats `~` as a literal character, not a home-dir alias — so every read silently returned nil and the UIs showed "empty file" instead of the real content. Added a per-connection cached `resolveHome()` + a `resolveSFTPPath` helper applied to every SFTP entry point (readFile / writeFile / fileExists / stat / listDirectory / createDirectory / removeFile). This was the single biggest blocker on pass-1. 3. IOSMemoryViewModel.swift + MemoryListView.swift — SOUL.md added as a third Memory row. SOUL.md lives in the Personalities feature on Mac; folding it into Memory on iOS matches the on-the-go scope (all agent prompt inputs in one place). Uses the existing `HermesPathSet.soulMD` path; no new plumbing. 4. project.pbxproj — bundle id rename for ScarfGo branding: - CFBundleDisplayName: "Scarf Mobile" -> "ScarfGo" - PRODUCT_BUNDLE_IDENTIFIER: com.scarf-mobile.app -> com.scarfgo.app Xcode target name stays "scarf mobile" internally (rename surgery isn't worth the PBX churn). Home-screen label + bundle id now match the product name. Both schemes build green. Phase 1 starter commit — per-item M7 fixes follow in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import Observation
|
|||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class IOSMemoryViewModel {
|
public final class IOSMemoryViewModel {
|
||||||
public enum Kind: Sendable, Equatable {
|
public enum Kind: Sendable, Equatable, CaseIterable {
|
||||||
/// `~/.hermes/memories/MEMORY.md` — the agent's persistent
|
/// `~/.hermes/memories/MEMORY.md` — the agent's persistent
|
||||||
/// memory. Visible (and editable) to the agent at every
|
/// memory. Visible (and editable) to the agent at every
|
||||||
/// session start.
|
/// session start.
|
||||||
@@ -23,12 +23,18 @@ public final class IOSMemoryViewModel {
|
|||||||
/// `~/.hermes/memories/USER.md` — user-profile notes the
|
/// `~/.hermes/memories/USER.md` — user-profile notes the
|
||||||
/// agent reads but (by default) does not write.
|
/// agent reads but (by default) does not write.
|
||||||
case user
|
case user
|
||||||
|
/// `~/.hermes/SOUL.md` — the agent's persona / character
|
||||||
|
/// (voice, tone, style). Lives in the Personalities feature
|
||||||
|
/// on macOS; on iOS we fold it into Memory so the whole
|
||||||
|
/// "edit the agent's prompt inputs" surface is in one place.
|
||||||
|
case soul
|
||||||
|
|
||||||
/// Heading shown in the UI.
|
/// Heading shown in the UI.
|
||||||
public var displayName: String {
|
public var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .memory: return "MEMORY.md"
|
case .memory: return "MEMORY.md"
|
||||||
case .user: return "USER.md"
|
case .user: return "USER.md"
|
||||||
|
case .soul: return "SOUL.md"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ public final class IOSMemoryViewModel {
|
|||||||
switch self {
|
switch self {
|
||||||
case .memory: return "brain.head.profile"
|
case .memory: return "brain.head.profile"
|
||||||
case .user: return "person.crop.square"
|
case .user: return "person.crop.square"
|
||||||
|
case .soul: return "sparkles"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,16 +54,19 @@ public final class IOSMemoryViewModel {
|
|||||||
return "Agent's persistent memory. Appears in every session prompt."
|
return "Agent's persistent memory. Appears in every session prompt."
|
||||||
case .user:
|
case .user:
|
||||||
return "Notes about you. Read by the agent but not modified automatically."
|
return "Notes about you. Read by the agent but not modified automatically."
|
||||||
|
case .soul:
|
||||||
|
return "Agent persona — voice, tone, personality."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the remote path for this memory file on the
|
/// Resolve the remote path for this memory file on the
|
||||||
/// given context. `ServerContext.paths` exposes both
|
/// given context. `ServerContext.paths` exposes
|
||||||
/// `memoryMD` and `userMD` directly.
|
/// `memoryMD`, `userMD`, and `soulMD` directly.
|
||||||
public func path(on context: ServerContext) -> String {
|
public func path(on context: ServerContext) -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .memory: return context.paths.memoryMD
|
case .memory: return context.paths.memoryMD
|
||||||
case .user: return context.paths.userMD
|
case .user: return context.paths.userMD
|
||||||
|
case .soul: return context.paths.soulMD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,12 +54,22 @@ public extension ACPClient {
|
|||||||
let key = try await keyProvider()
|
let key = try await keyProvider()
|
||||||
let client = try await openSSHClient(config: sshConfig, key: key)
|
let client = try await openSSHClient(config: sshConfig, key: key)
|
||||||
|
|
||||||
// Command to spawn. `hermes acp` is the ACP entry point; if
|
// Command to spawn. `hermes acp` is the ACP entry point.
|
||||||
// the user configured a non-default hermes binary path we
|
//
|
||||||
// honour that via `paths.hermesBinary`. The `exec` command
|
// SSH `exec` (RFC 4254) runs a non-interactive, non-login shell
|
||||||
// is invoked via SSH RFC 4254 exec (no TTY) — binary-clean
|
// whose PATH is the sshd default (`/usr/bin:/bin:/usr/sbin:/sbin`).
|
||||||
// stdin/stdout for JSON-RPC bytes.
|
// Common Hermes install locations like `~/.local/bin` (pipx),
|
||||||
let command = context.paths.hermesBinary + " acp"
|
// `/opt/homebrew/bin`, and `/usr/local/bin` aren't on that PATH,
|
||||||
|
// and `-l` alone doesn't help because most users add PATH exports
|
||||||
|
// to `.zshrc` which is only sourced for interactive shells.
|
||||||
|
//
|
||||||
|
// We mirror `HermesPathSet.hermesBinaryCandidates` (the Mac-side
|
||||||
|
// local probe list) by prepending all four common locations to
|
||||||
|
// PATH before `exec`-ing hermes. Binary-clean stdio (JSON-RPC on
|
||||||
|
// stdin/stdout) is preserved because `exec` replaces the shell
|
||||||
|
// process — no intermediate layer buffers the bytes.
|
||||||
|
let hermesCmd = context.paths.hermesBinary + " acp"
|
||||||
|
let command = "PATH=\"$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH\" exec \(hermesCmd)"
|
||||||
|
|
||||||
return try await SSHExecACPChannel(
|
return try await SSHExecACPChannel(
|
||||||
client: client,
|
client: client,
|
||||||
|
|||||||
@@ -216,9 +216,23 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
|
|
||||||
// MARK: - Async primitives (package-private, testable through subclassing)
|
// MARK: - Async primitives (package-private, testable through subclassing)
|
||||||
|
|
||||||
|
/// Rewrite a leading `~/` or bare `~` to the probed absolute
|
||||||
|
/// `$HOME`. SFTP (per RFC 4254 / SFTP protocol) does NOT expand
|
||||||
|
/// tildes — a path like `~/.hermes/memories/MEMORY.md` is treated
|
||||||
|
/// as a relative path with a literal `~` directory name, so every
|
||||||
|
/// SFTP op silently fails to locate the file. Normalize here before
|
||||||
|
/// handing paths to Citadel's SFTP client.
|
||||||
|
private func resolveSFTPPath(_ path: String) async throws -> String {
|
||||||
|
guard path == "~" || path.hasPrefix("~/") else { return path }
|
||||||
|
let home = try await connectionHolder.resolveHome()
|
||||||
|
if path == "~" { return home }
|
||||||
|
return home + "/" + path.dropFirst(2)
|
||||||
|
}
|
||||||
|
|
||||||
private func asyncReadFile(_ path: String) async throws -> Data {
|
private func asyncReadFile(_ path: String) async throws -> Data {
|
||||||
let sftp = try await connectionHolder.sftp()
|
let sftp = try await connectionHolder.sftp()
|
||||||
return try await sftp.withFile(filePath: path, flags: [.read]) { file in
|
let resolved = try await resolveSFTPPath(path)
|
||||||
|
return try await sftp.withFile(filePath: resolved, flags: [.read]) { file in
|
||||||
let buf = try await file.readAll()
|
let buf = try await file.readAll()
|
||||||
return Data(buffer: buf)
|
return Data(buffer: buf)
|
||||||
}
|
}
|
||||||
@@ -226,9 +240,10 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
|
|
||||||
private func asyncWriteFile(_ path: String, data: Data) async throws {
|
private func asyncWriteFile(_ path: String, data: Data) async throws {
|
||||||
let sftp = try await connectionHolder.sftp()
|
let sftp = try await connectionHolder.sftp()
|
||||||
|
let resolved = try await resolveSFTPPath(path)
|
||||||
let byteBuffer = ByteBuffer(bytes: data)
|
let byteBuffer = ByteBuffer(bytes: data)
|
||||||
try await sftp.withFile(
|
try await sftp.withFile(
|
||||||
filePath: path,
|
filePath: resolved,
|
||||||
flags: [.write, .create, .truncate]
|
flags: [.write, .create, .truncate]
|
||||||
) { file in
|
) { file in
|
||||||
try await file.write(byteBuffer, at: 0)
|
try await file.write(byteBuffer, at: 0)
|
||||||
@@ -237,8 +252,9 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
|
|
||||||
private func asyncFileExists(_ path: String) async throws -> Bool {
|
private func asyncFileExists(_ path: String) async throws -> Bool {
|
||||||
let sftp = try await connectionHolder.sftp()
|
let sftp = try await connectionHolder.sftp()
|
||||||
|
let resolved = try await resolveSFTPPath(path)
|
||||||
do {
|
do {
|
||||||
_ = try await sftp.getAttributes(at: path)
|
_ = try await sftp.getAttributes(at: resolved)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
@@ -247,8 +263,9 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
|
|
||||||
private func asyncStat(_ path: String) async throws -> FileStat? {
|
private func asyncStat(_ path: String) async throws -> FileStat? {
|
||||||
let sftp = try await connectionHolder.sftp()
|
let sftp = try await connectionHolder.sftp()
|
||||||
|
let resolved = try await resolveSFTPPath(path)
|
||||||
do {
|
do {
|
||||||
let attrs = try await sftp.getAttributes(at: path)
|
let attrs = try await sftp.getAttributes(at: resolved)
|
||||||
let size = attrs.size.map { Int64($0) } ?? 0
|
let size = attrs.size.map { Int64($0) } ?? 0
|
||||||
let mtime = attrs.accessModificationTime?.modificationTime ?? Date(timeIntervalSince1970: 0)
|
let mtime = attrs.accessModificationTime?.modificationTime ?? Date(timeIntervalSince1970: 0)
|
||||||
// SFTPFileAttributes doesn't expose a "type" field directly;
|
// SFTPFileAttributes doesn't expose a "type" field directly;
|
||||||
@@ -265,7 +282,8 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
|
|
||||||
private func asyncListDirectory(_ path: String) async throws -> [String] {
|
private func asyncListDirectory(_ path: String) async throws -> [String] {
|
||||||
let sftp = try await connectionHolder.sftp()
|
let sftp = try await connectionHolder.sftp()
|
||||||
let listing = try await sftp.listDirectory(atPath: path)
|
let resolved = try await resolveSFTPPath(path)
|
||||||
|
let listing = try await sftp.listDirectory(atPath: resolved)
|
||||||
// Flatten all components across the response batches, strip the
|
// Flatten all components across the response batches, strip the
|
||||||
// conventional "." / ".." entries to match
|
// conventional "." / ".." entries to match
|
||||||
// `FileManager.contentsOfDirectory` behaviour.
|
// `FileManager.contentsOfDirectory` behaviour.
|
||||||
@@ -275,12 +293,13 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
|
|
||||||
private func asyncCreateDirectory(_ path: String) async throws {
|
private func asyncCreateDirectory(_ path: String) async throws {
|
||||||
let sftp = try await connectionHolder.sftp()
|
let sftp = try await connectionHolder.sftp()
|
||||||
|
let resolved = try await resolveSFTPPath(path)
|
||||||
// `createDirectory` at Citadel layer fails if the dir exists;
|
// `createDirectory` at Citadel layer fails if the dir exists;
|
||||||
// we want mkdir -p semantics so we walk the path and create
|
// we want mkdir -p semantics so we walk the path and create
|
||||||
// each component. Absolute paths only — the iOS app never
|
// each component. Absolute paths only — the iOS app never
|
||||||
// passes a relative path.
|
// passes a relative path.
|
||||||
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
let components = resolved.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||||
var cursor = path.hasPrefix("/") ? "" : ""
|
var cursor = resolved.hasPrefix("/") ? "" : ""
|
||||||
for component in components {
|
for component in components {
|
||||||
cursor += "/" + component
|
cursor += "/" + component
|
||||||
do {
|
do {
|
||||||
@@ -299,10 +318,11 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
|||||||
|
|
||||||
private func asyncRemoveFile(_ path: String) async throws {
|
private func asyncRemoveFile(_ path: String) async throws {
|
||||||
let sftp = try await connectionHolder.sftp()
|
let sftp = try await connectionHolder.sftp()
|
||||||
|
let resolved = try await resolveSFTPPath(path)
|
||||||
// Parallel to LocalTransport: no-op if the file doesn't exist.
|
// Parallel to LocalTransport: no-op if the file doesn't exist.
|
||||||
let exists = try await asyncFileExists(path)
|
let exists = try await asyncFileExists(resolved)
|
||||||
if !exists { return }
|
if !exists { return }
|
||||||
try await sftp.remove(at: path)
|
try await sftp.remove(at: resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func asyncRunProcess(
|
private func asyncRunProcess(
|
||||||
@@ -449,6 +469,12 @@ private actor ConnectionHolder {
|
|||||||
|
|
||||||
private var sshClient: SSHClient?
|
private var sshClient: SSHClient?
|
||||||
private var sftpClient: SFTPClient?
|
private var sftpClient: SFTPClient?
|
||||||
|
/// Resolved absolute `$HOME` on the remote host. Probed once per
|
||||||
|
/// connection via `echo $HOME` over SSH exec, then memoized. Used
|
||||||
|
/// to rewrite `~/…` SFTP paths (SFTP does NOT expand tildes — it
|
||||||
|
/// treats them as literal characters, so `~/.hermes/…` reads fail
|
||||||
|
/// unless we rewrite to the absolute path client-side).
|
||||||
|
private var resolvedHome: String?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
contextID: ServerID,
|
contextID: ServerID,
|
||||||
@@ -460,6 +486,22 @@ private actor ConnectionHolder {
|
|||||||
self.keyProvider = keyProvider
|
self.keyProvider = keyProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Probe + cache the remote user's home directory. Returns the
|
||||||
|
/// absolute path (e.g. `/Users/alan`). Falls back to the original
|
||||||
|
/// tilde-form on probe failure so callers get a best-effort path
|
||||||
|
/// rather than a hard error; those callers will surface the real
|
||||||
|
/// failure via the subsequent SFTP op.
|
||||||
|
func resolveHome() async throws -> String {
|
||||||
|
if let cached = resolvedHome { return cached }
|
||||||
|
let client = try await ssh()
|
||||||
|
let buffer = try await client.executeCommand("echo $HOME")
|
||||||
|
let raw = buffer.getString(at: 0, length: buffer.readableBytes) ?? ""
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let home = trimmed.isEmpty ? "~" : trimmed
|
||||||
|
resolvedHome = home
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
|
||||||
func ssh() async throws -> SSHClient {
|
func ssh() async throws -> SSHClient {
|
||||||
if let existing = sshClient, existing.isConnected {
|
if let existing = sshClient, existing.isConnected {
|
||||||
return existing
|
return existing
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
|
|
||||||
/// Entry screen for the Memory feature. Two rows: MEMORY.md and
|
/// Entry screen for the Memory feature. Three rows: MEMORY.md,
|
||||||
/// USER.md. Each taps into `MemoryEditorView`. Pure SwiftUI — the
|
/// USER.md, and SOUL.md (persona). SOUL lives in the Personalities
|
||||||
/// actual load/save happens in `IOSMemoryViewModel` which lives in
|
/// feature on macOS; we fold it in here on iOS so the whole
|
||||||
/// ScarfCore and is tested on Linux.
|
/// "agent prompt inputs" surface is one tap away. Each row taps into
|
||||||
|
/// `MemoryEditorView`. Pure SwiftUI — the actual load/save happens in
|
||||||
|
/// `IOSMemoryViewModel` which lives in ScarfCore.
|
||||||
struct MemoryListView: View {
|
struct MemoryListView: View {
|
||||||
let config: IOSServerConfig
|
let config: IOSServerConfig
|
||||||
|
|
||||||
@@ -18,8 +20,9 @@ struct MemoryListView: View {
|
|||||||
Section {
|
Section {
|
||||||
memoryRow(.memory, context: ctx)
|
memoryRow(.memory, context: ctx)
|
||||||
memoryRow(.user, context: ctx)
|
memoryRow(.user, context: ctx)
|
||||||
|
memoryRow(.soul, context: ctx)
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("These files live under `~/.hermes/memories/` on the remote host.")
|
Text("MEMORY.md and USER.md live under `~/.hermes/memories/`. SOUL.md lives at `~/.hermes/SOUL.md`.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,7 +527,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
|
INFOPLIST_KEY_CFBundleDisplayName = ScarfGo;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -540,7 +540,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.3.0;
|
MARKETING_VERSION = 2.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -565,7 +565,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
|
INFOPLIST_KEY_CFBundleDisplayName = ScarfGo;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -578,7 +578,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.3.0;
|
MARKETING_VERSION = 2.3.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarfgo.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
Reference in New Issue
Block a user