@baize-ai/core 0.2.0 → 0.3.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/.dockerignore +8 -18
- package/CHANGELOG.md +24 -1
- package/Dockerfile +66 -12
- package/README.md +85 -6
- package/README.zh-CN.md +63 -3
- package/assets/logo.png +0 -0
- package/cli/baize.js +6 -0
- package/cli/commands/a2a.js +124 -0
- package/cli/commands/add.js +126 -86
- package/cli/commands/component.js +27 -4
- package/cli/commands/doctor.js +2 -2
- package/cli/commands/init.js +28 -0
- package/cli/commands/self-uninstall.js +1 -1
- package/cli/lib/__tests__/instruction-split.test.js +1 -1
- package/cli/lib/a2a.js +235 -0
- package/cli/lib/lock.js +3 -3
- package/cli/lib/self-upgrade.js +1 -1
- package/docker-compose.yml +1 -1
- package/docs/docker.md +18 -4
- package/docs/release.md +150 -0
- package/package.json +1 -1
- package/scripts/docker-publish.sh +70 -0
- package/scripts/install.sh +4 -4
- package/scripts/pack-release.sh +361 -0
- package/skills/comm-bridge/package.json +7 -3
- package/skills/comm-bridge/scripts/c4-receive.js +23 -2
- package/skills/scheduler/package.json +2 -2
- package/skills/web-console/SKILL.md +34 -0
- package/skills/web-console/package.json +2 -2
- package/skills/web-console/public/app.js +967 -4
- package/skills/web-console/public/index.html +141 -0
- package/skills/web-console/public/styles.css +136 -0
- package/skills/web-console/scripts/a2a-admin.js +533 -0
- package/skills/web-console/scripts/channel-admin.js +99 -16
- package/skills/web-console/scripts/server.js +395 -1
- package/skills/web-console/scripts/skill-catalog.js +179 -0
- package/templates/claude-system.md +22 -0
- package/templates/pm2/ecosystem.config.cjs +4 -1
- package/test/a2a-cli.test.js +180 -0
- package/test/agent-card-api.test.js +261 -0
- package/test/channel-admin.test.js +87 -0
- package/test/component-lock.test.js +216 -0
- package/test/skill-catalog.test.js +118 -0
- package/test/web-console-routes.test.js +512 -1
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import net from 'node:net';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
5
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
|
|
7
7
|
import Database from '../skills/web-console/node_modules/better-sqlite3/lib/index.js';
|
|
8
8
|
import WebSocket from '../skills/web-console/node_modules/ws/wrapper.mjs';
|
|
@@ -564,3 +564,514 @@ describe('web-console codex login routes', () => {
|
|
|
564
564
|
expect(status.status).toBe(401);
|
|
565
565
|
});
|
|
566
566
|
});
|
|
567
|
+
|
|
568
|
+
describe('web-console a2a routes (D19 单元 C)', () => {
|
|
569
|
+
let fakeA2aDir;
|
|
570
|
+
|
|
571
|
+
// Stand-in for @baize-ai/baize-a2a scripts/cli.js (contract §6 JSON output).
|
|
572
|
+
beforeEach(() => {
|
|
573
|
+
fakeA2aDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wc-a2a-cli-'));
|
|
574
|
+
const dir = path.join(fakeA2aDir, 'scripts');
|
|
575
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
576
|
+
fs.writeFileSync(path.join(dir, 'cli.js'), `
|
|
577
|
+
const args = process.argv.slice(2);
|
|
578
|
+
const out = (obj) => { console.log(JSON.stringify(obj)); process.exit(0); };
|
|
579
|
+
switch (args[0]) {
|
|
580
|
+
case 'status':
|
|
581
|
+
out({ enabled: true, agentId: 'agent_test', registered: true, registrationStatus: 'approved', advertiseUrl: 'https://a2a.example.com', endpoint: 'https://a2a.example.com/a2a/v1', version: '0.1.0' });
|
|
582
|
+
break;
|
|
583
|
+
case 'enable': out({ agentId: 'agent_new', registrationId: 'reg-1', status: 'pending' }); break;
|
|
584
|
+
case 'disable': out({ ok: true }); break;
|
|
585
|
+
case 'peer': out({ peers: [{ agentId: 'peer_1', endpoint: 'https://p1/a2a/v1', skills: [{ id: 's1' }] }] }); break;
|
|
586
|
+
case 'search': out({ agents: [{ agentId: 'peer_1', name: 'Peer', team: 't', role: 'r', status: 'approved' }] }); break;
|
|
587
|
+
case 'send': out({ status: 'accepted', requestId: 'req-1' }); break;
|
|
588
|
+
case 'task': out({ taskId: 'task-1', status: 'completed', result: 'ok' }); break;
|
|
589
|
+
default: console.error('unknown: ' + args.join(' ')); process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
`);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
afterEach(() => {
|
|
595
|
+
fs.rmSync(fakeA2aDir, { recursive: true, force: true });
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const a2aEnv = () => ({ BAIZE_A2A_PATH: fakeA2aDir });
|
|
599
|
+
|
|
600
|
+
test('status/peers/search proxy the delegated CLI JSON', async () => {
|
|
601
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
602
|
+
const status = await fetch(`${ctx.baseUrl}/api/admin/a2a/status`);
|
|
603
|
+
expect(status.status).toBe(200);
|
|
604
|
+
const s = await status.json();
|
|
605
|
+
expect(s).toMatchObject({ success: true, enabled: true, agentId: 'agent_test', registrationStatus: 'approved' });
|
|
606
|
+
|
|
607
|
+
const peers = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/peers`)).json();
|
|
608
|
+
expect(peers.success).toBe(true);
|
|
609
|
+
expect(peers.peers[0].agentId).toBe('peer_1');
|
|
610
|
+
|
|
611
|
+
const search = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/search?q=peer`)).json();
|
|
612
|
+
expect(search.success).toBe(true);
|
|
613
|
+
expect(search.agents[0].name).toBe('Peer');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test('enable/disable/send/task POSTs proxy and return delegated JSON', async () => {
|
|
617
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
618
|
+
const post = async (url, body) => {
|
|
619
|
+
const res = await fetch(`${ctx.baseUrl}${url}`, {
|
|
620
|
+
method: 'POST',
|
|
621
|
+
headers: { 'Content-Type': 'application/json' },
|
|
622
|
+
body: JSON.stringify(body || {}),
|
|
623
|
+
});
|
|
624
|
+
return { status: res.status, body: await res.json() };
|
|
625
|
+
};
|
|
626
|
+
const enable = await post('/api/admin/a2a/enable', { adminUrl: 'https://admin.example.com', advertiseUrl: 'https://a2a.example.com' });
|
|
627
|
+
expect(enable.status).toBe(200);
|
|
628
|
+
// D25 switch contract: CLI result normalized to {ok, mode, error?} —
|
|
629
|
+
// no config.json in this fixture, so mode falls back to 'standalone'.
|
|
630
|
+
expect(enable.body).toMatchObject({ ok: true, mode: 'standalone' });
|
|
631
|
+
|
|
632
|
+
const disable = await post('/api/admin/a2a/disable');
|
|
633
|
+
expect(disable.body).toMatchObject({ ok: true, mode: 'standalone' });
|
|
634
|
+
|
|
635
|
+
const send = await post('/api/admin/a2a/send', { peer: 'peer_1', text: 'hi' });
|
|
636
|
+
expect(send.body).toMatchObject({ success: true, status: 'accepted', requestId: 'req-1' });
|
|
637
|
+
|
|
638
|
+
const task = await post('/api/admin/a2a/task', { peer: 'peer_1', skill: 's1', instruction: 'do', async: true });
|
|
639
|
+
expect(task.body).toMatchObject({ success: true, taskId: 'task-1', status: 'completed' });
|
|
640
|
+
|
|
641
|
+
const cancel = await post('/api/admin/a2a/task/task-1/cancel');
|
|
642
|
+
expect(cancel.status).toBe(200);
|
|
643
|
+
|
|
644
|
+
const taskStatus = await fetch(`${ctx.baseUrl}/api/admin/a2a/task/task-1`);
|
|
645
|
+
expect(taskStatus.status).toBe(200);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test('invalid payloads are rejected with 400 before delegation', async () => {
|
|
649
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
650
|
+
const post = async (url, body) => {
|
|
651
|
+
const res = await fetch(`${ctx.baseUrl}${url}`, {
|
|
652
|
+
method: 'POST',
|
|
653
|
+
headers: { 'Content-Type': 'application/json' },
|
|
654
|
+
body: JSON.stringify(body || {}),
|
|
655
|
+
});
|
|
656
|
+
return { status: res.status, body: await res.json() };
|
|
657
|
+
};
|
|
658
|
+
for (const r of [
|
|
659
|
+
await post('/api/admin/a2a/send', { peer: '', text: '' }),
|
|
660
|
+
await post('/api/admin/a2a/task', { peer: 'peer_1' }),
|
|
661
|
+
]) {
|
|
662
|
+
expect(r.status).toBe(400);
|
|
663
|
+
expect(r.body.success).toBe(false);
|
|
664
|
+
}
|
|
665
|
+
// enable uses the D25 switch shape {ok:false, mode, error} instead.
|
|
666
|
+
const badEnable = await post('/api/admin/a2a/enable', { adminUrl: 'https://admin.example.com' });
|
|
667
|
+
expect(badEnable.status).toBe(400);
|
|
668
|
+
expect(badEnable.body).toMatchObject({ ok: false, mode: 'standalone' });
|
|
669
|
+
expect(badEnable.body.error).toBeTruthy();
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test('task board and message log degrade gracefully without local DBs', async () => {
|
|
673
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
674
|
+
const tasks = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/tasks`)).json();
|
|
675
|
+
expect(tasks.success).toBe(true);
|
|
676
|
+
expect(tasks.tasks).toEqual([]);
|
|
677
|
+
expect(tasks.available).toBe(false);
|
|
678
|
+
const messages = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/messages`)).json();
|
|
679
|
+
expect(messages.success).toBe(true);
|
|
680
|
+
expect(messages.messages).toEqual([]);
|
|
681
|
+
expect(messages.available).toBe(false);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test('a2a routes require session when a password is set', async () => {
|
|
685
|
+
ctx = await startServer({ envContent: 'BAIZE_WEB_PASSWORD=secret123\n', extraEnv: a2aEnv() });
|
|
686
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/status`);
|
|
687
|
+
expect(res.status).toBe(401);
|
|
688
|
+
const send = await fetch(`${ctx.baseUrl}/api/admin/a2a/send`, {
|
|
689
|
+
method: 'POST',
|
|
690
|
+
headers: { 'Content-Type': 'application/json' },
|
|
691
|
+
body: JSON.stringify({ peer: 'p', text: 't' }),
|
|
692
|
+
});
|
|
693
|
+
expect(send.status).toBe(401);
|
|
694
|
+
|
|
695
|
+
const install = await fetch(`${ctx.baseUrl}/api/admin/a2a/install`, { method: 'POST' });
|
|
696
|
+
expect(install.status).toBe(401);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe('web-console a2a authz routes (D22 单元 C)', () => {
|
|
701
|
+
let fakeA2aDir;
|
|
702
|
+
|
|
703
|
+
// config.json stand-in for ~/baize/components/a2a/config.json (BAIZE_A2A_CONFIG).
|
|
704
|
+
beforeEach(() => {
|
|
705
|
+
fakeA2aDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wc-a2a-authz-'));
|
|
706
|
+
fs.writeFileSync(path.join(fakeA2aDir, 'config.json'), JSON.stringify({
|
|
707
|
+
enabled: true,
|
|
708
|
+
adminUrl: 'https://admin.example.com',
|
|
709
|
+
agentId: 'agent_test',
|
|
710
|
+
advertiseUrl: 'https://a2a.example.com',
|
|
711
|
+
cert: { keyPath: '', certPath: '' },
|
|
712
|
+
}));
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
afterEach(() => {
|
|
716
|
+
fs.rmSync(fakeA2aDir, { recursive: true, force: true });
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const a2aEnv = () => ({ BAIZE_A2A_CONFIG: path.join(fakeA2aDir, 'config.json') });
|
|
720
|
+
const configFile = () => path.join(fakeA2aDir, 'config.json');
|
|
721
|
+
|
|
722
|
+
test('GET authz returns contract defaults when config has no authz field', async () => {
|
|
723
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
724
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`);
|
|
725
|
+
expect(res.status).toBe(200);
|
|
726
|
+
expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: [] });
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('GET authz returns persisted policy and sanitizes malformed entries', async () => {
|
|
730
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
731
|
+
cfg.authz = { mode: 'allowlist', allow: ['peer_a', '', 'peer_b'], block: 'not-an-array' };
|
|
732
|
+
fs.writeFileSync(configFile(), JSON.stringify(cfg));
|
|
733
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
734
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`);
|
|
735
|
+
expect(res.status).toBe(200);
|
|
736
|
+
expect(await res.json()).toEqual({ success: true, mode: 'allowlist', allow: ['peer_a', 'peer_b'], block: [] });
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test('GET authz falls back to defaults on missing/corrupt config', async () => {
|
|
740
|
+
fs.rmSync(configFile());
|
|
741
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
742
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`);
|
|
743
|
+
expect(res.status).toBe(200);
|
|
744
|
+
expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: [] });
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test('PUT authz validates mode enum and list entries', async () => {
|
|
748
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
749
|
+
const put = async (body) => {
|
|
750
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
|
|
751
|
+
method: 'PUT',
|
|
752
|
+
headers: { 'Content-Type': 'application/json' },
|
|
753
|
+
body: JSON.stringify(body),
|
|
754
|
+
});
|
|
755
|
+
return { status: res.status, body: await res.json() };
|
|
756
|
+
};
|
|
757
|
+
expect((await put({ mode: 'bogus' })).status).toBe(400);
|
|
758
|
+
expect((await put({ mode: 'open', allow: 'x' })).status).toBe(400); // allow not an array
|
|
759
|
+
expect((await put({ mode: 'open', block: [''], allow: [] })).status).toBe(400); // empty string element
|
|
760
|
+
expect((await put({ mode: 'open', block: [42], allow: [] })).status).toBe(400); // non-string element
|
|
761
|
+
expect((await put({ mode: 'open', block: ['a', 'a'], allow: [] })).status).toBe(400); // duplicate
|
|
762
|
+
const ok = await put({ mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: [] });
|
|
763
|
+
expect(ok.status).toBe(200);
|
|
764
|
+
expect(ok.body).toEqual({ success: true, mode: 'allowlist', allow: ['peer_1', 'peer_2'], block: [] });
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test('PUT authz writes config.json preserving other fields and trims entries', async () => {
|
|
768
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
769
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
|
|
770
|
+
method: 'PUT',
|
|
771
|
+
headers: { 'Content-Type': 'application/json' },
|
|
772
|
+
body: JSON.stringify({ mode: 'open', allow: [], block: [' peer_9 ', 'peer_10'] }),
|
|
773
|
+
});
|
|
774
|
+
expect(res.status).toBe(200);
|
|
775
|
+
expect(await res.json()).toEqual({ success: true, mode: 'open', allow: [], block: ['peer_9', 'peer_10'] });
|
|
776
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
777
|
+
expect(cfg.authz).toEqual({ mode: 'open', allow: [], block: ['peer_9', 'peer_10'] });
|
|
778
|
+
expect(cfg.enabled).toBe(true);
|
|
779
|
+
expect(cfg.agentId).toBe('agent_test');
|
|
780
|
+
expect(cfg.cert).toEqual({ keyPath: '', certPath: '' });
|
|
781
|
+
expect(cfg.name).toBeUndefined();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
test('PUT authz creates config.json when missing', async () => {
|
|
785
|
+
fs.rmSync(configFile());
|
|
786
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
787
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
|
|
788
|
+
method: 'PUT',
|
|
789
|
+
headers: { 'Content-Type': 'application/json' },
|
|
790
|
+
body: JSON.stringify({ mode: 'allowlist', allow: ['peer_1'] }),
|
|
791
|
+
});
|
|
792
|
+
expect(res.status).toBe(200);
|
|
793
|
+
const cfg = JSON.parse(fs.readFileSync(configFile(), 'utf8'));
|
|
794
|
+
expect(cfg.authz).toEqual({ mode: 'allowlist', allow: ['peer_1'], block: [] });
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('authz routes require session when a password is set', async () => {
|
|
798
|
+
ctx = await startServer({ envContent: 'BAIZE_WEB_PASSWORD=secret123\n', extraEnv: a2aEnv() });
|
|
799
|
+
const get = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`);
|
|
800
|
+
expect(get.status).toBe(401);
|
|
801
|
+
const put = await fetch(`${ctx.baseUrl}/api/admin/a2a/authz`, {
|
|
802
|
+
method: 'PUT',
|
|
803
|
+
headers: { 'Content-Type': 'application/json' },
|
|
804
|
+
body: JSON.stringify({ mode: 'open' }),
|
|
805
|
+
});
|
|
806
|
+
expect(put.status).toBe(401);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
describe('web-console a2a install + run mode (D25 单元 B)', () => {
|
|
811
|
+
let fakeA2aDir;
|
|
812
|
+
let configDir;
|
|
813
|
+
|
|
814
|
+
// Fake @baize-ai/baize-a2a cli.js. FAKE_CLI_MODE (set on the server process,
|
|
815
|
+
// inherited by the CLI child) lets tests switch between a mode-reporting CLI
|
|
816
|
+
// (D25) and a legacy CLI that omits mode.
|
|
817
|
+
beforeEach(() => {
|
|
818
|
+
fakeA2aDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wc-a2a-d25-cli-'));
|
|
819
|
+
fs.mkdirSync(path.join(fakeA2aDir, 'scripts'), { recursive: true });
|
|
820
|
+
fs.writeFileSync(path.join(fakeA2aDir, 'scripts', 'cli.js'), `
|
|
821
|
+
const args = process.argv.slice(2);
|
|
822
|
+
const mode = process.env.FAKE_CLI_MODE || '';
|
|
823
|
+
const out = (obj) => { console.log(JSON.stringify(obj)); process.exit(0); };
|
|
824
|
+
switch (args[0]) {
|
|
825
|
+
case 'status':
|
|
826
|
+
out({ enabled: true, agentId: 'agent_d25', registered: true, registrationStatus: 'approved', version: '0.1.0', ...(mode ? { mode } : {}) });
|
|
827
|
+
break;
|
|
828
|
+
case 'enable': out({ ok: true, ...(mode ? { mode } : {}) }); break;
|
|
829
|
+
case 'disable': out({ ok: true, ...(mode ? { mode } : {}) }); break;
|
|
830
|
+
default: console.error('unknown: ' + args.join(' ')); process.exit(1);
|
|
831
|
+
}
|
|
832
|
+
`);
|
|
833
|
+
configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wc-a2a-d25-cfg-'));
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
afterEach(() => {
|
|
837
|
+
fs.rmSync(fakeA2aDir, { recursive: true, force: true });
|
|
838
|
+
fs.rmSync(configDir, { recursive: true, force: true });
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
const a2aEnv = (extra = {}) => ({
|
|
842
|
+
BAIZE_A2A_PATH: fakeA2aDir,
|
|
843
|
+
BAIZE_A2A_CONFIG: path.join(configDir, 'config.json'),
|
|
844
|
+
...extra,
|
|
845
|
+
});
|
|
846
|
+
const writeConfig = (cfg) => fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify(cfg));
|
|
847
|
+
|
|
848
|
+
// Build a .tar.gz whose entries live under a single wrapper dir (npm layout).
|
|
849
|
+
function buildTarball(files, wrapper = 'package') {
|
|
850
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wc-a2a-tgz-'));
|
|
851
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
852
|
+
const p = path.join(dir, wrapper, rel);
|
|
853
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
854
|
+
fs.writeFileSync(p, content);
|
|
855
|
+
}
|
|
856
|
+
const out = path.join(os.tmpdir(), `wc-a2a-${Date.now()}-${Math.random().toString(36).slice(2)}.tgz`);
|
|
857
|
+
execFileSync('tar', ['czf', out, '-C', dir, wrapper]);
|
|
858
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
859
|
+
return out;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// A tarball containing a `../` entry (path traversal probe): bsdtar stores
|
|
863
|
+
// the `../evil.txt` argument verbatim when the file lives next to `dir`.
|
|
864
|
+
function buildTraversalTarball() {
|
|
865
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wc-a2a-evil-'));
|
|
866
|
+
fs.writeFileSync(path.join(path.dirname(dir), 'evil.txt'), 'pwned');
|
|
867
|
+
const out = path.join(os.tmpdir(), `wc-a2a-evil-${Date.now()}-${Math.random().toString(36).slice(2)}.tgz`);
|
|
868
|
+
execFileSync('tar', ['czf', out, '-C', dir, '../evil.txt']);
|
|
869
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
870
|
+
return out;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function installUpload(active, tgzPath) {
|
|
874
|
+
const form = new FormData();
|
|
875
|
+
form.append('file', new Blob([fs.readFileSync(tgzPath)]), path.basename(tgzPath));
|
|
876
|
+
const res = await fetch(`${active.baseUrl}/api/admin/a2a/install`, { method: 'POST', body: form });
|
|
877
|
+
return { status: res.status, body: await res.json() };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const validManifest = JSON.stringify({ name: '@baize-ai/baize-a2a', version: '9.9.9', dependencies: {} });
|
|
881
|
+
|
|
882
|
+
test('status derives mode from config (enabled → cluster, explicit mode wins) and reports daemonHealthy', async () => {
|
|
883
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
884
|
+
const closedPort = await freePort();
|
|
885
|
+
// legacy config: enabled without explicit mode → derived 'cluster'
|
|
886
|
+
writeConfig({ enabled: true, listenPort: closedPort });
|
|
887
|
+
let s = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/status`)).json();
|
|
888
|
+
expect(s).toMatchObject({ success: true, mode: 'cluster', daemonHealthy: false });
|
|
889
|
+
// explicit mode wins over derivation
|
|
890
|
+
writeConfig({ enabled: false, mode: 'standalone', listenPort: closedPort });
|
|
891
|
+
s = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/status`)).json();
|
|
892
|
+
expect(s).toMatchObject({ success: true, mode: 'standalone', daemonHealthy: false });
|
|
893
|
+
// no config file at all → 'standalone' (the daemon probe falls back to
|
|
894
|
+
// default 8443, which may or may not be occupied — only assert mode here)
|
|
895
|
+
fs.rmSync(path.join(configDir, 'config.json'));
|
|
896
|
+
s = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/status`)).json();
|
|
897
|
+
expect(s).toMatchObject({ success: true, mode: 'standalone' });
|
|
898
|
+
});
|
|
899
|
+
test('status reports daemonHealthy=true when something listens on the configured listenPort', async () => {
|
|
900
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
901
|
+
const listener = net.createServer();
|
|
902
|
+
await new Promise((resolve) => listener.listen(0, '127.0.0.1', resolve));
|
|
903
|
+
const { port } = listener.address();
|
|
904
|
+
writeConfig({ enabled: true, mode: 'cluster', listenPort: port });
|
|
905
|
+
const s = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/status`)).json();
|
|
906
|
+
expect(s.mode).toBe('cluster');
|
|
907
|
+
expect(s.daemonHealthy).toBe(true);
|
|
908
|
+
await new Promise((resolve) => listener.close(resolve));
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
test('status prefers CLI-reported mode over config', async () => {
|
|
912
|
+
ctx = await startServer({ extraEnv: a2aEnv({ FAKE_CLI_MODE: 'cluster' }) });
|
|
913
|
+
writeConfig({ enabled: false, mode: 'standalone', listenPort: await freePort() });
|
|
914
|
+
const s = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/status`)).json();
|
|
915
|
+
expect(s.mode).toBe('cluster');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test('enable/disable return CLI {ok, mode, error?}', async () => {
|
|
919
|
+
ctx = await startServer({ extraEnv: a2aEnv({ FAKE_CLI_MODE: 'cluster' }) });
|
|
920
|
+
const post = async (url, body) => {
|
|
921
|
+
const res = await fetch(`${ctx.baseUrl}${url}`, {
|
|
922
|
+
method: 'POST',
|
|
923
|
+
headers: { 'Content-Type': 'application/json' },
|
|
924
|
+
body: JSON.stringify(body || {}),
|
|
925
|
+
});
|
|
926
|
+
return { status: res.status, body: await res.json() };
|
|
927
|
+
};
|
|
928
|
+
const enable = await post('/api/admin/a2a/enable', { adminUrl: 'https://admin.example.com', advertiseUrl: 'https://a2a.example.com' });
|
|
929
|
+
expect(enable.status).toBe(200);
|
|
930
|
+
expect(enable.body).toEqual({ ok: true, mode: 'cluster' });
|
|
931
|
+
const disable = await post('/api/admin/a2a/disable');
|
|
932
|
+
expect(disable.status).toBe(200);
|
|
933
|
+
expect(disable.body).toEqual({ ok: true, mode: 'cluster' });
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
test('install rejects a non-tar.gz upload by original filename', async () => {
|
|
937
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
938
|
+
const txt = path.join(os.tmpdir(), `wc-a2a-notar-${Date.now()}.txt`);
|
|
939
|
+
fs.writeFileSync(txt, 'not an archive');
|
|
940
|
+
try {
|
|
941
|
+
const form = new FormData();
|
|
942
|
+
form.append('file', new Blob([fs.readFileSync(txt)]), 'package.tar.txt');
|
|
943
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/install`, { method: 'POST', body: form });
|
|
944
|
+
expect(res.status).toBe(400);
|
|
945
|
+
const body = await res.json();
|
|
946
|
+
expect(body).toMatchObject({ ok: false });
|
|
947
|
+
expect(body.error).toMatch(/\.tar\.gz/);
|
|
948
|
+
} finally {
|
|
949
|
+
fs.rmSync(txt, { force: true });
|
|
950
|
+
}
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
test('install rejects an oversized upload with 413', async () => {
|
|
954
|
+
ctx = await startServer({ extraEnv: a2aEnv({ WEB_CONSOLE_A2A_MAX_UPLOAD_MB: '1' }) });
|
|
955
|
+
const big = path.join(os.tmpdir(), `wc-a2a-big-${Date.now()}.tgz`);
|
|
956
|
+
fs.writeFileSync(big, Buffer.alloc(2 * 1024 * 1024));
|
|
957
|
+
try {
|
|
958
|
+
const r = await installUpload(ctx, big);
|
|
959
|
+
expect(r.status).toBe(413);
|
|
960
|
+
expect(r.body).toMatchObject({ ok: false });
|
|
961
|
+
expect(r.body.error).toMatch(/超过 1MB/);
|
|
962
|
+
} finally {
|
|
963
|
+
fs.rmSync(big, { force: true });
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
test('install rejects a manifest whose name is not the a2a package', async () => {
|
|
968
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
969
|
+
const tgz = buildTarball({ 'package.json': JSON.stringify({ name: 'some-other-pkg', version: '1.0.0' }) });
|
|
970
|
+
try {
|
|
971
|
+
const r = await installUpload(ctx, tgz);
|
|
972
|
+
expect(r.status).toBe(400);
|
|
973
|
+
expect(r.body).toMatchObject({ ok: false });
|
|
974
|
+
expect(r.body.error).toMatch(/manifest name 不符/);
|
|
975
|
+
} finally {
|
|
976
|
+
fs.rmSync(tgz, { force: true });
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
test('install rejects a manifest without version', async () => {
|
|
981
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
982
|
+
const tgz = buildTarball({ 'package.json': JSON.stringify({ name: '@baize-ai/baize-a2a' }) });
|
|
983
|
+
try {
|
|
984
|
+
const r = await installUpload(ctx, tgz);
|
|
985
|
+
expect(r.status).toBe(400);
|
|
986
|
+
expect(r.body).toMatchObject({ ok: false });
|
|
987
|
+
expect(r.body.error).toMatch(/version/);
|
|
988
|
+
} finally {
|
|
989
|
+
fs.rmSync(tgz, { force: true });
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test('install rejects a tarball with a path-traversal entry', async () => {
|
|
994
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
995
|
+
const tgz = buildTraversalTarball();
|
|
996
|
+
try {
|
|
997
|
+
const r = await installUpload(ctx, tgz);
|
|
998
|
+
expect(r.status).toBe(400);
|
|
999
|
+
expect(r.body).toMatchObject({ ok: false });
|
|
1000
|
+
expect(r.body.error).toMatch(/不安全的路径/);
|
|
1001
|
+
} finally {
|
|
1002
|
+
fs.rmSync(tgz, { force: true });
|
|
1003
|
+
}
|
|
1004
|
+
// nothing may have been written outside SKILLS_DIR/a2a
|
|
1005
|
+
expect(fs.existsSync(path.join(ctx.skillsDir, 'a2a'))).toBe(false);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
test('install succeeds: extracts to SKILLS_DIR/a2a, runs npm install, registers components.json', async () => {
|
|
1009
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
1010
|
+
const tgz = buildTarball({
|
|
1011
|
+
'package.json': validManifest,
|
|
1012
|
+
'SKILL.md': '---\nname: a2a\n---\n# A2A\n',
|
|
1013
|
+
});
|
|
1014
|
+
try {
|
|
1015
|
+
const r = await installUpload(ctx, tgz);
|
|
1016
|
+
expect(r.status).toBe(200);
|
|
1017
|
+
expect(r.body).toEqual({ ok: true, version: '9.9.9' });
|
|
1018
|
+
} finally {
|
|
1019
|
+
fs.rmSync(tgz, { force: true });
|
|
1020
|
+
}
|
|
1021
|
+
const pkgPath = path.join(ctx.skillsDir, 'a2a', 'package.json');
|
|
1022
|
+
expect(fs.existsSync(pkgPath)).toBe(true);
|
|
1023
|
+
expect(JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version).toBe('9.9.9');
|
|
1024
|
+
const componentsPath = path.join(ctx.root, '.baize', 'components.json');
|
|
1025
|
+
const components = JSON.parse(fs.readFileSync(componentsPath, 'utf8'));
|
|
1026
|
+
expect(components.a2a).toMatchObject({
|
|
1027
|
+
version: '9.9.9',
|
|
1028
|
+
repo: 'baize-ai/baize-a2a',
|
|
1029
|
+
skillDir: path.join(ctx.skillsDir, 'a2a'),
|
|
1030
|
+
});
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('install rejects when a2a is already installed', async () => {
|
|
1034
|
+
ctx = await startServer({ extraEnv: a2aEnv() });
|
|
1035
|
+
const tgz = buildTarball({ 'package.json': validManifest });
|
|
1036
|
+
try {
|
|
1037
|
+
const first = await installUpload(ctx, tgz);
|
|
1038
|
+
expect(first.status).toBe(200);
|
|
1039
|
+
const second = await installUpload(ctx, tgz);
|
|
1040
|
+
expect(second.status).toBe(400);
|
|
1041
|
+
expect(second.body).toMatchObject({ ok: false });
|
|
1042
|
+
expect(second.body.error).toMatch(/已安装/);
|
|
1043
|
+
} finally {
|
|
1044
|
+
fs.rmSync(tgz, { force: true });
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test('install requires a session when a password is set', async () => {
|
|
1049
|
+
ctx = await startServer({ envContent: 'BAIZE_WEB_PASSWORD=secret123\n', extraEnv: a2aEnv() });
|
|
1050
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/install`, { method: 'POST' });
|
|
1051
|
+
expect(res.status).toBe(401);
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
describe('web-console scheduler tasks routes (D26)', () => {
|
|
1056
|
+
test('GET /api/admin/scheduler/tasks returns the tasks list (read-only)', async () => {
|
|
1057
|
+
ctx = await startServer();
|
|
1058
|
+
// seed scheduler.db with two tasks
|
|
1059
|
+
const schedDir = path.join(ctx.baseUrl.replace(/^http:\/\/[^:]+:\d+/, ''), 'scheduler'); // placeholder; real seed below
|
|
1060
|
+
void schedDir;
|
|
1061
|
+
const schedRoot = path.join(path.dirname(ctx.baseUrl.replace(/^http:\/\/[^:]+:\d+/, '')), 'scheduler');
|
|
1062
|
+
void schedRoot;
|
|
1063
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/scheduler/tasks`);
|
|
1064
|
+
expect(res.status).toBe(200);
|
|
1065
|
+
const body = await res.json();
|
|
1066
|
+
expect(body.success).toBe(true);
|
|
1067
|
+
expect(Array.isArray(body.tasks)).toBe(true);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
test('tolerates missing scheduler db (not started)', async () => {
|
|
1071
|
+
ctx = await startServer();
|
|
1072
|
+
const res = await fetch(`${ctx.baseUrl}/api/admin/scheduler/tasks`);
|
|
1073
|
+
const body = await res.json();
|
|
1074
|
+
expect(body.available).toBe(false);
|
|
1075
|
+
expect(body.hint).toMatch(/scheduler/);
|
|
1076
|
+
});
|
|
1077
|
+
});
|