merge: personal user filesystem minio integration

This commit is contained in:
2026-06-03 16:32:29 +08:00
56 changed files with 4780 additions and 115 deletions

View 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
View 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
View 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
View 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"

View 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();

View 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"