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_STATE_ROOT = path.join( /* turbopackIgnore: true */ process.cwd(), "../../.port_sessions/accounts", ); const USERS_PATH = path.join(AUTH_STATE_ROOT, "users.json"); const SESSIONS_PATH = path.join(AUTH_STATE_ROOT, "auth_sessions.json"); const COOKIE_NAME = authCookieName(); 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; username: string; passwordHash: string; createdAt: string; }; type UsersFile = { users: UserRecord[]; }; type SessionsFile = { sessions: Record< string, { accountId: string; createdAt: string; updatedAt?: string } >; }; export type AccountSession = { id: string; username: string; }; export async function registerAccount(username: string, password: string) { const cleanUsername = normalizeUsername(username); validatePassword(password); const usersFile = await readUsers(); if (usersFile.users.some((user) => user.username === cleanUsername)) { throw new Error("账号已存在"); } const account: UserRecord = { id: cleanUsername, username: cleanUsername, passwordHash: hashPassword(password), createdAt: new Date().toISOString(), }; usersFile.users.push(account); await ensureLinuxAccount(account.id, password); await writeUsers(usersFile); await createAccountDirectories(account.id); return createSession(account); } export async function loginAccount(username: string, password: string) { const cleanUsername = normalizeUsername(username); const usersFile = await readUsers(); const account = usersFile.users.find( (user) => user.username === cleanUsername, ); if (!account || !verifyPassword(password, account.passwordHash)) { throw new Error("账号或密码错误"); } await ensureLinuxAccount(account.id, password); await createAccountDirectories(account.id); return createSession(account); } const XIAOMI_EMAIL_RE = /^([\w.-]+)@xiaomi\.com$/i; export function accountIdFromEmail(email: string): string { const match = XIAOMI_EMAIL_RE.exec((email ?? "").trim()); if (!match) { throw new Error("请使用小米邮箱(@xiaomi.com)登录"); } const prefix = match[1].toLowerCase(); if (!/^[a-z0-9._-]{2,40}$/.test(prefix)) { throw new Error( "邮箱前缀只能包含小写字母、数字、点、下划线或中划线,长度 2-40", ); } return prefix; } export async function registerByEmail(email: string, password: string) { const accountId = accountIdFromEmail(email); validatePassword(password); const usersFile = await readUsers(); if (usersFile.users.some((user) => user.id === accountId)) { throw new Error("该邮箱已注册,请直接登录"); } const account: UserRecord = { id: accountId, username: accountId, passwordHash: hashPassword(password), createdAt: new Date().toISOString(), }; usersFile.users.push(account); await ensureLinuxAccount(account.id, password); await writeUsers(usersFile); await createAccountDirectories(account.id); return createSession(account); } export async function loginByEmail(email: string, password: string) { const accountId = accountIdFromEmail(email); const usersFile = await readUsers(); const account = usersFile.users.find((user) => user.id === accountId); if (!account) { throw new Error("账号不存在,请先注册"); } if (!account.passwordHash) { throw new Error("该账号尚未设置密码,请联系管理员重置"); } if (!verifyPassword(password, account.passwordHash)) { throw new Error("邮箱或密码错误"); } await ensureLinuxAccount(account.id, password); await createAccountDirectories(account.id); return createSession(account); } export async function logoutAccount() { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; if (token) { const sessionsFile = await readSessions(); delete sessionsFile.sessions[token]; await writeSessions(sessionsFile); } cookieStore.delete(COOKIE_NAME); } export async function getCurrentAccount(): Promise { const cookieStore = await cookies(); const token = cookieStore.get(COOKIE_NAME)?.value; if (!token) return null; const sessionsFile = await readSessions(); const session = sessionsFile.sessions[token]; if (!session) return null; const usersFile = await readUsers(); const account = usersFile.users.find((user) => user.id === session.accountId); if (!account) return null; if (shouldRefreshSession(session.updatedAt ?? session.createdAt)) { session.updatedAt = new Date().toISOString(); await writeSessions(sessionsFile); await setSessionCookie(token); } return { id: account.id, username: account.username }; } export function accountUploadRoot(accountId: string) { return path.join(accountBaseRoot(accountId), "sessions"); } export function accountOutputRoot(accountId: string) { return path.join(accountBaseRoot(accountId), "sessions"); } export function accountBaseRoot(accountId: string) { 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) { return path.join(accountBaseRoot(accountId), "sessions", sessionId, "input"); } export function accountSessionOutputRoot(accountId: string, sessionId: string) { return path.join(accountBaseRoot(accountId), "sessions", sessionId, "output"); } export function accountSessionScratchpadRoot( accountId: string, sessionId: string, ) { return path.join( accountBaseRoot(accountId), "sessions", sessionId, "scratchpad", ); } export function accountSessionRoot(accountId: string, sessionId: string) { return path.join(accountBaseRoot(accountId), "sessions", sessionId); } async function createSession(account: UserRecord) { const token = randomBytes(32).toString("hex"); const sessionsFile = await readSessions(); sessionsFile.sessions[token] = { accountId: account.id, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; await writeSessions(sessionsFile); await setSessionCookie(token); return { id: account.id, username: account.username }; } async function setSessionCookie(token: string) { const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, token, { httpOnly: true, sameSite: "lax", path: "/", maxAge: SESSION_MAX_AGE_SECONDS, }); } async function createAccountDirectories(accountId: string) { 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 { await mkdir(AUTH_STATE_ROOT, { recursive: true }); try { return JSON.parse(await readFile(USERS_PATH, "utf8")) as UsersFile; } catch { return { users: [] }; } } async function writeUsers(payload: UsersFile) { await mkdir(AUTH_STATE_ROOT, { recursive: true }); await writeFile(USERS_PATH, JSON.stringify(payload, null, 2), "utf8"); } async function readSessions(): Promise { await mkdir(AUTH_STATE_ROOT, { recursive: true }); try { return JSON.parse(await readFile(SESSIONS_PATH, "utf8")) as SessionsFile; } catch { return { sessions: {} }; } } async function writeSessions(payload: SessionsFile) { 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", ); } return cleanUsername; } function validatePassword(password: string) { if (password.length < 6) throw new Error("密码至少 6 位"); } function hashPassword(password: string) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(password, salt, 64).toString("hex"); return `${salt}:${hash}`; } function verifyPassword(password: string, passwordHash: string) { const [salt, expectedHash] = passwordHash.split(":"); if (!salt || !expectedHash) return false; const actual = Buffer.from(scryptSync(password, salt, 64).toString("hex")); const expected = Buffer.from(expectedHash); return actual.length === expected.length && timingSafeEqual(actual, expected); } function shouldRefreshSession(value?: string) { if (!value) return true; const timestamp = Date.parse(value); if (Number.isNaN(timestamp)) return true; return Date.now() - timestamp > SESSION_REFRESH_INTERVAL_MS; } function authCookieName() { const explicit = sanitizeCookieNamePart(process.env.CLAW_AUTH_COOKIE_NAME); if (explicit) return explicit; const instance = sanitizeCookieNamePart(process.env.CLAW_DEPLOY_INSTANCE) || sanitizeCookieNamePart(process.env.CLAW_SERVICE_SCOPE) || "local"; const port = sanitizeCookieNamePart(process.env.CLAW_FRONTEND_PORT) || "dev"; return `claw_account_session_${instance}_${port}`; } function sanitizeCookieNamePart(value?: string) { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized) return ""; return normalized.replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, ""); } 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(); }, ); }