d4faee3a1e
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
/**
|
||
* 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 must be an array with at least one item.");
|
||
}
|
||
return input.map((item, i) => {
|
||
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
||
throw badRequest(`input[${i}] must be an object.`);
|
||
}
|
||
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 must be a non-empty string.`);
|
||
}
|
||
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 only supports data: or http(s) URLs.`);
|
||
}
|
||
return imageUrlMessage(url);
|
||
}
|
||
throw badRequest(`input[${i}].type must be one of 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 — both or neither. Neither half is ever
|
||
// inferred from the other, so half a reference is rejected here instead of being
|
||
// resolved (core does the same validation; this catches it early). Omitting both
|
||
// falls back to the Project's default model.
|
||
if ((modelId === undefined) !== (provider === undefined)) {
|
||
throw badRequest(
|
||
"modelId and provider must be given together as a model reference pair: specify both, or neither to use the Project's default model.",
|
||
);
|
||
}
|
||
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 does not exist or you do not have access.",
|
||
);
|
||
}
|
||
try {
|
||
deps.projectService.requireProjectAccess(c.var.user.userId, row.projectId);
|
||
} catch {
|
||
throw new HttpError(
|
||
404,
|
||
"session_not_found",
|
||
"Session does not exist or you do not have access.",
|
||
);
|
||
}
|
||
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 must be a string.");
|
||
}
|
||
title = titleRaw.trim();
|
||
if (!title || title.length > SESSION_TITLE_MAX) {
|
||
throw new HttpError(
|
||
400,
|
||
"invalid_title",
|
||
`title must be 1–${SESSION_TITLE_MAX} characters.`,
|
||
);
|
||
}
|
||
}
|
||
if (approvalMode === undefined && archived === undefined && title === undefined) {
|
||
throw new HttpError(
|
||
400,
|
||
"no_update",
|
||
"No updatable field provided (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", "File does not exist.");
|
||
}
|
||
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", "File does not exist.");
|
||
}
|
||
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",
|
||
"Approval does not exist or has already been decided.",
|
||
);
|
||
}
|
||
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";
|
||
// Sandboxed top-level preview ("open in a new tab" for html): the document keeps its REAL
|
||
// content type but carries a CSP sandbox WITHOUT allow-same-origin — it renders and runs
|
||
// fully in an opaque origin, so agent-generated markup cannot reach this origin's cookies
|
||
// or API. The request itself still authenticates (top-level GET sends the Lax cookie).
|
||
const preview = !download && c.req.query("preview") === "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, and sandboxed previews keep it under the
|
||
// CSP above. Paired with nosniff to prevent MIME sniffing from undoing this.
|
||
const effectiveType =
|
||
!download && scriptable && !preview ? "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",
|
||
...(preview && scriptable
|
||
? {
|
||
"Content-Security-Policy":
|
||
"sandbox allow-scripts allow-popups allow-modals allow-forms",
|
||
}
|
||
: {}),
|
||
},
|
||
});
|
||
});
|
||
|
||
// 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 must be an array of strings (≤${STAT_MAX_PATHS} items, each ≤${STAT_MAX_PATH_LEN} characters).`,
|
||
);
|
||
}
|
||
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 must be a base64 string.");
|
||
}
|
||
const data = Buffer.from(body.dataBase64, "base64");
|
||
if (data.length > MAX_UPLOAD_BYTES) {
|
||
throw new HttpError(413, "file_too_large", "Uploaded file exceeds the 14MB limit.");
|
||
}
|
||
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;
|
||
}
|