mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
fix: preserve sandbox state across session scopes
This commit is contained in:
@@ -17,7 +17,7 @@ Auto Approve scope is intentionally excluded and tracked separately in https://g
|
||||
Use two durable values with different responsibilities:
|
||||
|
||||
1. **Session state**: Store the desired sandbox state in the existing session metadata under a Kilo-owned key such as `kilocode.sandbox`.
|
||||
2. **New-session default**: Store the most recently selected state in a shared VS Code `globalState` preference. Fall back to `experimental.sandbox` until the user explicitly selects a state.
|
||||
2. **New-session default**: Store the most recently selected state in the VS Code workspace state. Fall back to `experimental.sandbox` until the user explicitly selects a state.
|
||||
|
||||
The effective backend state is:
|
||||
|
||||
@@ -53,11 +53,12 @@ Fork B -> fork enabled
|
||||
| Session switch | Fetch the selected session's state and discard stale responses from the previously selected session. |
|
||||
| Existing session toggle | Persist the selected state in that session, then update the sticky default. Do not alter any other session. |
|
||||
| Session fork | Copy the source session metadata. Ignore the sticky default. Parent and child become independent after the fork. |
|
||||
| Task child | Copy the parent session's explicit sandbox state, then keep the child independent. |
|
||||
| Continue in Worktree | Preserve the source session state through the existing fork flow. |
|
||||
| Move or promote a session | Preserve state because the same session is being moved or associated, not created. |
|
||||
| Session deletion | Delete state with the session row and retain serialization against an in-flight toggle. |
|
||||
| Backend restart | Reload existing session state from metadata rather than reverting to config. |
|
||||
| VS Code reload | Reload the sticky default from `globalState` and existing state from backend metadata. |
|
||||
| VS Code reload | Reload the sticky default from workspace state and existing state from backend metadata. |
|
||||
| Sidebar and editor tabs | Use the same shared default service; session state remains backend-owned. |
|
||||
| Cloud preview | Keep the control hidden for synthetic `cloud:` sessions. |
|
||||
| Cloud continuation/import | Preserve imported session metadata. Do not overwrite it with the local new-session default. |
|
||||
@@ -105,7 +106,7 @@ Regenerate OpenAPI and `packages/sdk/js/` after adding the endpoint.
|
||||
|
||||
### 3. Add a Shared Sticky Default Service
|
||||
|
||||
Add a small extension service, for example `packages/kilo-vscode/src/services/sandbox-preference.ts`, backed by `ExtensionContext.globalState`.
|
||||
Add a small extension service, for example `packages/kilo-vscode/src/services/sandbox-preference.ts`, backed by `ExtensionContext.workspaceState`.
|
||||
|
||||
The service should:
|
||||
|
||||
|
||||
@@ -1105,10 +1105,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await this.fetchAndSendSandboxStatus(message.sessionID)
|
||||
break
|
||||
case "requestSandboxDefault":
|
||||
await this.fetchAndSendSandboxDefault()
|
||||
await this.fetchAndSendSandboxDefault(message.contextDirectory)
|
||||
break
|
||||
case "setSandboxDefault":
|
||||
await this.handleSetSandboxDefault(message.enabled, message.requestID)
|
||||
await this.handleSetSandboxDefault(message.enabled, message.requestID, message.contextDirectory)
|
||||
break
|
||||
case "toggleSandbox":
|
||||
await this.handleToggleSandbox(message)
|
||||
@@ -2494,14 +2494,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage(getWorkStylePayload())
|
||||
}
|
||||
|
||||
private async fetchAndSendSandboxDefault(requestID?: string): Promise<void> {
|
||||
private async fetchAndSendSandboxDefault(directory = this.getContextDirectory(), requestID?: string): Promise<void> {
|
||||
const revision = ++this.sandboxRevision
|
||||
const generation = this.connectionGeneration
|
||||
const client = this.client
|
||||
const sandbox = sandboxClient(client)
|
||||
if (!client || !sandbox || this.connectionState !== "connected") return
|
||||
try {
|
||||
const directory = this.getContextDirectory()
|
||||
const [desired, result] = await Promise.all([
|
||||
sandboxDefault(this.connectionService.sandboxPreference, client, directory),
|
||||
sandbox.support({ directory }, { throwOnError: true }),
|
||||
@@ -2532,20 +2531,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSetSandboxDefault(enabled: boolean, requestID: string): Promise<void> {
|
||||
private async handleSetSandboxDefault(
|
||||
enabled: boolean,
|
||||
requestID: string,
|
||||
directory = this.getContextDirectory(),
|
||||
): Promise<void> {
|
||||
const client = this.client
|
||||
const sandbox = sandboxClient(client)
|
||||
if (!client || !sandbox || this.connectionState !== "connected") {
|
||||
await this.fetchAndSendSandboxDefault(requestID)
|
||||
await this.fetchAndSendSandboxDefault(directory, requestID)
|
||||
return
|
||||
}
|
||||
const directory = this.getContextDirectory()
|
||||
try {
|
||||
await this.connectionService.sandboxPreference.set(enabled, async () => {
|
||||
const { data } = await sandbox.support({ directory }, { throwOnError: true })
|
||||
if (!data.available) throw new Error(data.reason ?? "Sandbox backend is unavailable")
|
||||
})
|
||||
await this.fetchAndSendSandboxDefault(requestID)
|
||||
await this.fetchAndSendSandboxDefault(directory, requestID)
|
||||
vscode.window.showInformationMessage(
|
||||
enabled ? "Sandbox enabled for new sessions" : "Sandbox disabled for new sessions",
|
||||
)
|
||||
|
||||
@@ -440,11 +440,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
if ((m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && !m.sessionID) {
|
||||
if (
|
||||
m.type === "requestSandboxDefault" ||
|
||||
m.type === "setSandboxDefault" ||
|
||||
((m.type === "sendMessage" || m.type === "sendCommand" || m.type === "toggleSandbox") && !m.sessionID)
|
||||
) {
|
||||
const ctx = typeof m.agentManagerContext === "string" ? m.agentManagerContext : undefined
|
||||
const worktree = ctx && ctx !== "local" ? this.getStateManager()?.getWorktree(ctx) : undefined
|
||||
if (worktree) {
|
||||
if (m.draftID) this.activeSessionId = m.draftID
|
||||
if ("draftID" in m && m.draftID) this.activeSessionId = m.draftID
|
||||
return { ...msg, contextDirectory: worktree.path }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,26 @@ describe("AgentManagerProvider worktree creation", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([{ type: "requestSandboxDefault" }, { type: "setSandboxDefault", enabled: false, requestID: "request-1" }])(
|
||||
"routes $type to the selected worktree directory",
|
||||
async (message) => {
|
||||
const manager = createHarness()
|
||||
const state = {
|
||||
getWorktree: vi.fn().mockReturnValue({ id: "wt-1", path: "/repo/.kilo/worktrees/wt-1" }),
|
||||
}
|
||||
manager.getStateManager.mockReturnValue(state)
|
||||
manager.contextTarget.mockResolvedValue(undefined)
|
||||
|
||||
const result = await manager.onMessage({ ...message, agentManagerContext: "wt-1" })
|
||||
|
||||
expect(result).toEqual({
|
||||
...message,
|
||||
agentManagerContext: "wt-1",
|
||||
contextDirectory: "/repo/.kilo/worktrees/wt-1",
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it("resolves new sandbox toggles to the selected worktree directory", async () => {
|
||||
const manager = createHarness()
|
||||
const state = {
|
||||
|
||||
@@ -639,6 +639,20 @@ interface SendCommandIn {
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
interface RequestSandboxDefaultIn {
|
||||
type: "requestSandboxDefault"
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
interface SetSandboxDefaultIn {
|
||||
type: "setSandboxDefault"
|
||||
enabled: boolean
|
||||
requestID: string
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
interface ToggleSandboxIn {
|
||||
type: "toggleSandbox"
|
||||
sessionID?: string
|
||||
@@ -792,6 +806,8 @@ export type AgentManagerInMessage =
|
||||
| LoadMessagesIn
|
||||
| SendMessageIn
|
||||
| SendCommandIn
|
||||
| RequestSandboxDefaultIn
|
||||
| SetSandboxDefaultIn
|
||||
| ToggleSandboxIn
|
||||
| RequestTerminalContextIn
|
||||
| ClearSessionIn
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { KiloConnectionService } from "./connection-service"
|
||||
|
||||
function state(value: boolean) {
|
||||
return {
|
||||
get: <T>() => value as T,
|
||||
update: async () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe("KiloConnectionService sandbox preference", () => {
|
||||
test("uses workspace state instead of extension-global state", () => {
|
||||
const service = new KiloConnectionService({
|
||||
workspaceState: state(false),
|
||||
globalState: state(true),
|
||||
} as any)
|
||||
|
||||
expect(service.sandboxPreference.resolve(true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService viewed sessions", () => {
|
||||
test("keeps Agent Manager sessions when sidebar focus changes during a flush", async () => {
|
||||
const service = new KiloConnectionService({} as any)
|
||||
|
||||
@@ -93,7 +93,7 @@ export class KiloConnectionService {
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
const state =
|
||||
context.globalState ??
|
||||
context.workspaceState ??
|
||||
({
|
||||
get: <T>(_key: string, fallback?: T) => fallback,
|
||||
update: async () => undefined,
|
||||
|
||||
@@ -74,6 +74,8 @@ function createClient(options?: {
|
||||
const reverted: Array<Record<string, unknown>> = []
|
||||
const created: Array<Record<string, unknown>> = []
|
||||
const sandboxed: Array<Record<string, unknown>> = []
|
||||
const sandboxSupport: Array<Record<string, unknown>> = []
|
||||
const configReads: Array<Record<string, unknown>> = []
|
||||
return {
|
||||
calls,
|
||||
stopped,
|
||||
@@ -82,6 +84,8 @@ function createClient(options?: {
|
||||
reverted,
|
||||
created,
|
||||
sandboxed,
|
||||
sandboxSupport,
|
||||
configReads,
|
||||
session: {
|
||||
list: async () => ({ data: [] }),
|
||||
create: async (params: Record<string, unknown>) => {
|
||||
@@ -118,7 +122,10 @@ function createClient(options?: {
|
||||
},
|
||||
},
|
||||
sandbox: {
|
||||
support: async () => options?.supportDeferred?.promise ?? { data: { available: true } },
|
||||
support: async (params: Record<string, unknown>) => {
|
||||
sandboxSupport.push(params)
|
||||
return options?.supportDeferred?.promise ?? { data: { available: true } }
|
||||
},
|
||||
toggle: async (params: Record<string, unknown>) => {
|
||||
sandboxed.push(params)
|
||||
options?.sandboxStarted?.resolve(undefined)
|
||||
@@ -137,7 +144,12 @@ function createClient(options?: {
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
config: {
|
||||
get: async (params: Record<string, unknown>) => {
|
||||
configReads.push(params)
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
kilo: {
|
||||
notifications: async () => ({ data: [] }),
|
||||
profile: async () => ({ data: {} }),
|
||||
@@ -207,7 +219,8 @@ type ProviderInternals = {
|
||||
handleAbort: (sid?: string) => Promise<void>
|
||||
handleRevertSession: (sid: string, messageID: string) => Promise<void>
|
||||
handleSendMessage: (text: string, messageID?: string, sessionID?: string, draftID?: string) => Promise<void>
|
||||
handleSetSandboxDefault: (enabled: boolean, requestID: string) => Promise<void>
|
||||
fetchAndSendSandboxDefault: (directory?: string, requestID?: string) => Promise<void>
|
||||
handleSetSandboxDefault: (enabled: boolean, requestID: string, directory?: string) => Promise<void>
|
||||
handleToggleSandbox: (input: { sessionID: string; requestID: string }) => Promise<void>
|
||||
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
|
||||
handleDeleteSession: (sid: string) => Promise<void>
|
||||
@@ -334,6 +347,16 @@ describe("KiloProvider sandbox toggle", () => {
|
||||
notice.mockRestore()
|
||||
})
|
||||
|
||||
it("resolves a blank worktree default against the routed directory", async () => {
|
||||
const client = createClient()
|
||||
const { internal } = makeProvider(client)
|
||||
|
||||
await internal.fetchAndSendSandboxDefault("/repo/.kilo/worktrees/wt-1")
|
||||
|
||||
expect(client.configReads).toEqual([{ directory: "/repo/.kilo/worktrees/wt-1" }])
|
||||
expect(client.sandboxSupport).toEqual([{ directory: "/repo/.kilo/worktrees/wt-1" }])
|
||||
})
|
||||
|
||||
it("waits for a blank toggle before creating the first prompt session", async () => {
|
||||
const support = defer<{ data: { available: boolean } }>()
|
||||
const client = createClient({ supportDeferred: support })
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(toggle).toContain('type: "toggleSandbox"')
|
||||
expect(toggle).toContain('type: "setSandboxDefault"')
|
||||
expect(toggle).toContain("enabled: !sandboxDefault()!.desired")
|
||||
expect(toggle).toContain("agentManagerContext: ctx()")
|
||||
expect(toggle).toContain("sessionID,")
|
||||
expect(toggle).toContain("requestID,")
|
||||
expect(toggle).not.toContain("draftID:")
|
||||
@@ -57,10 +58,11 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(move).toBeGreaterThan(save)
|
||||
})
|
||||
|
||||
it("requires the enabled experiment for visibility and uses effective runtime state for the button", () => {
|
||||
it("keeps persisted sandbox state visible independently of the configured default", () => {
|
||||
expect(src).toContain(
|
||||
'return features().sandboxControls && config().experimental?.sandbox === true && !id?.startsWith("cloud:")',
|
||||
'const sandboxVisible = () => features().sandboxControls && !session.currentSessionID()?.startsWith("cloud:")',
|
||||
)
|
||||
expect(src).not.toContain("config().experimental?.sandbox === true")
|
||||
expect(src).toContain("<Show when={sandboxVisible()}>")
|
||||
expect(src).toContain("{ action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() }")
|
||||
expect(src).toContain('if (!sandboxVisible()) hidden.add("sandbox")')
|
||||
@@ -72,7 +74,7 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(src).toContain("const target = untrack(sandboxTarget)")
|
||||
expect(src).toContain("if (target !== undefined && target !== sessionID) clearSandboxRequest()")
|
||||
expect(src).toContain("sandboxID() ? sandbox()?.enabled : sandboxDefault()?.enabled")
|
||||
expect(src).toContain('type: "requestSandboxDefault"')
|
||||
expect(src).toContain('type: "requestSandboxDefault", agentManagerContext: ctx()')
|
||||
expect(src).toContain("aria-pressed={sandboxEnabled()}")
|
||||
expect(src).toContain("!sandboxReady()")
|
||||
expect(src).toContain("if (sandboxRequest() && target === null) return")
|
||||
|
||||
@@ -202,10 +202,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const id = session.currentSessionID()
|
||||
return id?.startsWith("cloud:") ? undefined : id
|
||||
}
|
||||
const sandboxVisible = () => {
|
||||
const id = session.currentSessionID()
|
||||
return features().sandboxControls && config().experimental?.sandbox === true && !id?.startsWith("cloud:")
|
||||
}
|
||||
const sandboxVisible = () => features().sandboxControls && !session.currentSessionID()?.startsWith("cloud:")
|
||||
const sandbox = () => {
|
||||
const state = sandboxState()
|
||||
return state?.sessionID === sandboxID() ? state : undefined
|
||||
@@ -224,7 +221,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
vscode.postMessage({ type: "requestSandboxStatus", sessionID })
|
||||
return
|
||||
}
|
||||
vscode.postMessage({ type: "requestSandboxDefault" })
|
||||
vscode.postMessage({ type: "requestSandboxDefault", agentManagerContext: ctx() })
|
||||
}
|
||||
const toggleSandbox = () => {
|
||||
const sessionID = sandboxID()
|
||||
@@ -234,7 +231,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setSandboxRequest(requestID)
|
||||
setSandboxTarget(sessionID ?? null)
|
||||
if (!sessionID) {
|
||||
vscode.postMessage({ type: "setSandboxDefault", enabled: !sandboxDefault()!.desired, requestID })
|
||||
vscode.postMessage({
|
||||
type: "setSandboxDefault",
|
||||
enabled: !sandboxDefault()!.desired,
|
||||
requestID,
|
||||
agentManagerContext: ctx(),
|
||||
})
|
||||
return
|
||||
}
|
||||
vscode.postMessage({
|
||||
|
||||
@@ -931,12 +931,16 @@ export interface RequestSandboxStatusMessage {
|
||||
|
||||
export interface RequestSandboxDefaultMessage {
|
||||
type: "requestSandboxDefault"
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
export interface SetSandboxDefaultMessage {
|
||||
type: "setSandboxDefault"
|
||||
enabled: boolean
|
||||
requestID: string
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
}
|
||||
|
||||
export interface ToggleSandboxMessage {
|
||||
|
||||
@@ -24,6 +24,12 @@ export function merge(metadata: Record<string, unknown> | null | undefined, valu
|
||||
return { ...metadata, [key]: value }
|
||||
}
|
||||
|
||||
export function inherit(metadata: Record<string, unknown> | null | undefined) {
|
||||
const value = parse(metadata)
|
||||
if (!value) return
|
||||
return merge(undefined, { enabled: value.enabled, version: 0 })
|
||||
}
|
||||
|
||||
export function remove(metadata: Record<string, unknown> | null | undefined) {
|
||||
if (!metadata || !(key in metadata)) return metadata
|
||||
const next = { ...metadata }
|
||||
|
||||
@@ -14,6 +14,7 @@ import { KiloTask } from "../kilocode/tool/task" // kilocode_change
|
||||
import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change
|
||||
import { KiloSessionProcessor } from "../kilocode/session/processor" // kilocode_change
|
||||
import { KiloSession } from "../kilocode/session" // kilocode_change
|
||||
import * as SandboxState from "../kilocode/sandbox/state" // kilocode_change
|
||||
import { errorMessage } from "@/util/error" // kilocode_change
|
||||
import { Cause, Effect, Exit, Schema, Scope } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
@@ -181,6 +182,7 @@ export const TaskTool = Tool.define(
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${next.name} subagent)`,
|
||||
platform, // kilocode_change
|
||||
metadata: SandboxState.inherit(parent.metadata), // kilocode_change - preserve explicit parent sandbox state
|
||||
// kilocode_change start - dedupe inherited restrictions before child prompt toggles persist
|
||||
permission: KiloTask.merge(
|
||||
deriveSubagentSessionPermission({
|
||||
|
||||
@@ -112,6 +112,21 @@ it.instance(
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
)
|
||||
|
||||
it.instance("persists metadata without requiring sandbox backend support", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const id = yield* create({ source: "test" })
|
||||
|
||||
yield* SandboxState.write(id, { enabled: false, version: 7 })
|
||||
|
||||
expect(yield* SandboxState.read(id)).toEqual({ enabled: false, version: 7 })
|
||||
expect((yield* sessions.get(id)).metadata).toEqual({
|
||||
source: "test",
|
||||
[SandboxState.key]: { enabled: false, version: 7 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("preserves unrelated metadata through the production persistence callback", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Provider } from "../../src/provider/provider"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
|
||||
import * as SandboxState from "../../src/kilocode/sandbox/state"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
@@ -155,6 +156,45 @@ describe("Kilo task nesting", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("inherits the parent's explicit sandbox state", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
yield* sessions.setMetadata({
|
||||
sessionID: chat.id,
|
||||
metadata: SandboxState.merge(chat.metadata, { enabled: false, version: 3 }),
|
||||
})
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* tool.init()
|
||||
|
||||
const result = yield* def.execute(
|
||||
{
|
||||
description: "inspect sandbox",
|
||||
prompt: "check the child sandbox state",
|
||||
subagent_type: "explore",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps: stubOps() },
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
|
||||
const child = yield* sessions.get(result.metadata.sessionId)
|
||||
expect(SandboxState.parse(child.metadata)).toEqual({ enabled: false, version: 0 })
|
||||
expect((yield* (yield* Config.Service).get()).experimental?.sandbox).toBe(true)
|
||||
}),
|
||||
{ config: { experimental: { sandbox: true } } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("disables nested task and question tools even when global permissions allow them", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user