@app-vitals/agent-admin 0.8.0 → 0.9.1
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/e2e/chat-thread-page.e2e.ts +0 -79
- package/openapi.json +14 -1
- package/openapi.platform.json +14 -1
- package/package.json +2 -2
- package/prisma/client/internal/class.ts +4 -4
- package/prisma/client/internal/prismaNamespace.ts +1 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +1 -0
- package/prisma/client/models/Agent.ts +76 -1
- package/prisma/migrations/20261029000000_add_agent_specialty/migration.sql +5 -0
- package/prisma/schema/schema.prisma +3 -0
- package/src/admin-local-agent.smoke.test.ts +6 -2
- package/src/admin-ui-pages.ts +109 -153
- package/src/admin-ui-pages.unit.test.ts +85 -221
- package/src/admin-ui-push-test.ts +5 -1
- package/src/admin-ui.smoke.test.ts +69 -6
- package/src/admin-ui.ts +71 -10
- package/src/agent-cron-jobs.ts +6 -4
- package/src/agent-specialty.ts +22 -0
- package/src/agent-specialty.unit.test.ts +43 -0
- package/src/agents-api.smoke.test.ts +1 -0
- package/src/agents-api.ts +6 -1
- package/src/agents.integration.test.ts +90 -0
- package/src/agents.ts +39 -15
- package/src/agents.unit.test.ts +19 -2
- package/src/api.ts +11 -0
- package/src/chat.smoke.test.ts +78 -16
- package/src/http-range.ts +31 -0
- package/src/http-range.unit.test.ts +65 -0
- package/src/openapi-schemas.ts +3 -0
- package/src/push-toggle.ts +6 -2
- package/src/pwa.ts +1 -0
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
type MemberItem,
|
|
17
17
|
type PluginItem,
|
|
18
18
|
renderAddAnotherOrgAction,
|
|
19
|
-
AUTOPLAY_STORAGE_KEY_PREFIX,
|
|
20
19
|
renderAgentDetailPage,
|
|
21
20
|
renderAgentsPage,
|
|
22
21
|
renderChatMessageBubble,
|
|
@@ -3497,6 +3496,88 @@ describe("renderChatMessageBubble — audio attachment vs. filename badge (VM-3.
|
|
|
3497
3496
|
});
|
|
3498
3497
|
});
|
|
3499
3498
|
|
|
3499
|
+
describe("renderChatMessageBubble — voice reply play control (COS-63.1)", () => {
|
|
3500
|
+
const MSG: ChatMessage = {
|
|
3501
|
+
id: "msg-63",
|
|
3502
|
+
threadId: "thread-63",
|
|
3503
|
+
role: "assistant",
|
|
3504
|
+
body: "hello there",
|
|
3505
|
+
createdAt: "2024-01-01T00:00:00.000Z",
|
|
3506
|
+
claimedBy: null,
|
|
3507
|
+
repliedAt: null,
|
|
3508
|
+
tokens: null,
|
|
3509
|
+
costUsd: null,
|
|
3510
|
+
errorKind: null,
|
|
3511
|
+
attachmentFilename: "1700000000000-response.wav",
|
|
3512
|
+
attachmentSize: 10,
|
|
3513
|
+
};
|
|
3514
|
+
const URL63 =
|
|
3515
|
+
"/admin/chat/agent-1/threads/thread-63/messages/msg-63/attachment";
|
|
3516
|
+
|
|
3517
|
+
test("assistant audio renders a 56px labelled play button, time, progress and a hidden audio", () => {
|
|
3518
|
+
const html = renderChatMessageBubble(MSG, null, "agent-1");
|
|
3519
|
+
expect(html).toContain("chat-voice-play");
|
|
3520
|
+
expect(html).toContain('aria-label="Play"');
|
|
3521
|
+
expect(html).toContain("chat-voice-time");
|
|
3522
|
+
expect(html).toContain("chat-voice-progress");
|
|
3523
|
+
expect(html).toMatch(/<audio[^>]*hidden[^>]*>/);
|
|
3524
|
+
expect(html).not.toMatch(/<audio[^>]*controls/);
|
|
3525
|
+
expect(html).toContain(URL63);
|
|
3526
|
+
});
|
|
3527
|
+
|
|
3528
|
+
test("assistant transcript body is still rendered above the control", () => {
|
|
3529
|
+
const html = renderChatMessageBubble(MSG, null, "agent-1");
|
|
3530
|
+
expect(html.indexOf("hello there")).toBeLessThan(
|
|
3531
|
+
html.indexOf("chat-voice-play"),
|
|
3532
|
+
);
|
|
3533
|
+
});
|
|
3534
|
+
|
|
3535
|
+
test("includes an error fallback with a download link and message", () => {
|
|
3536
|
+
const html = renderChatMessageBubble(MSG, null, "agent-1");
|
|
3537
|
+
expect(html).toContain("Could not play this reply");
|
|
3538
|
+
expect(html).toMatch(
|
|
3539
|
+
new RegExp(
|
|
3540
|
+
`<a[^>]*download[^>]*href="${URL63}"|<a[^>]*href="${URL63}"[^>]*download`,
|
|
3541
|
+
),
|
|
3542
|
+
);
|
|
3543
|
+
});
|
|
3544
|
+
|
|
3545
|
+
test("user audio keeps native controls and no custom control", () => {
|
|
3546
|
+
const html = renderChatMessageBubble(
|
|
3547
|
+
{ ...MSG, role: "user" },
|
|
3548
|
+
null,
|
|
3549
|
+
"agent-1",
|
|
3550
|
+
);
|
|
3551
|
+
expect(html).toMatch(/<audio[^>]*controls/);
|
|
3552
|
+
expect(html).not.toContain("chat-voice-play");
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
test("thread page ships the 56px CSS and delegated click handler", () => {
|
|
3556
|
+
const html = renderChatThreadPage(
|
|
3557
|
+
"agent-1",
|
|
3558
|
+
{ id: "thread-63", title: "t" } as never,
|
|
3559
|
+
[MSG],
|
|
3560
|
+
"alice",
|
|
3561
|
+
);
|
|
3562
|
+
expect(html).toMatch(/\.chat-voice-play\s*\{[^}]*width:56px/);
|
|
3563
|
+
expect(html).toContain("chat-voice-play");
|
|
3564
|
+
});
|
|
3565
|
+
|
|
3566
|
+
test("chat stylesheet has no orphaned comment text before .chat-voice", () => {
|
|
3567
|
+
const html = renderChatThreadPage(
|
|
3568
|
+
"agent-1",
|
|
3569
|
+
{ id: "thread-63", title: "t" } as never,
|
|
3570
|
+
[MSG],
|
|
3571
|
+
"alice",
|
|
3572
|
+
);
|
|
3573
|
+
// A stray comment tail would swallow the next rule's selector.
|
|
3574
|
+
expect(html).not.toContain("will speak on their own");
|
|
3575
|
+
expect(html).toMatch(
|
|
3576
|
+
/\}\s*\/\*[^*]*\*\/\s*\.chat-voice\s*\{[^}]*display:flex/,
|
|
3577
|
+
);
|
|
3578
|
+
});
|
|
3579
|
+
});
|
|
3580
|
+
|
|
3500
3581
|
describe("renderChatThreadPage — CFB-3.2 mobile drawer", () => {
|
|
3501
3582
|
const THREAD: ChatThread = {
|
|
3502
3583
|
id: "thread-abc",
|
|
@@ -4321,18 +4402,9 @@ describe("queue-activity links (AEP-3.6)", () => {
|
|
|
4321
4402
|
|
|
4322
4403
|
describe("renderAgentDetailPage — signed commits toggle", () => {
|
|
4323
4404
|
const render = (envVars: Record<string, string>) =>
|
|
4324
|
-
renderAgentDetailPage(
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
[],
|
|
4328
|
-
[],
|
|
4329
|
-
[],
|
|
4330
|
-
[],
|
|
4331
|
-
[],
|
|
4332
|
-
USER_NAME,
|
|
4333
|
-
true,
|
|
4334
|
-
{ timezone: "UTC" },
|
|
4335
|
-
);
|
|
4405
|
+
renderAgentDetailPage(AGENT, envVars, [], [], [], [], [], USER_NAME, true, {
|
|
4406
|
+
timezone: "UTC",
|
|
4407
|
+
});
|
|
4336
4408
|
|
|
4337
4409
|
test("hidden when the agent has no GH_APP_ID", () => {
|
|
4338
4410
|
expect(render({ SHIPWRIGHT_SIGNED_COMMITS: "1" })).not.toContain(
|
|
@@ -4354,211 +4426,3 @@ describe("renderAgentDetailPage — signed commits toggle", () => {
|
|
|
4354
4426
|
expect(checkbox).toContain("checked");
|
|
4355
4427
|
});
|
|
4356
4428
|
});
|
|
4357
|
-
|
|
4358
|
-
// ─── Auto-play voice replies toggle + phone mic sizing ───────────────────────
|
|
4359
|
-
|
|
4360
|
-
describe("renderChatThreadPage — auto-play voice replies toggle", () => {
|
|
4361
|
-
const THREAD: ChatThread = {
|
|
4362
|
-
id: "thread-ap",
|
|
4363
|
-
agentId: "agent-ap",
|
|
4364
|
-
title: "Voice Thread",
|
|
4365
|
-
memberId: null,
|
|
4366
|
-
createdAt: "2024-01-01T00:00:00.000Z",
|
|
4367
|
-
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
4368
|
-
};
|
|
4369
|
-
|
|
4370
|
-
test("renders an aria-pressed auto-play toggle right after the mic button", () => {
|
|
4371
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4372
|
-
const micIdx = html.indexOf('id="mic-btn"');
|
|
4373
|
-
const toggleIdx = html.indexOf('id="autoplay-btn"');
|
|
4374
|
-
const attachIdx = html.indexOf('id="attach-btn"');
|
|
4375
|
-
expect(micIdx).toBeGreaterThan(-1);
|
|
4376
|
-
expect(toggleIdx).toBeGreaterThan(micIdx);
|
|
4377
|
-
expect(attachIdx).toBeGreaterThan(toggleIdx);
|
|
4378
|
-
expect(html).toContain('aria-pressed="false"');
|
|
4379
|
-
expect(html).toContain('aria-label="Auto-play voice replies"');
|
|
4380
|
-
});
|
|
4381
|
-
|
|
4382
|
-
test("inline script carries the per-thread localStorage key prefix", () => {
|
|
4383
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4384
|
-
expect(html).toContain(JSON.stringify(AUTOPLAY_STORAGE_KEY_PREFIX));
|
|
4385
|
-
expect(html).toContain("maybeAutoplay(newAssistantNodes)");
|
|
4386
|
-
expect(html).toContain("data:audio/wav;base64,");
|
|
4387
|
-
});
|
|
4388
|
-
|
|
4389
|
-
test("phone breakpoint grows the mic and toggle to 56px round buttons", () => {
|
|
4390
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4391
|
-
expect(html).toContain(
|
|
4392
|
-
".chat-composer-btn--mic, .chat-composer-btn--autoplay { width:56px;height:56px;padding:0;border-radius:50%",
|
|
4393
|
-
);
|
|
4394
|
-
expect(html).toContain("@keyframes chat-mic-pulse");
|
|
4395
|
-
// Desktop rule stays the plain 44px text button.
|
|
4396
|
-
expect(html).toContain(".chat-composer-btn--mic { padding:0 16px }");
|
|
4397
|
-
});
|
|
4398
|
-
|
|
4399
|
-
// Runs the inline script against stub DOM/fetch/storage (no globals touched)
|
|
4400
|
-
// and fires the first poll with one new message; returns the audio stub's
|
|
4401
|
-
// play() call count.
|
|
4402
|
-
async function bootAndPoll(opts: {
|
|
4403
|
-
stored: string | null;
|
|
4404
|
-
role: "assistant" | "user";
|
|
4405
|
-
withAudio: boolean;
|
|
4406
|
-
seededInDom?: boolean;
|
|
4407
|
-
}): Promise<{ plays: number; storedKeys: string[] }> {
|
|
4408
|
-
const html = renderChatThreadPage("agent-ap", THREAD, [], "alice");
|
|
4409
|
-
const script = html.match(
|
|
4410
|
-
/<script>\n\(function\(\) \{[\s\S]*?\n<\/script>/,
|
|
4411
|
-
);
|
|
4412
|
-
if (!script) throw new Error("inline chat script not found");
|
|
4413
|
-
const code = script[0].replace(/^<script>/, "").replace(/<\/script>$/, "");
|
|
4414
|
-
|
|
4415
|
-
let plays = 0;
|
|
4416
|
-
const audioStub = {
|
|
4417
|
-
play() {
|
|
4418
|
-
plays++;
|
|
4419
|
-
return Promise.resolve();
|
|
4420
|
-
},
|
|
4421
|
-
addEventListener() {},
|
|
4422
|
-
getAttribute: () => "/attachment",
|
|
4423
|
-
currentSrc: "",
|
|
4424
|
-
parentNode: null,
|
|
4425
|
-
};
|
|
4426
|
-
const makeEl = (): Record<string, unknown> => ({
|
|
4427
|
-
style: {},
|
|
4428
|
-
classList: { add() {}, remove() {}, toggle() {} },
|
|
4429
|
-
addEventListener() {},
|
|
4430
|
-
setAttribute() {},
|
|
4431
|
-
getAttribute: () => null,
|
|
4432
|
-
appendChild() {},
|
|
4433
|
-
insertBefore() {},
|
|
4434
|
-
querySelectorAll: () => [],
|
|
4435
|
-
querySelector: () => null,
|
|
4436
|
-
});
|
|
4437
|
-
const bubbleNode = {
|
|
4438
|
-
...makeEl(),
|
|
4439
|
-
querySelector: (sel: string) =>
|
|
4440
|
-
sel === "audio" && opts.withAudio ? audioStub : null,
|
|
4441
|
-
};
|
|
4442
|
-
const seeded = opts.seededInDom
|
|
4443
|
-
? [{ getAttribute: () => "msg-new" }]
|
|
4444
|
-
: [];
|
|
4445
|
-
const container = {
|
|
4446
|
-
...makeEl(),
|
|
4447
|
-
scrollTop: 0,
|
|
4448
|
-
scrollHeight: 1000,
|
|
4449
|
-
clientHeight: 400,
|
|
4450
|
-
querySelectorAll: () => seeded,
|
|
4451
|
-
};
|
|
4452
|
-
const document = {
|
|
4453
|
-
getElementById: (id: string) =>
|
|
4454
|
-
id === "messages-container"
|
|
4455
|
-
? container
|
|
4456
|
-
: id === "live-status-bubble"
|
|
4457
|
-
? null
|
|
4458
|
-
: makeEl(),
|
|
4459
|
-
createElement: (tag: string) =>
|
|
4460
|
-
tag === "template"
|
|
4461
|
-
? { innerHTML: "", content: { firstChild: bubbleNode } }
|
|
4462
|
-
: makeEl(),
|
|
4463
|
-
querySelectorAll: () => [],
|
|
4464
|
-
};
|
|
4465
|
-
const storage = new Map<string, string>();
|
|
4466
|
-
if (opts.stored !== null)
|
|
4467
|
-
storage.set(`${AUTOPLAY_STORAGE_KEY_PREFIX}thread-ap`, opts.stored);
|
|
4468
|
-
const window = {
|
|
4469
|
-
addEventListener() {},
|
|
4470
|
-
localStorage: {
|
|
4471
|
-
getItem: (k: string) => storage.get(k) ?? null,
|
|
4472
|
-
setItem: (k: string, v: string) => storage.set(k, v),
|
|
4473
|
-
removeItem: (k: string) => storage.delete(k),
|
|
4474
|
-
},
|
|
4475
|
-
};
|
|
4476
|
-
const fetchStub = () =>
|
|
4477
|
-
Promise.resolve({
|
|
4478
|
-
json: () =>
|
|
4479
|
-
Promise.resolve({
|
|
4480
|
-
messages: [
|
|
4481
|
-
{
|
|
4482
|
-
id: "msg-new",
|
|
4483
|
-
role: opts.role,
|
|
4484
|
-
body: "",
|
|
4485
|
-
repliedAt: "2024-01-01T00:00:01.000Z",
|
|
4486
|
-
bubbleHtml: "<div><audio></audio></div>",
|
|
4487
|
-
},
|
|
4488
|
-
],
|
|
4489
|
-
}),
|
|
4490
|
-
});
|
|
4491
|
-
const timers: Array<() => void> = [];
|
|
4492
|
-
new Function(
|
|
4493
|
-
"document",
|
|
4494
|
-
"fetch",
|
|
4495
|
-
"setTimeout",
|
|
4496
|
-
"clearTimeout",
|
|
4497
|
-
"setInterval",
|
|
4498
|
-
"clearInterval",
|
|
4499
|
-
"window",
|
|
4500
|
-
code,
|
|
4501
|
-
)(
|
|
4502
|
-
document,
|
|
4503
|
-
fetchStub,
|
|
4504
|
-
(fn: () => void) => {
|
|
4505
|
-
timers.push(fn);
|
|
4506
|
-
return timers.length;
|
|
4507
|
-
},
|
|
4508
|
-
() => {},
|
|
4509
|
-
() => 0,
|
|
4510
|
-
() => {},
|
|
4511
|
-
window,
|
|
4512
|
-
);
|
|
4513
|
-
timers[timers.length - 1]();
|
|
4514
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
4515
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
4516
|
-
return { plays, storedKeys: [...storage.keys()] };
|
|
4517
|
-
}
|
|
4518
|
-
|
|
4519
|
-
test("switch on + new assistant audio bubble -> plays it once", async () => {
|
|
4520
|
-
const { plays } = await bootAndPoll({
|
|
4521
|
-
stored: "1",
|
|
4522
|
-
role: "assistant",
|
|
4523
|
-
withAudio: true,
|
|
4524
|
-
});
|
|
4525
|
-
expect(plays).toBe(1);
|
|
4526
|
-
});
|
|
4527
|
-
|
|
4528
|
-
test("switch off -> a new assistant audio bubble is not played", async () => {
|
|
4529
|
-
const { plays } = await bootAndPoll({
|
|
4530
|
-
stored: null,
|
|
4531
|
-
role: "assistant",
|
|
4532
|
-
withAudio: true,
|
|
4533
|
-
});
|
|
4534
|
-
expect(plays).toBe(0);
|
|
4535
|
-
});
|
|
4536
|
-
|
|
4537
|
-
test("switch on but the new bubble is the user's own recording -> not played", async () => {
|
|
4538
|
-
const { plays } = await bootAndPoll({
|
|
4539
|
-
stored: "1",
|
|
4540
|
-
role: "user",
|
|
4541
|
-
withAudio: true,
|
|
4542
|
-
});
|
|
4543
|
-
expect(plays).toBe(0);
|
|
4544
|
-
});
|
|
4545
|
-
|
|
4546
|
-
test("switch on but the message was already in the DOM at load -> never replayed", async () => {
|
|
4547
|
-
const { plays } = await bootAndPoll({
|
|
4548
|
-
stored: "1",
|
|
4549
|
-
role: "assistant",
|
|
4550
|
-
withAudio: true,
|
|
4551
|
-
seededInDom: true,
|
|
4552
|
-
});
|
|
4553
|
-
expect(plays).toBe(0);
|
|
4554
|
-
});
|
|
4555
|
-
|
|
4556
|
-
test("switch on but the assistant bubble has no audio -> no-op", async () => {
|
|
4557
|
-
const { plays } = await bootAndPoll({
|
|
4558
|
-
stored: "1",
|
|
4559
|
-
role: "assistant",
|
|
4560
|
-
withAudio: false,
|
|
4561
|
-
});
|
|
4562
|
-
expect(plays).toBe(0);
|
|
4563
|
-
});
|
|
4564
|
-
});
|
|
@@ -83,9 +83,13 @@ export function renderPushTestControl(): string {
|
|
|
83
83
|
btn.addEventListener('click', function() {
|
|
84
84
|
btn.disabled = true;
|
|
85
85
|
status.textContent = 'Sending…';
|
|
86
|
-
fetch(${JSON.stringify(PUSH_TEST_PATH)}, { method: 'POST', credentials: 'same-origin' })
|
|
86
|
+
fetch(${JSON.stringify(PUSH_TEST_PATH)}, { method: 'POST', credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
|
|
87
87
|
.then(function(res) { return res.json().then(function(body) { return { status: res.status, body: body }; }); })
|
|
88
88
|
.then(function(r) {
|
|
89
|
+
if (r.status === 401) {
|
|
90
|
+
window.location.href = '/admin/login?returnTo=' + encodeURIComponent(location.pathname + location.search);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
89
93
|
if (r.status !== 200 || !r.body || !r.body.ok) {
|
|
90
94
|
status.textContent = 'Push is not enabled on this server.';
|
|
91
95
|
} else if (r.body.delivered === 0) {
|
|
@@ -40,18 +40,81 @@ const TYPE = TYPE_MANIFEST.metadata.name;
|
|
|
40
40
|
// ─── Auth redirect tests ──────────────────────────────────────────────────────
|
|
41
41
|
|
|
42
42
|
describe("admin UI — unauthenticated redirects", () => {
|
|
43
|
-
|
|
43
|
+
const NAV = { "Sec-Fetch-Mode": "navigate" };
|
|
44
|
+
|
|
45
|
+
it("unauthenticated GET /admin/agents redirects to /admin/login with returnTo", async () => {
|
|
44
46
|
const app = createAdminUIApp(makeMockDeps());
|
|
45
|
-
const res = await app.request("/admin/agents");
|
|
47
|
+
const res = await app.request("/admin/agents", { headers: NAV });
|
|
46
48
|
expect(res.status).toBe(302);
|
|
47
|
-
expect(res.headers.get("Location")).toBe(
|
|
49
|
+
expect(res.headers.get("Location")).toBe(
|
|
50
|
+
"/admin/login?returnTo=%2Fadmin%2Fagents",
|
|
51
|
+
);
|
|
48
52
|
});
|
|
49
53
|
|
|
50
|
-
it("unauthenticated GET /admin/agents/:id redirects to /admin/login", async () => {
|
|
54
|
+
it("unauthenticated GET /admin/agents/:id redirects to /admin/login with returnTo", async () => {
|
|
51
55
|
const app = createAdminUIApp(makeMockDeps());
|
|
52
|
-
const res = await app.request(`/admin/agents/${AGENT_ID}
|
|
56
|
+
const res = await app.request(`/admin/agents/${AGENT_ID}`, {
|
|
57
|
+
headers: { Accept: "text/html,application/xhtml+xml" },
|
|
58
|
+
});
|
|
53
59
|
expect(res.status).toBe(302);
|
|
54
|
-
expect(res.headers.get("Location")).toBe(
|
|
60
|
+
expect(res.headers.get("Location")).toBe(
|
|
61
|
+
`/admin/login?returnTo=${encodeURIComponent(`/admin/agents/${AGENT_ID}`)}`,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("navigation redirect preserves the query string in returnTo", async () => {
|
|
66
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
67
|
+
const res = await app.request("/admin/agents?x=1&y=2", { headers: NAV });
|
|
68
|
+
expect(res.headers.get("Location")).toBe(
|
|
69
|
+
`/admin/login?returnTo=${encodeURIComponent("/admin/agents?x=1&y=2")}`,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("unauthenticated fetch-style request gets 401 signed_out JSON", async () => {
|
|
74
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
75
|
+
const res = await app.request("/admin/agents", {
|
|
76
|
+
headers: { Accept: "application/json", "Sec-Fetch-Mode": "cors" },
|
|
77
|
+
});
|
|
78
|
+
expect(res.status).toBe(401);
|
|
79
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("request with no navigation hints gets 401 signed_out JSON", async () => {
|
|
83
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
84
|
+
const res = await app.request("/admin/agents");
|
|
85
|
+
expect(res.status).toBe(401);
|
|
86
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("unauthenticated POST fetch gets 401 signed_out JSON", async () => {
|
|
90
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
91
|
+
const res = await app.request("/admin/agents", { method: "POST" });
|
|
92
|
+
expect(res.status).toBe(401);
|
|
93
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returnTo is never an open redirect (protocol-relative path dropped)", async () => {
|
|
97
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
98
|
+
const res = await app.request("//evil.com/x", { headers: NAV });
|
|
99
|
+
expect(res.headers.get("Location") ?? "").not.toContain("evil.com");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("admin UI — GET /admin/session.json", () => {
|
|
104
|
+
it("returns 200 {signedIn:true} with a valid session", async () => {
|
|
105
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
106
|
+
const res = await app.request("/admin/session.json", {
|
|
107
|
+
headers: { Cookie: `admin_session=${await makeSessionCookie()}` },
|
|
108
|
+
});
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect(await res.json()).toEqual({ signedIn: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns 401 without a session", async () => {
|
|
114
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
115
|
+
const res = await app.request("/admin/session.json");
|
|
116
|
+
expect(res.status).toBe(401);
|
|
117
|
+
expect(await res.json()).toEqual({ error: "signed_out" });
|
|
55
118
|
});
|
|
56
119
|
});
|
|
57
120
|
|
package/src/admin-ui.ts
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
audioContentTypeForFilename,
|
|
86
86
|
validateAttachment,
|
|
87
87
|
} from "./attachment-validation.ts";
|
|
88
|
+
import { parseByteRange } from "./http-range.ts";
|
|
88
89
|
import {
|
|
89
90
|
type CallerScopeResolver,
|
|
90
91
|
memberOnlyCallerScopeResolver,
|
|
@@ -515,6 +516,28 @@ async function getSessionUser(
|
|
|
515
516
|
|
|
516
517
|
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
|
517
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Accepts a `returnTo` value only when it is a same-origin relative path:
|
|
521
|
+
* must start with a single "/", so protocol-relative (//evil.com), absolute
|
|
522
|
+
* (https://evil.com) and backslash-smuggled (/\\evil.com) values are dropped.
|
|
523
|
+
*/
|
|
524
|
+
export function validateReturnTo(
|
|
525
|
+
raw: string | undefined | null,
|
|
526
|
+
): string | undefined {
|
|
527
|
+
return raw?.startsWith("/") && !raw.startsWith("//") && !raw.includes("\\")
|
|
528
|
+
? raw
|
|
529
|
+
: undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** True for browser page navigations (as opposed to fetch/XHR calls). */
|
|
533
|
+
function isNavigationRequest(c: Context): boolean {
|
|
534
|
+
if (c.req.header("Sec-Fetch-Mode") === "navigate") return true;
|
|
535
|
+
return (
|
|
536
|
+
c.req.method === "GET" &&
|
|
537
|
+
(c.req.header("Accept") ?? "").includes("text/html")
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
518
541
|
export function createUIAuthMiddleware(
|
|
519
542
|
sessionSecret: string,
|
|
520
543
|
): MiddlewareHandler<AdminUIEnv> {
|
|
@@ -524,7 +547,20 @@ export function createUIAuthMiddleware(
|
|
|
524
547
|
? await getSessionUser(sessionToken, sessionSecret)
|
|
525
548
|
: null;
|
|
526
549
|
if (!user) {
|
|
527
|
-
|
|
550
|
+
if (!isNavigationRequest(c)) {
|
|
551
|
+
return c.json({ error: "signed_out" }, 401);
|
|
552
|
+
}
|
|
553
|
+
const url = new URL(c.req.url);
|
|
554
|
+
const returnTo =
|
|
555
|
+
c.req.method === "GET"
|
|
556
|
+
? validateReturnTo(url.pathname + url.search)
|
|
557
|
+
: undefined;
|
|
558
|
+
return c.redirect(
|
|
559
|
+
returnTo
|
|
560
|
+
? `/admin/login?returnTo=${encodeURIComponent(returnTo)}`
|
|
561
|
+
: "/admin/login",
|
|
562
|
+
302,
|
|
563
|
+
);
|
|
528
564
|
}
|
|
529
565
|
c.set("userEmail", user.email);
|
|
530
566
|
c.set("isAdmin", user.isAdmin);
|
|
@@ -861,10 +897,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
861
897
|
path: "/",
|
|
862
898
|
});
|
|
863
899
|
// Redirect to returnTo if it's a valid same-origin relative path, otherwise default.
|
|
864
|
-
const destination =
|
|
865
|
-
returnTo?.startsWith("/") && !returnTo.startsWith("//")
|
|
866
|
-
? returnTo
|
|
867
|
-
: "/admin/agents";
|
|
900
|
+
const destination = validateReturnTo(returnTo) ?? "/admin/agents";
|
|
868
901
|
return c.redirect(destination, 302);
|
|
869
902
|
}
|
|
870
903
|
|
|
@@ -880,10 +913,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
880
913
|
const nonce = crypto.randomUUID();
|
|
881
914
|
|
|
882
915
|
const rawReturnTo = c.req.query("returnTo");
|
|
883
|
-
const returnTo =
|
|
884
|
-
rawReturnTo?.startsWith("/") && !rawReturnTo.startsWith("//")
|
|
885
|
-
? rawReturnTo
|
|
886
|
-
: undefined;
|
|
916
|
+
const returnTo = validateReturnTo(rawReturnTo);
|
|
887
917
|
|
|
888
918
|
const oauthState = JSON.stringify({ nonce, returnTo });
|
|
889
919
|
setCookie(c, OAUTH_STATE_COOKIE, oauthState, {
|
|
@@ -999,6 +1029,12 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
999
1029
|
}
|
|
1000
1030
|
});
|
|
1001
1031
|
|
|
1032
|
+
// Cheap signed-in probe for client JS (a 401 {error:"signed_out"} from
|
|
1033
|
+
// requireAuth means the session is gone).
|
|
1034
|
+
app.get("/admin/session.json", requireAuth, (c) =>
|
|
1035
|
+
c.json({ signedIn: true }),
|
|
1036
|
+
);
|
|
1037
|
+
|
|
1002
1038
|
app.get("/admin/login", (c) => {
|
|
1003
1039
|
const error = c.req.query("error") ?? undefined;
|
|
1004
1040
|
const returnTo = c.req.query("returnTo") ?? undefined;
|
|
@@ -3349,9 +3385,34 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
3349
3385
|
// .slice() normalizes to Uint8Array<ArrayBuffer> — Hono's Data type
|
|
3350
3386
|
// requires that exact generic, not the wider Uint8Array<ArrayBufferLike>
|
|
3351
3387
|
// that new Uint8Array(await res.arrayBuffer()) produces.
|
|
3352
|
-
|
|
3388
|
+
const total = attachment.bytes.byteLength;
|
|
3389
|
+
const baseHeaders = {
|
|
3353
3390
|
"Content-Type": contentType,
|
|
3354
3391
|
"Content-Disposition": `inline; filename="${attachment.filename.replace(/"/g, "")}"`,
|
|
3392
|
+
"Accept-Ranges": "bytes",
|
|
3393
|
+
};
|
|
3394
|
+
// iOS Safari only plays media from servers that honour byte ranges.
|
|
3395
|
+
const range = parseByteRange(c.req.header("Range"), total);
|
|
3396
|
+
if (range.kind === "unsatisfiable") {
|
|
3397
|
+
return c.body(null, 416, {
|
|
3398
|
+
...baseHeaders,
|
|
3399
|
+
"Content-Range": `bytes */${total}`,
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
if (range.kind === "range") {
|
|
3403
|
+
return c.body(
|
|
3404
|
+
attachment.bytes.slice(range.start, range.end + 1),
|
|
3405
|
+
206,
|
|
3406
|
+
{
|
|
3407
|
+
...baseHeaders,
|
|
3408
|
+
"Content-Range": `bytes ${range.start}-${range.end}/${total}`,
|
|
3409
|
+
"Content-Length": String(range.end - range.start + 1),
|
|
3410
|
+
},
|
|
3411
|
+
);
|
|
3412
|
+
}
|
|
3413
|
+
return c.body(attachment.bytes.slice(), 200, {
|
|
3414
|
+
...baseHeaders,
|
|
3415
|
+
"Content-Length": String(total),
|
|
3355
3416
|
});
|
|
3356
3417
|
} catch {
|
|
3357
3418
|
return c.json({ error: "failed to fetch attachment" }, 500);
|
package/src/agent-cron-jobs.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "./specialty-manifest-loader.ts";
|
|
18
18
|
import type { SpecialtyManifest } from "./specialty-registry.ts";
|
|
19
19
|
import { type Clock, SystemClock } from "./clock.ts";
|
|
20
|
+
import { resolveSpecialty } from "./agent-specialty.ts";
|
|
20
21
|
import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -488,15 +489,16 @@ export class AgentCronJobService {
|
|
|
488
489
|
// than leaving its crons untouched.
|
|
489
490
|
const agent = await this.prisma.agent.findUnique({
|
|
490
491
|
where: { id: agentId },
|
|
491
|
-
select: { typeName: true },
|
|
492
|
+
select: { typeName: true, specialty: true },
|
|
492
493
|
});
|
|
493
494
|
if (!agent) {
|
|
494
495
|
throw new NotFoundError(`agent ${agentId} not found`);
|
|
495
496
|
}
|
|
496
|
-
const
|
|
497
|
+
const specialty = resolveSpecialty(agent);
|
|
498
|
+
const manifest = this.specialtyRegistry.tryGetManifest(specialty);
|
|
497
499
|
if (!manifest) {
|
|
498
500
|
const defaultName = resolveDefaultSpecialtyName();
|
|
499
|
-
if (
|
|
501
|
+
if (specialty === defaultName) {
|
|
500
502
|
throw missingDefaultSpecialtyError(
|
|
501
503
|
defaultName,
|
|
502
504
|
resolveSpecialtiesDir(),
|
|
@@ -504,7 +506,7 @@ export class AgentCronJobService {
|
|
|
504
506
|
);
|
|
505
507
|
}
|
|
506
508
|
console.error(
|
|
507
|
-
`reconcileSystemCrons: no loadable manifest for
|
|
509
|
+
`reconcileSystemCrons: no loadable manifest for specialty "${specialty}" (agent ${agentId}) — leaving crons untouched`,
|
|
508
510
|
);
|
|
509
511
|
return { created: 0, updated: 0, deleted: 0 };
|
|
510
512
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGR-6.1: typeName -> specialty rename, ADD step. `Agent.specialty` is a
|
|
3
|
+
* nullable column written alongside `typeName`; reads prefer it and fall back
|
|
4
|
+
* to `typeName` for rows that predate the column. Nothing is retired yet.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SpecialtyBearingRow {
|
|
8
|
+
typeName: string;
|
|
9
|
+
specialty?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The agent's specialty: the new column when set, else the legacy `typeName`. */
|
|
13
|
+
export function resolveSpecialty(row: SpecialtyBearingRow): string {
|
|
14
|
+
return row.specialty ?? row.typeName;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Returns the row with `specialty` resolved to a non-null string. */
|
|
18
|
+
export function withResolvedSpecialty<T extends SpecialtyBearingRow>(
|
|
19
|
+
row: T,
|
|
20
|
+
): Omit<T, "specialty"> & { specialty: string } {
|
|
21
|
+
return { ...row, specialty: resolveSpecialty(row) };
|
|
22
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the AGR-6.1 specialty mapper (typeName -> specialty ADD step).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "bun:test";
|
|
6
|
+
import { resolveSpecialty, withResolvedSpecialty } from "./agent-specialty.ts";
|
|
7
|
+
|
|
8
|
+
describe("resolveSpecialty", () => {
|
|
9
|
+
it("prefers specialty when set", () => {
|
|
10
|
+
expect(
|
|
11
|
+
resolveSpecialty({ typeName: "coding", specialty: "research" }),
|
|
12
|
+
).toBe("research");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("falls back to typeName when specialty is null", () => {
|
|
16
|
+
expect(resolveSpecialty({ typeName: "coding", specialty: null })).toBe(
|
|
17
|
+
"coding",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("falls back to typeName when specialty is absent", () => {
|
|
22
|
+
expect(resolveSpecialty({ typeName: "coding" })).toBe("coding");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does not treat an empty-string specialty as unset", () => {
|
|
26
|
+
expect(resolveSpecialty({ typeName: "coding", specialty: "" })).toBe("");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("withResolvedSpecialty", () => {
|
|
31
|
+
it("returns a copy with specialty resolved and typeName preserved", () => {
|
|
32
|
+
const row = { id: "a1", typeName: "coding", specialty: null };
|
|
33
|
+
const out = withResolvedSpecialty(row);
|
|
34
|
+
expect(out).toEqual({ id: "a1", typeName: "coding", specialty: "coding" });
|
|
35
|
+
expect(row.specialty).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps an explicit specialty", () => {
|
|
39
|
+
expect(
|
|
40
|
+
withResolvedSpecialty({ typeName: "coding", specialty: "research" }),
|
|
41
|
+
).toEqual({ typeName: "coding", specialty: "research" });
|
|
42
|
+
});
|
|
43
|
+
});
|