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
+55
View File
@@ -0,0 +1,55 @@
/**
* Unified HTTP error: `{error: {code, message}}` response body,
* with message in Chinese.
*
* Routes and services express business errors via throw HttpError; app-level onError
* uniformly converges these into a JSON response, with unknown errors converged to 500
* (never leaking internal details to the client).
*/
import type { Context } from "hono";
import type { ErrorBody } from "../api/types.js";
export class HttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = "HttpError";
}
}
export function errorBody(code: string, message: string): ErrorBody {
return { error: { code, message } };
}
/**
* Model missing a credential: the provider SDK throws this at **client-construction
* time** (hit by both creating a Session and resuming a Session); the original message
* is full of environment variable names, meaningless to a user — uniformly replaced
* with a single actionable sentence. The frontend produces localized text by code
* (message is only a fallback); see web's lib/api-error.ts.
*/
export function isMissingCredential(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return /missing credentials|api[_ ]?key/i.test(message);
}
export function modelCredentialMissing(modelId: string): HttpError {
return new HttpError(
400,
"model_credential_missing",
`模型 ${modelId} 还没有可用的 API key,请先在「模型」页为它配置。`,
);
}
/** app.onError handler: maps HttpError through as-is; everything else is logged and converged to 500. */
export function handleError(err: Error, c: Context): Response {
if (err instanceof HttpError) {
return c.json(errorBody(err.code, err.message), err.status as 400);
}
// Unknown error: print the stack for diagnosis, but never expose details externally.
console.error(`[server] 未处理异常: ${err.stack ?? err.message}`);
return c.json(errorBody("internal", "服务器内部错误。"), 500);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Admin user-backend routes: only the built-in admin can use these (403 for non-admins).
* GET|POST /api/admin/users, POST /api/admin/users/:userId/password, DELETE /api/admin/users/:userId.
*/
import { Hono } from "hono";
import type { AdminUserCreateResponse, AdminUsersResponse } from "../../api/types.js";
import { HttpError } from "../errors.js";
import type { AppEnv } from "../../auth/middleware.js";
import { pathParam, readJson, requireString } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function adminUsersRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
if (!c.var.user.isAdmin) {
throw new HttpError(403, "admin_required", "该操作仅管理员可执行。");
}
await next();
});
app.get("/", (c) => {
return c.json({ users: deps.adminService.listUsers() } satisfies AdminUsersResponse);
});
app.post("/", async (c) => {
const body = await readJson(c);
const userId = requireString(body, "userId", { label: "userId" });
const password = requireString(body, "password", { label: "password" });
const user = await deps.adminService.createUser(userId, password);
return c.json({ user } satisfies AdminUserCreateResponse, 201);
});
app.post("/:userId/password", async (c) => {
const body = await readJson(c);
const password = requireString(body, "password", { label: "password" });
await deps.adminService.resetPassword(pathParam(c, "userId"), password);
return c.body(null, 204);
});
app.delete("/:userId", async (c) => {
await deps.adminService.deleteUser(pathParam(c, "userId"));
return c.body(null, 204);
});
return app;
}
@@ -0,0 +1,50 @@
/**
* Agent config routes (reads/writes system_config.yaml and AGENTS.md):
* GET|PUT /api/projects/:p/agents/:a/config. Members can read and write (unrestricted).
*/
import { Hono } from "hono";
import type { AgentConfigResponse, AgentConfigUpdateRequest } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { badRequest, optionalString, readJson, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function agentConfigRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Id validation happens before any path construction (FD-4: prevents agentId path traversal for cross-Project privilege escalation).
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const view = await deps.agentConfigService.getConfig(projectId, agentId);
return c.json({
...view,
activeSessionCount: deps.manager.activeCountForAgent(projectId, agentId),
} satisfies AgentConfigResponse);
});
app.put("/", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const body = await readJson(c);
const req: AgentConfigUpdateRequest = {};
const agentsMd = optionalString(body, "agentsMd", { label: "agentsMd" });
if (agentsMd !== undefined) req.agentsMd = agentsMd;
if (body.config !== undefined) {
if (body.config === null || typeof body.config !== "object" || Array.isArray(body.config)) {
throw badRequest("config 必须是对象。");
}
req.config = body.config as AgentConfigUpdateRequest["config"];
}
// Fine-grained validation (numeric ranges / enums) is done inside agent-config-service.
await deps.agentConfigService.updateConfig(projectId, agentId, req);
const view = await deps.agentConfigService.getConfig(projectId, agentId);
return c.json({
...view,
activeSessionCount: deps.manager.activeCountForAgent(projectId, agentId),
} satisfies AgentConfigResponse);
});
return app;
}
@@ -0,0 +1,48 @@
/**
* Agent-level Trace browsing routes:
* - GET /api/projects/:p/agents/:a/traces — drills down Agent -> date -> Session -> index (reverse order);
* - GET /api/projects/:p/agents/:a/traces/:sessionId/:index (including /analysis) —
* read-only Trace detail endpoints (FD-3): locate the Trace file directly by
* (projectId, agentId, sessionId), without depending on the sessions table for
* tracking — any entry visible in the directory tree (subagent child Sessions,
* CLI-created Sessions) can be opened and read; access is enforced by requireProjectAccess.
*/
import { Hono } from "hono";
import type { AppEnv } from "../../auth/middleware.js";
import { paginationQuery, positiveIntParam, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function agentTracesRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Id validation happens before any path construction (FD-4: prevents agentId path traversal for cross-Project privilege escalation).
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
return c.json(await deps.traceService.agentTraces(projectId, agentId));
});
app.get("/:sessionId/:index", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
const sessionId = requireValidId(c, "sessionId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const index = positiveIntParam(c, "index");
const { offset, limit } = paginationQuery(c);
return c.json(
await deps.traceService.readEvents(projectId, agentId, sessionId, index, offset, limit),
);
});
app.get("/:sessionId/:index/analysis", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
const sessionId = requireValidId(c, "sessionId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const index = positiveIntParam(c, "index");
return c.json(await deps.traceService.analyze(projectId, agentId, sessionId, index));
});
return app;
}
@@ -0,0 +1,55 @@
/**
* Agent State export/import routes:
* GET /api/projects/:p/agents/:a/export (any member; auto-packages if no snapshot exists, downloads tar.gz)
* POST /api/projects/:p/agents/:a/import (owner only; version conflicts require a confirm flag)
*/
import fs from "node:fs/promises";
import { Hono } from "hono";
import type { AgentImportResponse } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import type { AppDeps } from "../../app.js";
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
/** Import archive size cap: aligned with the global request body limit (stays within 20MB after base64). */
const MAX_ARCHIVE_BYTES = 14 * 1024 * 1024;
export function agentTransferRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/export", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const { file, fileName } = await deps.snapshots.exportArchive(projectId, agentId);
const bytes = await fs.readFile(file);
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": "application/gzip",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
});
});
app.post("/import", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const body = await readJson(c);
const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
const confirm = body.confirm === true;
let archive: Buffer;
try {
archive = Buffer.from(dataBase64, "base64");
} catch {
throw badRequest("dataBase64 不是合法的 base64。");
}
if (archive.byteLength === 0) throw badRequest("导入包为空。");
if (archive.byteLength > MAX_ARCHIVE_BYTES) throw badRequest("导入包超过 14MB 上限。");
const { version } = await deps.snapshots.importArchive(projectId, agentId, archive, confirm);
const res: AgentImportResponse = { version };
return c.json(res);
});
return app;
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Agent routes:
* GET|POST /api/projects/:p/agents, DELETE /:agentId (owner only).
* The list is the union of DB entries and directory scan results, including active
* Session count, total Session count, and config last-modified time.
*/
import { Hono } from "hono";
import type { AgentCreateResponse, AgentsResponse, AgentSummary } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { settleWithin } from "../settle.js";
import { optionalString, readJson, requireString, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
/** Window size in days for the card's activity sparkline (last 30 days, including today). */
const ACTIVITY_DAYS = 30;
export function agentsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Defensive id validation (FD-4): don't rely on the implicit invariant that requireProjectAccess always runs before path construction.
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const items = await deps.agentService.listAgents(projectId);
const agents: AgentSummary[] = await Promise.all(
items.map(async (item) => {
const stats = await deps.sessionService.sessionStats(
projectId,
item.agentId,
ACTIVITY_DAYS,
);
return {
...item,
activeSessionCount: deps.manager.activeCountForAgent(projectId, item.agentId),
sessionCount: stats.sessionCount,
sessionActivity: stats.activity,
};
}),
);
return c.json({ agents } satisfies AgentsResponse);
});
app.post("/", async (c) => {
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const body = await readJson(c);
const agentId = requireString(body, "agentId", { label: "agentId" });
const name = optionalString(body, "name", { minLen: 1, maxLen: 100, label: "name" });
const description = optionalString(body, "description", {
maxLen: 2000,
label: "description",
});
const item = await deps.agentService.createAgent(projectId, agentId, name, description);
const agent: AgentSummary = {
...item,
activeSessionCount: 0,
sessionCount: 0,
sessionActivity: Array.from({ length: ACTIVITY_DAYS }, () => 0),
};
return c.json({ agent } satisfies AgentCreateResponse, 201);
});
app.delete("/:agentId", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
// Deletion is a Project-level management operation: owner only.
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
// Mark as deleting and converge active runs (beginAgentDeletion): any new Task during
// this window gets 409, preventing the race where a new task recreates the directory
// and revives the Agent between abort and rm. Abort cleanup writes the Trace
// asynchronously; wait for it to finish before removing the directory, and clear the
// deleting flag once deletion completes (success or failure).
const runnings = deps.manager.beginAgentDeletion(projectId, agentId);
try {
await settleWithin(runnings, 5000);
await deps.agentService.deleteAgent(projectId, agentId);
deps.sessionsRepo.deleteByAgent(projectId, agentId);
} finally {
deps.manager.endAgentDeletion(projectId, agentId);
}
return c.body(null, 204);
});
return app;
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Auth routes: POST /api/auth/login | logout.
* No self-registration: users are created by an admin in the user backend (/api/admin/users).
* Login issues a cookie session; logout deletes the server-side session and clears the cookie.
*/
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import type { AuthResponse } from "../../api/types.js";
import { SESSION_COOKIE } from "../../auth/middleware.js";
import type { AppEnv } from "../../auth/middleware.js";
import { readJson, requireString } from "../validate.js";
import type { AppDeps } from "../../app.js";
/** Session cookie attributes: HttpOnly, SameSite=Lax, 7 days. */
function cookieOptions(c: { req: { header(name: string): string | undefined } }) {
return {
httpOnly: true,
sameSite: "Lax" as const,
path: "/",
maxAge: 7 * 24 * 60 * 60,
// Add Secure when the reverse proxy declares https.
...(c.req.header("x-forwarded-proto") === "https" ? { secure: true } : {}),
};
}
export function authRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.post("/login", async (c) => {
const body = await readJson(c);
const userId = requireString(body, "userId", { label: "userId" });
const password = requireString(body, "password", { label: "password" });
const { user, token } = await deps.authService.login(userId, password);
setCookie(c, SESSION_COOKIE, token, cookieOptions(c));
return c.json({ user } satisfies AuthResponse);
});
app.post("/logout", (c) => {
const token = getCookie(c, SESSION_COOKIE);
if (token) deps.authService.logout(token);
deleteCookie(c, SESSION_COOKIE, { path: "/" });
return c.body(null, 204);
});
return app;
}
@@ -0,0 +1,24 @@
/**
* Benchmark scoring routes:
* GET /api/projects/:p/agents/:a/benchmarks (any member, read-only)
* Returns the Agent's Benchmark list (title/description from benchmark_config.toml)
* along with the evaluations[] from scoreboard.yaml.
*/
import { Hono } from "hono";
import type { AppEnv } from "../../auth/middleware.js";
import type { AppDeps } from "../../app.js";
import { requireValidId } from "../validate.js";
export function benchmarksRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
return c.json(await deps.benchmarks.list(projectId, agentId));
});
return app;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Server directory browsing:
* GET /api/projects/:p/dirs?path=<absolute>.
*
* Lets the user interactively pick a Workspace directory when creating a Session via
* advanced mode. Defaults to the home directory of the account running the service, and
* can be browsed all the way up to the root `/` — reachability is governed by OS file
* permissions; the server no longer restricts browsing to within the Project directory
* tree (same convention as workspace-guard). Lists subdirectories only, not files.
*
* `projectId` remains the authorization anchor: the caller must have access to that Project.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Hono } from "hono";
import type { DirListResponse } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { HttpError } from "../errors.js";
import { requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function dirsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
// Default starting point: home directory; an explicit path must be absolute (the frontend always sends back the realpath result).
const raw = c.req.query("path");
const target = raw && raw.trim() ? raw.trim() : os.homedir();
if (!path.isAbsolute(target)) {
throw new HttpError(400, "dir_not_absolute", "目录必须是绝对路径。");
}
let real: string;
try {
real = await fs.realpath(target);
} catch {
throw new HttpError(404, "dir_not_found", `目录不存在或不可访问:${target}。`);
}
const stat = await fs.stat(real);
if (!stat.isDirectory()) {
throw new HttpError(400, "not_a_dir", "不是目录。");
}
let dirents: import("node:fs").Dirent[] = [];
try {
dirents = await fs.readdir(real, { withFileTypes: true });
} catch {
// No read permission: return an empty list instead of an error, so the user can still navigate back up.
dirents = [];
}
const entries = dirents
.filter((d) => d.isDirectory())
.map((d) => ({ name: d.name, path: path.join(real, d.name) }))
.sort((a, b) => a.name.localeCompare(b.name));
const parent = path.dirname(real);
return c.json({
path: real,
parent: parent === real ? null : parent,
entries,
} satisfies DirListResponse);
});
return app;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* User-level server event stream: GET /api/events (SSE user channel).
* Carries cross-Session notifications (reserved for automated tasks); sends a `hello` handshake event on connect.
*/
import { Hono } from "hono";
import type { AppEnv } from "../../auth/middleware.js";
import { sseEndpoint } from "../sse.js";
import type { AppDeps } from "../../app.js";
/** The user channel's key in ChannelHub. */
export function userChannelKey(userId: string): string {
return `user:${userId}`;
}
export function eventsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", (c) => {
const channel = deps.channels.get(userChannelKey(c.var.user.userId));
return sseEndpoint(c, channel, { initialEvents: [{ type: "hello" }] });
});
return app;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Current-user routes: GET /api/me, PUT /api/me/password, GET|PUT /api/me/prefs.
* ui_prefs is free-form JSON (theme / lastProjectId / credentialGuideSeen, etc.): GET reads
* it whole, PUT shallow-merges (PATCH semantics) — several independent writers each write
* their own fields without clobbering each other.
*/
import { Hono } from "hono";
import type { MeResponse, PrefsResponse, UiPrefs } from "../../api/types.js";
import { toUserInfo } from "../../auth/service.js";
import type { AppEnv } from "../../auth/middleware.js";
import { readJson, requireString } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function meRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", (c) => {
return c.json({ user: toUserInfo(c.var.user) } satisfies MeResponse);
});
// Self-service password change (user settings): validates the old password; on success, the initial-password prompt disappears from GET /api/me.
app.put("/password", async (c) => {
const body = await readJson(c);
const oldPassword = requireString(body, "oldPassword", { label: "oldPassword" });
const newPassword = requireString(body, "newPassword", { label: "newPassword" });
await deps.authService.changePassword(c.var.user.userId, oldPassword, newPassword);
return c.body(null, 204);
});
app.get("/prefs", (c) => {
const raw = deps.prefsRepo.get(c.var.user.userId);
let prefs: UiPrefs = {};
if (raw !== null) {
try {
prefs = JSON.parse(raw) as UiPrefs;
} catch {
prefs = {}; // Corrupted prefs fall back to an empty object
}
}
return c.json({ prefs } satisfies PrefsResponse);
});
// PATCH semantics: the request body is **shallow-merged** into existing prefs, not a
// full replace. prefs has several independent writers (lastProjectId /
// credentialGuideSeen, etc., each writing their own field); a full replace would wipe
// out each other's fields — e.g. writing lastProjectId when switching Projects would
// clear credentialGuideSeen, breaking the "show onboarding once ever" guarantee.
app.put("/prefs", async (c) => {
const body = await readJson(c);
const raw = deps.prefsRepo.get(c.var.user.userId);
let current: UiPrefs = {};
if (raw !== null) {
try {
current = JSON.parse(raw) as UiPrefs;
} catch {
current = {}; // Corrupted prefs fall back to an empty object (consistent with GET).
}
}
const merged = { ...current, ...(body as UiPrefs) };
deps.prefsRepo.set(c.var.user.userId, JSON.stringify(merged));
return c.json({ prefs: merged } satisfies PrefsResponse);
});
return app;
}
@@ -0,0 +1,45 @@
/**
* Member authorization routes:
* GET|POST /api/projects/:p/members, DELETE /api/projects/:p/members/:userId.
* Reading requires access; adding/removing is owner-only (validated inside the service).
*/
import { Hono } from "hono";
import type { MemberAddResponse, MembersResponse } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { pathParam, readJson, requireString, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function membersRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", (c) => {
// Defensive id validation (FD-4).
const members = deps.projectService.listMembers(
c.var.user.userId,
requireValidId(c, "projectId"),
);
return c.json({ members } satisfies MembersResponse);
});
app.post("/", async (c) => {
const body = await readJson(c);
const userId = requireString(body, "userId", { label: "userId" });
const member = deps.projectService.addMember(
c.var.user.userId,
requireValidId(c, "projectId"),
userId,
);
return c.json({ member } satisfies MemberAddResponse, 201);
});
app.delete("/:userId", (c) => {
deps.projectService.removeMember(
c.var.user.userId,
requireValidId(c, "projectId"),
pathParam(c, "userId"),
);
return c.body(null, 204);
});
return app;
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Model & credential config routes:
* GET|PUT /api/projects/:p/models, POST /api/projects/:p/models/test (the model reference
* `(provider, modelId)` is sent as a pair in the request body, avoiding URL-encoding
* issues). Any member can read (api_key is masked); only the owner can modify or test.
*/
import { Hono } from "hono";
import type {
ModelRefDto,
ModelsUpdateRequest,
ModelTestRequest,
ModelUpdateEntry,
} from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
/** Validate a paired reference object ({ provider, modelId }); shape mismatch throws 400. */
function parseRef(value: unknown, label: string): ModelRefDto {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw badRequest(`${label} 必须是 { provider, modelId } 对象。`);
}
const r = value as Record<string, unknown>;
return {
provider: requireString(r, "provider", { minLen: 1, maxLen: 64, label: `${label}.provider` }),
modelId: requireString(r, "modelId", { minLen: 1, maxLen: 200, label: `${label}.modelId` }),
};
}
/** Validate the PUT request body and shape it into a ModelsUpdateRequest (rejects any shape errors). */
function parseModelsUpdate(body: Record<string, unknown>): ModelsUpdateRequest {
if (!Array.isArray(body.models)) throw badRequest("models 必须是数组。");
const models: ModelUpdateEntry[] = body.models.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`models[${i}] 必须是对象。`);
}
const m = item as Record<string, unknown>;
const entry: ModelUpdateEntry = {
provider: requireString(m, "provider", {
minLen: 1,
maxLen: 64,
label: `models[${i}].provider`,
}),
modelId: requireString(m, "modelId", {
minLen: 1,
maxLen: 200,
label: `models[${i}].modelId`,
}),
};
if (m.displayName !== undefined) {
if (typeof m.displayName !== "string" || m.displayName.length > 100) {
throw badRequest(`models[${i}].displayName 必须是长度不超过 100 的字符串。`);
}
if (m.displayName) entry.displayName = m.displayName;
}
// A key change (either the provider group or the upstream id) goes through renamedFrom's paired old reference; unknown fields are ignored.
if (m.renamedFrom !== undefined) {
entry.renamedFrom = parseRef(m.renamedFrom, `models[${i}].renamedFrom`);
}
if (m.contextWindow !== undefined) {
if (typeof m.contextWindow !== "number" || !(m.contextWindow > 0)) {
throw badRequest(`models[${i}].contextWindow 必须是正数。`);
}
entry.contextWindow = m.contextWindow;
}
if (m.clientType !== undefined) {
if (typeof m.clientType !== "string" || m.clientType.length > 64) {
throw badRequest(`models[${i}].clientType 必须是长度不超过 64 的字符串。`);
}
// An empty string is treated as "unspecified", leaving AgentHub to infer it from modelId.
if (m.clientType) entry.clientType = m.clientType;
}
if (m.vision !== undefined) {
if (typeof m.vision !== "boolean") {
throw badRequest(`models[${i}].vision 必须是布尔值。`);
}
entry.vision = m.vision;
}
if (m.pricing !== undefined) {
const p = m.pricing as Record<string, unknown>;
if (p === null || typeof p !== "object" || Array.isArray(p)) {
throw badRequest(`models[${i}].pricing 必须是对象。`);
}
for (const key of ["cacheRead", "cacheWrite", "output"] as const) {
const v = p[key];
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
throw badRequest(`models[${i}].pricing.${key} 必须是非负数字。`);
}
}
entry.pricing = {
cacheRead: p.cacheRead as number,
cacheWrite: p.cacheWrite as number,
output: p.output as number,
};
}
if (m.apiKey !== undefined) {
if (typeof m.apiKey !== "string" || m.apiKey.length === 0) {
throw badRequest(`models[${i}].apiKey 必须是非空字符串。`);
}
entry.apiKey = m.apiKey;
}
if (m.clearApiKey !== undefined) {
if (typeof m.clearApiKey !== "boolean") {
throw badRequest(`models[${i}].clearApiKey 必须是布尔值。`);
}
entry.clearApiKey = m.clearApiKey;
}
if (m.baseUrl !== undefined) {
if (m.baseUrl !== null && typeof m.baseUrl !== "string") {
throw badRequest(`models[${i}].baseUrl 必须是字符串或 null。`);
}
entry.baseUrl = m.baseUrl as string | null;
}
return entry;
});
const req: ModelsUpdateRequest = { models };
if (body.defaultModel !== undefined) {
req.defaultModel = parseRef(body.defaultModel, "defaultModel");
}
if (body.visionModel !== undefined) {
req.visionModel = parseRef(body.visionModel, "visionModel");
}
return req;
}
export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Defensive id validation (FD-4).
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
return c.json(await deps.projectConfigService.getModels(projectId));
});
app.put("/", async (c) => {
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const req = parseModelsUpdate(await readJson(c));
return c.json(await deps.projectConfigService.updateModels(projectId, req));
});
// Connectivity test (owner): the model reference `(provider, modelId)` is sent as a pair
// in the request body; sends one minimal request using that model's config. May include
// not-yet-saved apiKey / baseUrl / clientType — when the model isn't in the config yet
// (adding a custom model), everything is taken from the request body.
app.post("/test", async (c) => {
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const body = await readJson(c);
const req: ModelTestRequest = {
provider: requireString(body, "provider", { minLen: 1, maxLen: 64 }),
modelId: requireString(body, "modelId", { minLen: 1, maxLen: 200 }),
};
if (body.apiKey !== undefined) {
if (typeof body.apiKey !== "string") throw badRequest("apiKey 必须是字符串。");
if (body.apiKey) req.apiKey = body.apiKey;
}
if (body.clearApiKey !== undefined) {
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey 必须是布尔值。");
req.clearApiKey = body.clearApiKey;
}
// null = explicit clear (test against the draft, don't fall back to the stored value); empty string is treated as null.
if (body.baseUrl !== undefined) {
if (body.baseUrl !== null && typeof body.baseUrl !== "string") {
throw badRequest("baseUrl 必须是字符串或 null。");
}
req.baseUrl = body.baseUrl ? body.baseUrl : null;
}
if (body.clientType !== undefined) {
if (typeof body.clientType !== "string") throw badRequest("clientType 必须是字符串。");
if (body.clientType) req.clientType = body.clientType;
}
return c.json(await deps.projectConfigService.testModel(projectId, req));
});
return app;
}
@@ -0,0 +1,33 @@
/**
* Project routes: GET|POST /api/projects, DELETE /api/projects/:p.
*/
import { Hono } from "hono";
import type { ProjectCreateResponse, ProjectsResponse } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { optionalString, readJson, requireString, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
export function projectsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
const projects = await deps.projectService.listProjects(c.var.user.userId);
return c.json({ projects } satisfies ProjectsResponse);
});
app.post("/", async (c) => {
const body = await readJson(c);
const projectId = requireString(body, "projectId", { label: "projectId" });
const name = optionalString(body, "name", { minLen: 1, maxLen: 100, label: "name" });
const project = await deps.projectService.createProject(c.var.user, projectId, name);
return c.json({ project } satisfies ProjectCreateResponse, 201);
});
app.delete("/:projectId", async (c) => {
// Defensive id validation (FD-4): deleteProject constructs the project directory path and recursively deletes it.
await deps.projectService.deleteProject(c.var.user.userId, requireValidId(c, "projectId"));
return c.body(null, 204);
});
return app;
}
@@ -0,0 +1,243 @@
/**
* Schedule routes:
* GET|POST /api/projects/:p/agents/:a/schedules
* GET|PUT|DELETE /api/projects/:p/agents/:a/schedules/:name (name is the file name)
* Any member can read; only the owner can modify. The file is declarative intent:
* POST/PUT fully replace the file, validation always goes through parseScheduleFile
* (same rules as hand-edited files), and writes take effect immediately via reconciliation.
*/
import { createHash } from "node:crypto";
import { Hono } from "hono";
import { isValidId } from "@prismshadow/penguin-core";
import type { ScheduleItem, ScheduleStatus, SchedulesResponse } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import type { AppDeps } from "../../app.js";
import { HttpError } from "../errors.js";
import {
badRequest,
optionalString,
readJson,
requireString,
requireValidId,
} from "../validate.js";
import type { ScheduleDefinition } from "../../runtime/schedule-file.js";
import {
latestSlotAt,
nextSlotAfter,
parseScheduleFile,
slotInWindow,
} from "../../runtime/schedule-file.js";
import type { ScheduleStateRow } from "../../db/repos/schedules.js";
import {
deleteScheduleFile,
readScheduleFile,
serializeSchedule,
validateScheduleModelRef,
writeScheduleFile,
} from "../../runtime/schedule-store.js";
/** Validate and shape the POST/PUT request body into file fields (semantic validation is left to parseScheduleFile). */
function parseUpsertBody(body: Record<string, unknown>): {
prompt: string;
enabled: boolean;
startAt: string;
period?: string;
endAt?: string;
sessionId?: string;
workspace?: string;
modelId?: string;
provider?: string;
} {
if (typeof body.enabled !== "boolean") throw badRequest("enabled 必须是布尔值。");
const prompt = requireString(body, "prompt", { minLen: 1, maxLen: 100_000 });
const startAt = requireString(body, "startAt", { minLen: 1, maxLen: 100 });
const period = optionalString(body, "period", { minLen: 1, maxLen: 20 });
const endAt = optionalString(body, "endAt", { minLen: 1, maxLen: 100 });
const sessionId = optionalString(body, "sessionId", { minLen: 1, maxLen: 200 });
const workspace = optionalString(body, "workspace", { minLen: 1, maxLen: 4096 });
const modelId = optionalString(body, "modelId", { minLen: 1, maxLen: 200 });
const provider = optionalString(body, "provider", { minLen: 1, maxLen: 64 });
return {
prompt,
enabled: body.enabled,
startAt,
...(period !== undefined ? { period } : {}),
...(endAt !== undefined ? { endAt } : {}),
...(sessionId !== undefined ? { sessionId } : {}),
...(workspace !== undefined ? { workspace } : {}),
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
};
}
/** Next scheduled fire time: none when disabled/invalid/done/missed; an undigested due slot counts as-is. */
function nextFireAt(
def: ScheduleDefinition,
state: ScheduleStateRow,
nowMs: number,
): string | undefined {
if (!def.enabled || state.invalidReason !== null) return undefined;
if (def.periodMs === undefined && (state.firedOnce || state.missed)) return undefined;
const due = latestSlotAt(def, nowMs);
if (
due !== null &&
slotInWindow(def, due) &&
(state.lastSlotMs === null || due > state.lastSlotMs)
) {
return new Date(due).toISOString();
}
const next = nextSlotAfter(def, nowMs);
return next !== null ? new Date(next).toISOString() : undefined;
}
/** Displayed status precedence: invalid > done/missed (one-shot) > expired > enabled flag. */
function statusOf(def: ScheduleDefinition, state: ScheduleStateRow, nowMs: number): ScheduleStatus {
if (state.invalidReason !== null) return "invalid";
if (def.periodMs === undefined && state.firedOnce) return "done";
if (def.periodMs === undefined && state.missed) return "missed";
if (def.endAtMs !== undefined && nowMs > def.endAtMs) return "expired";
return def.enabled ? "active" : "disabled";
}
function toItem(
def: ScheduleDefinition,
state: ScheduleStateRow,
queued: boolean,
nowMs: number,
): ScheduleItem {
const next = nextFireAt(def, state, nowMs);
return {
name: def.name,
prompt: def.prompt,
enabled: def.enabled,
startAt: def.startAt,
...(def.period !== undefined ? { period: def.period } : {}),
...(def.endAt !== undefined ? { endAt: def.endAt } : {}),
...(def.sessionId !== undefined ? { sessionId: def.sessionId } : {}),
...(def.workspace !== undefined ? { workspace: def.workspace } : {}),
...(def.modelId !== undefined ? { modelId: def.modelId } : {}),
...(def.provider !== undefined ? { provider: def.provider } : {}),
status: statusOf(def, state, nowMs),
...(state.invalidReason !== null ? { invalidReason: state.invalidReason } : {}),
...(next !== undefined ? { nextFireAt: next } : {}),
...(state.lastFiredAt !== null ? { lastFiredAt: state.lastFiredAt } : {}),
queued,
...(state.creatorUserId !== null ? { creatorUserId: state.creatorUserId } : {}),
};
}
/** Schedule name in the path: same character rules as directories/files, validated before any path construction. */
function requireScheduleName(raw: string | undefined): string {
if (!raw || !isValidId(raw)) throw badRequest("定时任务名非法。");
return raw;
}
export function scheduleRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const { entries, invalid } = await deps.scheduler.listAgent(projectId, agentId);
const nowMs = Date.now();
const res: SchedulesResponse = {
schedules: entries.map((e) => toItem(e.def, e.state, e.queued, nowMs)),
invalidFiles: invalid,
};
return c.json(res);
});
app.post("/", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const body = await readJson(c);
const name = requireScheduleName(requireString(body, "name", { minLen: 1, maxLen: 100 }));
if (await readScheduleFile(deps.config.root, projectId, agentId, name)) {
throw new HttpError(409, "schedule_exists", `定时任务已存在:${name}`);
}
await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
return c.json(await readItem(deps, projectId, agentId, name), 201);
});
app.get("/:name", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const name = requireScheduleName(c.req.param("name"));
const item = await readItem(deps, projectId, agentId, name);
return c.json(item);
});
app.put("/:name", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const name = requireScheduleName(c.req.param("name"));
if (!(await readScheduleFile(deps.config.root, projectId, agentId, name))) {
throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`);
}
const body = await readJson(c);
await upsert(deps, c.var.user.userId, projectId, agentId, name, body);
return c.json(await readItem(deps, projectId, agentId, name));
});
app.delete("/:name", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const name = requireScheduleName(c.req.param("name"));
const removed = await deleteScheduleFile(deps.config.root, projectId, agentId, name);
if (!removed) throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`);
deps.scheduler.dropEntry(projectId, agentId, name);
return c.body(null, 204);
});
return app;
}
/** Write + register creator + reconcile immediately (API changes take effect right away). */
async function upsert(
deps: AppDeps,
userId: string,
projectId: string,
agentId: string,
name: string,
body: Record<string, unknown>,
): Promise<void> {
const fields = parseUpsertBody(body);
const raw = serializeSchedule(fields);
const parsed = parseScheduleFile(name, raw);
if (!parsed.ok) throw badRequest(`定时任务配置非法:${parsed.error}`);
// At save time, verify the model reference resolves (resolveModelRef semantics; same rules as reconciliation) so we never persist a broken file.
const refError = await validateScheduleModelRef(deps.config.root, projectId, parsed.def);
if (refError !== null) throw badRequest(`定时任务配置非法:${refError}`);
await writeScheduleFile(deps.config.root, projectId, agentId, name, raw);
// Creator attribution: the API writer is the creator (falls back to the Project owner only for hand-edited files).
deps.schedulesRepo.registerOrSync({
projectId,
agentId,
name,
startAtMs: parsed.def.startAtMs,
defHash: createHash("sha1").update(raw).digest("hex"),
creatorUserId: userId,
});
await deps.scheduler.reconcileAgent(projectId, agentId);
}
async function readItem(
deps: AppDeps,
projectId: string,
agentId: string,
name: string,
): Promise<ScheduleItem> {
const { entries, invalid } = await deps.scheduler.listAgent(projectId, agentId);
const entry = entries.find((e) => e.def.name === name);
if (entry) return toItem(entry.def, entry.state, entry.queued, Date.now());
const bad = invalid.find((i) => i.name === name);
if (bad) throw badRequest(`定时任务文件非法:${bad.error}`);
throw new HttpError(404, "schedule_not_found", `定时任务不存在:${name}`);
}
+430
View File
@@ -0,0 +1,430 @@
/**
* Session routes.
*
* Two entry groups:
* - Agent-level: GET|POST /api/projects/:p/agents/:a/sessions (list including run state / create);
* - Session-level: /api/sessions/:sessionId/* (no projectId; looks up project_id via the
* sessions index, then goes through requireProjectAccess; 404 if the index has no such Session).
*/
import fs from "node:fs/promises";
import path from "node:path";
import { Hono } from "hono";
import type { Context } from "hono";
import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import type {
ApprovalMode,
FilesStatResponse,
MessagesResponse,
ServerEvent,
SessionCreateResponse,
SessionResponse,
SessionsResponse,
TaskCreateResponse,
} from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import type { SessionRow } from "../../db/repos/sessions.js";
import { assertWorkspaceAllowed } from "../../services/workspace-guard.js";
import { HttpError } from "../errors.js";
import { sseEndpoint } from "../sse.js";
import {
badRequest,
optionalEnum,
optionalString,
paginationQuery,
pathParam,
positiveIntParam,
readJson,
requireEnum,
requireValidId,
} from "../validate.js";
import type { AppDeps } from "../../app.js";
import { MAX_UPLOAD_BYTES } from "../../services/workspace-files-service.js";
/** Max title length for manual renames: looser than the auto-generated 30-char limit, to accommodate users' own organizing conventions. */
const SESSION_TITLE_MAX = 120;
/** Max path count and per-path length for a single files/stat check (message file-card candidates never exceed this scale). */
const STAT_MAX_PATHS = 100;
const STAT_MAX_PATH_LEN = 512;
const APPROVAL_MODES: readonly ApprovalMode[] = [
"allow-all",
"deny-all",
"read-only",
"always-ask",
];
/** Validate Prompt input parts: text or image (data: / http(s) URL). */
function parseTaskInput(body: Record<string, unknown>): OmniMessage[] {
const input = body.input;
if (!Array.isArray(input) || input.length === 0) {
throw badRequest("input 必须是至少包含一项的数组。");
}
return input.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`input[${i}] 必须是对象。`);
}
const part = item as Record<string, unknown>;
if (part.type === "text") {
if (typeof part.text !== "string" || part.text.length === 0) {
throw badRequest(`input[${i}].text 必须是非空字符串。`);
}
return userText(part.text);
}
if (part.type === "image_url") {
const url = part.imageUrl;
if (
typeof url !== "string" ||
!(url.startsWith("data:") || url.startsWith("http://") || url.startsWith("https://"))
) {
throw badRequest(`input[${i}].imageUrl 仅支持 data: 或 http(s) URL。`);
}
return imageUrlMessage(url);
}
throw badRequest(`input[${i}].type 必须是 text / image_url 之一。`);
});
}
/** Agent-level entry: /api/projects/:p/agents/:a/sessions. */
export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Id validity is checked before any path is constructed (FD-4: guards against agentId path traversal across Projects).
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const sessions = await deps.sessionService.listSessions(projectId, agentId);
return c.json({ sessions } satisfies SessionsResponse);
});
app.post("/", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const body = await readJson(c);
const modelId = optionalString(body, "modelId", { minLen: 1, label: "modelId" });
const provider = optionalString(body, "provider", { minLen: 1, label: "provider" });
// Model reference is submitted as a pair: provider can't appear without modelId (core does the same validation; this catches it early).
if (provider !== undefined && modelId === undefined) {
throw badRequest("指定了 provider 却未指定 modelId:模型引用须成对给出。");
}
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
let workspace = optionalString(body, "workspace", { minLen: 1, label: "workspace" });
if (workspace !== undefined) {
// An explicitly specified Workspace must be an existing directory (never auto-created); reachability is determined by file permissions.
workspace = await assertWorkspaceAllowed({ workspace });
}
const session = await deps.sessionService.createSession({
projectId,
agentId,
...(modelId !== undefined ? { modelId } : {}),
...(provider !== undefined ? { provider } : {}),
...(workspace !== undefined ? { workspace } : {}),
...(approvalMode !== undefined ? { approvalMode } : {}),
});
return c.json({ session } satisfies SessionCreateResponse, 201);
});
return app;
}
/** Session-level entry point: /api/sessions/:sessionId/*. */
export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
/** Look up ownership and check access (404 if the index has no such Session, or access is denied — never leaking existence). */
const resolveSession = (c: Context<AppEnv>): SessionRow => {
const sessionId = c.req.param("sessionId");
const row = sessionId ? deps.sessionsRepo.findById(sessionId) : null;
if (!row) {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
}
try {
deps.projectService.requireProjectAccess(c.var.user.userId, row.projectId);
} catch {
throw new HttpError(404, "session_not_found", "Session 不存在或无权访问。");
}
return row;
};
app.get("/:sessionId", async (c) => {
const row = resolveSession(c);
const hasTrace = await deps.sessionService.hasTrace(row);
return c.json({ session: deps.sessionService.toInfo(row, hasTrace) } satisfies SessionResponse);
});
app.patch("/:sessionId", async (c) => {
const row = resolveSession(c);
const body = await readJson(c);
const approvalMode = optionalEnum(body, "approvalMode", APPROVAL_MODES);
const archivedRaw = (body as Record<string, unknown>).archived;
const archived = typeof archivedRaw === "boolean" ? archivedRaw : undefined;
const titleRaw = (body as Record<string, unknown>).title;
let title: string | undefined;
if (titleRaw !== undefined) {
if (typeof titleRaw !== "string") {
throw new HttpError(400, "invalid_title", "title 必须是字符串。");
}
title = titleRaw.trim();
if (!title || title.length > SESSION_TITLE_MAX) {
throw new HttpError(400, "invalid_title", `title 需为 1–${SESSION_TITLE_MAX} 个字符。`);
}
}
if (approvalMode === undefined && archived === undefined && title === undefined) {
throw new HttpError(400, "no_update", "缺少可更新字段(approvalMode / archived / title)。");
}
let updated: SessionRow = { ...row };
if (title !== undefined) {
// Manual renaming takes priority over auto-generation: TitleGenerator only persists a title while it's still NULL.
deps.sessionsRepo.updateTitle(row.sessionId, title);
updated = { ...updated, title };
}
if (approvalMode !== undefined) {
// Takes effect immediately: a running approve callback re-reads the DB on every decision.
deps.sessionsRepo.updateApprovalMode(row.sessionId, approvalMode);
updated = { ...updated, approvalMode };
}
if (archived !== undefined) {
const at = archived ? new Date().toISOString() : null;
deps.sessionsRepo.setArchived(row.sessionId, at);
updated = { ...updated, archivedAt: at };
}
const hasTrace = await deps.sessionService.hasTrace(updated);
return c.json({
session: deps.sessionService.toInfo(updated, hasTrace),
} satisfies SessionResponse);
});
app.delete("/:sessionId", async (c) => {
const row = resolveSession(c);
// Mark as being deleted and converge active runs (beginSessionDeletion): new
// Tasks/compactions are always rejected with 409 during this window
// (assertSessionNotDeleting), preventing the race where a new task recreates the
// entry and Trace after abort but before the files are deleted, reviving an
// already-deleted Session. Interrupt cleanup writes the Trace asynchronously, so we
// wait for it to finish (≤5s cap) before deleting the files and index row; the
// being-deleted marker is cleared once deletion finishes (success or failure).
const runnings = deps.manager.beginSessionDeletion(row.sessionId);
try {
if (runnings.length > 0) {
await Promise.race([
Promise.allSettled(runnings).then(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, 5000).unref?.()),
]);
}
await deps.traceService.deleteSessionTraces(row.projectId, row.agentId, row.sessionId);
// The session-level scratchpad (model temp files + input images saved to disk for image-unsupported models) is deleted along with the session.
await fs.rm(
path.join(scratchpadDir(deps.config.root, row.projectId, row.agentId), row.sessionId),
{ recursive: true, force: true },
);
deps.sessionsRepo.deleteById(row.sessionId);
} finally {
deps.manager.endSessionDeletion(row.sessionId);
}
return c.body(null, 204);
});
// Session scratchpad files (e.g. input images saved to disk for image-unsupported
// models): read by filename, so the conversation UI can render a message's
// "[attached image: <path>]" attachment line back into an image. Restricted to this
// session's own scratchpad directory (the filename must not contain a path
// separator, blocking traversal); filenames include a timestamp and are globally
// unique, so the response is marked immutable and long-cacheable.
app.get("/:sessionId/scratchpad/:fileName", async (c) => {
const row = resolveSession(c);
const fileName = c.req.param("fileName") ?? "";
if (!/^[A-Za-z0-9._-]+$/.test(fileName) || fileName.includes("..")) {
throw new HttpError(404, "file_not_found", "文件不存在。");
}
const filePath = path.join(
scratchpadDir(deps.config.root, row.projectId, row.agentId),
row.sessionId,
fileName,
);
let bytes: Buffer;
try {
bytes = await fs.readFile(filePath);
} catch {
throw new HttpError(404, "file_not_found", "文件不存在。");
}
const MIME_BY_EXT: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
};
const mime = MIME_BY_EXT[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
return c.body(new Uint8Array(bytes), 200, {
"content-type": mime,
"cache-control": "private, max-age=31536000, immutable",
});
});
app.get("/:sessionId/messages", async (c) => {
const row = resolveSession(c);
const messages = await deps.traceService.readMessages(
row.projectId,
row.agentId,
row.sessionId,
);
return c.json({ messages } satisfies MessagesResponse);
});
app.get("/:sessionId/stream", (c) => {
const row = resolveSession(c);
const channel = deps.channels.get(row.sessionId);
// FD-1: the first event of every new subscription (including reconnects and resync
// rebuilds) is always a snapshot of the current running state — the frontend treats
// this as authoritative, eliminating input-area lockup or premature Task closure
// caused by a stale running/idle in the list; followed by replaying all still-pending
// approval requests.
const initialEvents: ServerEvent[] = [
{ type: "task_state", state: deps.manager.statusOf(row.sessionId) },
...deps.manager.pendingApprovals(row.sessionId).map((p) => ({
type: "approval_request" as const,
toolCall: p.toolCall,
...(p.origin !== undefined ? { origin: p.origin } : {}),
})),
];
return sseEndpoint(c, channel, { initialEvents });
});
app.post("/:sessionId/tasks", async (c) => {
const row = resolveSession(c);
const input = parseTaskInput(await readJson(c));
// 202: the Task executes on the server, decoupled from the SSE connection; sessionId is the current actual id (the new id after self-heal).
const { sessionId } = await deps.manager.startTask(row.sessionId, input);
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
});
app.post("/:sessionId/approvals/:toolCallId", async (c) => {
const row = resolveSession(c);
const body = await readJson(c);
const decision = requireEnum(body, "decision", ["allow", "deny"] as const);
const ok = deps.manager.decideApproval(row.sessionId, pathParam(c, "toolCallId"), decision);
if (!ok) {
throw new HttpError(404, "approval_not_found", "该审批不存在或已被决定。");
}
return c.body(null, 204);
});
app.post("/:sessionId/abort", (c) => {
const row = resolveSession(c);
const aborted = deps.manager.abortTask(row.sessionId);
// No Task in progress → 204 no-op; interrupt was triggered → 202 (wrap-up is completed by the SDK's "interrupt cleanup").
return c.body(null, aborted ? 202 : 204);
});
app.post("/:sessionId/compact", async (c) => {
const row = resolveSession(c);
const { sessionId } = await deps.manager.startCompact(row.sessionId);
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
});
// —— Workspace file browsing (Files tab) ——
app.get("/:sessionId/files", async (c) => {
const row = resolveSession(c);
const rel = c.req.query("path") ?? "";
return c.json(await deps.workspaceFiles.list(row.workspace, rel));
});
app.get("/:sessionId/files/content", async (c) => {
const row = resolveSession(c);
const rel = c.req.query("path") ?? "";
const download = c.req.query("download") === "1";
const { data, fileName, contentType, scriptable } = await deps.workspaceFiles.read(
row.workspace,
rel,
);
const disposition = download ? "attachment" : "inline";
// Same-origin XSS defense: html/svg inline previews are always returned as plain
// text (Workspace files may be Agent-generated and untrusted); downloads
// (attachment) keep the real content type. Paired with nosniff to prevent MIME
// sniffing from undoing this.
const effectiveType = !download && scriptable ? "text/plain; charset=utf-8" : contentType;
return new Response(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": effectiveType,
"Content-Disposition": `${disposition}; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"X-Content-Type-Options": "nosniff",
},
});
});
// Bulk existence check (message file cards list only files that actually exist):
// path-confinement resolution shares the same logic as files/content
// (WorkspaceFilesService.statExisting reuses resolveRead); out-of-bounds or
// resolution failures count as not-existing, always 200 — existence itself is the
// question being answered, and a 4xx would only leak confinement details.
app.post("/:sessionId/files/stat", async (c) => {
const row = resolveSession(c);
const body = await readJson(c);
const paths = body.paths;
if (
!Array.isArray(paths) ||
paths.length > STAT_MAX_PATHS ||
!paths.every((p) => typeof p === "string" && p.length <= STAT_MAX_PATH_LEN)
) {
throw badRequest(
`paths 必须是字符串数组(≤${STAT_MAX_PATHS} 项,每项 ≤${STAT_MAX_PATH_LEN} 字符)。`,
);
}
const existing = await deps.workspaceFiles.statExisting(row.workspace, paths as string[]);
return c.json({ existing } satisfies FilesStatResponse);
});
app.put("/:sessionId/files/content", async (c) => {
const row = resolveSession(c);
const rel = c.req.query("path") ?? "";
const body = await readJson(c);
if (typeof body.dataBase64 !== "string") {
throw badRequest("dataBase64 必须是 base64 字符串。");
}
const data = Buffer.from(body.dataBase64, "base64");
if (data.length > MAX_UPLOAD_BYTES) {
throw new HttpError(413, "file_too_large", "上传文件超过 14MB 上限。");
}
await deps.workspaceFiles.write(row.workspace, rel, data);
return c.body(null, 204);
});
app.get("/:sessionId/traces", async (c) => {
const row = resolveSession(c);
const files = await deps.traceService.listTraceFiles(row.projectId, row.agentId, row.sessionId);
return c.json({ files });
});
app.get("/:sessionId/traces/:index", async (c) => {
const row = resolveSession(c);
const index = positiveIntParam(c, "index");
const { offset, limit } = paginationQuery(c);
return c.json(
await deps.traceService.readEvents(
row.projectId,
row.agentId,
row.sessionId,
index,
offset,
limit,
),
);
});
app.get("/:sessionId/traces/:index/analysis", async (c) => {
const row = resolveSession(c);
const index = positiveIntParam(c, "index");
return c.json(
await deps.traceService.analyze(row.projectId, row.agentId, row.sessionId, index),
);
});
return app;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Skill library & Agent-installed-Skills routes:
* GET /api/skills # library groups & metadata (any logged-in user)
* GET|POST /api/projects/:p/agents/:a/skills # installed list / install from library (any member)
* DELETE /api/projects/:p/agents/:a/skills/:name # uninstall (any member)
* Installing writes the library's SKILL.md verbatim to agent_state/skills/<name>/;
* reinstalling overwrites with the library content (i.e. an update). The scope is small
* enough to skip a service layer — routes call core's disk-writing functions directly.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { Hono } from "hono";
import {
installSkill,
listInstalledSkills,
removeSkill,
skillsDir,
} from "@prismshadow/penguin-core";
import { librarySkill, loadSkillGroups } from "@prismshadow/penguin-skills";
import type { LibrarySkill, SkillMetadata } from "@prismshadow/penguin-skills";
import type {
AgentSkillsResponse,
SkillLibraryResponse,
SkillMetadataItem,
} from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import type { AppDeps } from "../../app.js";
import { HttpError } from "../errors.js";
import { badRequest, readJson, requireValidId } from "../validate.js";
/**
* Strips the content off a LibrarySkill: the API only sends metadata; the full body is
* written to disk on install and read by the model on demand. The optional short
* description (shortDescription(Zh)) and custom icon (icon.svg source) are conditionally
* passed through — both the library side (LibrarySkill) and the installed side (core
* InstalledSkill) carry these fields.
*/
function toMetadataItem(skill: SkillMetadata & { icon?: string }): SkillMetadataItem {
return {
name: skill.name,
description: skill.description,
...(skill.shortDescription !== undefined ? { shortDescription: skill.shortDescription } : {}),
...(skill.shortDescriptionZh !== undefined
? { shortDescriptionZh: skill.shortDescriptionZh }
: {}),
...(skill.icon !== undefined ? { icon: skill.icon } : {}),
version: skill.version,
updated: skill.updated,
};
}
/** Library listing response: the files are the source of truth — read and parse the library directory fresh on every request (files are small, requests infrequent, no caching needed). */
function libraryResponse(): SkillLibraryResponse {
return {
groups: loadSkillGroups().map((group) => ({
id: group.id,
title: group.title,
...(group.titleZh !== undefined ? { titleZh: group.titleZh } : {}),
skills: group.skills.map(toMetadataItem),
})),
};
}
/** Validate the POST request body: names must be a non-empty array of strings. */
function parseInstallNames(body: Record<string, unknown>): string[] {
if (!Array.isArray(body.names) || body.names.length === 0) {
throw badRequest("names 必须是非空数组。");
}
return body.names.map((v, i) => {
if (typeof v !== "string" || v.length === 0) {
throw badRequest(`names[${i}] 必须是非空字符串。`);
}
return v;
});
}
/** GET /api/skills: Skill library groups & metadata (any logged-in user; no Project check). */
export function skillLibraryRoutes(): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", (c) => c.json(libraryResponse()));
return app;
}
/** /api/projects/:p/agents/:a/skills: read, install, and uninstall are all Project-member operations. */
export function agentSkillsRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
const listResponse = async (
projectId: string,
agentId: string,
): Promise<AgentSkillsResponse> => ({
skills: (await listInstalledSkills(deps.config.root, projectId, agentId)).map(toMetadataItem),
});
app.get("/", async (c) => {
// Defensive id validation happens before any path construction (FD-4: prevents path traversal for cross-Project privilege escalation).
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
return c.json(await listResponse(projectId, agentId));
});
app.post("/", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const names = parseInstallNames(await readJson(c));
// Verify all names up front before writing anything: if any name isn't in the library, reject the whole request rather than leaving a half-installed state.
const skills: LibrarySkill[] = names.map((name) => {
const skill = librarySkill(name);
if (!skill) throw new HttpError(404, "unknown_skill", `Skill 库中不存在:${name}`);
return skill;
});
for (const skill of skills) {
await installSkill(deps.config.root, projectId, agentId, skill);
}
return c.json(await listResponse(projectId, agentId), 201);
});
app.delete("/:name", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const name = requireValidId(c, "name");
// Installed-check uses the same criterion as listInstalledSkills: skills/<name>/SKILL.md exists.
const file = path.join(skillsDir(deps.config.root, projectId, agentId), name, "SKILL.md");
try {
await fs.access(file);
} catch {
throw new HttpError(404, "not_found", `Skill 未安装:${name}`);
}
await removeSkill(deps.config.root, projectId, agentId, name);
return c.body(null, 204);
});
return app;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Usage statistics routes:
* GET /api/projects/:p/usage?from&to&groupBy&agentId&provider&modelId
* (model filter is paired: provider and modelId are given together).
*/
import { Hono } from "hono";
import type { UsageGroupBy } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { badRequest, optionalDateParam, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
const GROUP_BYS: readonly UsageGroupBy[] = ["date", "agent", "model", "session"];
export function usageRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Defensive id validation (FD-4).
const projectId = requireValidId(c, "projectId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const groupByRaw = c.req.query("groupBy") ?? "date";
if (!(GROUP_BYS as readonly string[]).includes(groupByRaw)) {
throw badRequest(`groupBy 必须是 ${GROUP_BYS.join(" / ")} 之一。`);
}
const from = optionalDateParam(c.req.query("from"), "from");
const to = optionalDateParam(c.req.query("to"), "to");
const agentId = c.req.query("agentId");
const provider = c.req.query("provider");
const modelId = c.req.query("modelId");
return c.json(
await deps.usageService.query(projectId, {
groupBy: groupByRaw as UsageGroupBy,
// Unattributed errors (login failures, process crashes, etc. with no Project
// context) are visible only to admins: requireProjectAccess only guarantees
// "is a member of this Project" — a regular member seeing another tenant's errors
// would be a cross-tenant information leak.
includeGlobalErrors: c.var.user.isAdmin,
...(from !== undefined ? { from } : {}),
...(to !== undefined ? { to } : {}),
...(agentId !== undefined && agentId !== "" ? { agentId } : {}),
...(provider !== undefined && provider !== "" ? { provider } : {}),
...(modelId !== undefined && modelId !== "" ? { modelId } : {}),
}),
);
});
return app;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Vault environment variable routes:
* GET|PUT /api/projects/:p/agents/:a/vault (Agent-level, agent_state/.vault.toml).
* Any member can read (values masked); only the owner can modify; 404 if the Agent doesn't exist.
*/
import { Hono } from "hono";
import type { VaultEntryUpdate, VaultUpdateRequest } from "../../api/types.js";
import type { AppEnv } from "../../auth/middleware.js";
import { badRequest, readJson, requireString, requireValidId } from "../validate.js";
import type { AppDeps } from "../../app.js";
/** Validate the PUT request body and shape it into a VaultUpdateRequest (semantic checks like key-name rules live in the service layer). */
function parseVaultUpdate(body: Record<string, unknown>): VaultUpdateRequest {
if (!Array.isArray(body.entries)) throw badRequest("entries 必须是数组。");
const entries: VaultEntryUpdate[] = body.entries.map((item, i) => {
if (item === null || typeof item !== "object" || Array.isArray(item)) {
throw badRequest(`entries[${i}] 必须是对象。`);
}
const e = item as Record<string, unknown>;
const entry: VaultEntryUpdate = {
key: requireString(e, "key", { minLen: 1, maxLen: 200, label: `entries[${i}].key` }),
};
if (e.value !== undefined) {
if (typeof e.value !== "string" || e.value.length === 0) {
throw badRequest(`entries[${i}].value 必须是非空字符串。`);
}
entry.value = e.value;
}
return entry;
});
return { entries };
}
export function vaultRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
app.get("/", async (c) => {
// Defensive id validation happens before any path construction (FD-4: prevents agentId path traversal for cross-Project privilege escalation).
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
return c.json(await deps.agentConfigService.getVault(projectId, agentId));
});
app.put("/", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
const req = parseVaultUpdate(await readJson(c));
const res = await deps.agentConfigService.updateVault(projectId, agentId, req);
// Effective-value semantics: no hot update — an already-built runtime is neither
// evicted nor reloaded; the new value only applies to Sessions created or resumed
// afterward.
return c.json(res);
});
return app;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Convergent wait for delete routes (currently used by agents DELETE; sessions DELETE
* uses the same inline pattern and could later be unified onto this): waits for aborted
* runs to wind down, up to ms milliseconds; returns whether all of them settled within
* the window. The timer is unref'd so it never blocks process exit.
*/
export async function settleWithin(promises: Promise<unknown>[], ms: number): Promise<boolean> {
if (promises.length === 0) return true;
return Promise.race([
Promise.allSettled(promises).then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), ms).unref?.()),
]);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* SSE endpoint adapter:
* writes the runtime/channel event stream as an SSE response, shared by both the Session
* channel and the user channel.
*
* - Response headers: text/event-stream, no-cache, `X-Accel-Buffering: no` (disables
* buffering on reverse proxies);
* - Heartbeat: writes a `: ping` comment line every 20s; the connection is torn down on write failure;
* - Replay protocol: a fresh subscription without Last-Event-ID does not replay the buffer
* (history is served by the messages endpoint) — it only sends the initial events the
* caller supplied (pending approvals / hello). With a Last-Event-ID that hits the buffer,
* replay resumes from there; on a miss, `resync_required` is sent first, then the
* connection continues.
* Docs: /docs/server-api § "Delivery Guarantees".
*/
import type { Context } from "hono";
import { streamSSE } from "hono/streaming";
import type { ServerEvent } from "../api/types.js";
import type { Channel, ChannelEvent, ChannelListener } from "../runtime/channel.js";
const HEARTBEAT_MS = 20_000;
export interface SseEndpointOptions {
/** Initial server events to send privately after the subscription is established (in order): pending-approval replay / user channel hello. */
initialEvents?: ServerEvent[];
}
/** Stream out a Channel as an SSE response. */
export function sseEndpoint(c: Context, channel: Channel, opts: SseEndpointOptions = {}): Response {
const lastEventIdHeader = c.req.header("Last-Event-ID");
c.header("X-Accel-Buffering", "no");
c.header("Cache-Control", "no-cache");
return streamSSE(c, async (stream) => {
let closed = false;
let finish: () => void = () => {};
const done = new Promise<void>((resolve) => {
finish = () => {
if (closed) return;
closed = true;
resolve();
};
});
// Write serialization: SSE events must be written fully and in order; any write failure tears down the connection.
let chain: Promise<void> = Promise.resolve();
const enqueue = (write: () => Promise<unknown>): void => {
chain = chain
.then(async () => {
if (!closed) await write();
})
.catch(() => finish());
};
const listener: ChannelListener = (evt: ChannelEvent) => {
enqueue(() =>
stream.writeSSE({
data: evt.data,
// Event id is an opaque string generated by the channel (`<epoch>-<seq>`), passed through as-is.
id: evt.id,
...(evt.event !== undefined ? { event: evt.event } : {}),
}),
);
};
const unsubscribe = channel.subscribe(listener);
// Subscribe first (to avoid dropping events in a race with broadcasts), then replay
// synchronously — event order: buffered replay (or resync_required) -> initial events
// (task_state snapshot / pending approvals / hello) -> live stream.
if (lastEventIdHeader !== undefined) {
const replay = channel.replayAfter(lastEventIdHeader);
if (!replay.hit) {
const resync: ServerEvent = { type: "resync_required" };
channel.sendTo(listener, resync, "server_event");
} else {
for (const evt of replay.events) listener(evt);
}
}
for (const event of opts.initialEvents ?? []) {
channel.sendTo(listener, event, "server_event");
}
const heartbeat = setInterval(() => {
enqueue(() => stream.write(": ping\n\n"));
}, HEARTBEAT_MS);
stream.onAbort(() => finish());
try {
await done;
} finally {
clearInterval(heartbeat);
unsubscribe();
}
});
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Hand-rolled request body validation helpers (fields follow TypeScript types;
* this adds a runtime safety net).
*
* No validation library: each helper checks one basic shape, throwing a 400 HttpError on failure.
*/
import type { Context } from "hono";
import { isValidId } from "@prismshadow/penguin-core";
import { HttpError } from "./errors.js";
export function badRequest(message: string): HttpError {
return new HttpError(400, "bad_request", message);
}
/**
* Get a path parameter (under sub-route mounting, hono infers string | undefined; the
* route guarantees presence at runtime — treat a defensive missing value as 404).
*/
export function pathParam(c: Context, name: string): string {
const v = c.req.param(name);
if (v === undefined || v === "") {
throw new HttpError(404, "not_found", "路径参数缺失。");
}
return v;
}
/**
* Get a path parameter and validate the id (alphanumeric, underscore, and hyphen
* only, to prevent path traversal). Hono decodes URL-encoded `%2F` into a single path
* parameter; an id containing `/` or `..` passed straight into path construction could
* escape the resource directory (cross-Project privilege escalation). So validate right
* after reading the value — any invalid id is rejected with 404 (not leaking resource
* existence), before any service-layer or path-construction code runs.
*/
export function requireValidId(c: Context, name: string): string {
const v = pathParam(c, name);
if (!isValidId(v)) {
throw new HttpError(404, "not_found", "资源不存在或无权访问。");
}
return v;
}
/** Parse a positive-integer path parameter (e.g. Trace file index). */
export function positiveIntParam(c: Context, name: string): number {
const v = Number.parseInt(pathParam(c, name), 10);
if (!Number.isInteger(v) || v < 1) throw badRequest(`${name} 必须是正整数。`);
return v;
}
/** Parse Trace pagination query params: offset >= 0 (default 0), limit 1-1000 (default 200). */
export function paginationQuery(c: Context): { offset: number; limit: number } {
const offset = Number.parseInt(c.req.query("offset") ?? "0", 10);
const limit = Number.parseInt(c.req.query("limit") ?? "200", 10);
if (!Number.isInteger(offset) || offset < 0) throw badRequest("offset 必须是非负整数。");
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
throw badRequest("limit 必须是 1~1000 的整数。");
}
return { offset, limit };
}
/** Read the JSON request body (parse failure / non-object -> 400). */
export async function readJson(c: Context): Promise<Record<string, unknown>> {
let body: unknown;
try {
body = await c.req.json();
} catch {
throw badRequest("请求体必须是合法 JSON。");
}
if (body === null || typeof body !== "object" || Array.isArray(body)) {
throw badRequest("请求体必须是 JSON 对象。");
}
return body as Record<string, unknown>;
}
export interface StringRule {
minLen?: number;
maxLen?: number;
pattern?: RegExp;
/** Display name for the field in error messages (defaults to key). */
label?: string;
}
export function requireString(
obj: Record<string, unknown>,
key: string,
rule: StringRule = {},
): string {
const v = obj[key];
const label = rule.label ?? key;
if (typeof v !== "string") throw badRequest(`${label} 必须是字符串。`);
if (rule.minLen !== undefined && v.length < rule.minLen) {
throw badRequest(`${label} 长度至少 ${rule.minLen} 个字符。`);
}
if (rule.maxLen !== undefined && v.length > rule.maxLen) {
throw badRequest(`${label} 长度不能超过 ${rule.maxLen} 个字符。`);
}
if (rule.pattern !== undefined && !rule.pattern.test(v)) {
throw badRequest(`${label} 格式不合法。`);
}
return v;
}
export function optionalString(
obj: Record<string, unknown>,
key: string,
rule: StringRule = {},
): string | undefined {
if (obj[key] === undefined) return undefined;
return requireString(obj, key, rule);
}
export function requireEnum<T extends string>(
obj: Record<string, unknown>,
key: string,
values: readonly T[],
label = key,
): T {
const v = obj[key];
if (typeof v !== "string" || !(values as readonly string[]).includes(v)) {
throw badRequest(`${label} 必须是 ${values.join(" / ")} 之一。`);
}
return v as T;
}
export function optionalEnum<T extends string>(
obj: Record<string, unknown>,
key: string,
values: readonly T[],
label = key,
): T | undefined {
if (obj[key] === undefined) return undefined;
return requireEnum(obj, key, values, label);
}
export interface NumberRule {
/** Require positive or -1 (Agent runtime parameter convention: >0 active, -1 disabled). */
positiveOrMinusOne?: boolean;
/** Require non-negative. */
nonNegative?: boolean;
/** Require integer. */
integer?: boolean;
label?: string;
}
export function optionalNumber(
obj: Record<string, unknown>,
key: string,
rule: NumberRule = {},
): number | undefined {
const v = obj[key];
if (v === undefined) return undefined;
const label = rule.label ?? key;
if (typeof v !== "number" || !Number.isFinite(v)) throw badRequest(`${label} 必须是数字。`);
if (rule.integer && !Number.isInteger(v)) throw badRequest(`${label} 必须是整数。`);
if (rule.positiveOrMinusOne && !(v > 0 || v === -1)) {
throw badRequest(`${label} 必须大于 0 或为 -1。`);
}
if (rule.nonNegative && v < 0) throw badRequest(`${label} 不能为负数。`);
return v;
}
/** Validate a yyyy-mm-dd query parameter (defaults to undefined). */
export function optionalDateParam(value: string | undefined, label: string): string | undefined {
if (value === undefined || value === "") return undefined;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw badRequest(`${label} 必须是 YYYY-MM-DD 格式。`);
}
return value;
}