Merge remote-tracking branch 'origin/main' into wsh_dev

# Conflicts:
#	backend/api/server.py
This commit is contained in:
hupenglong1
2026-05-25 11:46:44 +08:00
77 changed files with 11155 additions and 2259 deletions
+117 -15
View File
@@ -1,17 +1,19 @@
import { spawn } from "node:child_process";
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { cookies } from "next/headers";
const AUTH_ROOT = path.join(
const AUTH_STATE_ROOT = path.join(
/* turbopackIgnore: true */ process.cwd(),
"../../.port_sessions/accounts",
);
const USERS_PATH = path.join(AUTH_ROOT, "users.json");
const SESSIONS_PATH = path.join(AUTH_ROOT, "auth_sessions.json");
const USERS_PATH = path.join(AUTH_STATE_ROOT, "users.json");
const SESSIONS_PATH = path.join(AUTH_STATE_ROOT, "auth_sessions.json");
const COOKIE_NAME = "claw_account_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const SESSION_REFRESH_INTERVAL_MS = 60 * 60 * 12 * 1000;
const LINUX_ACCOUNT_WORKSPACE_NAME = "zk-agent";
type UserRecord = {
id: string;
@@ -51,6 +53,7 @@ export async function registerAccount(username: string, password: string) {
createdAt: new Date().toISOString(),
};
usersFile.users.push(account);
await ensureLinuxAccount(account.id, password);
await writeUsers(usersFile);
await createAccountDirectories(account.id);
return createSession(account);
@@ -65,6 +68,7 @@ export async function loginAccount(username: string, password: string) {
if (!account || !verifyPassword(password, account.passwordHash)) {
throw new Error("账号或密码错误");
}
await ensureLinuxAccount(account.id, password);
await createAccountDirectories(account.id);
return createSession(account);
}
@@ -153,15 +157,18 @@ export async function getCurrentAccount(): Promise<AccountSession | null> {
}
export function accountUploadRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId, "sessions");
return path.join(accountBaseRoot(accountId), "sessions");
}
export function accountOutputRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId, "sessions");
return path.join(accountBaseRoot(accountId), "sessions");
}
export function accountBaseRoot(accountId: string) {
return path.join(AUTH_ROOT, accountId);
if (linuxAccountsEnabled()) {
return path.join("/home", accountId, LINUX_ACCOUNT_WORKSPACE_NAME);
}
return path.join(AUTH_STATE_ROOT, accountId);
}
export function accountSessionInputRoot(accountId: string, sessionId: string) {
@@ -213,15 +220,18 @@ async function setSessionCookie(token: string) {
}
async function createAccountDirectories(accountId: string) {
await Promise.all(
["sessions"].map((name) =>
mkdir(path.join(AUTH_ROOT, accountId, name), { recursive: true }),
),
);
const base = accountBaseRoot(accountId);
await Promise.all([
mkdir(path.join(base, "sessions"), { recursive: true }),
mkdir(path.join(base, "python"), { recursive: true }),
mkdir(path.join(base, "memory"), { recursive: true }),
mkdir(path.join(base, "integrations"), { recursive: true }),
]);
await chownAccountPath(accountId, base, true);
}
async function readUsers(): Promise<UsersFile> {
await mkdir(AUTH_ROOT, { recursive: true });
await mkdir(AUTH_STATE_ROOT, { recursive: true });
try {
return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile;
} catch {
@@ -230,12 +240,12 @@ async function readUsers(): Promise<UsersFile> {
}
async function writeUsers(payload: UsersFile) {
await mkdir(AUTH_ROOT, { recursive: true });
await mkdir(AUTH_STATE_ROOT, { recursive: true });
await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8");
}
async function readSessions(): Promise<SessionsFile> {
await mkdir(AUTH_ROOT, { recursive: true });
await mkdir(AUTH_STATE_ROOT, { recursive: true });
try {
return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile;
} catch {
@@ -244,12 +254,20 @@ async function readSessions(): Promise<SessionsFile> {
}
async function writeSessions(payload: SessionsFile) {
await mkdir(AUTH_ROOT, { recursive: true });
await mkdir(AUTH_STATE_ROOT, { recursive: true });
await writeFile(SESSIONS_PATH, JSON.stringify(payload, null, 2), "utf8");
}
function normalizeUsername(username: string) {
const cleanUsername = username.trim().toLowerCase();
if (linuxAccountsEnabled()) {
if (!/^[a-z_][a-z0-9_-]{1,31}$/.test(cleanUsername)) {
throw new Error(
"启用 Linux 账号时,账号只能包含小写字母、数字、下划线或中划线,且必须以小写字母或下划线开头,长度 2-32",
);
}
return cleanUsername;
}
if (!/^[a-z0-9._-]{2,40}$/.test(cleanUsername)) {
throw new Error(
"账号只能包含小写字母、数字、点、下划线或中划线,长度 2-40",
@@ -282,3 +300,87 @@ function shouldRefreshSession(value?: string) {
if (Number.isNaN(timestamp)) return true;
return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS;
}
function linuxAccountsEnabled() {
return ["1", "true", "yes", "on"].includes(
(process.env.CLAW_ENABLE_LINUX_ACCOUNTS ?? "").trim().toLowerCase(),
);
}
async function ensureLinuxAccount(accountId: string, password: string) {
if (!linuxAccountsEnabled()) return;
if (process.platform !== "linux") {
throw new Error("CLAW_ENABLE_LINUX_ACCOUNTS=1 只支持 Linux 部署环境");
}
if (typeof process.getuid === "function" && process.getuid() !== 0) {
throw new Error(
"CLAW_ENABLE_LINUX_ACCOUNTS=1 需要 Web 服务以 root 身份运行",
);
}
await runCommand("id", ["-u", accountId], { allowFailure: true }).then(
async (result) => {
if (result.code === 0) return;
await runCommand("useradd", [
"-m",
"-d",
`/home/${accountId}`,
"-s",
"/bin/bash",
accountId,
]);
},
);
await runCommand("chpasswd", [], {
input: `${accountId}:${password}\n`,
});
}
export async function chownAccountPath(
accountId: string,
targetPath: string,
recursive = false,
) {
if (!linuxAccountsEnabled()) return;
await runCommand("chown", [
...(recursive ? ["-R"] : []),
accountId,
targetPath,
]);
}
function runCommand(
command: string,
args: string[],
options: { input?: string; allowFailure?: boolean } = {},
) {
return new Promise<{ code: number; stdout: string; stderr: string }>(
(resolve, reject) => {
const child = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", reject);
child.on("close", (code) => {
const exitCode = code ?? 1;
if (exitCode !== 0 && !options.allowFailure) {
reject(
new Error(
`${command} ${args.join(" ")} 失败:${stderr || stdout || exitCode}`,
),
);
return;
}
resolve({ code: exitCode, stdout, stderr });
});
if (options.input) child.stdin.write(options.input);
child.stdin.end();
},
);
}