chapterhouse 0.5.2 → 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/.pr-types.json +14 -0
- package/README.md +6 -0
- 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/config.test.js +29 -0
- 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/memory-coordinator.js +234 -0
- package/dist/copilot/memory-coordinator.test.js +257 -0
- package/dist/copilot/orchestrator.js +81 -221
- package/dist/copilot/orchestrator.test.js +238 -1
- package/dist/copilot/pr-title.js +92 -0
- package/dist/copilot/pr-title.test.js +54 -0
- package/dist/copilot/router.test.js +30 -0
- package/dist/copilot/session-manager.js +34 -0
- package/dist/copilot/threat-model.js +50 -0
- package/dist/copilot/threat-model.test.js +129 -0
- package/dist/copilot/tools.js +61 -37
- package/dist/copilot/tools.wiki.test.js +15 -6
- package/dist/setup.js +15 -5
- package/dist/setup.test.js +20 -3
- package/dist/sprint-merge.js +168 -0
- package/dist/sprint-merge.test.js +131 -0
- package/dist/store/db.js +63 -0
- package/dist/store/db.test.js +279 -0
- package/dist/test/setup-env.js +2 -1
- package/dist/test/setup-env.test.js +8 -1
- package/package.json +8 -1
- package/web/dist/assets/index-DuKYxMIR.css +10 -0
- package/web/dist/assets/index-DytB69KC.js +223 -0
- package/web/dist/assets/index-DytB69KC.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CPaILy2j.js +0 -223
- package/web/dist/assets/index-CPaILy2j.js.map +0 -1
- package/web/dist/assets/index-Cs7AGeaL.css +0 -10
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");
|
package/dist/config.test.js
CHANGED
|
@@ -271,7 +271,19 @@ test("rejects invalid SSE replay settings", async () => {
|
|
|
271
271
|
CHAPTERHOUSE_SSE_BUFFER_CAPACITY: "0",
|
|
272
272
|
}), /CHAPTERHOUSE_SSE_BUFFER_CAPACITY must be a positive integer/);
|
|
273
273
|
});
|
|
274
|
+
test("personal mode starts cleanly without Entra env vars", async () => {
|
|
275
|
+
// Security: personal mode should boot in its intended unauthenticated configuration instead of requiring enterprise auth by accident.
|
|
276
|
+
const configModule = await import("./config.js");
|
|
277
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
278
|
+
const parsed = configModule.parseRuntimeConfig({
|
|
279
|
+
CHAPTERHOUSE_MODE: "personal",
|
|
280
|
+
});
|
|
281
|
+
assert.equal(parsed.chapterhouseMode, "personal");
|
|
282
|
+
assert.equal(parsed.entraAuthEnabled, false);
|
|
283
|
+
assert.doesNotMatch(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
|
|
284
|
+
});
|
|
274
285
|
test("personal mode rejects explicit Entra auth enablement with a clear fix suggestion", async () => {
|
|
286
|
+
// Security: personal deployments must fail fast instead of starting in a broken auth state that looks protected but is not.
|
|
275
287
|
const configModule = await import("./config.js");
|
|
276
288
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
277
289
|
assert.throws(() => configModule.parseRuntimeConfig({
|
|
@@ -281,7 +293,23 @@ test("personal mode rejects explicit Entra auth enablement with a clear fix sugg
|
|
|
281
293
|
ENTRA_CLIENT_ID: "client-id",
|
|
282
294
|
}), /Personal mode cannot be used with ENTRA_AUTH_ENABLED=true[\s\S]*CHAPTERHOUSE_MODE=team[\s\S]*unset ENTRA_AUTH_ENABLED/);
|
|
283
295
|
});
|
|
296
|
+
test("team mode accepts Entra auth when required settings are present", async () => {
|
|
297
|
+
// Security: team mode is the only safe place for Entra auth, so valid enterprise settings must remain supported there.
|
|
298
|
+
const configModule = await import("./config.js");
|
|
299
|
+
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
300
|
+
const parsed = configModule.parseRuntimeConfig({
|
|
301
|
+
CHAPTERHOUSE_MODE: "team",
|
|
302
|
+
ENTRA_AUTH_ENABLED: "true",
|
|
303
|
+
ENTRA_TENANT_ID: "tenant-id",
|
|
304
|
+
ENTRA_CLIENT_ID: "client-id",
|
|
305
|
+
});
|
|
306
|
+
assert.equal(parsed.chapterhouseMode, "team");
|
|
307
|
+
assert.equal(parsed.entraAuthEnabled, true);
|
|
308
|
+
assert.equal(parsed.entraTenantId, "tenant-id");
|
|
309
|
+
assert.equal(parsed.entraClientId, "client-id");
|
|
310
|
+
});
|
|
284
311
|
test("personal mode still warns about leftover Entra settings when auth is not explicitly enabled", async () => {
|
|
312
|
+
// Security: leftover enterprise env vars should be called out so personal-mode operators know those settings are being ignored.
|
|
285
313
|
const configModule = await import("./config.js");
|
|
286
314
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
287
315
|
const parsed = configModule.parseRuntimeConfig({
|
|
@@ -293,6 +321,7 @@ test("personal mode still warns about leftover Entra settings when auth is not e
|
|
|
293
321
|
assert.match(parsed.modeCompatibilityWarnings.join("\n"), /Entra/i);
|
|
294
322
|
});
|
|
295
323
|
test("personal mode warns and disables incomplete Teams notification settings instead of throwing", async () => {
|
|
324
|
+
// Security: personal mode should ignore team-only integrations without partially enabling cross-tenant notification flows.
|
|
296
325
|
const configModule = await import("./config.js");
|
|
297
326
|
assert.equal(typeof configModule.parseRuntimeConfig, "function", "parseRuntimeConfig should be exported");
|
|
298
327
|
const parsed = configModule.parseRuntimeConfig({
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* session:tool_call — tool invoked payload: { toolName, toolArgs, resultType?, _kind?, _seq?, _ts?, _summary? }
|
|
12
12
|
* session:destroyed — subagent finished payload: { agentName, reason: "complete" | "error" | "abort" }
|
|
13
13
|
* session:error — subagent failed payload: { agentName, error }
|
|
14
|
+
* agent_saved — custom agent saved payload: { slug }
|
|
14
15
|
*
|
|
15
16
|
* @module copilot/agent-event-bus
|
|
16
17
|
*/
|
package/dist/copilot/agents.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, rmSync
|
|
|
3
3
|
import { createHash } from "crypto";
|
|
4
4
|
import { join, dirname, sep } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
|
+
import { load as yamlLoad, dump as yamlDump } from "js-yaml";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { approveAll } from "@github/copilot-sdk";
|
|
8
9
|
import { AGENTS_DIR, SESSIONS_DIR } from "../paths.js";
|
|
@@ -34,48 +35,42 @@ const agentFrontmatterSchema = z.object({
|
|
|
34
35
|
// Agent Registry
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
let agentRegistry = [];
|
|
38
|
+
const defaultAgentSaveRuntimeHooks = {
|
|
39
|
+
getPersistentSessionState: () => "none",
|
|
40
|
+
reloadPersistentSession: async () => "none",
|
|
41
|
+
emitAgentReloadEvent: () => { },
|
|
42
|
+
};
|
|
43
|
+
let agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
|
|
37
44
|
/** Bundled agents shipped with the package */
|
|
38
45
|
const BUNDLED_AGENTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "agents");
|
|
39
|
-
const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
|
|
40
|
-
const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
46
|
+
export const RESERVED_SLUGS = new Set(["chapterhouse", "designer", "coder", "general-purpose"]);
|
|
47
|
+
export const SLUG_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
48
|
+
const BUILTIN_AGENT_SLUGS = new Set(RESERVED_SLUGS);
|
|
49
|
+
if (existsSync(BUNDLED_AGENTS_DIR)) {
|
|
50
|
+
for (const file of readdirSync(BUNDLED_AGENTS_DIR)) {
|
|
51
|
+
if (file.endsWith(".agent.md")) {
|
|
52
|
+
BUILTIN_AGENT_SLUGS.add(file.replace(/\.agent\.md$/, ""));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function isBuiltinAgent(slug) {
|
|
57
|
+
return BUILTIN_AGENT_SLUGS.has(slug);
|
|
58
|
+
}
|
|
41
59
|
/** Parse YAML frontmatter and markdown body from an .agent.md file. */
|
|
42
|
-
export function
|
|
60
|
+
export function parseAgentMdOrThrow(content, slug) {
|
|
43
61
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\s*([\s\S]*)$/);
|
|
44
|
-
if (!fmMatch)
|
|
45
|
-
|
|
62
|
+
if (!fmMatch) {
|
|
63
|
+
throw new Error("Missing YAML frontmatter");
|
|
64
|
+
}
|
|
46
65
|
const frontmatterRaw = fmMatch[1];
|
|
47
66
|
const body = fmMatch[2].trim();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const idx = line.indexOf(": ");
|
|
52
|
-
if (idx <= 0)
|
|
53
|
-
continue;
|
|
54
|
-
const key = line.slice(0, idx).trim();
|
|
55
|
-
let value = line.slice(idx + 2).trim();
|
|
56
|
-
// Handle YAML quoted strings
|
|
57
|
-
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
|
|
58
|
-
value = value.slice(1, -1);
|
|
59
|
-
}
|
|
60
|
-
parsed[key] = value;
|
|
67
|
+
const loaded = yamlLoad(frontmatterRaw);
|
|
68
|
+
if (loaded !== undefined && (loaded === null || typeof loaded !== "object" || Array.isArray(loaded))) {
|
|
69
|
+
throw new Error("Agent frontmatter must be a YAML object");
|
|
61
70
|
}
|
|
62
|
-
|
|
63
|
-
for (const key of ["skills", "tools", "mcpServers", "allowed_paths"]) {
|
|
64
|
-
const raw = parsed[key];
|
|
65
|
-
if (typeof raw === "string") {
|
|
66
|
-
const arrMatch = raw.match(/^\[(.*)\]$/);
|
|
67
|
-
if (arrMatch) {
|
|
68
|
-
parsed[key] = arrMatch[1]
|
|
69
|
-
.split(",")
|
|
70
|
-
.map((s) => s.trim().replace(/^['"]|['"]$/g, ""))
|
|
71
|
-
.filter(Boolean);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
const result = agentFrontmatterSchema.safeParse(parsed);
|
|
71
|
+
const result = agentFrontmatterSchema.safeParse(loaded ?? {});
|
|
76
72
|
if (!result.success) {
|
|
77
|
-
|
|
78
|
-
return null;
|
|
73
|
+
throw new Error(z.prettifyError(result.error));
|
|
79
74
|
}
|
|
80
75
|
const fm = result.data;
|
|
81
76
|
return {
|
|
@@ -92,6 +87,29 @@ export function parseAgentMd(content, slug) {
|
|
|
92
87
|
systemMessage: body,
|
|
93
88
|
};
|
|
94
89
|
}
|
|
90
|
+
export function parseAgentMd(content, slug) {
|
|
91
|
+
try {
|
|
92
|
+
return parseAgentMdOrThrow(content, slug);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
log.warn({ slug, err: err instanceof Error ? err.message : err }, "Invalid agent file");
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function serializeAgentMd(agent) {
|
|
100
|
+
const frontmatter = {
|
|
101
|
+
name: agent.name,
|
|
102
|
+
description: agent.description,
|
|
103
|
+
model: agent.model,
|
|
104
|
+
...(agent.persistent !== undefined ? { persistent: agent.persistent } : {}),
|
|
105
|
+
...(agent.scope !== undefined ? { scope: agent.scope } : {}),
|
|
106
|
+
...(agent.skills !== undefined ? { skills: agent.skills } : {}),
|
|
107
|
+
...(agent.tools !== undefined ? { tools: agent.tools } : {}),
|
|
108
|
+
...(agent.mcpServers !== undefined ? { mcpServers: agent.mcpServers } : {}),
|
|
109
|
+
...(agent.allowedPaths !== undefined ? { allowed_paths: agent.allowedPaths } : {}),
|
|
110
|
+
};
|
|
111
|
+
return `---\n${yamlDump(frontmatter, { lineWidth: -1 })}---\n\n${agent.systemMessage}`;
|
|
112
|
+
}
|
|
95
113
|
/** Scan ~/.chapterhouse/agents/ for .agent.md files and load configs. */
|
|
96
114
|
export function loadAgents() {
|
|
97
115
|
if (!existsSync(AGENTS_DIR)) {
|
|
@@ -132,6 +150,49 @@ export function getAgent(nameOrSlug) {
|
|
|
132
150
|
export function getAgentRegistry() {
|
|
133
151
|
return [...agentRegistry];
|
|
134
152
|
}
|
|
153
|
+
export function setAgentSaveRuntimeHooks(hooks) {
|
|
154
|
+
agentSaveRuntimeHooks = {
|
|
155
|
+
...agentSaveRuntimeHooks,
|
|
156
|
+
...hooks,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function resetAgentSaveRuntimeHooks() {
|
|
160
|
+
agentSaveRuntimeHooks = { ...defaultAgentSaveRuntimeHooks };
|
|
161
|
+
}
|
|
162
|
+
export async function notifyAgentSaved(slug, savedConfig) {
|
|
163
|
+
const config = savedConfig ?? parseAgentMdOrThrow(readFileSync(join(AGENTS_DIR, `${slug}.agent.md`), "utf-8"), slug);
|
|
164
|
+
const existingIndex = agentRegistry.findIndex((entry) => entry.slug === slug);
|
|
165
|
+
if (existingIndex >= 0) {
|
|
166
|
+
agentRegistry[existingIndex] = config;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
agentRegistry.push(config);
|
|
170
|
+
}
|
|
171
|
+
void import("./orchestrator.js").then(({ enqueueForSse }) => {
|
|
172
|
+
enqueueForSse({ type: "agent_saved", slug });
|
|
173
|
+
}).catch((err) => {
|
|
174
|
+
log.warn({ slug, err: err instanceof Error ? err.message : err }, "Failed to emit agent_saved SSE event");
|
|
175
|
+
});
|
|
176
|
+
const emitReloaded = () => {
|
|
177
|
+
agentSaveRuntimeHooks.emitAgentReloadEvent({
|
|
178
|
+
type: "agent_reloaded",
|
|
179
|
+
slug,
|
|
180
|
+
reason: "session_restart",
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
const result = await agentSaveRuntimeHooks.reloadPersistentSession(slug, emitReloaded);
|
|
184
|
+
if (result === "scheduled") {
|
|
185
|
+
agentSaveRuntimeHooks.emitAgentReloadEvent({
|
|
186
|
+
type: "agent_reload_pending",
|
|
187
|
+
slug,
|
|
188
|
+
reason: "in_flight",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (result === "reloaded") {
|
|
193
|
+
emitReloaded();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
135
196
|
/** Copy bundled agents to ~/.chapterhouse/agents/, updating stale copies when the bundled version changes.
|
|
136
197
|
* Respects user customizations: if the user edited the deployed file after our last sync, we skip it. */
|
|
137
198
|
export function ensureDefaultAgents() {
|
|
@@ -186,16 +247,14 @@ export function createAgentFile(slug, name, description, model, systemPrompt, sk
|
|
|
186
247
|
if (existsSync(filePath)) {
|
|
187
248
|
return `Agent '${slug}' already exists. Edit it directly or remove it first.`;
|
|
188
249
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
frontmatter += "\n---\n\n";
|
|
198
|
-
writeFileSync(filePath, frontmatter + systemPrompt + "\n");
|
|
250
|
+
writeFileSync(filePath, serializeAgentMd({
|
|
251
|
+
name,
|
|
252
|
+
description,
|
|
253
|
+
model,
|
|
254
|
+
skills,
|
|
255
|
+
tools,
|
|
256
|
+
systemMessage: `${systemPrompt}\n`,
|
|
257
|
+
}));
|
|
199
258
|
return null;
|
|
200
259
|
}
|
|
201
260
|
/** Remove an agent .md file. Returns error string or null on success. */
|
|
@@ -203,7 +262,7 @@ export function removeAgentFile(slug) {
|
|
|
203
262
|
if (!SLUG_REGEX.test(slug)) {
|
|
204
263
|
return `Invalid slug '${slug}'.`;
|
|
205
264
|
}
|
|
206
|
-
if (
|
|
265
|
+
if (isBuiltinAgent(slug)) {
|
|
207
266
|
return `Cannot remove built-in agent '${slug}'. You can edit its file instead.`;
|
|
208
267
|
}
|
|
209
268
|
const filePath = join(AGENTS_DIR, `${slug}.agent.md`);
|
|
@@ -323,6 +382,14 @@ export function filterToolsForAgent(agent, allTools) {
|
|
|
323
382
|
}
|
|
324
383
|
return allTools.filter((t) => !MANAGEMENT_TOOL_NAMES.has(t.name));
|
|
325
384
|
}
|
|
385
|
+
/** Filter MCP servers based on agent config. */
|
|
386
|
+
export function filterMcpServersForAgent(agent, allMcpServers) {
|
|
387
|
+
if (agent.mcpServers && agent.mcpServers.length > 0) {
|
|
388
|
+
const allowed = new Set(agent.mcpServers);
|
|
389
|
+
return Object.fromEntries(Object.entries(allMcpServers).filter(([name]) => allowed.has(name)));
|
|
390
|
+
}
|
|
391
|
+
return allMcpServers;
|
|
392
|
+
}
|
|
326
393
|
/** Create an ephemeral session for an agent. Always creates a fresh session — caller is responsible for destroying it. */
|
|
327
394
|
export async function createEphemeralAgentSession(slug, client, allTools, modelOverride, systemMessagePrefix, taskId) {
|
|
328
395
|
const agent = getAgent(slug);
|
|
@@ -334,7 +401,8 @@ export async function createEphemeralAgentSession(slug, client, allTools, modelO
|
|
|
334
401
|
? modelOverride
|
|
335
402
|
: (agent.model === "auto" ? "claude-sonnet-4.6" : agent.model);
|
|
336
403
|
const tools = bindToolsToAgent(agent.slug, filterToolsForAgent(agent, allTools), taskId);
|
|
337
|
-
const
|
|
404
|
+
const allMcpServers = loadMcpConfig();
|
|
405
|
+
const mcpServers = filterMcpServersForAgent(agent, allMcpServers);
|
|
338
406
|
const skillDirectories = getSkillDirectories();
|
|
339
407
|
const baseSystemMessage = composeAgentSystemMessage(agent);
|
|
340
408
|
const systemMessageContent = systemMessagePrefix
|