mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
feat(beehive): restore retained local keys through a hidden import wizard
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -5,6 +5,29 @@ Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; continuation starts at
|
||||
`023c9274767ef50fa0f5b37ef1883336f8be59fd`. Candidate is the commit containing
|
||||
this checkpoint (`git rev-parse HEAD`). No merge/release.
|
||||
|
||||
## Secure local key reuse increment — intentionally partial second phase
|
||||
|
||||
Published K1 separately as 08be5d6eb4c1a367363e16b36bb4e2824c138b0e.
|
||||
This continuation adds an executable `import-agent-key` local wizard, hidden
|
||||
terminal input (protected stdin supported), exact public-key match, installation
|
||||
lock and shared retained-authority validation before a secret-only atomic manifest
|
||||
write. It cannot mint authority, recreate unknown slots or replace existing keys.
|
||||
Retained standby/source-consumed assignment is not changed by importing its key.
|
||||
Actual CLI/PTY import test + real host/relay standby denial passes (1.41s); strict
|
||||
passes. Initial PTY attempts failed because macOS script refuses Node socketpair
|
||||
stdin; retained import-focused logs 1–4 capture this adapter failure, then fixed
|
||||
adapter uses a real shell pipe. No product assertions or timeouts were weakened.
|
||||
Evidence: `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/K1_928F/`.
|
||||
|
||||
Grounding read: HARNESS_PROVIDER_UX_GROUNDING.md. No provider abstraction or
|
||||
executable/auth claim added. **Second-phase partial:** multiple reusable local
|
||||
bindings CRUD, remote exact A/B selection/fingerprints, integrated initial
|
||||
new/import/reuse wizard, and second Goose executable/provider/service-env path
|
||||
remain unimplemented. Next executable step is local binding management + advertised
|
||||
selection at the governing host preflight/configuration boundary, followed by
|
||||
source-pinned Goose ACP fixture acceptance; not a design-only task or parity claim.
|
||||
Final strict/default concurrent installed-enabled results will be recorded below.
|
||||
|
||||
## K1 retained-authority removal — explicit closure
|
||||
|
||||
Inspected immutable 2151ea3a: K1 was NOT fixed by the named Move commit.
|
||||
@@ -1107,3 +1130,31 @@ Selected-next != actual-run; stopped identity remains assigned. No remote secret
|
||||
credential/workspace/session transfer or unreachable-host takeover. Hash/spawn
|
||||
TOCTOU, hostile journals, rollback/clones, outbox pruning and stronger OS containment
|
||||
remain outside evidence.
|
||||
|
||||
## 928F final validation/publication record
|
||||
|
||||
Final executable tree: strict passes (`K1_928F/final-strict.log`). Exactly one final
|
||||
FULL DEFAULT concurrent installed-enabled package run naturally completed: **50/51,
|
||||
1 failure, no skips/cancellations, 35.322s** (`full-installed-default.log`). All six
|
||||
key-removal/import tests passed, including actual CLI K1 negatives, real Move and
|
||||
hidden-input macOS PTY. Failure: `slots.test.ts:131` waiting for standby host
|
||||
`Host online` via launch:50; stderr is discarded by that existing test driver.
|
||||
Cause remains **unclassified**. No serial masking, rerun-to-green, timeout inflation,
|
||||
assertion weakening or claim of full-suite acceptance. Prior four 110s timeouts and
|
||||
other historical failures above remain independently scoped and unclassified except
|
||||
for the specifically traced private-journal/TUI observation revision race.
|
||||
|
||||
Self-review checked common retained-state loader extraction, exact key matching,
|
||||
no import authority synthesis, key input/output boundaries, atomic manifest-only
|
||||
writes and stopped/lock gates. macOS PTY adapter failures and strict extraction
|
||||
failure are retained alongside successful focused runs. Installed binaries were
|
||||
read-only and hashed separately: buzz-acp
|
||||
`10612d0025d1420bdd9e9afcc2e7129377da2414e75049a8b640e81d857441ea`, buzz CLI
|
||||
`147cc2ccf276ddedc5b84d13f5399a95282066303c9dca566bb2af5a37b856c5`.
|
||||
These are installed runtime + deterministic ACP/loopback fixtures, NOT live provider,
|
||||
OAuth, production kind-40002 or full-product parity evidence. No dependency changes.
|
||||
Source hashes and publication verification live beside the logs; the final source
|
||||
commit is the commit containing this section. Required author/committer Logan
|
||||
Johnson <loganj@squareup.com>, DCO, no cryptographic signing claim. No repo-wide CI,
|
||||
merge or release. Remaining second-phase work and full-suite failure investigation
|
||||
are explicit handoff items, not closed by this partial key-import tracer.
|
||||
|
||||
@@ -585,3 +585,24 @@ selected-next, and actual/history retain full snapshots. A later Save gets a lat
|
||||
revision, is never overwritten, and forces consumed-grant acceptance to remain stopped.
|
||||
Reservations alone never confer Start authority. Legacy saved grant/event identity hashes
|
||||
are not changed or re-signed; new materialization requires compatible host versions.
|
||||
|
||||
### Explicit local key reuse for a retained public-only slot
|
||||
|
||||
`node src/cli.ts import-agent-key <host-directory> <agent-public-key>` is a small
|
||||
local repair wizard: confirm the public identity, then paste the exact 64-hex
|
||||
private key at a **hidden** prompt. Never put the private key in argv, environment,
|
||||
a remote message or terminal transcript. Protected piped stdin is supported;
|
||||
the key is never printed. It restores only a previously removed local copy, not
|
||||
an unknown identity or an assignment. The host must be stopped and its lock free.
|
||||
A valid retained journal is mandatory; wrong keys, missing/malformed authority,
|
||||
non-stopped slots and already-present keys refuse without modifying state.
|
||||
Standby or consumed-source import does **not** grant Start. All retained history,
|
||||
public configuration, sibling keys and journals remain unchanged. Ordinary remote
|
||||
Save/Start/Restart still never recreate removed keys. This is deliberate local
|
||||
provisioning, not global revocation/rotation or restoration of compromised authority.
|
||||
|
||||
This increment does not yet integrate import/new/reuse into the initial setup
|
||||
wizard, manage multiple local harness bindings, or add Goose. Those, remote A/B
|
||||
selection with immutable applied inputs, and remaining Claude/Codex/preset/custom/
|
||||
mesh/compute parity remain unfinished. Existing file-based standby enrollment is
|
||||
unchanged; no provider login/cache access or live-provider acceptance is claimed.
|
||||
|
||||
@@ -356,3 +356,19 @@ journal gate means the separate TUI has consumed inventory. Gate failures close
|
||||
owned UI and propagate; existing assertions and observation/test deadlines are unchanged.
|
||||
Retained direct diagnostic captured a stale-observation Restart revision-conflict;
|
||||
other older timeout observations lacking that trace remain unclassified.
|
||||
|
||||
## K1 and explicit local key import continuation
|
||||
|
||||
`remove-key.test.ts` adds actual removal CLI missing-assignment/mismatched-genesis
|
||||
and malformed owner/holder/root/predecessor/grant/operation negatives, retaining
|
||||
manifest and journal bytes. Stopped, standby and consumed-source positive controls
|
||||
use the same read-only host admission loader. Existing real Move tests remain.
|
||||
|
||||
The import test exercises actual CLI confirmation, wrong key and malformed hidden
|
||||
input, lock and corrupt-authority refusal, exact secret-only manifest change,
|
||||
0600/0700 permissions and unchanged sibling/history. On macOS it also runs the
|
||||
actual wizard inside `/usr/bin/script`'s PTY and asserts no key echo. The fixed
|
||||
`cat | script` test adapter gives macOS a real pipe instead of Node socketpair
|
||||
stdin; it does not inject key argv. Other platforms exercise the subprocess but
|
||||
not that macOS PTY. A real host and loopback relay then deny imported standby Start
|
||||
without spawning or remotely mutating the manifest. No provider calls are involved.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readAgentSecret } from './key-input.ts';
|
||||
import { Profiles, profile, profileRevision, type Profile } from './profiles.ts';
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin, stdout } from 'node:process';
|
||||
@@ -5,7 +6,7 @@ import { existsSync, mkdirSync, realpathSync, rmdirSync } from 'node:fs';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { relay } from './relay.ts';
|
||||
import { host, validateSetup, provision, migrateAssignment } from './host.ts';
|
||||
import { migrateSlots, addSlot, installationSlots, saveDefaultHarness, removeSlotKey } from './slots.ts';
|
||||
import { migrateSlots, addSlot, installationSlots, saveDefaultHarness, removeSlotKey, importSlotKey } from './slots.ts';
|
||||
import { managementClient } from './intents.ts';
|
||||
import { message, newKey, publicKey, object, text, type Message } from './protocol.ts';
|
||||
import { readPrivate, writePrivate } from './storage.ts';
|
||||
@@ -21,6 +22,7 @@ const help = `Beehive — isolated development preview (loopback relay only)
|
||||
migrate-slots <host-directory> Explicit stopped upgrade, preserves journal
|
||||
add-agent <host-directory> New identity using shared local harness
|
||||
remove-agent-key <host-directory> [agent-public-key] Remove ONE local key copy (public slot retained)
|
||||
import-agent-key <host-directory> <agent-public-key> Restore retained identity via hidden local entry
|
||||
assignment-export <host-directory> <new-file> Export public pinned genesis locally
|
||||
migrate-assignment <host-directory> Explicit stopped legacy enrollment
|
||||
auth-info <host-directory> Print exact local sign-in context (no login)
|
||||
@@ -104,6 +106,22 @@ async function main() {
|
||||
removeSlotKey(dir, entry.agent);
|
||||
console.log(`Local key copy removed for agent ${entry.agent}; public-only slot retained on ${entry.setup.host}. Assignment, journal history, configurations and receipts are unchanged; Stop/Save/history stay available. Start/Restart now reject before spawn; remote operations never recreate the secret. Sibling slots and the owner identity are unaffected. Re-provision requires explicit local reconciliation.`);
|
||||
} finally { ui.close(); }
|
||||
} else if (command === 'import-agent-key') {
|
||||
if (args.length !== 2) throw Error('Use host directory and public key only; private keys are never arguments');
|
||||
const dir = resolve(text(args[0])), agent = text(args[1]);
|
||||
if (!/^[0-9a-f]{64}$/.test(agent)) throw Error('Invalid agent public key');
|
||||
const entry = installationSlots(dir).find(e => e.agent === agent);
|
||||
if (!entry) throw Error('Unknown retained public slot; import cannot invent authority');
|
||||
if (entry.keyPresent) throw Error('Local key already present; reuse the existing identity without import');
|
||||
const ui = createInterface({ input: stdin, output: stdout });
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = await ui.question(`Restore ONLY the local key for ${agent}? Retained assignment/history remain unchanged; standby import never grants Start. [yes/no]: `) === 'yes';
|
||||
} finally { ui.close(); }
|
||||
if (!confirmed) return;
|
||||
const secret = await readAgentSecret();
|
||||
importSlotKey(dir, agent, secret);
|
||||
console.log(`Local key restored for ${agent}; retained assignment and history unchanged. Start still requires this host's assignment. No keys or sessions transferred.`);
|
||||
} else if (command === 'assignment-export') {
|
||||
const entries = installationSlots(resolve(text(args[0])));
|
||||
const entry = args[2] ? entries.find(e => e.agent === args[2]) : entries.length === 1 ? entries[0] : undefined;
|
||||
|
||||
19
beehive/src/key-input.ts
Normal file
19
beehive/src/key-input.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { Writable } from 'node:stream';
|
||||
import { stdin, stdout } from 'node:process';
|
||||
import { publicKey } from './protocol.ts';
|
||||
|
||||
/** Local secret entry only: disable terminal echo and never route key bytes to output.
|
||||
* Piped input is supported for protected automation; no secret argv or environment. */
|
||||
export async function readAgentSecret(): Promise<string> {
|
||||
const muted = new Writable({ write(_chunk, _encoding, done) { done(); } });
|
||||
const ui = createInterface({ input: stdin, output: muted, terminal: Boolean(stdin.isTTY) });
|
||||
try {
|
||||
stdout.write('Agent private key (64 hex; hidden, never passed as an argument): ');
|
||||
const value = await ui.question('');
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(value)) throw Error('Invalid private key; expected 64 hex characters');
|
||||
const secret = value.toLowerCase();
|
||||
try { publicKey(secret); } catch { throw Error('Invalid private key'); }
|
||||
return secret;
|
||||
} finally { ui.close(); muted.end(); stdout.write('\n'); }
|
||||
}
|
||||
@@ -127,6 +127,25 @@ export function removeSlotKey(directory: string, agentKey: string): void {
|
||||
} finally { rmdirSync(lock); }
|
||||
}
|
||||
|
||||
/** Explicit local restoration of a retained public identity, never enrollment or
|
||||
* assignment repair. Importing on standby/consumed source grants no execution. */
|
||||
export function importSlotKey(directory: string, agentKey: string, secret: string): void {
|
||||
if (!/^[0-9a-f]{64}$/.test(agentKey) || publicKey(secret) !== agentKey) throw Error('Imported key does not match the selected public identity');
|
||||
const lock = join(directory, 'host.lock');
|
||||
mkdirSync(lock, { mode: 0o700 });
|
||||
try {
|
||||
const i = readInstallation(directory);
|
||||
const entry = i.agents[agentKey];
|
||||
if (!entry) throw Error('Unknown retained public slot; import cannot invent authority');
|
||||
if (entry.secret !== null) throw Error('Local key already present; reuse the existing identity without import');
|
||||
const setup = validateSetup({ ...i.setups[entry.setup], host: i.host, ownerSecret: i.ownerSecret });
|
||||
const state = loadSlotState(setup, slotPath(directory, agentKey, entry.legacy), agentKey);
|
||||
if (state.phase !== 'stopped' || state.actual !== null) throw Error('Key import requires a stopped slot with no actual run');
|
||||
entry.secret = secret;
|
||||
writePrivate(join(directory, 'setup.json'), i);
|
||||
} finally { rmdirSync(lock); }
|
||||
}
|
||||
|
||||
/** Caller holds the installation lock; update shared harness inventory without key copies. */
|
||||
export function saveDefaultHarness(directory: string, setup: Setup): void {
|
||||
const raw = object(readPrivate(join(directory, 'setup.json')));
|
||||
|
||||
@@ -2,13 +2,13 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { mkdtempSync, realpathSync, rmSync, readFileSync, existsSync, statSync, mkdirSync, rmdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { createGenesis, validateGenesis } from '../src/assignment.ts';
|
||||
import { provision, host, type Setup } from '../src/host.ts';
|
||||
import { migrateSlots, addSlot, removeSlotKey } from '../src/slots.ts';
|
||||
import { migrateSlots, addSlot, removeSlotKey, importSlotKey } from '../src/slots.ts';
|
||||
import { newKey, publicKey, message, type Message } from '../src/protocol.ts';
|
||||
import { readPrivate, writePrivate } from '../src/storage.ts';
|
||||
import { relay } from '../src/relay.ts';
|
||||
@@ -23,20 +23,25 @@ async function closeChild(child: ChildProcess) {
|
||||
const done = once(child, 'exit'); child.kill('SIGTERM'); await done;
|
||||
}
|
||||
/** Actual CLI subprocess path; answers are matched against real prompts. */
|
||||
async function cli(args: string[], answers: { prompt: string; answer: string }[] = [], timeoutMs = 20000): Promise<{ code: number | null; out: string; err: string }> {
|
||||
const child = spawn(process.execPath, ['src/cli.ts', ...args], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
async function cli(args: string[], answers: { prompt: string; answer: string }[] = [], timeoutMs = 20000, pty = false): Promise<{ code: number | null; out: string; err: string }> {
|
||||
// macOS script needs a real pipe rather than Node's socketpair stdin.
|
||||
// The fixed adapter changes no user command or input; secrets only traverse stdin.
|
||||
const child = spawn(pty ? '/bin/sh' : process.execPath, pty
|
||||
? ['-c', 'cat | /usr/bin/script -q /dev/null "$@"', 'beehive-pty', process.execPath, 'src/cli.ts', ...args]
|
||||
: ['src/cli.ts', ...args], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let out = '', err = '', cursor = 0, index = 0, pending = false;
|
||||
child.stderr.on('data', c => { err += c.toString(); });
|
||||
child.stdout.on('data', c => {
|
||||
out += c.toString(); const step = answers[index];
|
||||
child.stderr?.on('data', c => { err += c.toString(); });
|
||||
const receive = (c: Buffer) => {
|
||||
out += c.toString(); if (pty && out.includes('Local key restored')) child.stdin.end(); const step = answers[index];
|
||||
if (!step || pending) return;
|
||||
const position = out.indexOf(step.prompt, cursor); if (position < 0) return;
|
||||
pending = true;
|
||||
void (async () => { await delay(60); cursor = position + step.prompt.length; index++; pending = false; child.stdin.write(`${step.answer}\n`); })();
|
||||
});
|
||||
void (async () => { await delay(60); cursor = position + step.prompt.length; index++; pending = false; child.stdin!.write(`${step.answer}\n`); })();
|
||||
};
|
||||
child.stdout?.on('data', receive);
|
||||
const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
|
||||
try { const [code] = await once(child, 'exit'); return { code, out, err }; }
|
||||
finally { clearTimeout(timer); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); }
|
||||
finally { clearTimeout(timer); child.stdin.end(); if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); }
|
||||
}
|
||||
|
||||
test('deliberate local key removal keeps the public slot: CLI confirmation, retained sibling/history, reopen, denied Start/Restart before spawn, neutral Stop/Save, reconnect', { timeout: 40000 }, async () => {
|
||||
@@ -342,3 +347,56 @@ test('K1 actual removal CLI validates retained host authority before destructive
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('explicit hidden local key import reuses retained identity without resetting standby authority (CLI and PTY)', { timeout: 30000 }, async () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-import-'))), d = join(dir, 'standby');
|
||||
const ownerSecret = newKey(), x = newKey(), y = newKey(), X = publicKey(x), Y = publicKey(y);
|
||||
provision(d, { host: 'standby', ownerSecret, agentSecret: x, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }, createGenesis(publicKey(ownerSecret), X, 'source'));
|
||||
migrateSlots(d); addSlot(d, y, createGenesis(publicKey(ownerSecret), Y, 'standby')); removeSlotKey(d, X);
|
||||
const jp = join(d, 'journal.json'), mp = join(d, 'setup.json'), yp = join(d, 'agents', Y, 'journal.json');
|
||||
const journalBefore = readFileSync(jp), siblingBefore = readFileSync(yp), removedManifest = readFileSync(mp);
|
||||
let h: Awaited<ReturnType<typeof host>> | undefined;
|
||||
const server = await relay(0, publicKey(ownerSecret), join(dir, 'relay.json'));
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const url = `ws://127.0.0.1:${address.port}`, seen: Message[] = [];
|
||||
const ui = connect(url, ownerSecret, m => seen.push(m)); await ui.ready;
|
||||
try {
|
||||
const declined = await cli(['import-agent-key', d, X], [{ prompt: 'Restore ONLY', answer: 'no' }]);
|
||||
assert.equal(declined.code, 0); assert.deepEqual(readFileSync(mp), removedManifest);
|
||||
for (const value of [y, 'invalid-private-value']) {
|
||||
const rejected = await cli(['import-agent-key', d, X], [{ prompt: 'Restore ONLY', answer: 'yes' }, { prompt: 'Agent private key (', answer: value }]);
|
||||
assert.equal(rejected.code, 1, rejected.out); assert.doesNotMatch(rejected.out, /Local key restored/);
|
||||
assert.ok(!rejected.out.includes(value) && !rejected.err.includes(value));
|
||||
assert.deepEqual(readFileSync(mp), removedManifest);
|
||||
}
|
||||
mkdirSync(join(d, 'host.lock'));
|
||||
assert.throws(() => importSlotKey(d, X, x)); assert.ok(existsSync(join(d, 'host.lock')));
|
||||
rmdirSync(join(d, 'host.lock'));
|
||||
const bad = JSON.parse(journalBefore.toString()); delete bad.assignment;
|
||||
writePrivate(jp, bad); assert.throws(() => importSlotKey(d, X, x), /assignment/);
|
||||
assert.deepEqual(readFileSync(mp), removedManifest); writePrivate(jp, JSON.parse(journalBefore.toString()));
|
||||
const imported = await cli(['import-agent-key', d, X], [{ prompt: 'Restore ONLY', answer: 'yes' }, { prompt: 'Agent private key (', answer: x }], 20000, process.platform === 'darwin');
|
||||
assert.equal(imported.code, 0, (imported.err + imported.out).replaceAll(x, '[redacted]')); assert.match(imported.out, /Local key restored/);
|
||||
for (const secret of [x, y, ownerSecret]) assert.ok(!imported.out.includes(secret) && !imported.err.includes(secret), 'including real PTY echo');
|
||||
assert.deepEqual(readFileSync(jp), journalBefore); assert.deepEqual(readFileSync(yp), siblingBefore);
|
||||
const restored = JSON.parse(readFileSync(mp, 'utf8')); const expected = JSON.parse(removedManifest.toString()); expected.agents[X].secret = x;
|
||||
assert.deepEqual(restored, expected, 'only the selected local secret changes');
|
||||
assert.equal(statSync(mp).mode & 0o777, 0o600); assert.equal(statSync(d).mode & 0o777, 0o700);
|
||||
assert.throws(() => importSlotKey(d, X, x), /already present/);
|
||||
const restoredManifest = readFileSync(mp);
|
||||
h = await host(d, url);
|
||||
await until(() => seen.some(m => m.type === 'inventory' && m.agent === X));
|
||||
const start = message('start', 'standby', X, 0); ui.send(start);
|
||||
await until(() => seen.some(m => m.type === 'receipt' && m.body.operation === start.id));
|
||||
assert.notEqual(seen.find(m => m.type === 'receipt' && m.body.operation === start.id)!.body.result, 'accepted');
|
||||
assert.equal(JSON.parse(readFileSync(jp, 'utf8')).assignment.assignedHost, 'source');
|
||||
assert.equal(existsSync(join(dir, 'received-instructions.jsonl')), false, 'imported standby cannot spawn');
|
||||
assert.deepEqual(readFileSync(mp), restoredManifest, 'remote Start cannot mutate local keys');
|
||||
assert.deepEqual(readFileSync(yp), siblingBefore, 'sibling remains untouched');
|
||||
await h.close(); h = undefined;
|
||||
removeSlotKey(d, X); assert.equal(JSON.parse(readFileSync(mp, 'utf8')).agents[X].secret, null);
|
||||
} finally {
|
||||
await h?.close(); ui.close(); for (const c of server.clients) c.terminate();
|
||||
await new Promise<void>(resolve => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user