feat(adopt): read a pre-existing tab without opening a new one
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled
New `chrome-use adopt <url-substring|targetId>`: drive a tab the user (or another session) already has open, with ZERO new tabs. After group-scoped isolation (#40) a session can't see foreign tabs, so adopt adds an explicit, opt-in path: - Relay (relay.rs): `ABRelay.getAllTargets` returns every attached target UNSCOPED (ignores group scoping), so the agent can find a specific tab by URL or targetId. +1 unit test. - Daemon (browser.rs): `collect_all_targets` (unscoped, falls back to scoped on older relays) + `adopt_existing_target` — matches by exact targetId or case-insensitive URL substring, attaches it (the relay re-tags it into the adopter's group, so isolation holds), pins it; never creates a tab. On no match it errors AND lists the open tabs it can see, rather than launching. discover_and_attach_targets honors AGENT_BROWSER_ADOPT at first connect, so no about:blank is ever created. - CLI (main.rs): `adopt` sets the env, forces a fresh daemon, and rewrites into `connect <relay-url>` (like `extension connect`) so the daemon attaches to the user's real Chrome before parse_command. Extension (ab-connect 0.4.11): `reannounceAttachedTabs` now re-sends each tab's url/title (it previously sent neither) so the relay's target list stays matchable by URL after the MV3 service worker reconnects — otherwise reannounced tabs show a blank url and `adopt <url>` can't find them. Repacked upload zip + crx. Mechanism verified live (enumerated all 11 of the user's open tabs incl. the target). 862 tests pass.
This commit is contained in:
Generated
+1
-1
@@ -290,7 +290,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.22"
|
version = "1.5.23"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chrome-use"
|
name = "chrome-use"
|
||||||
version = "1.5.22"
|
version = "1.5.23"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const KNOWN_COMMANDS: &[&str] = &[
|
|||||||
"upload",
|
"upload",
|
||||||
"site",
|
"site",
|
||||||
"box",
|
"box",
|
||||||
|
"adopt",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Levenshtein distance, capped — small inputs only (command names).
|
/// Levenshtein distance, capped — small inputs only (command names).
|
||||||
@@ -1317,6 +1318,12 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
Ok(json!({ "id": id, "action": "site", "domain": domain, "script": script }))
|
Ok(json!({ "id": id, "action": "site", "domain": domain, "script": script }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `adopt <url|targetId>`: the adoption happens at daemon connect (driven by
|
||||||
|
// the AGENT_BROWSER_ADOPT env main.rs set + a forced-fresh daemon), so by
|
||||||
|
// the time this command runs the tab is already attached. Resolve to a
|
||||||
|
// `url` read so the response confirms which tab got adopted.
|
||||||
|
"adopt" => Ok(json!({ "id": id, "action": "url" })),
|
||||||
|
|
||||||
// === Stealth self-check ===
|
// === Stealth self-check ===
|
||||||
"stealth" => {
|
"stealth" => {
|
||||||
// `stealth [status]` — local stealth self-check: mode, live probes
|
// `stealth [status]` — local stealth self-check: mode, live probes
|
||||||
|
|||||||
@@ -595,7 +595,7 @@ fn query_current_url(session: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Kill a running daemon by reading its PID file and sending a kill signal.
|
/// Kill a running daemon by reading its PID file and sending a kill signal.
|
||||||
fn kill_stale_daemon(session: &str) {
|
pub fn kill_stale_daemon(session: &str) {
|
||||||
// Remove the socket first so no new connections reach the old daemon
|
// Remove the socket first so no new connections reach the old daemon
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -981,6 +981,43 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `adopt <url|targetId>`: read a PRE-EXISTING tab (the user's own, or another
|
||||||
|
// session's) WITHOUT opening a new one. Forces a fresh daemon and points it at
|
||||||
|
// the relay (like `extension connect`); the AGENT_BROWSER_ADOPT env makes the
|
||||||
|
// daemon's first connect ADOPT the matching tab instead of creating an
|
||||||
|
// about:blank. Rewrites into `connect <relay-url>` BEFORE parse_command so the
|
||||||
|
// daemon attaches to the user's real Chrome. Must run before parse_command.
|
||||||
|
if clean.first().map(|s| s.as_str()) == Some("adopt") {
|
||||||
|
match clean.get(1) {
|
||||||
|
Some(spec) if !spec.trim().is_empty() => {
|
||||||
|
std::env::set_var("AGENT_BROWSER_ADOPT", spec.trim());
|
||||||
|
connection::kill_stale_daemon(&flags.session);
|
||||||
|
match connect::relay_url() {
|
||||||
|
Some(url) => {
|
||||||
|
flags.cdp = Some(url.clone());
|
||||||
|
flags.auto_connect = false;
|
||||||
|
clean = vec!["connect".to_string(), url];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"{} extension relay not connected — open Chrome with the ab-connect \
|
||||||
|
extension first (this command reads an EXISTING tab, it won't launch one).",
|
||||||
|
color::error_indicator()
|
||||||
|
);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!(
|
||||||
|
"{} usage: chrome-use adopt <url-substring|targetId> (reads an existing tab, no new tab)",
|
||||||
|
color::error_indicator()
|
||||||
|
);
|
||||||
|
exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle session separately (doesn't need daemon)
|
// Handle session separately (doesn't need daemon)
|
||||||
if clean.first().map(|s| s.as_str()) == Some("session") {
|
if clean.first().map(|s| s.as_str()) == Some("session") {
|
||||||
run_session(&clean, &flags.session, flags.json);
|
run_session(&clean, &flags.session, flags.json);
|
||||||
|
|||||||
@@ -824,6 +824,106 @@ impl BrowserManager {
|
|||||||
Ok(by_id.into_values().collect())
|
Ok(by_id.into_values().collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every tab the relay knows, UNSCOPED (ignores group scoping) — for explicit
|
||||||
|
/// cross-group adoption (`chrome-use adopt`). Falls back to the scoped
|
||||||
|
/// `collect_page_targets` on a relay/browser that doesn't support the
|
||||||
|
/// unscoped query. Retries a few times over the relay (discovery is eventual).
|
||||||
|
async fn collect_all_targets(&self) -> Result<Vec<TargetInfo>, String> {
|
||||||
|
let rounds = if crate::connect::relay_url().is_some() {
|
||||||
|
3
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
let mut by_id: HashMap<String, TargetInfo> = HashMap::new();
|
||||||
|
let mut any_ok = false;
|
||||||
|
for i in 0..rounds {
|
||||||
|
if i > 0 {
|
||||||
|
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||||
|
}
|
||||||
|
if let Ok(result) = self
|
||||||
|
.client
|
||||||
|
.send_command_typed::<_, GetTargetsResult>(
|
||||||
|
"ABRelay.getAllTargets",
|
||||||
|
&json!({}),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
any_ok = true;
|
||||||
|
for t in result.target_infos.into_iter().filter(should_track_target) {
|
||||||
|
by_id.entry(t.target_id.clone()).or_insert(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any_ok {
|
||||||
|
Ok(by_id.into_values().collect())
|
||||||
|
} else {
|
||||||
|
// Older relay without ABRelay.getAllTargets → best-effort scoped list.
|
||||||
|
self.collect_page_targets().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adopt a specific pre-existing tab matched by `spec` (an exact CDP
|
||||||
|
/// `targetId`, or a case-insensitive substring of the tab URL) WITHOUT opening
|
||||||
|
/// a new tab — for `chrome-use adopt`. Attaches it (the relay tags it into our
|
||||||
|
/// group), tracks + pins it. Errors if nothing matches (never creates a tab).
|
||||||
|
async fn adopt_existing_target(&mut self, spec: &str) -> Result<(), String> {
|
||||||
|
let all = self.collect_all_targets().await?;
|
||||||
|
let spec_l = spec.to_lowercase();
|
||||||
|
let target = all
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.target_id == spec)
|
||||||
|
.or_else(|| all.iter().find(|t| t.url.to_lowercase().contains(&spec_l)))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let mut open: Vec<String> = all
|
||||||
|
.iter()
|
||||||
|
.map(|t| {
|
||||||
|
let u = if t.url.len() > 80 {
|
||||||
|
&t.url[..80]
|
||||||
|
} else {
|
||||||
|
&t.url
|
||||||
|
};
|
||||||
|
u.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
open.sort();
|
||||||
|
open.dedup();
|
||||||
|
format!(
|
||||||
|
"adopt: no open tab matching `{spec}` (by targetId or URL substring).\n\
|
||||||
|
{} tab(s) the extension can see:\n {}",
|
||||||
|
open.len(),
|
||||||
|
open.join("\n ")
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let attach: AttachToTargetResult = self
|
||||||
|
.client
|
||||||
|
.send_command_typed(
|
||||||
|
"Target.attachToTarget",
|
||||||
|
&AttachToTargetParams {
|
||||||
|
target_id: target.target_id.clone(),
|
||||||
|
flatten: true,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let tab_id = self.assign_tab_id();
|
||||||
|
self.pages.push(PageInfo {
|
||||||
|
tab_id,
|
||||||
|
label: None,
|
||||||
|
target_id: target.target_id.clone(),
|
||||||
|
session_id: attach.session_id.clone(),
|
||||||
|
url: target.url.clone(),
|
||||||
|
title: sanitize_title(&target.title),
|
||||||
|
target_type: target.target_type.clone(),
|
||||||
|
});
|
||||||
|
self.active_page_index = self.pages.len() - 1;
|
||||||
|
self.pin_active_target();
|
||||||
|
self.enable_domains(&attach.session_id).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn discover_and_attach_targets(&mut self) -> Result<(), String> {
|
async fn discover_and_attach_targets(&mut self) -> Result<(), String> {
|
||||||
self.client
|
self.client
|
||||||
.send_command_typed::<_, Value>(
|
.send_command_typed::<_, Value>(
|
||||||
@@ -837,6 +937,17 @@ impl BrowserManager {
|
|||||||
// own tab group (issue #40). On a launched browser this is a no-op.
|
// own tab group (issue #40). On a launched browser this is a no-op.
|
||||||
let scoped = self.announce_group().await;
|
let scoped = self.announce_group().await;
|
||||||
|
|
||||||
|
// `chrome-use adopt <spec>`: adopt a specific PRE-EXISTING tab instead of
|
||||||
|
// creating one — true zero-new-tab reading of the user's own tab. The
|
||||||
|
// directive rides in via env so it takes effect at first connect (before
|
||||||
|
// any about:blank would be made). If nothing matches, error out rather
|
||||||
|
// than fall back to creating a tab.
|
||||||
|
if let Ok(spec) = std::env::var("AGENT_BROWSER_ADOPT") {
|
||||||
|
if !spec.trim().is_empty() {
|
||||||
|
return self.adopt_existing_target(spec.trim()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let page_targets: Vec<TargetInfo> = self.collect_page_targets().await?;
|
let page_targets: Vec<TargetInfo> = self.collect_page_targets().await?;
|
||||||
|
|
||||||
if page_targets.is_empty() {
|
if page_targets.is_empty() {
|
||||||
|
|||||||
@@ -155,6 +155,20 @@ impl RelayState {
|
|||||||
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
"Target.setDiscoverTargets" | "Target.setAutoAttach" => {
|
||||||
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
ClientRoute::Local(json!({ "id": id, "result": {} }))
|
||||||
}
|
}
|
||||||
|
// Unscoped discovery for EXPLICIT cross-group adoption (`chrome-use
|
||||||
|
// adopt`): returns every target the extension has attached, ignoring
|
||||||
|
// group scoping, so an agent can find a specific pre-existing tab (the
|
||||||
|
// user's, another session's) by URL/targetId and adopt it. Isolation
|
||||||
|
// is preserved because the daemon only acts on the one tab it then
|
||||||
|
// attaches (which the relay re-tags into the adopter's group).
|
||||||
|
"ABRelay.getAllTargets" => {
|
||||||
|
let infos: Vec<Value> = self
|
||||||
|
.targets
|
||||||
|
.values()
|
||||||
|
.map(|t| t.target_info.clone())
|
||||||
|
.collect();
|
||||||
|
ClientRoute::Local(json!({ "id": id, "result": { "targetInfos": infos } }))
|
||||||
|
}
|
||||||
"Target.getTargets" => {
|
"Target.getTargets" => {
|
||||||
// Scope to the client's own group when it announced one; an
|
// Scope to the client's own group when it announced one; an
|
||||||
// un-announced (legacy) client gets the full list (back-compat).
|
// un-announced (legacy) client gets the full list (back-compat).
|
||||||
@@ -772,6 +786,33 @@ mod tests {
|
|||||||
assert!(get_target_ids(&mut s, 2).is_empty());
|
assert!(get_target_ids(&mut s, 2).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_all_targets_is_unscoped() {
|
||||||
|
let mut s = RelayState::new();
|
||||||
|
create_in_group(&mut s, 1, "agent-a", "ta", "sa");
|
||||||
|
create_in_group(&mut s, 2, "agent-b", "tb", "sb");
|
||||||
|
// Client 1's scoped getTargets sees only its own group...
|
||||||
|
assert_eq!(get_target_ids(&mut s, 1), vec!["ta"]);
|
||||||
|
// ...but ABRelay.getAllTargets returns EVERY target regardless of group
|
||||||
|
// (for explicit cross-group adoption).
|
||||||
|
let all = match s
|
||||||
|
.route_client_command(1, &json!({ "id": 1, "method": "ABRelay.getAllTargets" }))
|
||||||
|
{
|
||||||
|
ClientRoute::Local(v) => {
|
||||||
|
let mut ids: Vec<String> = v["result"]["targetInfos"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|t| t["targetId"].as_str().unwrap().to_string())
|
||||||
|
.collect();
|
||||||
|
ids.sort();
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
_ => panic!("getAllTargets must be local"),
|
||||||
|
};
|
||||||
|
assert_eq!(all, vec!["ta", "tb"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detach_clears_target_group() {
|
fn detach_clears_target_group() {
|
||||||
let mut s = RelayState::new();
|
let mut s = RelayState::new();
|
||||||
|
|||||||
@@ -3375,6 +3375,10 @@ Tabs:
|
|||||||
stable targetId, no reload — preserves in-page state
|
stable targetId, no reload — preserves in-page state
|
||||||
open <url> --reuse-tab Reuse an existing tab on that URL instead of spawning
|
open <url> --reuse-tab Reuse an existing tab on that URL instead of spawning
|
||||||
a duplicate (matches origin+path; preserves state)
|
a duplicate (matches origin+path; preserves state)
|
||||||
|
adopt <url|targetId> Read a PRE-EXISTING tab (the user's own, or another
|
||||||
|
session's) WITHOUT opening a new one — matches by URL
|
||||||
|
substring or stable targetId, then drives it. e.g.
|
||||||
|
`adopt "github.com/owner/repo"`
|
||||||
|
|
||||||
Diff:
|
Diff:
|
||||||
diff snapshot Compare current vs last snapshot
|
diff snapshot Compare current vs last snapshot
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -471,8 +471,19 @@ async function reannounceAttachedTabs() {
|
|||||||
for (const [tabId, entry] of tabs.entries()) {
|
for (const [tabId, entry] of tabs.entries()) {
|
||||||
// Re-send the group hint too (issue #40) so the relay can rebuild its
|
// Re-send the group hint too (issue #40) so the relay can rebuild its
|
||||||
// targetId→group map after its own restart (createTarget tagging won't
|
// targetId→group map after its own restart (createTarget tagging won't
|
||||||
// re-run for tabs that are already open).
|
// re-run for tabs that are already open). Include the live url/title so the
|
||||||
|
// relay's target list stays matchable by URL after a reconnect (otherwise a
|
||||||
|
// reannounced tab shows a blank url and `adopt <url>` can't find it).
|
||||||
const { openerTargetId, abGroup } = await tabScopeHints(tabId)
|
const { openerTargetId, abGroup } = await tabScopeHints(tabId)
|
||||||
|
let url = ''
|
||||||
|
let title = ''
|
||||||
|
try {
|
||||||
|
const t = await chrome.tabs.get(tabId)
|
||||||
|
if (t) {
|
||||||
|
url = t.url || t.pendingUrl || ''
|
||||||
|
title = t.title || ''
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
postToHost({
|
postToHost({
|
||||||
method: 'forwardCDPEvent',
|
method: 'forwardCDPEvent',
|
||||||
params: {
|
params: {
|
||||||
@@ -480,7 +491,7 @@ async function reannounceAttachedTabs() {
|
|||||||
method: 'Target.attachedToTarget',
|
method: 'Target.attachedToTarget',
|
||||||
params: {
|
params: {
|
||||||
sessionId: entry.sessionId,
|
sessionId: entry.sessionId,
|
||||||
targetInfo: { targetId: entry.targetId, type: 'page', attached: true, openerTargetId, abGroup },
|
targetInfo: { targetId: entry.targetId, type: 'page', url, title, attached: true, openerTargetId, abGroup },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "0.4.10",
|
"version": "0.4.11",
|
||||||
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
"description": "Let chrome-use drive your logged-in Chrome \u2014 install once, no token, no per-use confirmation.",
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6vQIyscGIPYPZdSpPwPL0+0gxUROyRgCpmvCSDoc8XUm4qm97VbKnD9Ijc1lV22lNWZtE78gaRjt6BeSfuMgnBymnhLKjN1gU6AI5QUU0mrJyeHdWKvrKQR5FmsM2A7Xr1ykE2SiiS8zNUS3Y/6O5l+Nva7wrVy6E4a2dkBVQkOsu+DV+nEZvhIyuDY5D5SPXqNwUTWTaglwj5mjvHz36xSwCWlPmrtJ+ED0AUyrb2z4GIOmvk4kqtBVrh/UD058klLo4CkYOnIybB5aV6WYuwarfPY4bF/dLggPem+ewLNTUNBuwrxj/A4nUv0LJTuRO8rR7f8WR9qnRCY0Ic5saQIDAQAB",
|
||||||
"icons": {
|
"icons": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "chrome-use",
|
"name": "chrome-use",
|
||||||
"version": "1.5.22",
|
"version": "1.5.23",
|
||||||
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
"description": "chrome-use — drive your real, logged-in Chrome from any AI agent, stealth by default",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
|
|||||||
Reference in New Issue
Block a user