Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
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.
This commit is contained in:
@@ -160,6 +160,10 @@ export function modelsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
if (typeof body.clearApiKey !== "boolean") throw badRequest("clearApiKey must be a boolean.");
|
||||
req.clearApiKey = body.clearApiKey;
|
||||
}
|
||||
if (body.speed !== undefined) {
|
||||
if (typeof body.speed !== "boolean") throw badRequest("speed must be a boolean.");
|
||||
req.speed = body.speed;
|
||||
}
|
||||
// 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") {
|
||||
|
||||
@@ -213,7 +213,8 @@ async function upsert(
|
||||
const raw = serializeSchedule(fields);
|
||||
const parsed = parseScheduleFile(name, raw);
|
||||
if (!parsed.ok) throw badRequest(`Invalid schedule configuration: ${parsed.error}`);
|
||||
// At save time, verify the model reference resolves (resolveModelRef semantics; same rules as reconciliation) so we never persist a broken file.
|
||||
// At save time, verify the (provider, modelId) pair names a configured model (same rules as reconciliation) so we never persist a broken file.
|
||||
// The pairing rule itself is enforced by parseScheduleFile above, which rejects half a reference.
|
||||
const refError = await validateScheduleModelRef(deps.config.root, projectId, parsed.def);
|
||||
if (refError !== null) throw badRequest(`Invalid schedule configuration: ${refError}`);
|
||||
await writeScheduleFile(deps.config.root, projectId, agentId, name, raw);
|
||||
|
||||
@@ -108,10 +108,13 @@ export function agentSessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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) {
|
||||
// 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(
|
||||
"provider is specified but modelId is not: a model reference must be given as a pair.",
|
||||
"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);
|
||||
@@ -361,6 +364,11 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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,
|
||||
@@ -368,15 +376,22 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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;
|
||||
// (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",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,9 +48,10 @@ export function vaultRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
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.
|
||||
// Effective-value semantics: no hot swap into a Task already in flight, but every
|
||||
// runtime built before this update is invalidated — the next Task on any Session
|
||||
// of this Agent re-resumes and picks up the new vault values.
|
||||
deps.manager.invalidateAgentRuntimes(projectId, agentId);
|
||||
return c.json(res);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user