Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
/**
* Auth middleware: cookie -> auth_session ->
* user injected into c.var.
*
* Accessing a protected API while logged out -> 401 `{error:{code:"unauthorized"}}`.
* CSRF (MVP): SameSite=Lax cookie + write requests only accept
* `Content-Type: application/json` (an HTML form can't forge that Content-Type),
* see the README security notes.
*/
import type { MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie";
import { HttpError } from "../http/errors.js";
import type { UserRow } from "../db/repos/users.js";
import type { AuthService } from "./service.js";
/** Session cookie name. */
export const SESSION_COOKIE = "penguin_session";
/** Hono env: variables injected by the auth middleware. */
export type AppEnv = {
Variables: {
user: UserRow;
};
};
/** Gets the current user (available after authMiddleware). */
export function currentUser(c: { var: { user: UserRow } }): UserRow {
return c.var.user;
}
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const token = getCookie(c, SESSION_COOKIE);
const user = token ? auth.authenticate(token) : null;
if (!user) {
throw new HttpError(401, "unauthorized", "未登录或登录已过期。");
}
c.set("user", user);
await next();
};
}
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
/**
* Content-Type defense for write requests: a write request with a Content-Type
* other than application/json is rejected (a request with no Content-Type and an
* empty body is let through — an HTML form always carries a form-type Content-Type).
*/
export const jsonOnlyWrites: MiddlewareHandler = async (c, next) => {
if (WRITE_METHODS.has(c.req.method)) {
const contentType = c.req.header("content-type");
if (contentType && !contentType.toLowerCase().startsWith("application/json")) {
throw new HttpError(415, "unsupported_media_type", "写请求仅接受 application/json。");
}
}
await next();
};
+72
View File
@@ -0,0 +1,72 @@
/**
* Password hashing (slow-hash storage).
*
* Uses node:crypto's scrypt (built-in, no extra dependency, meets the same
* slow-hash requirement as bcrypt/argon2). Storage format:
* `scrypt$N$r$p$<salt b64>$<hash b64>` — parameters are stored alongside the hash,
* so old hashes remain verifiable after future parameter tuning; comparison uses
* timingSafeEqual to guard against timing side-channels.
*/
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const SCRYPT_N = 16384;
const SCRYPT_R = 8;
const SCRYPT_P = 1;
const SALT_BYTES = 16;
const KEY_BYTES = 64;
function scryptAsync(
password: string,
salt: Buffer,
keyLen: number,
n: number,
r: number,
p: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {
if (err) reject(err);
else resolve(key);
});
});
}
/** Generates a password hash in `scrypt$N$r$p$salt$hash` format. */
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(SALT_BYTES);
const key = await scryptAsync(password, salt, KEY_BYTES, SCRYPT_N, SCRYPT_R, SCRYPT_P);
return [
"scrypt",
String(SCRYPT_N),
String(SCRYPT_R),
String(SCRYPT_P),
salt.toString("base64"),
key.toString("base64"),
].join("$");
}
/** Verifies a password; returns false if the stored string has an invalid format (never throws, so the login path can uniformly treat it as a credential error). */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const n = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
let salt: Buffer;
let expected: Buffer;
try {
salt = Buffer.from(parts[4]!, "base64");
expected = Buffer.from(parts[5]!, "base64");
} catch {
return false;
}
if (salt.length === 0 || expected.length === 0) return false;
let actual: Buffer;
try {
actual = await scryptAsync(password, salt, expected.length, n, r, p);
} catch {
return false;
}
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Auth service: built-in admin seeding /
* login / logout / password change / session validation.
*
* - No open registration: on startup, if there are no users at all, the built-in
* admin `admin` is seeded (initial password admin123), and it adopts
* `default_project`; all other users are created by an admin via the user
* backend (admin-service).
* - An initial password (whether seeded or set by an admin) is flagged with
* password_is_initial, which the frontend uses to prompt for a password change soon.
* - Sessions: a 32-byte random token, with only its sha256 hash stored in the DB;
* valid for 7 days, with sliding renewal once less than 6 days remain.
*/
import { createHash, randomBytes } from "node:crypto";
import type { UserInfo } from "../api/types.js";
import { HttpError } from "../http/errors.js";
import type { AuthSessionsRepo } from "../db/repos/auth-sessions.js";
import type { UserRow, UsersRepo } from "../db/repos/users.js";
import { hashPassword, verifyPassword } from "./password.js";
export const MIN_PASSWORD_LENGTH = 8;
/** Built-in admin: user_id and initial password (matches the README and login-page hint). */
export const ADMIN_USER_ID = "admin";
export const ADMIN_INITIAL_PASSWORD = "admin123";
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
export function toUserInfo(row: UserRow): UserInfo {
return {
userId: row.userId,
isAdmin: row.isAdmin,
passwordIsInitial: row.passwordIsInitial,
createdAt: row.createdAt,
};
}
export interface AuthServiceDeps {
users: UsersRepo;
authSessions: AuthSessionsRepo;
/** Provisions the initial Project at signup (injected by project-service, to avoid a circular dependency). */
provisionInitialProject: (user: UserRow, isAdmin: boolean) => Promise<void>;
sessionTtlMs: number;
sessionRenewMs: number;
now?: () => Date;
}
export class AuthService {
private readonly now: () => Date;
constructor(private readonly deps: AuthServiceDeps) {
this.now = deps.now ?? (() => new Date());
}
/**
* Startup seeding (idempotent): creates the built-in admin and adopts
* default_project when the users table is empty; if the initial Project fails,
* the user row is rolled back and the server retries on next startup.
*/
async seedAdmin(): Promise<void> {
if (this.deps.users.count() > 0) return;
const user: UserRow = {
userId: ADMIN_USER_ID,
passwordHash: await hashPassword(ADMIN_INITIAL_PASSWORD),
isAdmin: true,
passwordIsInitial: true,
createdAt: this.now().toISOString(),
};
this.deps.users.insert(user);
try {
await this.deps.provisionInitialProject(user, true);
} catch (err) {
this.deps.users.delete(user.userId);
throw err;
}
}
async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> {
const row = this.deps.users.findById(userId);
const ok = row !== null && (await verifyPassword(password, row.passwordHash));
if (!row || !ok) {
throw new HttpError(401, "invalid_credentials", "用户名或密码错误。");
}
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
}
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
const row = this.deps.users.findById(userId);
if (!row || !(await verifyPassword(oldPassword, row.passwordHash))) {
throw new HttpError(400, "password_mismatch", "当前密码不正确。");
}
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "密码至少 8 个字符。");
}
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
logout(token: string): void {
this.deps.authSessions.delete(sha256Hex(token));
}
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
authenticate(token: string): UserRow | null {
const tokenHash = sha256Hex(token);
const session = this.deps.authSessions.findByTokenHash(tokenHash);
if (!session) return null;
const now = this.now();
const expiresAt = Date.parse(session.expiresAt);
if (!(expiresAt > now.getTime())) {
this.deps.authSessions.delete(tokenHash);
return null;
}
if (expiresAt - now.getTime() < this.deps.sessionRenewMs) {
this.deps.authSessions.touch(
tokenHash,
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
);
}
return this.deps.users.findById(session.userId);
}
private issueSession(userId: string): string {
const token = randomBytes(32).toString("base64url");
const now = this.now();
this.deps.authSessions.insert({
tokenHash: sha256Hex(token),
userId,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
});
return token;
}
}