chapterhouse 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/agent-edit-access.js +11 -0
- package/dist/api/agents.api.test.js +48 -0
- package/dist/api/server.js +182 -11
- package/dist/api/server.test.js +334 -3
- package/dist/copilot/agent-event-bus.js +1 -0
- package/dist/copilot/agents.js +114 -46
- package/dist/copilot/agents.mcp-servers.test.js +87 -0
- package/dist/copilot/agents.parse.test.js +69 -0
- package/dist/copilot/agents.test.js +125 -1
- package/dist/copilot/orchestrator.js +50 -12
- package/dist/copilot/orchestrator.test.js +127 -1
- package/dist/copilot/session-manager.js +34 -0
- package/dist/test/setup-env.js +2 -1
- package/dist/test/setup-env.test.js +8 -1
- package/package.json +3 -1
- package/web/dist/assets/index-DuKYxMIR.css +10 -0
- package/web/dist/assets/{index-B5oDsQ5y.js → index-DytB69KC.js} +88 -88
- package/web/dist/assets/index-DytB69KC.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
- package/web/dist/assets/index-DknKAtDS.css +0 -10
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ForbiddenError } from "./errors.js";
|
|
2
|
+
export function assertAgentEditAccess(options, user) {
|
|
3
|
+
if (!options.entraAuthEnabled) {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
if (user?.role === "team-lead") {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
throw new ForbiddenError("Admin access required");
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=agent-edit-access.js.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createAgentFile, isBuiltinAgent, parseAgentMd } from "../copilot/agents.js";
|
|
4
|
+
test("isBuiltinAgent returns true for shipped agent slugs", () => {
|
|
5
|
+
assert.equal(isBuiltinAgent("chapterhouse"), true);
|
|
6
|
+
assert.equal(isBuiltinAgent("coder"), true);
|
|
7
|
+
assert.equal(isBuiltinAgent("designer"), true);
|
|
8
|
+
assert.equal(isBuiltinAgent("general-purpose"), true);
|
|
9
|
+
assert.equal(isBuiltinAgent("custom-helper"), false);
|
|
10
|
+
});
|
|
11
|
+
test("parseAgentMd parses block-style YAML arrays", () => {
|
|
12
|
+
const agent = parseAgentMd([
|
|
13
|
+
"---",
|
|
14
|
+
"name: Designer",
|
|
15
|
+
"description: Handles UI flows",
|
|
16
|
+
"model: claude-sonnet-4.6",
|
|
17
|
+
"skills:",
|
|
18
|
+
" - frontend-design",
|
|
19
|
+
" - ux-copy",
|
|
20
|
+
"tools:",
|
|
21
|
+
" - read",
|
|
22
|
+
" - write",
|
|
23
|
+
"---",
|
|
24
|
+
"",
|
|
25
|
+
"You are Designer.",
|
|
26
|
+
].join("\n"), "designer");
|
|
27
|
+
assert.ok(agent, "agent charter should parse");
|
|
28
|
+
assert.deepEqual(agent.skills, ["frontend-design", "ux-copy"]);
|
|
29
|
+
assert.deepEqual(agent.tools, ["read", "write"]);
|
|
30
|
+
});
|
|
31
|
+
test("slug validation regex rejects traversal-like agent slugs", () => {
|
|
32
|
+
const error = createAgentFile("..%2F..%2F", "Traversal", "Should fail validation", "claude-sonnet-4.6", "You are not valid.");
|
|
33
|
+
assert.equal(error, "Invalid slug '..%2F..%2F': must be kebab-case (a-z0-9 with hyphens).");
|
|
34
|
+
});
|
|
35
|
+
test("parseAgentMd returns null for invalid frontmatter", () => {
|
|
36
|
+
const agent = parseAgentMd([
|
|
37
|
+
"---",
|
|
38
|
+
"name: Designer",
|
|
39
|
+
"description: Handles UI flows",
|
|
40
|
+
"model: claude-sonnet-4.6",
|
|
41
|
+
"skills: [frontend-design",
|
|
42
|
+
"---",
|
|
43
|
+
"",
|
|
44
|
+
"You are Designer.",
|
|
45
|
+
].join("\n"), "designer");
|
|
46
|
+
assert.equal(agent, null);
|
|
47
|
+
});
|
|
48
|
+
//# sourceMappingURL=agents.api.test.js.map
|
package/dist/api/server.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import cors from "cors";
|
|
2
2
|
import express from "express";
|
|
3
3
|
import helmet from "helmet";
|
|
4
|
-
import { existsSync } from "fs";
|
|
5
|
-
import { join, dirname } from "path";
|
|
4
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
5
|
+
import { join, dirname, resolve, sep } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse,
|
|
8
|
+
import { sendToOrchestrator, interruptCurrentTurn, enqueueForSse, cancelCurrentMessage, interruptSessionTurn, getLastRouteResult, getCurrentSessionKey, getPersistentAgentSessionState, reloadPersistentAgent } from "../copilot/orchestrator.js";
|
|
9
9
|
import { agentEventBus } from "../copilot/agent-event-bus.js";
|
|
10
|
-
import { ensureDefaultAgents, getAgentRegistry, loadAgents } from "../copilot/agents.js";
|
|
10
|
+
import { ensureDefaultAgents, getAgent, getAgentRegistry, isBuiltinAgent, loadAgents, notifyAgentSaved, parseAgentMd, parseAgentMdOrThrow, serializeAgentMd, setAgentSaveRuntimeHooks, SLUG_REGEX, } from "../copilot/agents.js";
|
|
11
11
|
import { config, persistModel } from "../config.js";
|
|
12
12
|
import { ModeContext } from "../mode-context.js";
|
|
13
13
|
import { getRouterConfig, updateRouterConfig } from "../copilot/router.js";
|
|
14
14
|
import { searchIndex, parseIndex } from "../wiki/index-manager.js";
|
|
15
15
|
import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
|
|
16
|
+
import { assertAgentEditAccess } from "./agent-edit-access.js";
|
|
16
17
|
import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
|
|
17
18
|
import { createTeamRouter } from "./team.js";
|
|
18
19
|
import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, } from "../wiki/fs.js";
|
|
@@ -23,7 +24,7 @@ import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
|
23
24
|
import { withWikiWrite } from "../wiki/lock.js";
|
|
24
25
|
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
25
26
|
import { restartDaemon } from "../daemon.js";
|
|
26
|
-
import { API_TOKEN_PATH } from "../paths.js";
|
|
27
|
+
import { AGENTS_DIR, API_TOKEN_PATH } from "../paths.js";
|
|
27
28
|
import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../store/db.js";
|
|
28
29
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
29
30
|
import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
@@ -78,6 +79,22 @@ const autoRequestSchema = z.object({
|
|
|
78
79
|
const wikiWriteSchema = z.object({
|
|
79
80
|
content: z.string({ error: "Missing 'content' string in request body" }),
|
|
80
81
|
}).strict();
|
|
82
|
+
const agentPatchSchema = z.object({
|
|
83
|
+
name: requiredString("name must be a non-empty string").optional(),
|
|
84
|
+
description: requiredString("description must be a non-empty string").optional(),
|
|
85
|
+
model: requiredString("model must be a non-empty string").optional(),
|
|
86
|
+
systemPrompt: z.string().optional(),
|
|
87
|
+
}).passthrough().superRefine((value, ctx) => {
|
|
88
|
+
for (const key of ["slug", "scope", "tools", "skills"]) {
|
|
89
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
90
|
+
ctx.addIssue({
|
|
91
|
+
code: "custom",
|
|
92
|
+
path: [key],
|
|
93
|
+
message: `'${key}' is read-only`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
});
|
|
81
98
|
const projectCreateSchema = z.object({
|
|
82
99
|
slug: requiredString("Missing 'slug' in request body")
|
|
83
100
|
.regex(/^[a-z0-9][a-z0-9-]*$/, "Project slug must be a lowercase slug"),
|
|
@@ -284,6 +301,18 @@ Create your first page via the wiki UI or by editing files under \`pages/\`.
|
|
|
284
301
|
const sseClients = new Map();
|
|
285
302
|
const pendingSseMessages = [];
|
|
286
303
|
let connectionCounter = 0;
|
|
304
|
+
function broadcastSsePayload(payload) {
|
|
305
|
+
for (const [, res] of sseClients) {
|
|
306
|
+
res.write(formatSseData(payload));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
setAgentSaveRuntimeHooks({
|
|
310
|
+
getPersistentSessionState: getPersistentAgentSessionState,
|
|
311
|
+
reloadPersistentSession: reloadPersistentAgent,
|
|
312
|
+
emitAgentReloadEvent: (event) => {
|
|
313
|
+
broadcastSsePayload(event);
|
|
314
|
+
},
|
|
315
|
+
});
|
|
287
316
|
// ---------------------------------------------------------------------------
|
|
288
317
|
// Bootstrap — hands the API token to the same-origin SPA on first load.
|
|
289
318
|
// Loopback-only by IP / Origin check.
|
|
@@ -316,18 +345,145 @@ const handleHealth = (_req, res) => {
|
|
|
316
345
|
};
|
|
317
346
|
app.get("/status", handleHealth);
|
|
318
347
|
app.get("/health", handleHealth);
|
|
348
|
+
function getLoadedAgents() {
|
|
349
|
+
let agents = getAgentRegistry();
|
|
350
|
+
if (agents.length === 0) {
|
|
351
|
+
ensureDefaultAgents();
|
|
352
|
+
agents = loadAgents();
|
|
353
|
+
}
|
|
354
|
+
return agents;
|
|
355
|
+
}
|
|
356
|
+
function getAgentLastEdited(slug) {
|
|
357
|
+
try {
|
|
358
|
+
return statSync(join(AGENTS_DIR, `${slug}.agent.md`)).mtime.toISOString();
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
319
364
|
// ---------------------------------------------------------------------------
|
|
320
365
|
// Workers / agents
|
|
321
366
|
// ---------------------------------------------------------------------------
|
|
322
367
|
app.get("/api/agents", (_req, res) => {
|
|
323
|
-
|
|
368
|
+
const agents = getLoadedAgents()
|
|
369
|
+
.map((agent) => ({
|
|
370
|
+
name: agent.name,
|
|
371
|
+
slug: agent.slug,
|
|
372
|
+
description: agent.description,
|
|
373
|
+
model: agent.model,
|
|
374
|
+
scope: agent.scope ?? null,
|
|
375
|
+
type: isBuiltinAgent(agent.slug) ? "builtin" : "custom",
|
|
376
|
+
lastEdited: getAgentLastEdited(agent.slug),
|
|
377
|
+
}))
|
|
378
|
+
.sort((left, right) => {
|
|
379
|
+
if (left.type !== right.type) {
|
|
380
|
+
return left.type === "builtin" ? -1 : 1;
|
|
381
|
+
}
|
|
382
|
+
return left.name.localeCompare(right.name);
|
|
383
|
+
});
|
|
384
|
+
res.json(agents);
|
|
324
385
|
});
|
|
325
|
-
app.get("/api/
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
386
|
+
app.get("/api/agents/:slug", (req, res, next) => {
|
|
387
|
+
const slugParam = req.params.slug;
|
|
388
|
+
const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
|
|
389
|
+
if (slug === "stream") {
|
|
390
|
+
next();
|
|
391
|
+
return;
|
|
330
392
|
}
|
|
393
|
+
if (!SLUG_REGEX.test(slug)) {
|
|
394
|
+
res.status(400).json({ error: "Invalid slug" });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
getLoadedAgents();
|
|
398
|
+
const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
|
|
399
|
+
const resolvedAgentsDir = resolve(AGENTS_DIR);
|
|
400
|
+
const resolvedFilePath = resolve(filePath);
|
|
401
|
+
if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
|
|
402
|
+
res.status(403).json({ error: "Access denied" });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
let content;
|
|
406
|
+
try {
|
|
407
|
+
content = readFileSync(filePath, "utf-8");
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
res.status(404).json({ error: `Agent '${slug}' not found` });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const agent = parseAgentMd(content, slug);
|
|
414
|
+
if (!agent) {
|
|
415
|
+
res.status(500).json({ error: `Agent '${slug}' could not be parsed` });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const builtin = isBuiltinAgent(slug);
|
|
419
|
+
res.json({
|
|
420
|
+
name: agent.name,
|
|
421
|
+
slug: agent.slug,
|
|
422
|
+
description: agent.description,
|
|
423
|
+
model: agent.model,
|
|
424
|
+
scope: agent.scope ?? null,
|
|
425
|
+
persistent: agent.persistent ?? false,
|
|
426
|
+
skills: agent.skills ?? [],
|
|
427
|
+
type: builtin ? "builtin" : "custom",
|
|
428
|
+
editable: !builtin,
|
|
429
|
+
systemPrompt: agent.systemMessage,
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
app.patch("/api/agents/:slug", async (req, res) => {
|
|
433
|
+
const slugParam = req.params.slug;
|
|
434
|
+
const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam;
|
|
435
|
+
if (!SLUG_REGEX.test(slug)) {
|
|
436
|
+
throw new BadRequestError("Invalid slug");
|
|
437
|
+
}
|
|
438
|
+
if (modeContext.isTeam() && req.user?.role !== "team-lead") {
|
|
439
|
+
throw new ForbiddenError("Forbidden");
|
|
440
|
+
}
|
|
441
|
+
const patch = parseRequest(agentPatchSchema, req.body ?? {});
|
|
442
|
+
const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
|
|
443
|
+
const resolvedAgentsDir = resolve(AGENTS_DIR);
|
|
444
|
+
const resolvedFilePath = resolve(filePath);
|
|
445
|
+
if (!(resolvedFilePath === resolvedAgentsDir || resolvedFilePath.startsWith(`${resolvedAgentsDir}${sep}`))) {
|
|
446
|
+
throw new ForbiddenError("Access denied");
|
|
447
|
+
}
|
|
448
|
+
assertAgentEditAccess({ entraAuthEnabled: config.entraAuthEnabled }, req.user);
|
|
449
|
+
if (isBuiltinAgent(slug)) {
|
|
450
|
+
throw new ForbiddenError("Built-in agents are read-only");
|
|
451
|
+
}
|
|
452
|
+
if (!existsSync(filePath)) {
|
|
453
|
+
throw new NotFoundError("Agent not found");
|
|
454
|
+
}
|
|
455
|
+
try {
|
|
456
|
+
const current = parseAgentMdOrThrow(readFileSync(filePath, "utf-8"), slug);
|
|
457
|
+
const updated = {
|
|
458
|
+
...current,
|
|
459
|
+
name: patch.name ?? current.name,
|
|
460
|
+
description: patch.description ?? current.description,
|
|
461
|
+
model: patch.model ?? current.model,
|
|
462
|
+
systemMessage: patch.systemPrompt ?? current.systemMessage,
|
|
463
|
+
};
|
|
464
|
+
const nextContent = serializeAgentMd(updated);
|
|
465
|
+
writeFileSync(filePath, nextContent, "utf-8");
|
|
466
|
+
await notifyAgentSaved(slug, updated);
|
|
467
|
+
res.json({
|
|
468
|
+
name: updated.name,
|
|
469
|
+
slug: updated.slug,
|
|
470
|
+
description: updated.description,
|
|
471
|
+
model: updated.model,
|
|
472
|
+
scope: updated.scope ?? null,
|
|
473
|
+
persistent: updated.persistent ?? false,
|
|
474
|
+
skills: updated.skills ?? [],
|
|
475
|
+
type: "custom",
|
|
476
|
+
editable: true,
|
|
477
|
+
systemPrompt: updated.systemMessage,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
482
|
+
throw new BadRequestError(`Invalid content: ${message}`);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
app.get("/api/channels", (_req, res) => {
|
|
486
|
+
const agents = getLoadedAgents();
|
|
331
487
|
const persistentAgentChannels = agents
|
|
332
488
|
.filter((agent) => agent.persistent)
|
|
333
489
|
.map((agent) => ({
|
|
@@ -650,6 +806,21 @@ app.post("/api/cancel", async (_req, res) => {
|
|
|
650
806
|
}
|
|
651
807
|
res.json({ status: "ok", cancelled });
|
|
652
808
|
});
|
|
809
|
+
app.post("/api/agents/:slug/reload-confirm", async (req, res) => {
|
|
810
|
+
const slugParam = Array.isArray(req.params.slug) ? req.params.slug[0] : req.params.slug;
|
|
811
|
+
const slug = slugParam?.trim() || "";
|
|
812
|
+
const agent = getAgent(slug);
|
|
813
|
+
if (!agent?.persistent) {
|
|
814
|
+
throw new NotFoundError("Agent not found");
|
|
815
|
+
}
|
|
816
|
+
const sessionKey = `agent:${agent.slug}`;
|
|
817
|
+
await interruptSessionTurn(sessionKey);
|
|
818
|
+
const reloadResult = await reloadPersistentAgent(agent.slug);
|
|
819
|
+
if (reloadResult === "reloaded") {
|
|
820
|
+
broadcastSsePayload({ type: "agent_reloaded", slug: agent.slug, reason: "confirmed_restart" });
|
|
821
|
+
}
|
|
822
|
+
res.json({ status: "ok" });
|
|
823
|
+
});
|
|
653
824
|
// Cancel the active turn for one session key without touching other channels.
|
|
654
825
|
app.post("/api/session/:sessionKey/interrupt", async (req, res) => {
|
|
655
826
|
const sessionKey = Array.isArray(req.params.sessionKey)
|
package/dist/api/server.test.js
CHANGED
|
@@ -124,6 +124,22 @@ function readProjectRegistryRows(testRoot) {
|
|
|
124
124
|
db.close();
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
+
function getAgentFilePath(testRoot, slug) {
|
|
128
|
+
return join(testRoot, ".chapterhouse", "agents", `${slug}.agent.md`);
|
|
129
|
+
}
|
|
130
|
+
function writeAgentFile(testRoot, slug, content) {
|
|
131
|
+
mkdirSync(join(testRoot, ".chapterhouse", "agents"), { recursive: true });
|
|
132
|
+
writeFileSync(getAgentFilePath(testRoot, slug), content, "utf-8");
|
|
133
|
+
}
|
|
134
|
+
function markBundledAgent(testRoot, slug, hash = "bundled-hash") {
|
|
135
|
+
const db = new Database(getProjectDbPath(testRoot));
|
|
136
|
+
try {
|
|
137
|
+
db.prepare(`INSERT OR REPLACE INTO max_state (key, value) VALUES (?, ?)`).run(`bundled_agent_hash:${slug}.agent.md`, hash);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
db.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
127
143
|
function setMemoryActiveScope(testRoot, slug) {
|
|
128
144
|
const db = new Database(getProjectDbPath(testRoot));
|
|
129
145
|
try {
|
|
@@ -192,6 +208,15 @@ async function withStartedServer(run, extraEnv = {}, timeoutMs = DEFAULT_API_SER
|
|
|
192
208
|
rmSync(testRoot, { recursive: true, force: true });
|
|
193
209
|
}
|
|
194
210
|
}
|
|
211
|
+
test("agent edits require a team lead when Entra auth is enabled", async () => {
|
|
212
|
+
const accessModule = await import("./agent-edit-access.js");
|
|
213
|
+
assert.equal(typeof accessModule.assertAgentEditAccess, "function", "assertAgentEditAccess should be exported");
|
|
214
|
+
const assertAgentEditAccess = accessModule.assertAgentEditAccess;
|
|
215
|
+
assert.doesNotThrow(() => assertAgentEditAccess({ entraAuthEnabled: false }, { role: "engineer" }));
|
|
216
|
+
assert.throws(() => assertAgentEditAccess({ entraAuthEnabled: true }, { role: "engineer" }), /Admin access required/);
|
|
217
|
+
assert.throws(() => assertAgentEditAccess({ entraAuthEnabled: true }), /Admin access required/);
|
|
218
|
+
assert.doesNotThrow(() => assertAgentEditAccess({ entraAuthEnabled: true }, { role: "team-lead" }));
|
|
219
|
+
});
|
|
195
220
|
test("server routes expose bootstrap and public config without auth", async () => {
|
|
196
221
|
await withStartedServer(async ({ baseUrl }) => {
|
|
197
222
|
const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
|
|
@@ -222,6 +247,312 @@ test("server channels route returns chapterhouse plus persistent agents in chann
|
|
|
222
247
|
]);
|
|
223
248
|
});
|
|
224
249
|
});
|
|
250
|
+
test("server agents routes return source-aware charter summaries and details", async () => {
|
|
251
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
252
|
+
writeAgentFile(testRoot, "alpha-custom", [
|
|
253
|
+
"---",
|
|
254
|
+
"name: Alpha Custom",
|
|
255
|
+
"description: First custom charter",
|
|
256
|
+
"model: gpt-5.4",
|
|
257
|
+
"scope: frontend",
|
|
258
|
+
"skills:",
|
|
259
|
+
" - ui-copy",
|
|
260
|
+
" - wireframes",
|
|
261
|
+
"---",
|
|
262
|
+
"",
|
|
263
|
+
"You are Alpha Custom.",
|
|
264
|
+
].join("\n"));
|
|
265
|
+
writeAgentFile(testRoot, "zulu-custom", [
|
|
266
|
+
"---",
|
|
267
|
+
"name: Zulu Custom",
|
|
268
|
+
"description: Last custom charter",
|
|
269
|
+
"model: claude-sonnet-4.6",
|
|
270
|
+
"persistent: true",
|
|
271
|
+
"---",
|
|
272
|
+
"",
|
|
273
|
+
"You are Zulu Custom.",
|
|
274
|
+
].join("\n"));
|
|
275
|
+
markBundledAgent(testRoot, "designer");
|
|
276
|
+
const listResponse = await fetch(`${baseUrl}/api/agents`, {
|
|
277
|
+
headers: { authorization: authHeader },
|
|
278
|
+
});
|
|
279
|
+
assert.equal(listResponse.status, 200);
|
|
280
|
+
const agents = await listResponse.json();
|
|
281
|
+
const firstCustomIndex = agents.findIndex((agent) => agent.type === "custom");
|
|
282
|
+
assert.notEqual(firstCustomIndex, -1);
|
|
283
|
+
assert.ok(agents.slice(0, firstCustomIndex).every((agent) => agent.type === "builtin"));
|
|
284
|
+
const customAgents = agents.filter((agent) => agent.type === "custom");
|
|
285
|
+
assert.deepEqual(customAgents.map((agent) => agent.name), ["Alpha Custom", "Zulu Custom"]);
|
|
286
|
+
const designer = agents.find((agent) => agent.slug === "designer");
|
|
287
|
+
assert.ok(designer);
|
|
288
|
+
assert.equal(designer.type, "builtin");
|
|
289
|
+
assert.equal(typeof designer.lastEdited, "string");
|
|
290
|
+
const alpha = agents.find((agent) => agent.slug === "alpha-custom");
|
|
291
|
+
assert.deepEqual(alpha, {
|
|
292
|
+
name: "Alpha Custom",
|
|
293
|
+
slug: "alpha-custom",
|
|
294
|
+
description: "First custom charter",
|
|
295
|
+
model: "gpt-5.4",
|
|
296
|
+
scope: "frontend",
|
|
297
|
+
type: "custom",
|
|
298
|
+
lastEdited: alpha?.lastEdited ?? null,
|
|
299
|
+
});
|
|
300
|
+
assert.equal(typeof alpha?.lastEdited, "string");
|
|
301
|
+
const detailResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("alpha-custom")}`, {
|
|
302
|
+
headers: { authorization: authHeader },
|
|
303
|
+
});
|
|
304
|
+
assert.equal(detailResponse.status, 200);
|
|
305
|
+
assert.deepEqual(await detailResponse.json(), {
|
|
306
|
+
name: "Alpha Custom",
|
|
307
|
+
slug: "alpha-custom",
|
|
308
|
+
description: "First custom charter",
|
|
309
|
+
model: "gpt-5.4",
|
|
310
|
+
scope: "frontend",
|
|
311
|
+
persistent: false,
|
|
312
|
+
skills: ["ui-copy", "wireframes"],
|
|
313
|
+
type: "custom",
|
|
314
|
+
editable: true,
|
|
315
|
+
systemPrompt: "You are Alpha Custom.",
|
|
316
|
+
});
|
|
317
|
+
const builtinDetailResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("designer")}`, {
|
|
318
|
+
headers: { authorization: authHeader },
|
|
319
|
+
});
|
|
320
|
+
assert.equal(builtinDetailResponse.status, 200);
|
|
321
|
+
const builtinDetail = await builtinDetailResponse.json();
|
|
322
|
+
assert.equal(builtinDetail.slug, "designer");
|
|
323
|
+
assert.equal(builtinDetail.type, "builtin");
|
|
324
|
+
assert.equal(builtinDetail.editable, false);
|
|
325
|
+
assert.equal(typeof builtinDetail.systemPrompt, "string");
|
|
326
|
+
assert.ok(builtinDetail.systemPrompt.length > 0);
|
|
327
|
+
const missingResponse = await fetch(`${baseUrl}/api/agents/${encodeURIComponent("missing-agent")}`, {
|
|
328
|
+
headers: { authorization: authHeader },
|
|
329
|
+
});
|
|
330
|
+
assert.equal(missingResponse.status, 404);
|
|
331
|
+
assert.deepEqual(await missingResponse.json(), { error: "Agent 'missing-agent' not found" });
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
test("server rejects invalid agent slugs before reading agent files", async () => {
|
|
335
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
336
|
+
writeFileSync(join(testRoot, "some-path.agent.md"), [
|
|
337
|
+
"---",
|
|
338
|
+
"name: Escaped Agent",
|
|
339
|
+
"description: Should never be readable",
|
|
340
|
+
"model: gpt-5.4",
|
|
341
|
+
"---",
|
|
342
|
+
"",
|
|
343
|
+
"You should not see this.",
|
|
344
|
+
].join("\n"), "utf-8");
|
|
345
|
+
const response = await fetch(`${baseUrl}/api/agents/..%2F..%2Fsome-path`, {
|
|
346
|
+
headers: { authorization: authHeader },
|
|
347
|
+
});
|
|
348
|
+
assert.equal(response.status, 400);
|
|
349
|
+
assert.deepEqual(await response.json(), { error: "Invalid slug" });
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
test("server patches a custom agent file and reloads the persistent agent registry", async () => {
|
|
353
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
354
|
+
writeAgentFile(testRoot, "scribe", [
|
|
355
|
+
"---",
|
|
356
|
+
"name: Scribe",
|
|
357
|
+
"description: Drafts release notes",
|
|
358
|
+
"model: claude-sonnet-4.6",
|
|
359
|
+
"persistent: true",
|
|
360
|
+
"scope: docs",
|
|
361
|
+
"skills:",
|
|
362
|
+
" - writing",
|
|
363
|
+
"tools:",
|
|
364
|
+
" - bash",
|
|
365
|
+
"---",
|
|
366
|
+
"",
|
|
367
|
+
"Original system prompt.",
|
|
368
|
+
].join("\n"));
|
|
369
|
+
const before = await fetch(`${baseUrl}/api/channels`, {
|
|
370
|
+
headers: { authorization: authHeader },
|
|
371
|
+
});
|
|
372
|
+
assert.equal(before.status, 200);
|
|
373
|
+
const response = await fetch(`${baseUrl}/api/agents/scribe`, {
|
|
374
|
+
method: "PATCH",
|
|
375
|
+
headers: {
|
|
376
|
+
authorization: authHeader,
|
|
377
|
+
"content-type": "application/json",
|
|
378
|
+
},
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
name: "Scribe Prime",
|
|
381
|
+
description: "Publishes saved agent edits",
|
|
382
|
+
systemPrompt: "# Updated Prompt\n\nShip it.",
|
|
383
|
+
}),
|
|
384
|
+
});
|
|
385
|
+
assert.equal(response.status, 200);
|
|
386
|
+
assert.deepEqual(await response.json(), {
|
|
387
|
+
name: "Scribe Prime",
|
|
388
|
+
slug: "scribe",
|
|
389
|
+
description: "Publishes saved agent edits",
|
|
390
|
+
model: "claude-sonnet-4.6",
|
|
391
|
+
scope: "docs",
|
|
392
|
+
persistent: true,
|
|
393
|
+
skills: ["writing"],
|
|
394
|
+
type: "custom",
|
|
395
|
+
editable: true,
|
|
396
|
+
systemPrompt: "# Updated Prompt\n\nShip it.",
|
|
397
|
+
});
|
|
398
|
+
const saved = readFileSync(getAgentFilePath(testRoot, "scribe"), "utf-8");
|
|
399
|
+
assert.match(saved, /^---\n[\s\S]*\n---\n\n# Updated Prompt\n\nShip it\.\n?$/);
|
|
400
|
+
assert.match(saved, /name: Scribe Prime/);
|
|
401
|
+
assert.match(saved, /description: Publishes saved agent edits/);
|
|
402
|
+
assert.match(saved, /model: claude-sonnet-4.6/);
|
|
403
|
+
assert.match(saved, /scope: docs/);
|
|
404
|
+
assert.match(saved, /tools:\n - bash/);
|
|
405
|
+
assert.match(saved, /skills:\n - writing/);
|
|
406
|
+
const after = await fetch(`${baseUrl}/api/channels`, {
|
|
407
|
+
headers: { authorization: authHeader },
|
|
408
|
+
});
|
|
409
|
+
assert.equal(after.status, 200);
|
|
410
|
+
const channels = await after.json();
|
|
411
|
+
assert.deepEqual(channels.find((channel) => channel.slug === "scribe"), {
|
|
412
|
+
key: "agent:scribe",
|
|
413
|
+
label: "# scribe",
|
|
414
|
+
slug: "scribe",
|
|
415
|
+
name: "Scribe Prime",
|
|
416
|
+
description: "Publishes saved agent edits",
|
|
417
|
+
scope: "docs",
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
test("server rejects PATCH /api/agents/:slug when the request changes skills", async () => {
|
|
422
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
423
|
+
writeAgentFile(testRoot, "scribe", [
|
|
424
|
+
"---",
|
|
425
|
+
"name: Scribe",
|
|
426
|
+
"description: Drafts release notes",
|
|
427
|
+
"model: claude-sonnet-4.6",
|
|
428
|
+
"skills:",
|
|
429
|
+
" - writing",
|
|
430
|
+
"---",
|
|
431
|
+
"",
|
|
432
|
+
"Original system prompt.",
|
|
433
|
+
].join("\n"));
|
|
434
|
+
const response = await fetch(`${baseUrl}/api/agents/scribe`, {
|
|
435
|
+
method: "PATCH",
|
|
436
|
+
headers: {
|
|
437
|
+
authorization: authHeader,
|
|
438
|
+
"content-type": "application/json",
|
|
439
|
+
},
|
|
440
|
+
body: JSON.stringify({ skills: ["editing", "review"] }),
|
|
441
|
+
});
|
|
442
|
+
assert.equal(response.status, 400);
|
|
443
|
+
assert.match((await response.json()).error, /skills/i);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
test("server rejects PATCH /api/agents/:slug when the request changes read-only fields", async () => {
|
|
447
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
448
|
+
writeAgentFile(testRoot, "scribe", [
|
|
449
|
+
"---",
|
|
450
|
+
"name: Scribe",
|
|
451
|
+
"description: Drafts release notes",
|
|
452
|
+
"model: claude-sonnet-4.6",
|
|
453
|
+
"---",
|
|
454
|
+
"",
|
|
455
|
+
"Original system prompt.",
|
|
456
|
+
].join("\n"));
|
|
457
|
+
const response = await fetch(`${baseUrl}/api/agents/scribe`, {
|
|
458
|
+
method: "PATCH",
|
|
459
|
+
headers: {
|
|
460
|
+
authorization: authHeader,
|
|
461
|
+
"content-type": "application/json",
|
|
462
|
+
},
|
|
463
|
+
body: JSON.stringify({ slug: "other-scribe" }),
|
|
464
|
+
});
|
|
465
|
+
assert.equal(response.status, 400);
|
|
466
|
+
assert.match((await response.json()).error, /slug/i);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
test("server rejects PATCH /api/agents/:slug for bundled agents", async () => {
|
|
470
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
471
|
+
const response = await fetch(`${baseUrl}/api/agents/designer`, {
|
|
472
|
+
method: "PATCH",
|
|
473
|
+
headers: {
|
|
474
|
+
authorization: authHeader,
|
|
475
|
+
"content-type": "application/json",
|
|
476
|
+
},
|
|
477
|
+
body: JSON.stringify({ description: "Should not save" }),
|
|
478
|
+
});
|
|
479
|
+
assert.equal(response.status, 403);
|
|
480
|
+
assert.deepEqual(await response.json(), { error: "Built-in agents are read-only" });
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
test("server rejects PATCH /api/agents/:slug for non-team-leads in team mode", async () => {
|
|
484
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
485
|
+
writeAgentFile(testRoot, "scribe", [
|
|
486
|
+
"---",
|
|
487
|
+
"name: Scribe",
|
|
488
|
+
"description: Drafts release notes",
|
|
489
|
+
"model: claude-sonnet-4.6",
|
|
490
|
+
"---",
|
|
491
|
+
"",
|
|
492
|
+
"Original system prompt.",
|
|
493
|
+
].join("\n"));
|
|
494
|
+
const response = await fetch(`${baseUrl}/api/agents/scribe`, {
|
|
495
|
+
method: "PATCH",
|
|
496
|
+
headers: {
|
|
497
|
+
authorization: authHeader,
|
|
498
|
+
"content-type": "application/json",
|
|
499
|
+
},
|
|
500
|
+
body: JSON.stringify({ description: "Should not save" }),
|
|
501
|
+
});
|
|
502
|
+
assert.equal(response.status, 403);
|
|
503
|
+
assert.deepEqual(await response.json(), { error: "Forbidden" });
|
|
504
|
+
}, {
|
|
505
|
+
CHAPTERHOUSE_MODE: "team",
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
test("server returns 404 when PATCH /api/agents/:slug targets a missing file", async () => {
|
|
509
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
510
|
+
const response = await fetch(`${baseUrl}/api/agents/missing-agent`, {
|
|
511
|
+
method: "PATCH",
|
|
512
|
+
headers: {
|
|
513
|
+
authorization: authHeader,
|
|
514
|
+
"content-type": "application/json",
|
|
515
|
+
},
|
|
516
|
+
body: JSON.stringify({ description: "Still missing" }),
|
|
517
|
+
});
|
|
518
|
+
assert.equal(response.status, 404);
|
|
519
|
+
assert.deepEqual(await response.json(), { error: "Agent not found" });
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
test("server returns 400 when PATCH /api/agents/:slug cannot parse malformed agent content", async () => {
|
|
523
|
+
await withStartedServer(async ({ baseUrl, authHeader, testRoot }) => {
|
|
524
|
+
writeAgentFile(testRoot, "scribe", [
|
|
525
|
+
"---",
|
|
526
|
+
"name: Scribe",
|
|
527
|
+
"description: Drafts release notes",
|
|
528
|
+
"model: claude-sonnet-4.6",
|
|
529
|
+
"skills: [writing",
|
|
530
|
+
"---",
|
|
531
|
+
"",
|
|
532
|
+
"Original system prompt.",
|
|
533
|
+
].join("\n"));
|
|
534
|
+
const response = await fetch(`${baseUrl}/api/agents/scribe`, {
|
|
535
|
+
method: "PATCH",
|
|
536
|
+
headers: {
|
|
537
|
+
authorization: authHeader,
|
|
538
|
+
"content-type": "application/json",
|
|
539
|
+
},
|
|
540
|
+
body: JSON.stringify({ description: "Broken input stays broken" }),
|
|
541
|
+
});
|
|
542
|
+
assert.equal(response.status, 400);
|
|
543
|
+
assert.match((await response.json()).error, /^Invalid content:/);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
test("server returns 404 when confirming reload for an unknown agent", async () => {
|
|
547
|
+
await withStartedServer(async ({ baseUrl, authHeader }) => {
|
|
548
|
+
const response = await fetch(`${baseUrl}/api/agents/missing/reload-confirm`, {
|
|
549
|
+
method: "POST",
|
|
550
|
+
headers: { authorization: authHeader },
|
|
551
|
+
});
|
|
552
|
+
assert.equal(response.status, 404);
|
|
553
|
+
assert.match(await response.text(), /Agent not found/i);
|
|
554
|
+
});
|
|
555
|
+
});
|
|
225
556
|
test("server runs in standalone mode without auth", async () => {
|
|
226
557
|
await withStartedServer(async ({ baseUrl }) => {
|
|
227
558
|
const bootstrap = await fetch(`${baseUrl}/api/bootstrap`);
|
|
@@ -916,17 +1247,17 @@ test("server caps concurrent SSE connections per IP", async () => {
|
|
|
916
1247
|
let secondResponse;
|
|
917
1248
|
try {
|
|
918
1249
|
firstResponse = await fetch(`${baseUrl}/stream`, {
|
|
919
|
-
headers: { authorization: authHeader },
|
|
1250
|
+
headers: { authorization: authHeader, connection: "close" },
|
|
920
1251
|
signal: firstController.signal,
|
|
921
1252
|
});
|
|
922
1253
|
secondResponse = await fetch(`${baseUrl}/stream`, {
|
|
923
|
-
headers: { authorization: authHeader },
|
|
1254
|
+
headers: { authorization: authHeader, connection: "close" },
|
|
924
1255
|
signal: secondController.signal,
|
|
925
1256
|
});
|
|
926
1257
|
assert.equal(firstResponse.status, 200);
|
|
927
1258
|
assert.equal(secondResponse.status, 200);
|
|
928
1259
|
const rejected = await fetch(`${baseUrl}/stream`, {
|
|
929
|
-
headers: { authorization: authHeader },
|
|
1260
|
+
headers: { authorization: authHeader, connection: "close" },
|
|
930
1261
|
});
|
|
931
1262
|
assert.equal(rejected.status, 429);
|
|
932
1263
|
assert.equal(rejected.headers.get("retry-after"), "60");
|