test: add commander no-daemon smoke contract
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createCommanderRequestHandler, type RuntimeConfig } from "../src/components/microservices/host-codex-commander/src/index";
|
||||
import { commanderHealth, summarizeCommanderTrace } from "../src/components/microservices/host-codex-commander/src/state";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, label: string): JsonRecord {
|
||||
assertCondition(typeof value === "object" && value !== null && !Array.isArray(value), `${label} must be an object`, value);
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
function asRecordArray(value: unknown, label: string): JsonRecord[] {
|
||||
assertCondition(Array.isArray(value) && value.every((item) => typeof item === "object" && item !== null && !Array.isArray(item)), `${label} must be object array`, value);
|
||||
return value as JsonRecord[];
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown, label: string): string[] {
|
||||
assertCondition(Array.isArray(value) && value.every((item) => typeof item === "string"), `${label} must be string array`, value);
|
||||
return value as string[];
|
||||
}
|
||||
|
||||
function runCli(args: string[], expectStatus: number): JsonRecord {
|
||||
const result = spawnSync("bun", ["scripts/cli.ts", ...args], {
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
assertCondition(result.status === expectStatus, `status mismatch for ${args.join(" ")}`, {
|
||||
status: result.status,
|
||||
stdout: result.stdout.slice(-2000),
|
||||
stderr: result.stderr.slice(-2000),
|
||||
});
|
||||
assertCondition(result.stdout.trim().length > 0, `command produced no stdout: ${args.join(" ")}`);
|
||||
return asRecord(JSON.parse(result.stdout) as unknown, "cli envelope");
|
||||
}
|
||||
|
||||
function dataOf(envelope: JsonRecord): JsonRecord {
|
||||
return asRecord(envelope.data, "data");
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<JsonRecord> {
|
||||
return asRecord(await response.json() as unknown, "response body");
|
||||
}
|
||||
|
||||
const sessionId = `no-daemon-smoke-contract-${process.pid}`;
|
||||
const liveSessionPath = join(process.cwd(), ".state", "commander", "sessions", `${sessionId}.json`);
|
||||
assertCondition(!existsSync(liveSessionPath), "precondition failed: smoke session path should not already exist", liveSessionPath);
|
||||
|
||||
const smoke = dataOf(runCli(["commander", "smoke", "--dry-run", "--session-id", sessionId], 0));
|
||||
assertCondition(smoke.ok === true, "smoke must succeed", smoke);
|
||||
assertCondition(smoke.phase === "source-contract", "smoke must remain source-contract phase", smoke);
|
||||
assertCondition(smoke.mode === "dry-run", "smoke must report dry-run mode", smoke);
|
||||
assertCondition(smoke.mutation === false, "smoke must be non-mutating", smoke);
|
||||
|
||||
const noDaemon = asRecord(smoke.noDaemonSmokeContract, "noDaemonSmokeContract");
|
||||
for (const flag of [
|
||||
"startsDaemon",
|
||||
"startsPtyBridge",
|
||||
"startsStdioBridge",
|
||||
"opensSshBridge",
|
||||
"sendsClaudeqq",
|
||||
"restartsServices",
|
||||
"interruptsTasks",
|
||||
"cancelsTasks",
|
||||
"deploys",
|
||||
"runsFullCheckOrE2e",
|
||||
]) {
|
||||
assertCondition(noDaemon[flag] === false, `${flag} must be false`, noDaemon);
|
||||
}
|
||||
assertCondition(asStringArray(noDaemon.allowedCommands, "allowedCommands").includes("bun scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"), "smoke should name this lightweight contract", noDaemon);
|
||||
|
||||
const validationPlan = asRecordArray(smoke.validationPlan, "validationPlan");
|
||||
const surfaces = validationPlan.map((item) => item.surface);
|
||||
for (const expected of [
|
||||
"health endpoint",
|
||||
"state file",
|
||||
"trace summary dry-run",
|
||||
"approval draft preview",
|
||||
"SSH bridge boundary",
|
||||
]) {
|
||||
assertCondition(surfaces.includes(expected), `missing validation surface ${expected}`, surfaces);
|
||||
}
|
||||
for (const item of validationPlan) {
|
||||
assertCondition(asStringArray(item.expectedEvidence, "expectedEvidence").length > 0, "each validation item must define evidence", item);
|
||||
assertCondition(asStringArray(item.noRuntimeSideEffects, "noRuntimeSideEffects").length > 0, "each validation item must define no-side-effect boundary", item);
|
||||
}
|
||||
|
||||
const smokeWithoutDryRun = dataOf(runCli(["commander", "smoke", "--session-id", sessionId], 1));
|
||||
assertCondition(smokeWithoutDryRun.error === "dry-run-required", "smoke must require --dry-run", smokeWithoutDryRun);
|
||||
assertCondition(!existsSync(liveSessionPath), "smoke CLI must not write live commander state", liveSessionPath);
|
||||
|
||||
const tmp = mkdtempSync(join(tmpdir(), "host-codex-commander-smoke-"));
|
||||
try {
|
||||
const runtime: RuntimeConfig = {
|
||||
rootDir: tmp,
|
||||
host: "127.0.0.1",
|
||||
port: 4261,
|
||||
logFile: join(tmp, "logs", "commander.jsonl"),
|
||||
serviceId: "host-codex-commander",
|
||||
stateRoot: tmp,
|
||||
sessionId,
|
||||
};
|
||||
const health = commanderHealth(runtime, "2026-05-21T00:00:00.000Z");
|
||||
assertCondition(health.ok === true && health.service === "host-codex-commander", "health helper must expose service metadata", health);
|
||||
assertCondition(health.stateRoot === tmp, "health helper must use temp state root", health);
|
||||
|
||||
const handler = createCommanderRequestHandler(runtime);
|
||||
const healthBody = await readJson(await handler(new Request("http://localhost/health")));
|
||||
assertCondition(healthBody.ok === true, "short-lived handler health route must succeed without Bun.serve", healthBody);
|
||||
|
||||
const trace = summarizeCommanderTrace({
|
||||
taskId: "task-smoke",
|
||||
sessionId,
|
||||
traceJsonl: [
|
||||
JSON.stringify({ seq: 1, kind: "message", status: "running", summary: "checking token=ghp_1234567890abcdef" }),
|
||||
JSON.stringify({ seq: 2, kind: "event", status: "attention_required", text: "needs approval" }),
|
||||
].join("\n"),
|
||||
taskSummary: "summary password=secret",
|
||||
});
|
||||
assertCondition(trace.taskId === "task-smoke", "trace summary must preserve task id", trace);
|
||||
assertCondition(trace.sessionId === sessionId, "trace summary must preserve session id", trace);
|
||||
assertCondition(trace.lastSeq === 2, "trace summary must compute last seq", trace);
|
||||
assertCondition(trace.status === "attention_required", "trace summary must derive attention_required status", trace);
|
||||
assertCondition(trace.redactionsApplied >= 2, "trace summary must redact mock secrets", trace);
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const approval = dataOf(runCli([
|
||||
"commander",
|
||||
"approval",
|
||||
"request",
|
||||
"--action",
|
||||
"code-queue-task-cancel",
|
||||
"--reason",
|
||||
"token=ghp_1234567890abcdef",
|
||||
"--dry-run",
|
||||
], 0));
|
||||
const claudeqq = asRecord(approval.claudeqq, "claudeqq");
|
||||
assertCondition(claudeqq.mutation === false, "approval preview must not mutate ClaudeQQ", claudeqq);
|
||||
assertCondition(claudeqq.sendImplemented === false, "approval preview must not implement sending", claudeqq);
|
||||
assertCondition(!JSON.stringify(approval).includes("ghp_1234567890abcdef"), "approval preview must redact secret-like reason", approval);
|
||||
|
||||
const doc = readFileSync("docs/reference/host-codex-commander.md", "utf8");
|
||||
for (const snippet of [
|
||||
"commander smoke --dry-run",
|
||||
"无 daemon smoke contract",
|
||||
"health endpoint",
|
||||
"SSH bridge boundary",
|
||||
]) {
|
||||
assertCondition(doc.includes(snippet), `reference doc missing snippet: ${snippet}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
"commander smoke --dry-run is non-mutating and dry-run required",
|
||||
"no-daemon smoke contract forbids daemon, SSH/PTY/stdio bridge, ClaudeQQ send, restart, interrupt, cancel, deploy, and full e2e",
|
||||
"health endpoint and trace summary are validated through short-lived source-level helpers",
|
||||
"approval draft preview remains sendImplemented=false and redacted",
|
||||
"reference doc describes the dev validation surfaces and no-daemon boundary",
|
||||
],
|
||||
}, null, 2)}\n`);
|
||||
@@ -25,6 +25,7 @@ const syntaxFiles = [
|
||||
"scripts/src/commander.ts",
|
||||
"scripts/src/remote.ts",
|
||||
"scripts/host-codex-commander-contract-test.ts",
|
||||
"scripts/host-codex-commander-no-daemon-smoke-contract-test.ts",
|
||||
"scripts/host-codex-commander-skeleton-contract-test.ts",
|
||||
"src/components/frontend/src/index.ts",
|
||||
"src/components/frontend/src/app.tsx",
|
||||
@@ -299,6 +300,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
fileItem("scripts/code-queue-pr-preflight-contract-test.ts"),
|
||||
fileItem("scripts/code-queue-submit-routing-contract-test.ts"),
|
||||
fileItem("scripts/host-codex-commander-skeleton-contract-test.ts"),
|
||||
fileItem("scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"),
|
||||
fileItem("scripts/provider-runner-triage-contract-test.ts"),
|
||||
fileItem("scripts/src/provider-triage.ts"),
|
||||
fileItem("src/components/microservices/code-queue/src/runner-error-classifier.ts"),
|
||||
@@ -326,6 +328,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(commandItem("code-queue:pr-preflight-contract", ["bun", "scripts/code-queue-pr-preflight-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("code-queue:submit-routing-contract", ["bun", "scripts/code-queue-submit-routing-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("host-codex-commander:skeleton-contract", ["bun", "scripts/host-codex-commander-skeleton-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("host-codex-commander:no-daemon-smoke-contract", ["bun", "scripts/host-codex-commander-no-daemon-smoke-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("provider:runner-triage-contract", ["bun", "scripts/provider-runner-triage-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("deploy:artifact-matrix-contract", ["bun", "scripts/deploy-artifact-matrix-contract-test.ts"], 30_000));
|
||||
items.push(commandItem("decision-center:desired-state-contract", ["bun", "scripts/decision-center-desired-state-contract-test.ts"], 30_000));
|
||||
@@ -347,6 +350,7 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(skippedItem("code-queue:pr-preflight-contract", "Code Queue PR preflight contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:submit-routing-contract", "Code Queue submit routing contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("host-codex-commander:skeleton-contract", "host Codex commander skeleton contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("host-codex-commander:no-daemon-smoke-contract", "host Codex commander no-daemon smoke contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("provider:runner-triage-contract", "Provider runner triage contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("deploy:artifact-matrix-contract", "deploy artifact matrix contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("decision-center:desired-state-contract", "Decision Center desired-state drift contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
|
||||
@@ -50,6 +50,7 @@ function commanderHelp(): Record<string, unknown> {
|
||||
usage: [
|
||||
"bun scripts/cli.ts commander contract",
|
||||
"bun scripts/cli.ts commander plan --dry-run [--session-id id]",
|
||||
"bun scripts/cli.ts commander smoke --dry-run [--session-id id]",
|
||||
"bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id]",
|
||||
],
|
||||
highRiskActions,
|
||||
@@ -211,6 +212,165 @@ function commanderPlan(args: string[]): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
function healthEndpointValidation(): Record<string, unknown> {
|
||||
return {
|
||||
surface: "health endpoint",
|
||||
endpoint: "GET /health",
|
||||
validationMethod: "invoke createCommanderRequestHandler with a temporary RuntimeConfig inside a short-lived contract test",
|
||||
expectedEvidence: [
|
||||
"body.ok=true",
|
||||
"body.service=host-codex-commander",
|
||||
"body.stateRoot points at the temp directory",
|
||||
"body.currentLogFile is reported",
|
||||
],
|
||||
noRuntimeSideEffects: [
|
||||
"do not run Bun.serve",
|
||||
"do not publish or expose a port",
|
||||
"do not restart any service",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function stateFileValidation(sessionId: string): Record<string, unknown> {
|
||||
return {
|
||||
surface: "state file",
|
||||
storageRoot: ".state/commander/",
|
||||
validationMethod: "write and read a session record only under a temporary directory owned by the contract test",
|
||||
files: [
|
||||
`sessions/${sessionId}.json`,
|
||||
`events/${sessionId}.jsonl`,
|
||||
"approvals/draft.json",
|
||||
"logs/commander.jsonl",
|
||||
],
|
||||
expectedEvidence: [
|
||||
"session state round-trips through writeCommanderSession/readCommanderSession",
|
||||
"secret-like notes are redacted before persistence",
|
||||
"temporary state root is deleted after the smoke contract",
|
||||
],
|
||||
noRuntimeSideEffects: [
|
||||
"do not touch the live .state/commander directory",
|
||||
"do not patch database state",
|
||||
"do not discover or signal live host Codex processes",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function traceSummaryValidation(sessionId: string): Record<string, unknown> {
|
||||
return {
|
||||
surface: "trace summary dry-run",
|
||||
validationMethod: "summarize mock JSONL trace input through summarizeCommanderTrace",
|
||||
inputPolicy: "bounded mock Code Queue trace JSONL only; no live codex task trace fetch",
|
||||
expectedEvidence: [
|
||||
"taskId and sessionId are preserved",
|
||||
"lastSeq is computed from mock seq fields",
|
||||
"status is derived without returning raw transcript",
|
||||
"secret-like text is redacted",
|
||||
],
|
||||
sampleSessionId: sessionId,
|
||||
noRuntimeSideEffects: [
|
||||
"do not call Code Queue manager",
|
||||
"do not mark tasks read",
|
||||
"do not interrupt or cancel tasks",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function approvalDraftValidation(): Record<string, unknown> {
|
||||
return {
|
||||
surface: "approval draft preview",
|
||||
validationMethod: "build a draft preview for one high-risk action with --dry-run",
|
||||
commandShape: "bun scripts/cli.ts commander approval request --action code-queue-task-interrupt --reason <text> --dry-run",
|
||||
expectedEvidence: [
|
||||
"requiresExplicitUserApproval=true",
|
||||
"claudeqq.mutation=false",
|
||||
"claudeqq.sendImplemented=false",
|
||||
"reason and messageTemplate are redacted",
|
||||
],
|
||||
noRuntimeSideEffects: [
|
||||
"do not POST to ClaudeQQ",
|
||||
"do not notify any QQ user or group",
|
||||
"do not record an approval as consumed",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function sshBridgeBoundaryValidation(): Record<string, unknown> {
|
||||
return {
|
||||
surface: "SSH bridge boundary",
|
||||
validationMethod: "assert contract-only bridge metadata from commander plan --dry-run",
|
||||
allowedAtThisStage: [
|
||||
"readonly contract description",
|
||||
"future reviewed maintenance command shape",
|
||||
"explicit approval precondition text",
|
||||
],
|
||||
noRuntimeSideEffects: [
|
||||
"do not open provider SSH session",
|
||||
"do not open PTY bridge",
|
||||
"do not open stdio bridge",
|
||||
"do not inject prompt",
|
||||
"do not restart services",
|
||||
"do not interrupt or cancel tasks",
|
||||
],
|
||||
expectedEvidence: [
|
||||
"bridge.mutation=false",
|
||||
"startPlan.enabled=false",
|
||||
"safetyBoundary.phaseOneMutationAllowed=false",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function commanderSmoke(args: string[]): Record<string, unknown> {
|
||||
if (!hasFlag(args, "--dry-run")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "dry-run-required",
|
||||
message: requiredDryRunMessage,
|
||||
command: "bun scripts/cli.ts commander smoke --dry-run",
|
||||
};
|
||||
}
|
||||
const sessionId = optionValue(args, "--session-id") ?? "primary";
|
||||
return {
|
||||
ok: true,
|
||||
phase: "source-contract",
|
||||
mode: "dry-run",
|
||||
mutation: false,
|
||||
serviceId: "host-codex-commander",
|
||||
noDaemonSmokeContract: {
|
||||
startsDaemon: false,
|
||||
startsPtyBridge: false,
|
||||
startsStdioBridge: false,
|
||||
opensSshBridge: false,
|
||||
sendsClaudeqq: false,
|
||||
restartsServices: false,
|
||||
interruptsTasks: false,
|
||||
cancelsTasks: false,
|
||||
deploys: false,
|
||||
runsFullCheckOrE2e: false,
|
||||
allowedCommands: [
|
||||
"bun scripts/cli.ts commander contract",
|
||||
"bun scripts/cli.ts commander plan --dry-run",
|
||||
"bun scripts/cli.ts commander smoke --dry-run",
|
||||
"bun scripts/cli.ts commander approval request --action <action> --dry-run",
|
||||
"bun scripts/host-codex-commander-no-daemon-smoke-contract-test.ts",
|
||||
],
|
||||
},
|
||||
validationPlan: [
|
||||
healthEndpointValidation(),
|
||||
stateFileValidation(sessionId),
|
||||
traceSummaryValidation(sessionId),
|
||||
approvalDraftValidation(),
|
||||
sshBridgeBoundaryValidation(),
|
||||
],
|
||||
manualAuthorizationBeforeLiveRuntime: [
|
||||
"operator explicitly names the exact live action and target session/task/service",
|
||||
"current source-contract smoke and skeleton contract tests are green",
|
||||
"risk review confirms no token output, no direct database patch, and no backend restart bypass",
|
||||
"ClaudeQQ approval draft is reviewed, sent by an authorized future path, and matched to an explicit approval id",
|
||||
"rollback and observation steps are written before enabling any daemon or bridge",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function commanderApprovalRequest(args: string[]): Record<string, unknown> {
|
||||
if (!hasFlag(args, "--dry-run")) {
|
||||
return {
|
||||
@@ -276,6 +436,7 @@ export function runCommanderCommand(args: string[]): Record<string, unknown> {
|
||||
if (sub === undefined || isHelpToken(sub)) return commanderHelp();
|
||||
if (sub === "contract") return commanderContract();
|
||||
if (sub === "plan") return commanderPlan(args.slice(1));
|
||||
if (sub === "smoke") return commanderSmoke(args.slice(1));
|
||||
if (sub === "approval" && second === "request") return commanderApprovalRequest(args.slice(2));
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
+4
-3
@@ -44,7 +44,7 @@ export function rootHelp(): unknown {
|
||||
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
|
||||
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
|
||||
{ command: "gh auth|issue|pr", description: "Run safe GitHub issue and PR CRUD/lifecycle operations through REST with body-file update replace/append, comment delete, token diagnostics, hard delete unsupported, and merge blocked." },
|
||||
{ command: "commander contract|plan --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." },
|
||||
{ command: "commander contract|plan --dry-run|smoke --dry-run|approval request --dry-run", description: "Host Codex commander skeleton contract, no-daemon smoke plan, and dry-run preview; exposes local health, state, trace summary, and approval draft helpers without live bridges or message sends." },
|
||||
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
|
||||
{ command: "schedule list|get|runs|run|retry-run|delete", description: "Manage backend-core scheduled tasks and run history; schedule run <id> supports --wait-ms N and retry-run reuses the failed run's schedule." },
|
||||
{ command: "schedule upsert-pgdata-backup [--time HH:MM] [--remote-base /SERVER_DATA/UNIDESK_PG_DATA]", description: "Create or update the daily PGDATA physical backup task that uploads monthly rotated archives to Baidu Netdisk." },
|
||||
@@ -188,14 +188,15 @@ function providerHelp(): unknown {
|
||||
|
||||
function commanderHelp(): unknown {
|
||||
return {
|
||||
command: "commander contract|plan|approval",
|
||||
command: "commander contract|plan|smoke|approval",
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts commander contract",
|
||||
"bun scripts/cli.ts commander plan --dry-run [--session-id id]",
|
||||
"bun scripts/cli.ts commander smoke --dry-run [--session-id id]",
|
||||
"bun scripts/cli.ts commander approval request --action <action> --dry-run [--reason text] [--task-id id]",
|
||||
],
|
||||
description: "Inspect the local host Codex commander skeleton contract, dry-run planner, state helpers, trace summary aggregator, and approval draft preview.",
|
||||
description: "Inspect the local host Codex commander skeleton contract, dry-run planner, no-daemon smoke validation plan, state helpers, trace summary aggregator, and approval draft preview.",
|
||||
boundary: [
|
||||
"the current skeleton is local-only and never attaches to live bridges",
|
||||
"dry-run commands never open SSH, PTY, or stdio bridges",
|
||||
|
||||
Reference in New Issue
Block a user