feat: integrate MinIO-backed user filesystem
This commit is contained in:
50
scripts/check-minio-prefix-policy.py
Executable file
50
scripts/check-minio-prefix-policy.py
Executable file
@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that a provisioned MinIO user can access only its own prefix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from io import BytesIO
|
||||
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
|
||||
def main() -> int:
|
||||
endpoint = _require("BEAVER_CHECK_MINIO_ENDPOINT")
|
||||
bucket = os.getenv("BEAVER_CHECK_MINIO_BUCKET", "beaver-user-files").strip()
|
||||
access_key = _require("BEAVER_CHECK_MINIO_ACCESS_KEY")
|
||||
secret_key = _require("BEAVER_CHECK_MINIO_SECRET_KEY")
|
||||
own_backend = _require("BEAVER_CHECK_MINIO_BACKEND_ID")
|
||||
other_backend = os.getenv("BEAVER_CHECK_MINIO_OTHER_BACKEND_ID", "policy-denied-other").strip()
|
||||
secure = os.getenv("BEAVER_CHECK_MINIO_SECURE", "0").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
client = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
|
||||
own_object = f"users/{own_backend}/uploads/policy-check.txt"
|
||||
other_object = f"users/{other_backend}/uploads/policy-check.txt"
|
||||
|
||||
client.put_object(bucket, own_object, BytesIO(b"ok"), length=2, content_type="text/plain")
|
||||
client.stat_object(bucket, own_object)
|
||||
|
||||
try:
|
||||
client.put_object(bucket, other_object, BytesIO(b"no"), length=2, content_type="text/plain")
|
||||
except S3Error as exc:
|
||||
if exc.code in {"AccessDenied", "AccessDeniedException"}:
|
||||
print(f"ok: {access_key} can access {own_object} and is denied for {other_object}")
|
||||
return 0
|
||||
raise
|
||||
|
||||
print(f"error: {access_key} unexpectedly wrote {other_object}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _require(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value:
|
||||
raise SystemExit(f"{name} is required")
|
||||
return value
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
143
scripts/cleanup-test-users.py
Executable file
143
scripts/cleanup-test-users.py
Executable file
@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib import error as urllib_error
|
||||
from urllib import parse as urllib_parse
|
||||
from urllib import request as urllib_request
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_REGISTRY = ROOT_DIR / "app-instance" / "runtime" / "registry" / "instances.json"
|
||||
SAFE_PREFIXES = ("smoke", "test", "debug")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Dry-run or purge Beaver smoke/test users.")
|
||||
parser.add_argument("--registry", default=str(DEFAULT_REGISTRY), help="Path to app-instance registry JSON.")
|
||||
parser.add_argument("--deploy-control-url", default=os.getenv("DEPLOY_CONTROL_URL", "http://127.0.0.1:8090"))
|
||||
parser.add_argument("--token", default=os.getenv("DEPLOY_CONTROL_API_TOKEN", ""))
|
||||
parser.add_argument("--backend-id", action="append", default=[], help="Explicit backend id to clean.")
|
||||
parser.add_argument("--instance-id", action="append", default=[], help="Explicit instance id to clean.")
|
||||
parser.add_argument("--username-prefix", action="append", default=[], help="Safe test username prefix, e.g. smoke.")
|
||||
parser.add_argument("--execute", action="store_true", help="Actually delete matched instances.")
|
||||
parser.add_argument("--purge-data", action="store_true", help="Delete local instance data when executing.")
|
||||
parser.add_argument(
|
||||
"--keep-user-files",
|
||||
action="store_true",
|
||||
help="Do not request MinIO/user-file purge when executing.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-any-prefix",
|
||||
action="store_true",
|
||||
help="Allow non-test username prefixes. Use only for controlled maintenance.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_registry(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
instances = payload.get("instances", []) if isinstance(payload, dict) else []
|
||||
return [item for item in instances if isinstance(item, dict)]
|
||||
|
||||
|
||||
def selected_instances(args: argparse.Namespace) -> list[dict[str, Any]]:
|
||||
instances = load_registry(Path(args.registry).expanduser())
|
||||
explicit_backend_ids = {item.strip() for item in args.backend_id if item.strip()}
|
||||
explicit_instance_ids = {item.strip() for item in args.instance_id if item.strip()}
|
||||
prefixes = tuple(item.strip() for item in args.username_prefix if item.strip())
|
||||
|
||||
if not explicit_backend_ids and not explicit_instance_ids and not prefixes:
|
||||
raise SystemExit("Refusing cleanup: provide --backend-id, --instance-id, or --username-prefix.")
|
||||
unsafe = [prefix for prefix in prefixes if not prefix.startswith(SAFE_PREFIXES)]
|
||||
if unsafe and not args.allow_any_prefix:
|
||||
raise SystemExit(
|
||||
"Refusing cleanup: username prefixes must start with "
|
||||
f"{', '.join(SAFE_PREFIXES)} unless --allow-any-prefix is set."
|
||||
)
|
||||
|
||||
matches: list[dict[str, Any]] = []
|
||||
for item in instances:
|
||||
instance_id = str(item.get("instance_id", "") or "")
|
||||
backend_id = str(item.get("backend_id", "") or "")
|
||||
username = str(item.get("username", "") or "")
|
||||
if instance_id in explicit_instance_ids or backend_id in explicit_backend_ids:
|
||||
matches.append(item)
|
||||
continue
|
||||
if prefixes and any(username.startswith(prefix) or backend_id.startswith(prefix) for prefix in prefixes):
|
||||
matches.append(item)
|
||||
return matches
|
||||
|
||||
|
||||
def delete_instance(args: argparse.Namespace, instance_id: str) -> dict[str, Any]:
|
||||
base_url = args.deploy_control_url.rstrip("/")
|
||||
url = f"{base_url}/api/instances/{urllib_parse.quote(instance_id, safe='')}"
|
||||
headers = {"Accept": "application/json"}
|
||||
if args.token.strip():
|
||||
headers["Authorization"] = f"Bearer {args.token.strip()}"
|
||||
if args.purge_data:
|
||||
headers["X-Purge-Data"] = "1"
|
||||
if not args.keep_user_files:
|
||||
headers["X-Purge-User-Files"] = "1"
|
||||
|
||||
request = urllib_request.Request(url, method="DELETE", headers=headers)
|
||||
try:
|
||||
with urllib_request.urlopen(request, timeout=120) as response:
|
||||
raw = response.read().decode("utf-8")
|
||||
except urllib_error.HTTPError as exc:
|
||||
detail = exc.reason
|
||||
try:
|
||||
payload = json.loads(exc.read().decode("utf-8"))
|
||||
if isinstance(payload, dict):
|
||||
detail = str(payload.get("detail") or detail)
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": False, "instance_id": instance_id, "error": detail, "status_code": exc.code}
|
||||
except urllib_error.URLError as exc:
|
||||
return {"ok": False, "instance_id": instance_id, "error": str(exc)}
|
||||
if not raw.strip():
|
||||
return {"ok": True, "instance_id": instance_id}
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {"ok": False, "instance_id": instance_id, "error": "deploy-control response was not valid JSON"}
|
||||
return payload if isinstance(payload, dict) else {"ok": False, "instance_id": instance_id, "error": "unexpected response"}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
matches = selected_instances(args)
|
||||
planned = [
|
||||
{
|
||||
"instance_id": str(item.get("instance_id", "") or ""),
|
||||
"backend_id": str(item.get("backend_id", "") or ""),
|
||||
"username": str(item.get("username", "") or ""),
|
||||
"instance_root": str(item.get("instance_root", "") or ""),
|
||||
}
|
||||
for item in matches
|
||||
]
|
||||
if not args.execute:
|
||||
print(json.dumps({"dry_run": True, "count": len(planned), "planned": planned}, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
results = []
|
||||
for item in planned:
|
||||
instance_id = item["instance_id"]
|
||||
if not instance_id:
|
||||
results.append({"ok": False, "error": "matched registry record is missing instance_id", "record": item})
|
||||
continue
|
||||
results.append(delete_instance(args, instance_id))
|
||||
ok = all(bool(item.get("ok")) for item in results)
|
||||
print(json.dumps({"dry_run": False, "count": len(results), "ok": ok, "results": results}, indent=2, ensure_ascii=False))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
190
scripts/smoke-auth-files.mjs
Executable file
190
scripts/smoke-auth-files.mjs
Executable file
@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const now = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
const username = process.env.BEAVER_SMOKE_USERNAME || `smoke${now}`;
|
||||
const password = process.env.BEAVER_SMOKE_PASSWORD || 'TestPass123!';
|
||||
const email = process.env.BEAVER_SMOKE_EMAIL || `${username}@example.test`;
|
||||
const portalUrl = (process.env.BEAVER_SMOKE_PORTAL_URL || 'http://127.0.0.1:3081').replace(/\/$/, '');
|
||||
const nextPath = process.env.BEAVER_SMOKE_NEXT_PATH || '/files';
|
||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined;
|
||||
const hostResolverRules = process.env.BEAVER_SMOKE_HOST_RESOLVER_RULES || '';
|
||||
const headless = process.env.BEAVER_SMOKE_HEADLESS !== '0';
|
||||
const verifyMinio = process.env.BEAVER_SMOKE_VERIFY_MINIO === '1';
|
||||
|
||||
const launchArgs = [];
|
||||
if (hostResolverRules) {
|
||||
launchArgs.push(`--host-resolver-rules=${hostResolverRules}`);
|
||||
}
|
||||
|
||||
const failedResponses = [];
|
||||
const failedRequests = [];
|
||||
|
||||
function remember(list, value) {
|
||||
list.push(value);
|
||||
if (list.length > 30) {
|
||||
list.shift();
|
||||
}
|
||||
}
|
||||
|
||||
async function clickFirstVisible(locators) {
|
||||
for (const locator of locators) {
|
||||
if (await locator.first().isVisible().catch(() => false)) {
|
||||
await locator.first().click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({
|
||||
headless,
|
||||
executablePath,
|
||||
args: launchArgs,
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
remember(failedResponses, `${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
remember(failedRequests, `${request.url()} ${request.failure()?.errorText || ''}`);
|
||||
});
|
||||
|
||||
try {
|
||||
const registerUrl = new URL('/register', portalUrl);
|
||||
registerUrl.searchParams.set('next', nextPath);
|
||||
await page.goto(registerUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(password);
|
||||
await page.locator('#confirmPassword').fill(password);
|
||||
const submitButton = page.locator('form').first().locator('button[type="submit"]');
|
||||
await submitButton.waitFor({ state: 'visible', timeout: 30000 });
|
||||
await submitButton.click();
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/api/runtime/register'),
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
|
||||
await page.getByText(/配置模型|Model Setup/).waitFor({ timeout: 180000 });
|
||||
const skipped = await clickFirstVisible([
|
||||
page.getByRole('button', { name: /跳过|Skip/i }),
|
||||
page.locator('button.secondary-button'),
|
||||
]);
|
||||
if (!skipped) {
|
||||
throw new Error('Model setup skip button was not found');
|
||||
}
|
||||
|
||||
await page.waitForURL(new RegExp(`${nextPath.replace('/', '\\/')}$`), { timeout: 60000 });
|
||||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
|
||||
await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 });
|
||||
|
||||
for (const root of ['uploads', 'outputs', 'shared', 'tasks']) {
|
||||
await page.getByText(root, { exact: true }).waitFor({ timeout: 30000 });
|
||||
}
|
||||
|
||||
const token = await page.evaluate(() => window.localStorage.getItem('beaver_access_token') || '');
|
||||
if (!token) {
|
||||
throw new Error('No Beaver access token found after handoff');
|
||||
}
|
||||
const uploadName = `smoke-${Date.now()}.txt`;
|
||||
const uploadBody = `hello from Beaver smoke ${username}`;
|
||||
const uploadResult = await page.evaluate(async ({ token, uploadName, uploadBody }) => {
|
||||
const form = new FormData();
|
||||
form.set('path', 'uploads');
|
||||
form.set('file', new File([uploadBody], uploadName, { type: 'text/plain' }));
|
||||
const response = await fetch('/api/user-files/upload', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form,
|
||||
});
|
||||
return { status: response.status, body: await response.json().catch(() => ({})) };
|
||||
}, { token, uploadName, uploadBody });
|
||||
if (uploadResult.status !== 200 || uploadResult.body.path !== `uploads/${uploadName}`) {
|
||||
throw new Error(`User file upload failed: ${JSON.stringify(uploadResult)}`);
|
||||
}
|
||||
const apiVisibility = await page.evaluate(async ({ token }) => {
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const [status, root] = await Promise.all([
|
||||
fetch('/api/user-files/status', { headers }).then((response) => response.json()),
|
||||
fetch('/api/user-files/browse', { headers }).then((response) => response.json()),
|
||||
]);
|
||||
return JSON.stringify({ status, root });
|
||||
}, { token });
|
||||
for (const forbidden of ['access_key', 'secret_key', 'bucket', 'namespace', 'object_prefix']) {
|
||||
if (apiVisibility.includes(forbidden)) {
|
||||
throw new Error(`User file API leaked storage field: ${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
const minioVerified = verifyMinio ? verifyUploadedObjectInMinio(username, uploadName) : false;
|
||||
|
||||
const finalUrl = page.url();
|
||||
await page.screenshot({ path: process.env.BEAVER_SMOKE_SCREENSHOT || '/tmp/beaver-auth-files-smoke.png', fullPage: true });
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
username,
|
||||
email,
|
||||
finalUrl,
|
||||
roots: ['uploads', 'outputs', 'shared', 'tasks'],
|
||||
uploadedPath: `uploads/${uploadName}`,
|
||||
minioVerified,
|
||||
failedResponses,
|
||||
failedRequests,
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: process.env.BEAVER_SMOKE_ERROR_SCREENSHOT || '/tmp/beaver-auth-files-smoke-error.png', fullPage: true }).catch(() => {});
|
||||
console.error(JSON.stringify({
|
||||
ok: false,
|
||||
username,
|
||||
email,
|
||||
currentUrl: page.url(),
|
||||
error: String(error),
|
||||
failedResponses,
|
||||
failedRequests,
|
||||
}, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
function verifyUploadedObjectInMinio(username, uploadName) {
|
||||
const endpoint = process.env.BEAVER_SMOKE_MINIO_ENDPOINT || 'http://127.0.0.1:9000';
|
||||
const accessKey = process.env.BEAVER_SMOKE_MINIO_ACCESS_KEY || process.env.BEAVER_MINIO_ROOT_USER || '';
|
||||
const secretKey = process.env.BEAVER_SMOKE_MINIO_SECRET_KEY || process.env.BEAVER_MINIO_ROOT_PASSWORD || '';
|
||||
const bucket = process.env.BEAVER_SMOKE_MINIO_BUCKET || process.env.BEAVER_USER_FILES_BUCKET || 'beaver-user-files';
|
||||
const network = process.env.BEAVER_SMOKE_MINIO_NETWORK || '';
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error('MinIO verification requested but access key or secret key is missing');
|
||||
}
|
||||
const containerArgs = ['run', '--rm', '--entrypoint', '/bin/sh'];
|
||||
if (network) {
|
||||
containerArgs.push('--network', network);
|
||||
}
|
||||
containerArgs.push(
|
||||
'minio/mc:latest',
|
||||
'-lc',
|
||||
[
|
||||
`mc alias set beaver ${shellQuote(endpoint)} ${shellQuote(accessKey)} ${shellQuote(secretKey)} >/dev/null`,
|
||||
`mc stat ${shellQuote(`beaver/${bucket}/users/${username}/uploads/${uploadName}`)} >/dev/null`,
|
||||
].join(' && '),
|
||||
);
|
||||
execFileSync('docker', containerArgs, { stdio: 'pipe' });
|
||||
return true;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
await main();
|
||||
16
scripts/smoke-auth-files.sh
Executable file
16
scripts/smoke-auth-files.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PLAYWRIGHT_WORKDIR="${BEAVER_PLAYWRIGHT_WORKDIR:-/tmp/beaver-playwright-smoke}"
|
||||
PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION:-1.60.0}"
|
||||
|
||||
mkdir -p "$PLAYWRIGHT_WORKDIR"
|
||||
|
||||
if ! NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" node -e "require.resolve('playwright')" >/dev/null 2>&1; then
|
||||
npm install --prefix "$PLAYWRIGHT_WORKDIR" --no-save "playwright@${PLAYWRIGHT_VERSION}"
|
||||
fi
|
||||
|
||||
export NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}"
|
||||
exec node "${PROJECT_ROOT}/scripts/smoke-auth-files.mjs"
|
||||
395
scripts/validate-filesystem-automation.mjs
Executable file
395
scripts/validate-filesystem-automation.mjs
Executable file
@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env node
|
||||
import { createRequire } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const now = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
const username = process.env.BEAVER_VALIDATE_USERNAME || `fsauto${now}`;
|
||||
const password = process.env.BEAVER_VALIDATE_PASSWORD || 'TestPass123!';
|
||||
const email = process.env.BEAVER_VALIDATE_EMAIL || `${username}@example.test`;
|
||||
const portalUrl = (process.env.BEAVER_VALIDATE_PORTAL_URL || 'http://127.0.0.1:3081').replace(/\/$/, '');
|
||||
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined;
|
||||
const hostResolverRules = process.env.BEAVER_VALIDATE_HOST_RESOLVER_RULES || process.env.BEAVER_SMOKE_HOST_RESOLVER_RULES || '';
|
||||
const headless = process.env.BEAVER_VALIDATE_HEADLESS !== '0';
|
||||
const verifyMinio = process.env.BEAVER_VALIDATE_VERIFY_MINIO === '1';
|
||||
const cleanupEnabled = process.env.BEAVER_VALIDATE_CLEANUP !== '0';
|
||||
const screenshotDir = process.env.BEAVER_VALIDATE_SCREENSHOT_DIR || '/tmp';
|
||||
const reportPath = process.env.BEAVER_VALIDATE_REPORT || '';
|
||||
|
||||
const taskScope = `ui-delete-${Date.now()}`;
|
||||
const deleteTargets = [
|
||||
{ root: 'uploads', path: 'uploads/delete-me-uploads.txt', dir: 'uploads', name: 'delete-me-uploads.txt' },
|
||||
{ root: 'outputs', path: 'outputs/delete-me-outputs.txt', dir: 'outputs', name: 'delete-me-outputs.txt' },
|
||||
{ root: 'shared', path: 'shared/delete-me-shared.txt', dir: 'shared', name: 'delete-me-shared.txt' },
|
||||
{ root: 'tasks', path: `tasks/${taskScope}/delete-me-tasks.txt`, dir: `tasks/${taskScope}`, name: 'delete-me-tasks.txt' },
|
||||
];
|
||||
|
||||
const pageChecks = [
|
||||
{ name: 'Files', path: '/files', marker: /文件管理|Files/ },
|
||||
{ name: 'Tasks', path: '/tasks', marker: /任务|Tasks/ },
|
||||
{ name: 'Tools/MCP', path: '/mcp', marker: /工具|Tools/ },
|
||||
{ name: 'Logs', path: '/logs', marker: /运行日志|Runtime Logs/ },
|
||||
{ name: 'Agents', path: '/agents', marker: /智能体|Agents/ },
|
||||
{ name: 'Skills', path: '/skills', marker: /技能|Skills/ },
|
||||
];
|
||||
|
||||
const launchArgs = [];
|
||||
if (hostResolverRules) {
|
||||
launchArgs.push(`--host-resolver-rules=${hostResolverRules}`);
|
||||
}
|
||||
|
||||
const failedResponses = [];
|
||||
const failedRequests = [];
|
||||
const consoleErrors = [];
|
||||
const pageEvidence = [];
|
||||
|
||||
function remember(list, value) {
|
||||
list.push(value);
|
||||
if (list.length > 80) {
|
||||
list.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitize(value) {
|
||||
return String(value)
|
||||
.replace(/(Authorization:\s*Bearer\s+)[^\s"']+/gi, '$1<redacted>')
|
||||
.replace(/(access[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>')
|
||||
.replace(/(secret[_-]?key["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>')
|
||||
.replace(/(password["']?\s*[:=]\s*["']?)[^"',\s]+/gi, '$1<redacted>');
|
||||
}
|
||||
|
||||
async function clickFirstVisible(locators) {
|
||||
for (const locator of locators) {
|
||||
if (await locator.first().isVisible().catch(() => false)) {
|
||||
await locator.first().click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function registerAndHandoff(page) {
|
||||
const registerUrl = new URL('/register', portalUrl);
|
||||
registerUrl.searchParams.set('next', '/files');
|
||||
await page.goto(registerUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
await page.locator('#username').fill(username);
|
||||
await page.locator('#email').fill(email);
|
||||
await page.locator('#password').fill(password);
|
||||
await page.locator('#confirmPassword').fill(password);
|
||||
await page.locator('form').first().locator('button[type="submit"]').click();
|
||||
await page.waitForResponse(
|
||||
(response) => response.url().includes('/api/runtime/register'),
|
||||
{ timeout: 180000 },
|
||||
);
|
||||
|
||||
await page.getByText(/配置模型|Model Setup/).waitFor({ timeout: 180000 });
|
||||
const skipped = await clickFirstVisible([
|
||||
page.getByRole('button', { name: /跳过|Skip/i }),
|
||||
page.locator('button.secondary-button'),
|
||||
]);
|
||||
if (!skipped) {
|
||||
throw new Error('Model setup skip button was not found');
|
||||
}
|
||||
|
||||
await page.waitForURL(/\/files$/, { timeout: 90000 });
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {});
|
||||
await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 });
|
||||
|
||||
const token = await page.evaluate(() => window.localStorage.getItem('beaver_access_token') || '');
|
||||
if (!token) {
|
||||
throw new Error('No Beaver access token found after app handoff');
|
||||
}
|
||||
const me = await apiFetch(page, token, '/api/auth/me');
|
||||
if (me.status !== 200 || me.body?.username !== username) {
|
||||
throw new Error(`App auth confirmation failed: ${JSON.stringify(me)}`);
|
||||
}
|
||||
assertAppHost(page.url());
|
||||
return token;
|
||||
}
|
||||
|
||||
function assertAppHost(url) {
|
||||
const current = new URL(url);
|
||||
const portal = new URL(portalUrl);
|
||||
if (current.host === portal.host || current.pathname.startsWith('/login') || current.pathname.startsWith('/register')) {
|
||||
throw new Error(`Browser is not on the app instance after handoff: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch(page, token, path, options = {}) {
|
||||
return page.evaluate(async ({ token, path, options }) => {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
return { status: response.status, body };
|
||||
}, { token, path, options });
|
||||
}
|
||||
|
||||
async function createDeleteTargets(page, token) {
|
||||
const result = await page.evaluate(async ({ token, deleteTargets, taskScope }) => {
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
const mkdir = await fetch(`/api/user-files/mkdir?path=${encodeURIComponent(`tasks/${taskScope}`)}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
if (!mkdir.ok && mkdir.status !== 409) {
|
||||
throw new Error(`mkdir failed: ${mkdir.status}`);
|
||||
}
|
||||
const uploaded = [];
|
||||
for (const target of deleteTargets) {
|
||||
const form = new FormData();
|
||||
form.set('path', target.dir);
|
||||
form.set('file', new File([`delete target ${target.path}`], target.name, { type: 'text/plain' }));
|
||||
const response = await fetch('/api/user-files/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok || body.path !== target.path) {
|
||||
throw new Error(`upload failed for ${target.path}: ${response.status} ${JSON.stringify(body)}`);
|
||||
}
|
||||
uploaded.push(body.path);
|
||||
}
|
||||
return uploaded;
|
||||
}, { token, deleteTargets, taskScope });
|
||||
if (result.length !== deleteTargets.length) {
|
||||
throw new Error(`Unexpected upload target count: ${result.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRootEntries(page) {
|
||||
await page.getByText(/文件管理|Files/).waitFor({ timeout: 30000 });
|
||||
for (const root of ['uploads', 'outputs', 'shared', 'tasks']) {
|
||||
await page.locator('button').filter({ hasText: root }).first().waitFor({ state: 'visible', timeout: 30000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToRoot(page) {
|
||||
await page.goto(`${new URL(page.url()).origin}/files`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await waitForRootEntries(page);
|
||||
}
|
||||
|
||||
async function openDirectory(page, name) {
|
||||
const row = page.locator('button').filter({ hasText: name }).first();
|
||||
await row.waitFor({ state: 'visible', timeout: 30000 });
|
||||
await row.click();
|
||||
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
||||
}
|
||||
|
||||
async function deleteFromFilesPage(page, target) {
|
||||
await navigateToRoot(page);
|
||||
await openDirectory(page, target.root);
|
||||
if (target.root === 'tasks') {
|
||||
await openDirectory(page, taskScope);
|
||||
}
|
||||
|
||||
const row = page.locator('button').filter({ hasText: target.name }).first();
|
||||
await row.waitFor({ state: 'visible', timeout: 30000 });
|
||||
await row.hover();
|
||||
await row.locator('[title="Delete"], [title="删除"]').last().click({ force: true });
|
||||
await page.waitForFunction(
|
||||
(name) => !document.body.innerText.includes(name),
|
||||
target.name,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyDeletedViaApi(page, token) {
|
||||
for (const target of deleteTargets) {
|
||||
const preview = await apiFetch(page, token, `/api/user-files/preview?path=${encodeURIComponent(target.path)}`);
|
||||
if (preview.status !== 404) {
|
||||
throw new Error(`Expected preview 404 after UI delete for ${target.path}, got ${preview.status}`);
|
||||
}
|
||||
}
|
||||
const roots = await apiFetch(page, token, '/api/user-files/browse');
|
||||
if (roots.status !== 200) {
|
||||
throw new Error(`Root browse failed after UI deletion: ${roots.status}`);
|
||||
}
|
||||
const names = new Set((roots.body?.items || []).map((item) => item.name));
|
||||
for (const root of ['uploads', 'outputs', 'shared', 'tasks']) {
|
||||
if (!names.has(root)) {
|
||||
throw new Error(`Virtual root disappeared after UI deletion: ${root}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyDeletedInMinio() {
|
||||
if (!verifyMinio) {
|
||||
return false;
|
||||
}
|
||||
const endpoint = process.env.BEAVER_VALIDATE_MINIO_ENDPOINT || process.env.BEAVER_SMOKE_MINIO_ENDPOINT || 'http://127.0.0.1:9000';
|
||||
const accessKey = process.env.BEAVER_VALIDATE_MINIO_ACCESS_KEY || process.env.BEAVER_SMOKE_MINIO_ACCESS_KEY || process.env.BEAVER_MINIO_ROOT_USER || '';
|
||||
const secretKey = process.env.BEAVER_VALIDATE_MINIO_SECRET_KEY || process.env.BEAVER_SMOKE_MINIO_SECRET_KEY || process.env.BEAVER_MINIO_ROOT_PASSWORD || '';
|
||||
const bucket = process.env.BEAVER_VALIDATE_MINIO_BUCKET || process.env.BEAVER_SMOKE_MINIO_BUCKET || process.env.BEAVER_USER_FILES_BUCKET || 'beaver-user-files';
|
||||
const network = process.env.BEAVER_VALIDATE_MINIO_NETWORK || process.env.BEAVER_SMOKE_MINIO_NETWORK || '';
|
||||
if (!accessKey || !secretKey) {
|
||||
throw new Error('MinIO verification requested but access key or secret key is missing');
|
||||
}
|
||||
const checks = deleteTargets
|
||||
.map((target) => `if mc stat ${shellQuote(`beaver/${bucket}/users/${username}/${target.path}`)} >/dev/null 2>&1; then echo ${shellQuote(`object still exists: ${target.path}`)}; exit 1; fi`)
|
||||
.join(' && ');
|
||||
const containerArgs = ['run', '--rm', '--entrypoint', '/bin/sh'];
|
||||
if (network) {
|
||||
containerArgs.push('--network', network);
|
||||
}
|
||||
containerArgs.push(
|
||||
'minio/mc:latest',
|
||||
'-lc',
|
||||
[
|
||||
`mc alias set beaver ${shellQuote(endpoint)} ${shellQuote(accessKey)} ${shellQuote(secretKey)} >/dev/null`,
|
||||
checks,
|
||||
].join(' && '),
|
||||
);
|
||||
execFileSync('docker', containerArgs, { stdio: 'pipe' });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function checkPage(page, appOrigin, check) {
|
||||
const beforeResponseCount = failedResponses.length;
|
||||
const beforeConsoleCount = consoleErrors.length;
|
||||
await page.goto(`${appOrigin}${check.path}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.getByText(check.marker).first().waitFor({ timeout: 30000 });
|
||||
if (check.path === '/agents') {
|
||||
const subagentTab = page.getByRole('tab', { name: /Persistent sub-agents|Persistent Sub-Agents|Sub-Agent/i }).first();
|
||||
if (await subagentTab.isVisible().catch(() => false)) {
|
||||
await subagentTab.click();
|
||||
await page.getByText(/Sub-Agent|sub-agent|子智能体/i).first().waitFor({ timeout: 30000 });
|
||||
}
|
||||
}
|
||||
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
||||
assertAppHost(page.url());
|
||||
const newResponses = failedResponses.slice(beforeResponseCount);
|
||||
const newConsoleErrors = consoleErrors.slice(beforeConsoleCount);
|
||||
const hardFailures = newResponses.filter((entry) => entry.status >= 500 || (entry.url.includes('/api/user-files/') && entry.status >= 400));
|
||||
if (hardFailures.length) {
|
||||
throw new Error(`Page ${check.name} had failing responses: ${hardFailures.map((item) => `${item.status} ${item.url}`).join('; ')}`);
|
||||
}
|
||||
const fileConsoleErrors = newConsoleErrors.filter((entry) => /user[-_ ]?files|filesystem|minio|storage/i.test(entry.text));
|
||||
if (fileConsoleErrors.length) {
|
||||
throw new Error(`Page ${check.name} had file-system-related console errors: ${fileConsoleErrors.map((item) => item.text).join('; ')}`);
|
||||
}
|
||||
pageEvidence.push({ name: check.name, path: check.path, finalUrl: page.url(), failedResponseCount: newResponses.length });
|
||||
}
|
||||
|
||||
async function cleanupDisposableUser() {
|
||||
const token = process.env.BEAVER_VALIDATE_DEPLOY_TOKEN || process.env.DEPLOY_CONTROL_API_TOKEN || '';
|
||||
const deployUrl = (process.env.BEAVER_VALIDATE_DEPLOY_CONTROL_URL || process.env.DEPLOY_CONTROL_URL || 'http://127.0.0.1:8090').replace(/\/$/, '');
|
||||
if (!cleanupEnabled || !token) {
|
||||
return { attempted: false, reason: cleanupEnabled ? 'missing deploy-control token' : 'disabled' };
|
||||
}
|
||||
const response = await fetch(`${deployUrl}/api/instances/${encodeURIComponent(username)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
'X-Purge-Data': '1',
|
||||
'X-Purge-User-Files': '1',
|
||||
},
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
return { attempted: true, status: response.status, ok: response.ok, body };
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless, executablePath, args: launchArgs });
|
||||
const page = await browser.newPage({ viewport: { width: 1365, height: 900 } });
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
remember(failedResponses, { status: response.status(), url: response.url() });
|
||||
}
|
||||
});
|
||||
page.on('requestfailed', (request) => {
|
||||
remember(failedRequests, { url: request.url(), error: request.failure()?.errorText || '' });
|
||||
});
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
remember(consoleErrors, { text: sanitize(message.text()) });
|
||||
}
|
||||
});
|
||||
|
||||
let cleanup = { attempted: false, reason: 'not reached' };
|
||||
try {
|
||||
const token = await registerAndHandoff(page);
|
||||
const appOrigin = new URL(page.url()).origin;
|
||||
await waitForRootEntries(page);
|
||||
await createDeleteTargets(page, token);
|
||||
|
||||
for (const target of deleteTargets) {
|
||||
await deleteFromFilesPage(page, target);
|
||||
}
|
||||
|
||||
await verifyDeletedViaApi(page, token);
|
||||
const minioVerified = verifyDeletedInMinio();
|
||||
|
||||
for (const check of pageChecks) {
|
||||
await checkPage(page, appOrigin, check);
|
||||
}
|
||||
|
||||
const finalScreenshot = `${screenshotDir}/beaver-filesystem-validation-${username}.png`;
|
||||
await page.screenshot({ path: finalScreenshot, fullPage: true }).catch(() => {});
|
||||
cleanup = await cleanupDisposableUser();
|
||||
const result = {
|
||||
ok: true,
|
||||
username,
|
||||
email,
|
||||
deletedRoots: deleteTargets.map((target) => target.root),
|
||||
deletedPaths: deleteTargets.map((target) => target.path),
|
||||
checkedPages: pageEvidence,
|
||||
minioVerified,
|
||||
cleanup,
|
||||
screenshot: finalScreenshot,
|
||||
failedResponses,
|
||||
failedRequests,
|
||||
consoleErrors,
|
||||
};
|
||||
if (reportPath) {
|
||||
writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} catch (error) {
|
||||
const errorScreenshot = `${screenshotDir}/beaver-filesystem-validation-${username}-error.png`;
|
||||
await page.screenshot({ path: errorScreenshot, fullPage: true }).catch(() => {});
|
||||
cleanup = await cleanupDisposableUser().catch((cleanupError) => ({ attempted: true, ok: false, error: sanitize(cleanupError) }));
|
||||
const result = {
|
||||
ok: false,
|
||||
username,
|
||||
email,
|
||||
currentUrl: page.url(),
|
||||
error: sanitize(error),
|
||||
cleanup,
|
||||
screenshot: errorScreenshot,
|
||||
failedResponses,
|
||||
failedRequests,
|
||||
consoleErrors,
|
||||
};
|
||||
if (reportPath) {
|
||||
writeFileSync(reportPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
console.error(JSON.stringify(result, null, 2));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
16
scripts/validate-filesystem-automation.sh
Executable file
16
scripts/validate-filesystem-automation.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PLAYWRIGHT_WORKDIR="${BEAVER_PLAYWRIGHT_WORKDIR:-/tmp/beaver-playwright-smoke}"
|
||||
PLAYWRIGHT_VERSION="${PLAYWRIGHT_VERSION:-1.60.0}"
|
||||
|
||||
mkdir -p "$PLAYWRIGHT_WORKDIR"
|
||||
|
||||
if ! NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}" node -e "require.resolve('playwright')" >/dev/null 2>&1; then
|
||||
npm install --prefix "$PLAYWRIGHT_WORKDIR" --no-save "playwright@${PLAYWRIGHT_VERSION}"
|
||||
fi
|
||||
|
||||
export NODE_PATH="${PLAYWRIGHT_WORKDIR}/node_modules${NODE_PATH:+:${NODE_PATH}}"
|
||||
exec node "${PROJECT_ROOT}/scripts/validate-filesystem-automation.mjs"
|
||||
Reference in New Issue
Block a user