@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.
Files changed (44) hide show
  1. package/.dockerignore +8 -18
  2. package/CHANGELOG.md +24 -1
  3. package/Dockerfile +66 -12
  4. package/README.md +85 -6
  5. package/README.zh-CN.md +63 -3
  6. package/assets/logo.png +0 -0
  7. package/cli/baize.js +6 -0
  8. package/cli/commands/a2a.js +124 -0
  9. package/cli/commands/add.js +126 -86
  10. package/cli/commands/component.js +27 -4
  11. package/cli/commands/doctor.js +2 -2
  12. package/cli/commands/init.js +28 -0
  13. package/cli/commands/self-uninstall.js +1 -1
  14. package/cli/lib/__tests__/instruction-split.test.js +1 -1
  15. package/cli/lib/a2a.js +235 -0
  16. package/cli/lib/lock.js +3 -3
  17. package/cli/lib/self-upgrade.js +1 -1
  18. package/docker-compose.yml +1 -1
  19. package/docs/docker.md +18 -4
  20. package/docs/release.md +150 -0
  21. package/package.json +1 -1
  22. package/scripts/docker-publish.sh +70 -0
  23. package/scripts/install.sh +4 -4
  24. package/scripts/pack-release.sh +361 -0
  25. package/skills/comm-bridge/package.json +7 -3
  26. package/skills/comm-bridge/scripts/c4-receive.js +23 -2
  27. package/skills/scheduler/package.json +2 -2
  28. package/skills/web-console/SKILL.md +34 -0
  29. package/skills/web-console/package.json +2 -2
  30. package/skills/web-console/public/app.js +967 -4
  31. package/skills/web-console/public/index.html +141 -0
  32. package/skills/web-console/public/styles.css +136 -0
  33. package/skills/web-console/scripts/a2a-admin.js +533 -0
  34. package/skills/web-console/scripts/channel-admin.js +99 -16
  35. package/skills/web-console/scripts/server.js +395 -1
  36. package/skills/web-console/scripts/skill-catalog.js +179 -0
  37. package/templates/claude-system.md +22 -0
  38. package/templates/pm2/ecosystem.config.cjs +4 -1
  39. package/test/a2a-cli.test.js +180 -0
  40. package/test/agent-card-api.test.js +261 -0
  41. package/test/channel-admin.test.js +87 -0
  42. package/test/component-lock.test.js +216 -0
  43. package/test/skill-catalog.test.js +118 -0
  44. package/test/web-console-routes.test.js +512 -1
@@ -0,0 +1,180 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, test, jest } from '@jest/globals';
5
+ import { a2aCliPath, a2aCliCandidates, formatA2aOutput } from '../cli/lib/a2a.js';
6
+ import { a2aCommand, A2A_SUBCOMMANDS } from '../cli/commands/a2a.js';
7
+
8
+ let tmp;
9
+ let envPath;
10
+
11
+ beforeEach(() => {
12
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'a2a-cli-'));
13
+ envPath = path.join(tmp, 'fake-a2a', 'scripts', 'cli.js');
14
+ fs.mkdirSync(path.dirname(envPath), { recursive: true });
15
+ fs.writeFileSync(envPath, 'console.log(JSON.stringify({ ok: true }));\n');
16
+ });
17
+
18
+ afterEach(() => {
19
+ delete process.env.BAIZE_A2A_PATH;
20
+ fs.rmSync(tmp, { recursive: true, force: true });
21
+ });
22
+
23
+ function runCommand(args, { runResult, cli = envPath, exit = () => {}, log = () => {}, logError = () => {} } = {}) {
24
+ const runA2aCli = runResult !== undefined
25
+ ? jest.fn(async () => runResult)
26
+ : jest.fn(async () => ({ success: true, json: { ok: true }, output: '{"ok":true}' }));
27
+ const result = { exitCode: null, logs: [], errors: [] };
28
+ return a2aCommand(args, {
29
+ cliPath: cli,
30
+ runA2aCli,
31
+ exit: (code) => { result.exitCode = code; },
32
+ log: (s) => result.logs.push(s),
33
+ logError: (s) => result.errors.push(s),
34
+ }).then(() => ({ runA2aCli, result }));
35
+ }
36
+
37
+ describe('a2aCliPath resolution', () => {
38
+ test('BAIZE_A2A_PATH file wins', () => {
39
+ process.env.BAIZE_A2A_PATH = envPath;
40
+ expect(a2aCliPath()).toBe(envPath);
41
+ });
42
+ test('BAIZE_A2A_PATH directory resolves scripts/cli.js inside', () => {
43
+ process.env.BAIZE_A2A_PATH = path.dirname(path.dirname(envPath));
44
+ expect(a2aCliPath()).toBe(envPath);
45
+ });
46
+ test('nonexistent env path falls through to candidates', () => {
47
+ process.env.BAIZE_A2A_PATH = path.join(tmp, 'missing', 'cli.js');
48
+ const found = a2aCliPath();
49
+ // In the dev workspace the ../baize-a2a sibling exists and is the fallback;
50
+ // otherwise (no install anywhere) resolution returns null.
51
+ const devSibling = a2aCliCandidates().at(-1);
52
+ if (fs.existsSync(devSibling)) {
53
+ expect(found).toBe(devSibling);
54
+ } else {
55
+ expect(found).toBeNull();
56
+ }
57
+ });
58
+ test('candidate list orders local node_modules → global npm → dev sibling', () => {
59
+ const candidates = a2aCliCandidates();
60
+ expect(candidates.length).toBeGreaterThanOrEqual(3);
61
+ // dev sibling is the final fallback
62
+ expect(candidates.at(-1).endsWith(path.join('baize-a2a', 'scripts', 'cli.js'))).toBe(true);
63
+ expect(candidates.some((c) => c.includes(path.join('@baize-ai', 'baize-a2a', 'scripts', 'cli.js')))).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe('a2aCommand help and validation', () => {
68
+ test('no args prints help listing all subcommands', async () => {
69
+ const { result } = await runCommand([]);
70
+ const help = result.logs.join('\n');
71
+ expect(help).toContain('baize a2a');
72
+ for (const s of ['status', 'enable', 'disable', 'peer list', 'search', 'send', 'task status', 'task cancel']) {
73
+ expect(help).toContain(s);
74
+ }
75
+ expect(result.exitCode).toBeNull();
76
+ });
77
+ test('--help prints help without delegating', async () => {
78
+ const { runA2aCli, result } = await runCommand(['--help']);
79
+ expect(result.logs.join('\n')).toContain('Subcommands');
80
+ expect(runA2aCli).not.toHaveBeenCalled();
81
+ });
82
+ test('unknown subcommand exits 1 with error', async () => {
83
+ const { runA2aCli, result } = await runCommand(['bogus']);
84
+ expect(result.exitCode).toBe(1);
85
+ expect(result.errors.join('\n')).toMatch(/Unknown a2a subcommand: bogus/);
86
+ expect(runA2aCli).not.toHaveBeenCalled();
87
+ });
88
+ test('exposes every contract subcommand in A2A_SUBCOMMANDS', () => {
89
+ expect(A2A_SUBCOMMANDS).toEqual(['status', 'enable', 'disable', 'peer', 'search', 'send', 'task']);
90
+ });
91
+ });
92
+
93
+ describe('a2aCommand delegation', () => {
94
+ test('status delegates with --json appended and prints formatted output', async () => {
95
+ const { runA2aCli, result } = await runCommand(['status'], {
96
+ runResult: {
97
+ success: true,
98
+ json: {
99
+ enabled: true, agentId: 'agent_1', registered: false, registrationStatus: 'pending',
100
+ advertiseUrl: 'https://a2a.example.com', endpoint: 'https://a2a.example.com/a2a/v1', version: '0.1.0',
101
+ },
102
+ output: '{}',
103
+ },
104
+ });
105
+ expect(runA2aCli).toHaveBeenCalledWith(['status'], expect.objectContaining({ cliPath: envPath }));
106
+ const out = result.logs.join('\n');
107
+ expect(out).toContain('启用: 是');
108
+ expect(out).toContain('已注册: 否');
109
+ expect(out).toContain('Agent ID: agent_1');
110
+ expect(out).toContain('注册状态: pending');
111
+ expect(result.exitCode).toBeNull();
112
+ });
113
+ test('enable passes through --admin/--advertise flags', async () => {
114
+ const { runA2aCli } = await runCommand(['enable', '--admin', 'https://admin.example.com', '--advertise', 'https://a2a.example.com'], {
115
+ runResult: { success: true, json: { agentId: 'a', registrationId: 'r', status: 'pending' }, output: '{}' },
116
+ });
117
+ expect(runA2aCli).toHaveBeenCalledWith(
118
+ ['enable', '--admin', 'https://admin.example.com', '--advertise', 'https://a2a.example.com'],
119
+ expect.objectContaining({ cliPath: envPath }),
120
+ );
121
+ });
122
+ test('task dispatch (sync) uses the 10-minute timeout', async () => {
123
+ const { runA2aCli } = await runCommand(['task', 'agent_x', 'do it'], {
124
+ runResult: { success: true, json: { taskId: 't1', status: 'completed' }, output: '{}' },
125
+ });
126
+ expect(runA2aCli).toHaveBeenCalledWith(['task', 'agent_x', 'do it'], expect.objectContaining({ timeout: 660000 }));
127
+ });
128
+ test('task with --async and task status use the default timeout', async () => {
129
+ const res = { success: true, json: { taskId: 't1', status: 'queued' }, output: '{}' };
130
+ const a = await runCommand(['task', 'agent_x', 'do it', '--async'], { runResult: res });
131
+ expect(a.runA2aCli).toHaveBeenCalledWith(['task', 'agent_x', 'do it', '--async'], expect.objectContaining({ timeout: 120000 }));
132
+ const b = await runCommand(['task', 'status', 't1'], { runResult: res });
133
+ expect(b.runA2aCli).toHaveBeenCalledWith(['task', 'status', 't1'], expect.objectContaining({ timeout: 120000 }));
134
+ });
135
+ test('existing --json flag is not duplicated', async () => {
136
+ const { runA2aCli } = await runCommand(['status', '--json'], {
137
+ runResult: { success: true, json: { enabled: false }, output: '{}' },
138
+ });
139
+ expect(runA2aCli).toHaveBeenCalledWith(['status', '--json'], expect.anything());
140
+ });
141
+ test('missing CLI exits 1 with guidance', async () => {
142
+ const { runA2aCli, result } = await runCommand(['status'], { cli: null });
143
+ expect(result.exitCode).toBe(1);
144
+ expect(result.errors.join('\n')).toMatch(/未找到 baize-a2a CLI/);
145
+ expect(runA2aCli).not.toHaveBeenCalled();
146
+ });
147
+ test('delegated failure prints error and exits 1', async () => {
148
+ const { result } = await runCommand(['status'], {
149
+ runResult: { success: false, error: '注册未通过审批', output: '{"error":"注册未通过审批"}' },
150
+ });
151
+ expect(result.exitCode).toBe(1);
152
+ expect(result.errors.join('\n')).toContain('注册未通过审批');
153
+ });
154
+ });
155
+
156
+ describe('formatA2aOutput', () => {
157
+ test('status shapes booleans as 是/否', () => {
158
+ const out = formatA2aOutput('status', { enabled: true, agentId: 'a1', registered: false });
159
+ expect(out).toContain('启用: 是');
160
+ expect(out).toContain('已注册: 否');
161
+ expect(out).toContain('Agent ID: a1');
162
+ });
163
+ test('peer list renders agent entries with endpoints', () => {
164
+ const out = formatA2aOutput('peer-list', { peers: [{ agentId: 'p1', endpoint: 'https://p1/a2a/v1', skills: [{ id: 's1' }] }] });
165
+ expect(out).toContain('p1');
166
+ expect(out).toContain('endpoint: https://p1/a2a/v1');
167
+ expect(out).toContain('skills: s1');
168
+ });
169
+ test('search renders agent rows', () => {
170
+ const out = formatA2aOutput('search', { agents: [{ agentId: 'x', name: 'X', status: 'approved' }] });
171
+ expect(out).toContain('x(X)');
172
+ expect(out).toContain('approved');
173
+ });
174
+ test('disable renders ok message', () => {
175
+ expect(formatA2aOutput('disable', { ok: true })).toContain('A2A 已停用');
176
+ });
177
+ test('unknown kind falls back to pretty JSON', () => {
178
+ expect(formatA2aOutput('weird', { a: 1 })).toContain('"a": 1');
179
+ });
180
+ });
@@ -0,0 +1,261 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { afterEach, beforeEach, describe, expect, test } from '@jest/globals';
7
+ import Database from '../skills/web-console/node_modules/better-sqlite3/lib/index.js';
8
+
9
+ const SERVER_PATH = path.resolve('skills/web-console/scripts/server.js');
10
+
11
+ let ctx;
12
+
13
+ function freePort() {
14
+ return new Promise((resolve, reject) => {
15
+ const server = net.createServer();
16
+ server.once('error', reject);
17
+ server.listen(0, '127.0.0.1', () => {
18
+ const { port } = server.address();
19
+ server.close(() => resolve(port));
20
+ });
21
+ });
22
+ }
23
+
24
+ function createDb(dbPath) {
25
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
26
+ const db = new Database(dbPath);
27
+ db.exec(`
28
+ CREATE TABLE conversations (
29
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ direction TEXT,
31
+ channel TEXT,
32
+ endpoint_id TEXT,
33
+ content TEXT,
34
+ timestamp TEXT
35
+ );
36
+ `);
37
+ db.close();
38
+ }
39
+
40
+ function writeSkill(skillsDir, dir, name, description) {
41
+ fs.mkdirSync(path.join(skillsDir, dir), { recursive: true });
42
+ fs.writeFileSync(
43
+ path.join(skillsDir, dir, 'SKILL.md'),
44
+ `---\nname: ${name}\ndescription: ${description}\n---\n`
45
+ );
46
+ }
47
+
48
+ async function startServer({ envContent = '' } = {}) {
49
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wc-card-api-'));
50
+ const skillsDir = path.join(root, 'skills');
51
+ writeSkill(skillsDir, 'doccraft', 'Doc Craft', 'Craft documentation');
52
+ writeSkill(skillsDir, 'memory', 'Memory', '>-\n Persistent memory\n across sessions');
53
+ writeSkill(skillsDir, 'feishu', 'Feishu', 'Feishu channel');
54
+ createDb(path.join(root, 'comm-bridge', 'c4.db'));
55
+ fs.mkdirSync(path.join(root, 'activity-monitor'), { recursive: true });
56
+ fs.writeFileSync(path.join(root, 'activity-monitor', 'agent-status.json'), '{"state":"idle"}');
57
+ fs.writeFileSync(path.join(root, '.env'), envContent);
58
+ const port = await freePort();
59
+
60
+ const child = spawn(process.execPath, [SERVER_PATH], {
61
+ cwd: path.resolve('.'),
62
+ env: {
63
+ ...process.env,
64
+ BAIZE_DIR: root,
65
+ WEB_CONSOLE_SKILLS_DIR: skillsDir,
66
+ WEB_CONSOLE_PORT: String(port),
67
+ WEB_CONSOLE_BIND: '127.0.0.1',
68
+ },
69
+ stdio: ['ignore', 'pipe', 'pipe']
70
+ });
71
+
72
+ let output = '';
73
+ child.stdout.on('data', (chunk) => { output += chunk.toString(); });
74
+ child.stderr.on('data', (chunk) => { output += chunk.toString(); });
75
+
76
+ const baseUrl = `http://127.0.0.1:${port}`;
77
+
78
+ // /api/health sits behind the auth gate when a password is set — log in first.
79
+ const passwordMatch = envContent.match(/^BAIZE_WEB_PASSWORD=(.*)$/m);
80
+ let probeHeaders = {};
81
+ if (passwordMatch) {
82
+ const loginDeadline = Date.now() + 5000;
83
+ while (Date.now() < loginDeadline) {
84
+ try {
85
+ const loginRes = await fetch(`${baseUrl}/api/auth`, {
86
+ method: 'POST',
87
+ headers: { 'Content-Type': 'application/json' },
88
+ body: JSON.stringify({ password: passwordMatch[1] }),
89
+ });
90
+ const setCookie = loginRes.headers.get('set-cookie');
91
+ if (setCookie) {
92
+ probeHeaders = { Cookie: setCookie.split(';')[0] };
93
+ break;
94
+ }
95
+ } catch {
96
+ // Not listening yet — retry.
97
+ }
98
+ await new Promise((resolve) => setTimeout(resolve, 50));
99
+ }
100
+ }
101
+
102
+ const deadline = Date.now() + 5000;
103
+ while (Date.now() < deadline) {
104
+ if (child.exitCode !== null) throw new Error(`server exited early: ${output}`);
105
+ try {
106
+ const res = await fetch(`${baseUrl}/api/health`, { headers: probeHeaders });
107
+ if (res.ok) return { root, skillsDir, baseUrl, child };
108
+ } catch {
109
+ // Retry until server is listening.
110
+ }
111
+ await new Promise((resolve) => setTimeout(resolve, 50));
112
+ }
113
+
114
+ child.kill('SIGTERM');
115
+ throw new Error(`server did not start: ${output}`);
116
+ }
117
+
118
+ function stopServer(active) {
119
+ if (!active) return;
120
+ active.child.kill('SIGTERM');
121
+ fs.rmSync(active.root, { recursive: true, force: true });
122
+ }
123
+
124
+ async function putCard(baseUrl, payload) {
125
+ const res = await fetch(`${baseUrl}/api/admin/a2a/card`, {
126
+ method: 'PUT',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify(payload),
129
+ });
130
+ return { status: res.status, body: await res.json() };
131
+ }
132
+
133
+ function readConfig(root) {
134
+ const file = path.join(root, 'components', 'a2a', 'config.json');
135
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
136
+ }
137
+
138
+ beforeEach(() => {
139
+ ctx = null;
140
+ });
141
+
142
+ afterEach(() => {
143
+ stopServer(ctx);
144
+ });
145
+
146
+ describe('web-console agent card API (D21)', () => {
147
+ test('GET /api/admin/a2a/card/skills lists installed skills sorted by name', async () => {
148
+ ctx = await startServer();
149
+ const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/card/skills`);
150
+ expect(res.status).toBe(200);
151
+ const { success, skills } = await res.json();
152
+ expect(success).toBe(true);
153
+ expect(skills.map((s) => s.id)).toEqual(['doccraft', 'feishu', 'memory']);
154
+ expect(skills[0]).toMatchObject({ id: 'doccraft', name: 'Doc Craft', description: 'Craft documentation' });
155
+ });
156
+
157
+ test('GET /api/admin/a2a/card returns defaults when config.json is absent', async () => {
158
+ ctx = await startServer();
159
+ const res = await fetch(`${ctx.baseUrl}/api/admin/a2a/card`);
160
+ expect(res.status).toBe(200);
161
+ const body = await res.json();
162
+ expect(body).toMatchObject({ success: true, name: 'Baize Agent', description: 'A Baize agent reachable over the baize-a2a protocol.', skills: [] });
163
+ });
164
+
165
+ test('PUT persists the card to config.json and returns the saved card', async () => {
166
+ ctx = await startServer();
167
+ const { status, body } = await putCard(ctx.baseUrl, {
168
+ name: 'My Baize Agent',
169
+ description: 'A helpful agent',
170
+ skills: ['doccraft', 'memory'],
171
+ });
172
+ expect(status).toBe(200);
173
+ expect(body).toMatchObject({
174
+ success: true,
175
+ name: 'My Baize Agent',
176
+ description: 'A helpful agent',
177
+ skills: [
178
+ { id: 'doccraft', name: 'Doc Craft', description: 'Craft documentation' },
179
+ { id: 'memory', name: 'Memory', description: 'Persistent memory across sessions' },
180
+ ],
181
+ });
182
+
183
+ const cfg = readConfig(ctx.root);
184
+ expect(cfg.name).toBe('My Baize Agent');
185
+ expect(cfg.description).toBe('A helpful agent');
186
+ expect(cfg.skills.map((s) => s.id)).toEqual(['doccraft', 'memory']);
187
+
188
+ // GET now reflects the saved card
189
+ const getRes = await (await fetch(`${ctx.baseUrl}/api/admin/a2a/card`)).json();
190
+ expect(getRes).toMatchObject({ name: 'My Baize Agent', description: 'A helpful agent' });
191
+ expect(getRes.skills.map((s) => s.id)).toEqual(['doccraft', 'memory']);
192
+ });
193
+
194
+ test('PUT preserves other config.json fields', async () => {
195
+ ctx = await startServer();
196
+ const configDir = path.join(ctx.root, 'components', 'a2a');
197
+ fs.mkdirSync(configDir, { recursive: true });
198
+ fs.writeFileSync(path.join(configDir, 'config.json'), JSON.stringify({
199
+ enabled: true,
200
+ agentId: 'agent-1',
201
+ advertiseUrl: 'https://a2a.example.com',
202
+ name: 'Old Name',
203
+ skills: [],
204
+ listenPort: 8443,
205
+ }));
206
+
207
+ const { status } = await putCard(ctx.baseUrl, { name: 'New Name', description: '', skills: [] });
208
+ expect(status).toBe(200);
209
+ const cfg = readConfig(ctx.root);
210
+ expect(cfg.enabled).toBe(true);
211
+ expect(cfg.agentId).toBe('agent-1');
212
+ expect(cfg.advertiseUrl).toBe('https://a2a.example.com');
213
+ expect(cfg.listenPort).toBe(8443);
214
+ expect(cfg.name).toBe('New Name');
215
+ });
216
+
217
+ test('PUT rejects missing or over-long name with 400', async () => {
218
+ ctx = await startServer();
219
+ const missing = await putCard(ctx.baseUrl, { description: 'x', skills: [] });
220
+ expect(missing.status).toBe(400);
221
+ expect(missing.body.success).toBe(false);
222
+
223
+ const tooLong = await putCard(ctx.baseUrl, { name: 'x'.repeat(61), description: '', skills: [] });
224
+ expect(tooLong.status).toBe(400);
225
+ expect(tooLong.body.success).toBe(false);
226
+ expect(tooLong.body.error).toContain('60');
227
+ });
228
+
229
+ test('PUT rejects over-long description with 400', async () => {
230
+ ctx = await startServer();
231
+ const res = await putCard(ctx.baseUrl, { name: 'ok', description: 'x'.repeat(301), skills: [] });
232
+ expect(res.status).toBe(400);
233
+ expect(res.body.success).toBe(false);
234
+ expect(res.body.error).toContain('300');
235
+ });
236
+
237
+ test('PUT rejects skills ids that are not installed', async () => {
238
+ ctx = await startServer();
239
+ const res = await putCard(ctx.baseUrl, { name: 'ok', description: '', skills: ['doccraft', 'made-up-skill'] });
240
+ expect(res.status).toBe(400);
241
+ expect(res.body.success).toBe(false);
242
+ expect(res.body.error).toContain('made-up-skill');
243
+ expect(fs.existsSync(path.join(ctx.root, 'components', 'a2a', 'config.json'))).toBe(false);
244
+ });
245
+
246
+ test('PUT rejects duplicate skills ids', async () => {
247
+ ctx = await startServer();
248
+ const res = await putCard(ctx.baseUrl, { name: 'ok', description: '', skills: ['doccraft', 'doccraft'] });
249
+ expect(res.status).toBe(400);
250
+ expect(res.body.success).toBe(false);
251
+ expect(res.body.error).toContain('doccraft');
252
+ });
253
+
254
+ test('card routes require session when a password is set', async () => {
255
+ ctx = await startServer({ envContent: 'BAIZE_WEB_PASSWORD=secret123\n' });
256
+ const get = await fetch(`${ctx.baseUrl}/api/admin/a2a/card`);
257
+ expect(get.status).toBe(401);
258
+ const put = await putCard(ctx.baseUrl, { name: 'x', description: '', skills: [] });
259
+ expect(put.status).toBe(401);
260
+ });
261
+ });
@@ -233,3 +233,90 @@ describe('installChannel / uninstallChannel', () => {
233
233
  expect(ca.cliPath()).toBe(repoCli);
234
234
  });
235
235
  });
236
+
237
+ describe('a2a builtin channel (D19 单元 C)', () => {
238
+ const a2aSchemaKeys = ['enabled', 'adminUrl', 'agentId', 'advertiseUrl', 'listenPort', 'certKeyPath', 'certCertPath'];
239
+
240
+ test('catalogue includes a2a with the contract config schema (target: config)', async () => {
241
+ const channels = await ca.discoverChannels({ fetch: noResultsFetch });
242
+ const a2a = channels.find((c) => c.name === 'a2a');
243
+ expect(a2a).toBeTruthy();
244
+ expect(a2a).toMatchObject({
245
+ npmPkg: '@baize-ai/baize-a2a',
246
+ repo: 'baize-ai/baize-a2a',
247
+ installed: false,
248
+ });
249
+ expect(a2a.configSchema.map((f) => f.key)).toEqual(a2aSchemaKeys);
250
+ expect(a2a.configSchema.every((f) => f.target === 'config')).toBe(true);
251
+ // nested cert paths persist via dotted configKey
252
+ expect(a2a.configSchema.find((f) => f.key === 'certKeyPath').configKey).toBe('cert.keyPath');
253
+ expect(a2a.configSchema.find((f) => f.key === 'certCertPath').configKey).toBe('cert.certPath');
254
+ });
255
+
256
+ test('verify: enabling without adminUrl/advertiseUrl is rejected', async () => {
257
+ const r1 = await ca.configureChannel('a2a', { enabled: 'true' });
258
+ expect(r1.success).toBe(false);
259
+ expect(r1.error).toMatch(/Admin 服务地址/);
260
+ const r2 = await ca.configureChannel('a2a', { enabled: 'true', adminUrl: 'https://admin.example.com' });
261
+ expect(r2.success).toBe(false);
262
+ expect(r2.error).toMatch(/对外宣告地址/);
263
+ });
264
+
265
+ test('disabled config does not require admin/advertise and persists enabled:false', async () => {
266
+ const r = await ca.configureChannel('a2a', { enabled: 'false' });
267
+ expect(r.success).toBe(true);
268
+ const config = JSON.parse(fs.readFileSync(path.join(baizeDir, 'components', 'a2a', 'config.json'), 'utf8'));
269
+ expect(config.enabled).toBe(false);
270
+ });
271
+
272
+ test('enabled config persists boolean enabled + adminUrl + advertiseUrl + nested cert', async () => {
273
+ const r = await ca.configureChannel('a2a', {
274
+ enabled: 'true',
275
+ adminUrl: 'https://admin.example.com',
276
+ advertiseUrl: 'https://a2a.example.com',
277
+ certKeyPath: '/etc/ssl/key.pem',
278
+ certCertPath: '/etc/ssl/cert.pem',
279
+ });
280
+ expect(r.success).toBe(true);
281
+ const config = JSON.parse(fs.readFileSync(path.join(baizeDir, 'components', 'a2a', 'config.json'), 'utf8'));
282
+ expect(config.enabled).toBe(true); // real boolean, not "true"
283
+ expect(config.adminUrl).toBe('https://admin.example.com');
284
+ expect(config.advertiseUrl).toBe('https://a2a.example.com');
285
+ expect(config.cert).toEqual({ keyPath: '/etc/ssl/key.pem', certPath: '/etc/ssl/cert.pem' });
286
+ });
287
+
288
+ test('channelStatus for installed a2a: configured only when enabled + both urls', async () => {
289
+ const entry = (await ca.discoverChannels({ fetch: noResultsFetch })).find((c) => c.name === 'a2a');
290
+ fs.writeFileSync(path.join(baizeDir, '.baize', 'components.json'), JSON.stringify({
291
+ a2a: { version: '0.1.0', source: { type: 'npm' } },
292
+ }));
293
+ // installed but not configured → not configured
294
+ const st0 = ca.channelStatus({ ...entry, installed: true }, {});
295
+ expect(st0.configured).toBe(false);
296
+ const a2aCfgDir = path.join(baizeDir, 'components', 'a2a');
297
+ fs.mkdirSync(a2aCfgDir, { recursive: true });
298
+ // disabled but urls present → not configured
299
+ fs.writeFileSync(path.join(a2aCfgDir, 'config.json'), JSON.stringify({
300
+ enabled: false, adminUrl: 'https://admin.example.com', advertiseUrl: 'https://a2a.example.com',
301
+ }));
302
+ const st1 = ca.channelStatus({ ...entry, installed: true }, {});
303
+ expect(st1.configured).toBe(false);
304
+ expect(st1.configSummary).toContain('未启用');
305
+ // enabled + urls → configured
306
+ fs.writeFileSync(path.join(a2aCfgDir, 'config.json'), JSON.stringify({
307
+ enabled: true, adminUrl: 'https://admin.example.com', advertiseUrl: 'https://a2a.example.com',
308
+ }));
309
+ const st2 = ca.channelStatus({ ...entry, installed: true }, {});
310
+ expect(st2.configured).toBe(true);
311
+ expect(st2.configSummary).toContain('admin: https://admin.example.com');
312
+ // generic lifecycle: a2a has no bespoke start/stop → channelAction falls through
313
+ expect(typeof ca.channelAction).toBe('function');
314
+ });
315
+
316
+ test('a2a lifecycle uses the generic PM2 path (no bespoke start hook)', async () => {
317
+ const r = await ca.channelAction('a2a', 'start');
318
+ // not installed → genericLifecycle reports not installed (no bespoke hook crashed)
319
+ expect(r.success).toBe(false);
320
+ expect(r.error).toMatch(/未安装/);
321
+ });
322
+ });