@baize-ai/core 0.2.0 → 0.3.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/.dockerignore +8 -18
- package/CHANGELOG.md +19 -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 +900 -4
- package/skills/web-console/public/index.html +126 -0
- package/skills/web-console/public/styles.css +130 -0
- package/skills/web-console/scripts/a2a-admin.js +506 -0
- package/skills/web-console/scripts/channel-admin.js +99 -16
- package/skills/web-console/scripts/server.js +386 -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 +488 -1
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A admin backend for the web console.
|
|
3
|
+
*
|
|
4
|
+
* Proxies to the @baize-ai/baize-a2a scripts/cli.js (D19 contract §6) for
|
|
5
|
+
* status/enable/disable/peers/search/send/task, and reads two local SQLite
|
|
6
|
+
* databases directly for the read-only boards:
|
|
7
|
+
* - task_runs board: ~/baize/scheduler/scheduler.db (source = 'a2a', §3)
|
|
8
|
+
* - message log: ~/baize/components/a2a/a2a.db (inbox/outbox, §0 db.js)
|
|
9
|
+
*
|
|
10
|
+
* The CLI resolution mirrors channel-admin.js cliPath()/runCli (web-console
|
|
11
|
+
* is deployed as a standalone skill, so it cannot import cli/lib).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import net from 'node:net';
|
|
18
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import Database from 'better-sqlite3';
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = path.dirname(__filename);
|
|
24
|
+
|
|
25
|
+
function baizeDir() {
|
|
26
|
+
return process.env.BAIZE_DIR || path.join(os.homedir(), 'baize');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Locate the baize-a2a scripts/cli.js. Precedence:
|
|
31
|
+
* BAIZE_A2A_PATH env → channel install (~/baize/.claude/skills/a2a) →
|
|
32
|
+
* global npm (@baize-ai/baize-a2a) → dev sibling ../baize-a2a.
|
|
33
|
+
*/
|
|
34
|
+
export function a2aCliPath() {
|
|
35
|
+
const envPath = process.env.BAIZE_A2A_PATH;
|
|
36
|
+
if (envPath && fs.existsSync(envPath)) {
|
|
37
|
+
if (fs.statSync(envPath).isDirectory()) {
|
|
38
|
+
const inner = path.join(envPath, 'scripts', 'cli.js');
|
|
39
|
+
if (fs.existsSync(inner)) return inner;
|
|
40
|
+
} else {
|
|
41
|
+
return envPath;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// baize-a2a is a channel package — `baize add` installs it under
|
|
45
|
+
// ~/baize/.claude/skills/a2a/ (SKILL.md layout), not npm global.
|
|
46
|
+
const baizeDir = process.env.BAIZE_DIR || path.join(os.homedir(), 'baize');
|
|
47
|
+
const candidates = [
|
|
48
|
+
path.join(baizeDir, '.claude', 'skills', 'a2a', 'scripts', 'cli.js'),
|
|
49
|
+
path.join(__dirname, '..', '..', '..', 'node_modules', '@baize-ai', 'baize-a2a', 'scripts', 'cli.js'),
|
|
50
|
+
];
|
|
51
|
+
try {
|
|
52
|
+
const npmRoot = execFileSync('npm', ['root', '-g'], { encoding: 'utf8', timeout: 10000 }).trim();
|
|
53
|
+
candidates.push(path.join(npmRoot, '@baize-ai', 'baize-a2a', 'scripts', 'cli.js'));
|
|
54
|
+
} catch {
|
|
55
|
+
// npm unavailable
|
|
56
|
+
}
|
|
57
|
+
// Dev-time sibling repo (../baize-a2a next to baize-core)
|
|
58
|
+
candidates.push(path.join(__dirname, '..', '..', '..', '..', 'baize-a2a', 'scripts', 'cli.js'));
|
|
59
|
+
return candidates.find((c) => fs.existsSync(c)) || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run a baize-a2a CLI command with --json appended (contract §6: JSON on
|
|
64
|
+
* stdout, exit 0 success / stderr + exit 1 failure).
|
|
65
|
+
*
|
|
66
|
+
* The child gets a piped stdin that is ended immediately: the baize-a2a CLI
|
|
67
|
+
* merges a JSON object from stdin and blocks until stdin EOF.
|
|
68
|
+
*/
|
|
69
|
+
export async function runA2aCli(args, { cliPath = a2aCliPath(), timeout = 120000, spawnFn = spawn } = {}) {
|
|
70
|
+
const cli = typeof cliPath === 'function' ? cliPath() : cliPath;
|
|
71
|
+
if (!cli) {
|
|
72
|
+
return { success: false, error: '未找到 baize-a2a CLI(设置 BAIZE_A2A_PATH 或安装 @baize-ai/baize-a2a)' };
|
|
73
|
+
}
|
|
74
|
+
const argv = args.includes('--json') ? [...args] : [...args, '--json'];
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
const child = spawnFn(process.execPath, [cli, ...argv], {
|
|
77
|
+
env: { ...process.env },
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
let stdout = '';
|
|
81
|
+
let stderr = '';
|
|
82
|
+
let settled = false;
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
child.kill('SIGTERM');
|
|
87
|
+
resolve({ success: false, error: `a2a 命令超时(${Math.round(timeout / 1000)}s)`, output: (stdout || stderr).trim() });
|
|
88
|
+
}, timeout);
|
|
89
|
+
child.stdout.on('data', (d) => { stdout += String(d); });
|
|
90
|
+
child.stderr.on('data', (d) => { stderr += String(d); });
|
|
91
|
+
child.on('error', (err) => {
|
|
92
|
+
if (settled) return;
|
|
93
|
+
settled = true;
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve({ success: false, error: err.message });
|
|
96
|
+
});
|
|
97
|
+
child.on('close', (code) => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
const raw = stdout.trim();
|
|
102
|
+
let json = null;
|
|
103
|
+
try {
|
|
104
|
+
json = raw ? JSON.parse(raw) : null;
|
|
105
|
+
} catch {
|
|
106
|
+
// non-JSON stdout
|
|
107
|
+
}
|
|
108
|
+
if (code === 0) {
|
|
109
|
+
resolve({ success: true, json: json || {}, output: raw });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const detail = (json?.error || stderr.trim() || raw || `exit ${code}`).slice(-1500);
|
|
113
|
+
resolve({ success: false, error: detail, json, output: raw || stderr.trim() });
|
|
114
|
+
});
|
|
115
|
+
child.stdin.end();
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Delegated commands (argv passthrough + field validation) ───────────────
|
|
120
|
+
|
|
121
|
+
export async function getA2aStatus() {
|
|
122
|
+
return runA2aCli(['status']);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function getA2aPeers() {
|
|
126
|
+
return runA2aCli(['peer', 'list']);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function searchA2aAgents(query) {
|
|
130
|
+
const q = String(query || '').trim();
|
|
131
|
+
if (!q) return { success: false, error: '缺少搜索关键字' };
|
|
132
|
+
return runA2aCli(['search', q]);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
export async function enableA2a({ adminUrl, advertiseUrl } = {}) {
|
|
137
|
+
const admin = String(adminUrl || '').trim();
|
|
138
|
+
const advertise = String(advertiseUrl || '').trim();
|
|
139
|
+
if (!admin || !advertise) return { success: false, error: '启用 A2A 需要 Admin 服务地址与对外宣告地址' };
|
|
140
|
+
// D25 switch contract: >60s is a failure.
|
|
141
|
+
return runA2aCli(['enable', '--admin', admin, '--advertise', advertise], { timeout: 60000 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function disableA2a() {
|
|
145
|
+
return runA2aCli(['disable'], { timeout: 60000 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function sendA2aMessage({ peer, text } = {}) {
|
|
149
|
+
const p = String(peer || '').trim();
|
|
150
|
+
const t = String(text || '').trim();
|
|
151
|
+
if (!p || !t) return { success: false, error: '发送消息需要 peer 与内容' };
|
|
152
|
+
return runA2aCli(['send', p, t]);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function submitA2aTask({ peer, skill, instruction, async: isAsync } = {}) {
|
|
156
|
+
const p = String(peer || '').trim();
|
|
157
|
+
const inst = String(instruction || '').trim();
|
|
158
|
+
if (!p || !inst) return { success: false, error: '派发任务需要 peer 与指令' };
|
|
159
|
+
const args = ['task', p];
|
|
160
|
+
if (String(skill || '').trim()) args.push(String(skill).trim());
|
|
161
|
+
args.push(inst);
|
|
162
|
+
if (isAsync) args.push('--async');
|
|
163
|
+
// Sync task waits for a terminal state (contract: up to 10 min).
|
|
164
|
+
return runA2aCli(args, { timeout: 660000 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function getA2aTask(taskId) {
|
|
168
|
+
const id = String(taskId || '').trim();
|
|
169
|
+
if (!id) return { success: false, error: '缺少 taskId' };
|
|
170
|
+
return runA2aCli(['task', 'status', id]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function cancelA2aTask(taskId) {
|
|
174
|
+
const id = String(taskId || '').trim();
|
|
175
|
+
if (!id) return { success: false, error: '缺少 taskId' };
|
|
176
|
+
return runA2aCli(['task', 'cancel', id]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Read-only boards ────────────────────────────────────────────────────────
|
|
180
|
+
// ── D25 单元 B: 私有交付安装 + 运行模式 / 守护进程健康 ───────────────────────
|
|
181
|
+
|
|
182
|
+
export const A2A_PACKAGE_NAME = '@baize-ai/baize-a2a';
|
|
183
|
+
export const A2A_MODES = Object.freeze(new Set(['standalone', 'cluster']));
|
|
184
|
+
const A2A_INSTALL_MAX_MB = 100;
|
|
185
|
+
const A2A_INSTALL_MAX_BYTES = A2A_INSTALL_MAX_MB * 1024 * 1024;
|
|
186
|
+
const TAR_TIMEOUT_MS = 30000;
|
|
187
|
+
const NPM_INSTALL_TIMEOUT_MS = 300000;
|
|
188
|
+
|
|
189
|
+
/** Same override chain as the web-console server (a2aConfigPath). */
|
|
190
|
+
export function a2aConfigPath() {
|
|
191
|
+
return process.env.BAIZE_A2A_CONFIG || path.join(baizeDir(), 'components', 'a2a', 'config.json');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function defaultSkillsDir() {
|
|
195
|
+
return process.env.WEB_CONSOLE_SKILLS_DIR || path.join(baizeDir(), '.claude', 'skills');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function componentsFile() {
|
|
199
|
+
return path.join(baizeDir(), '.baize', 'components.json');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readComponents(file) {
|
|
203
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { return {}; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function writeComponents(file, components) {
|
|
207
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
208
|
+
fs.writeFileSync(file, JSON.stringify(components, null, 2));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve the A2A run mode for display (D25 contract):
|
|
213
|
+
* CLI-reported mode → config.json mode → derived from enabled
|
|
214
|
+
* (legacy configs lack an explicit mode: enabled → 'cluster', else 'standalone').
|
|
215
|
+
*/
|
|
216
|
+
export function resolveA2aMode(cliMode) {
|
|
217
|
+
if (A2A_MODES.has(cliMode)) return cliMode;
|
|
218
|
+
let cfg = {};
|
|
219
|
+
try {
|
|
220
|
+
cfg = JSON.parse(fs.readFileSync(a2aConfigPath(), 'utf8'));
|
|
221
|
+
} catch {
|
|
222
|
+
return 'standalone';
|
|
223
|
+
}
|
|
224
|
+
if (A2A_MODES.has(cfg.mode)) return cfg.mode;
|
|
225
|
+
return cfg.enabled ? 'cluster' : 'standalone';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Daemon readiness = TCP connect to the configured listenPort (default 8443,
|
|
230
|
+
* config listenPort wins). The daemon is HTTPS; a plain TCP connect — not a
|
|
231
|
+
* TLS handshake — is the contract's readiness signal.
|
|
232
|
+
*/
|
|
233
|
+
export function probeA2aDaemonHealthy({ timeoutMs = 2000 } = {}) {
|
|
234
|
+
let port = 8443;
|
|
235
|
+
let host = '127.0.0.1';
|
|
236
|
+
try {
|
|
237
|
+
const cfg = JSON.parse(fs.readFileSync(a2aConfigPath(), 'utf8'));
|
|
238
|
+
const p = Number.parseInt(cfg.listenPort, 10);
|
|
239
|
+
if (Number.isInteger(p) && p > 0) port = p;
|
|
240
|
+
const rawHost = String(cfg.listenHost || '').trim();
|
|
241
|
+
if (rawHost && rawHost !== '0.0.0.0' && rawHost !== '::') host = rawHost;
|
|
242
|
+
} catch {
|
|
243
|
+
// defaults (8443 / 127.0.0.1)
|
|
244
|
+
}
|
|
245
|
+
return new Promise((resolve) => {
|
|
246
|
+
const socket = net.connect({ port, host });
|
|
247
|
+
let settled = false;
|
|
248
|
+
const finish = (ok) => {
|
|
249
|
+
if (settled) return;
|
|
250
|
+
settled = true;
|
|
251
|
+
socket.destroy();
|
|
252
|
+
resolve(ok);
|
|
253
|
+
};
|
|
254
|
+
socket.setTimeout(timeoutMs);
|
|
255
|
+
socket.once('connect', () => finish(true));
|
|
256
|
+
socket.once('timeout', () => finish(false));
|
|
257
|
+
socket.once('error', () => finish(false));
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function listTarballEntries(tarballPath) {
|
|
262
|
+
const listing = execFileSync('tar', ['tzf', tarballPath], {
|
|
263
|
+
timeout: TAR_TIMEOUT_MS, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
264
|
+
});
|
|
265
|
+
return listing.split('\n').map((e) => e.replace(/^\.\//, '').replace(/\/$/, '')).filter(Boolean);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isUnsafeArchiveEntry(entry) {
|
|
269
|
+
if (path.posix.isAbsolute(entry) || path.win32.isAbsolute(entry)) return true;
|
|
270
|
+
const normalized = entry.replace(/\\/g, '/');
|
|
271
|
+
return normalized === '..' || normalized.startsWith('../') || normalized.split('/').includes('..');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function assertNoArchiveLinks(tarballPath) {
|
|
275
|
+
const verbose = execFileSync('tar', ['tvzf', tarballPath], {
|
|
276
|
+
timeout: TAR_TIMEOUT_MS, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
277
|
+
});
|
|
278
|
+
const link = verbose.split('\n').find((line) => line.startsWith('l') || line.startsWith('h'));
|
|
279
|
+
if (link) throw new Error('归档包含符号链接或硬链接,已拒绝(防止越权写入)');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function readTarballPackageJson(tarballPath, entries) {
|
|
283
|
+
const pkgEntry = entries.find((e) => e === 'package.json' || e.endsWith('/package.json'));
|
|
284
|
+
if (!pkgEntry) return null;
|
|
285
|
+
const raw = execFileSync('tar', ['xzf', tarballPath, '-O', pkgEntry], {
|
|
286
|
+
timeout: TAR_TIMEOUT_MS, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
287
|
+
});
|
|
288
|
+
return JSON.parse(raw);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function extractTarballTo(tarballPath, destDir, entries) {
|
|
292
|
+
const firstParts = new Set(entries.map((e) => e.split('/')[0]));
|
|
293
|
+
const hasSingleWrapper = firstParts.size === 1 && entries.some((e) => e.includes('/'));
|
|
294
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
295
|
+
const args = ['xzf', tarballPath, '-C', destDir];
|
|
296
|
+
if (hasSingleWrapper) args.push('--strip-components=1');
|
|
297
|
+
execFileSync('tar', args, { timeout: TAR_TIMEOUT_MS, stdio: 'pipe' });
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function npmInstallDeps(cwd) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
// --ignore-scripts: uploaded tarballs are vendor-provided; never run
|
|
303
|
+
// their lifecycle hooks (postinstall etc.) with the web-console's privileges.
|
|
304
|
+
const child = spawn('npm', ['install', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], {
|
|
305
|
+
cwd, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'],
|
|
306
|
+
});
|
|
307
|
+
let stderr = '';
|
|
308
|
+
let settled = false;
|
|
309
|
+
child.stderr.on('data', (d) => { stderr += String(d); });
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
if (settled) return;
|
|
312
|
+
settled = true;
|
|
313
|
+
child.kill('SIGTERM');
|
|
314
|
+
resolve({ ok: false, error: `npm install 超时(${Math.round(NPM_INSTALL_TIMEOUT_MS / 1000)}s)` });
|
|
315
|
+
}, NPM_INSTALL_TIMEOUT_MS);
|
|
316
|
+
child.on('error', (err) => {
|
|
317
|
+
if (settled) return;
|
|
318
|
+
settled = true;
|
|
319
|
+
clearTimeout(timer);
|
|
320
|
+
resolve({ ok: false, error: err.message });
|
|
321
|
+
});
|
|
322
|
+
child.on('close', (code) => {
|
|
323
|
+
if (settled) return;
|
|
324
|
+
settled = true;
|
|
325
|
+
clearTimeout(timer);
|
|
326
|
+
resolve(code === 0 ? { ok: true } : { ok: false, error: (stderr.trim() || `exit ${code}`).slice(-500) });
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Install @baize-ai/baize-a2a from an uploaded .tar.gz (D25 私有交付).
|
|
333
|
+
* Pipeline: entry listing + path-safety vetting (no `..`/absolute/links) →
|
|
334
|
+
* manifest check (name = @baize-ai/baize-a2a, version present) → extract to
|
|
335
|
+
* SKILLS_DIR/a2a → npm install --omit=dev → components.json registration.
|
|
336
|
+
* Already installed → { ok:false, error } naming the installed version
|
|
337
|
+
* (mirrors `baize add`; version bumps go through `baize upgrade a2a`).
|
|
338
|
+
*
|
|
339
|
+
* @returns {Promise<{ok:boolean, version?:string, error?:string}>}
|
|
340
|
+
*/
|
|
341
|
+
export async function installA2aTarball(tarballPath, {
|
|
342
|
+
skillsDir = defaultSkillsDir(),
|
|
343
|
+
componentsFile: componentsPath = componentsFile(),
|
|
344
|
+
installDeps = npmInstallDeps,
|
|
345
|
+
originalName = null,
|
|
346
|
+
} = {}) {
|
|
347
|
+
const file = String(tarballPath || '');
|
|
348
|
+
const nameForType = String(originalName || file || '');
|
|
349
|
+
if (!/\.(?:tar\.gz|tgz)$/i.test(nameForType)) {
|
|
350
|
+
return { ok: false, error: '安装包必须是 .tar.gz 归档' };
|
|
351
|
+
}
|
|
352
|
+
let stat;
|
|
353
|
+
try {
|
|
354
|
+
stat = fs.statSync(file);
|
|
355
|
+
} catch {
|
|
356
|
+
return { ok: false, error: '上传文件不存在或不可读' };
|
|
357
|
+
}
|
|
358
|
+
if (stat.size > A2A_INSTALL_MAX_BYTES) {
|
|
359
|
+
return { ok: false, error: `文件超过 ${A2A_INSTALL_MAX_MB}MB 限制` };
|
|
360
|
+
}
|
|
361
|
+
let entries;
|
|
362
|
+
try {
|
|
363
|
+
entries = listTarballEntries(file);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
return { ok: false, error: `无法读取归档: ${err.message}` };
|
|
366
|
+
}
|
|
367
|
+
if (entries.length === 0) return { ok: false, error: '归档为空' };
|
|
368
|
+
const unsafe = entries.find(isUnsafeArchiveEntry);
|
|
369
|
+
if (unsafe) return { ok: false, error: `归档包含不安全的路径条目: ${unsafe}` };
|
|
370
|
+
try {
|
|
371
|
+
assertNoArchiveLinks(file);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { ok: false, error: err.message };
|
|
374
|
+
}
|
|
375
|
+
let pkg;
|
|
376
|
+
try {
|
|
377
|
+
pkg = readTarballPackageJson(file, entries);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
return { ok: false, error: `manifest 解析失败: ${err.message}` };
|
|
380
|
+
}
|
|
381
|
+
if (!pkg) return { ok: false, error: '归档缺少 package.json' };
|
|
382
|
+
if (pkg.name !== A2A_PACKAGE_NAME) {
|
|
383
|
+
return { ok: false, error: `manifest name 不符: 期望 ${A2A_PACKAGE_NAME},实际 ${pkg.name}` };
|
|
384
|
+
}
|
|
385
|
+
if (typeof pkg.version !== 'string' || !pkg.version.trim()) {
|
|
386
|
+
return { ok: false, error: 'manifest 缺少 version' };
|
|
387
|
+
}
|
|
388
|
+
const targetDir = path.join(skillsDir, 'a2a');
|
|
389
|
+
const installed = readComponents(componentsPath).a2a;
|
|
390
|
+
if (fs.existsSync(targetDir) || installed) {
|
|
391
|
+
const v = installed && installed.version ? installed.version : '未知';
|
|
392
|
+
return { ok: false, error: `a2a 已安装(v${v}),如需更新请先卸载或使用 baize upgrade a2a` };
|
|
393
|
+
}
|
|
394
|
+
try {
|
|
395
|
+
extractTarballTo(file, targetDir, entries);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return { ok: false, error: `解压失败: ${err.message}` };
|
|
398
|
+
}
|
|
399
|
+
const depResult = await installDeps(targetDir);
|
|
400
|
+
if (!depResult.ok) {
|
|
401
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
402
|
+
return { ok: false, error: `npm install 失败: ${depResult.error}` };
|
|
403
|
+
}
|
|
404
|
+
const components = readComponents(componentsPath);
|
|
405
|
+
components.a2a = {
|
|
406
|
+
version: pkg.version,
|
|
407
|
+
repo: 'baize-ai/baize-a2a',
|
|
408
|
+
isThirdParty: false,
|
|
409
|
+
installedAt: new Date().toISOString(),
|
|
410
|
+
skillDir: targetDir,
|
|
411
|
+
dataDir: path.join(baizeDir(), 'components', 'a2a'),
|
|
412
|
+
deliveredVia: { type: 'local-tarball', verified: true },
|
|
413
|
+
};
|
|
414
|
+
writeComponents(componentsPath, components);
|
|
415
|
+
return { ok: true, version: pkg.version };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function schedulerDbPath() {
|
|
419
|
+
return path.join(baizeDir(), 'scheduler', 'scheduler.db');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function a2aDbPath() {
|
|
423
|
+
return process.env.BAIZE_A2A_DB || path.join(baizeDir(), 'components', 'a2a', 'a2a.db');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Task board: task_runs rows created by the A2A worker (source = 'a2a', §3).
|
|
428
|
+
* Tolerant when the table/DB does not exist yet (baize-a2a not started).
|
|
429
|
+
*/
|
|
430
|
+
export function getA2aTaskRuns(limit = 100) {
|
|
431
|
+
const file = schedulerDbPath();
|
|
432
|
+
if (!fs.existsSync(file)) {
|
|
433
|
+
return { success: true, tasks: [], available: false, dbPath: file, hint: 'scheduler 数据库不存在(scheduler 服务未启动)' };
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
const db = new Database(file, { readonly: true });
|
|
437
|
+
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='task_runs'").get();
|
|
438
|
+
if (!hasTable) {
|
|
439
|
+
db.close();
|
|
440
|
+
return { success: true, tasks: [], available: false, dbPath: file, hint: 'task_runs 表尚未创建(A2A 组件首次运行后出现)' };
|
|
441
|
+
}
|
|
442
|
+
const rows = db.prepare(
|
|
443
|
+
`SELECT id, scheduler_task_id, source_id, caller_agent_id, context_id, skill_id, instruction,
|
|
444
|
+
status, priority, message, result, error, created_at, started_at, updated_at, completed_at
|
|
445
|
+
FROM task_runs WHERE source = 'a2a' ORDER BY created_at DESC LIMIT ?`,
|
|
446
|
+
).all(limit);
|
|
447
|
+
db.close();
|
|
448
|
+
return { success: true, tasks: rows, available: true, dbPath: file };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return { success: false, error: err.message, tasks: [] };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Message log: inbox (direction=in) + outbox (direction=out) from the
|
|
456
|
+
* baize-a2a SQLite store, merged by created_at (newest first).
|
|
457
|
+
*/
|
|
458
|
+
export function getA2aMessages(limit = 100) {
|
|
459
|
+
const file = a2aDbPath();
|
|
460
|
+
if (!fs.existsSync(file)) {
|
|
461
|
+
return { success: true, messages: [], available: false, dbPath: file, hint: 'A2A 消息库未就绪(baize-a2a 组件启动后生成)' };
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
const db = new Database(file, { readonly: true });
|
|
465
|
+
const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name));
|
|
466
|
+
const messages = [];
|
|
467
|
+
if (tables.has('inbox')) {
|
|
468
|
+
for (const r of db.prepare(
|
|
469
|
+
'SELECT caller_agent_id, request_id, op, response, created_at FROM inbox ORDER BY created_at DESC LIMIT ?',
|
|
470
|
+
).all(limit)) {
|
|
471
|
+
messages.push({
|
|
472
|
+
direction: 'in',
|
|
473
|
+
peer: r.caller_agent_id,
|
|
474
|
+
requestId: r.request_id,
|
|
475
|
+
kind: r.op,
|
|
476
|
+
content: r.response,
|
|
477
|
+
createdAt: r.created_at,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (tables.has('outbox')) {
|
|
482
|
+
for (const r of db.prepare(
|
|
483
|
+
`SELECT kind, peer_agent_id, peer_endpoint, request_id, context_id, task_id, payload, status,
|
|
484
|
+
last_error, created_at, updated_at, sent_at
|
|
485
|
+
FROM outbox ORDER BY created_at DESC LIMIT ?`,
|
|
486
|
+
).all(limit)) {
|
|
487
|
+
messages.push({
|
|
488
|
+
direction: 'out',
|
|
489
|
+
peer: r.peer_agent_id,
|
|
490
|
+
requestId: r.request_id,
|
|
491
|
+
kind: r.kind,
|
|
492
|
+
content: r.payload,
|
|
493
|
+
status: r.status,
|
|
494
|
+
taskId: r.task_id,
|
|
495
|
+
lastError: r.last_error,
|
|
496
|
+
createdAt: r.created_at,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
db.close();
|
|
501
|
+
messages.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
502
|
+
return { success: true, messages: messages.slice(0, limit), available: true, dbPath: file };
|
|
503
|
+
} catch (err) {
|
|
504
|
+
return { success: false, error: err.message, messages: [] };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
@@ -141,6 +141,40 @@ export const BUILTIN_CHANNELS = {
|
|
|
141
141
|
links: [{ label: '企业微信管理后台', url: 'https://work.weixin.qq.com/' }],
|
|
142
142
|
},
|
|
143
143
|
},
|
|
144
|
+
a2a: {
|
|
145
|
+
name: 'a2a',
|
|
146
|
+
title: 'A2A 智能体互联',
|
|
147
|
+
type: 'communication',
|
|
148
|
+
description: 'Agent-to-Agent 互联:HTTPS 消息/任务收发、注册到 admin-workspace 目录、peer 发现与任务看板。',
|
|
149
|
+
repo: 'baize-ai/baize-a2a',
|
|
150
|
+
npmPkg: '@baize-ai/baize-a2a',
|
|
151
|
+
configSchema: [
|
|
152
|
+
{
|
|
153
|
+
key: 'enabled', label: '启用 A2A', type: 'radio',
|
|
154
|
+
options: [
|
|
155
|
+
{ value: 'true', label: '启用(注册 + 接收消息/任务)' },
|
|
156
|
+
{ value: 'false', label: '停用' },
|
|
157
|
+
],
|
|
158
|
+
target: 'config', configKey: 'enabled', default: 'false',
|
|
159
|
+
},
|
|
160
|
+
{ key: 'adminUrl', label: 'Admin 服务地址', type: 'text', placeholder: 'https://admin.example.com', target: 'config', configKey: 'adminUrl' },
|
|
161
|
+
{ key: 'agentId', label: 'Agent ID(留空自动生成)', type: 'text', placeholder: 'agent_xxx', target: 'config', configKey: 'agentId' },
|
|
162
|
+
{ key: 'advertiseUrl', label: '对外宣告地址', type: 'text', placeholder: 'https://a2a.example.com', target: 'config', configKey: 'advertiseUrl' },
|
|
163
|
+
{ key: 'listenPort', label: '监听端口(默认 8443)', type: 'text', placeholder: '8443', target: 'config', configKey: 'listenPort', default: '8443' },
|
|
164
|
+
{ key: 'certKeyPath', label: 'TLS 私钥路径(可选)', type: 'text', placeholder: '/path/to/key.pem', target: 'config', configKey: 'cert.keyPath' },
|
|
165
|
+
{ key: 'certCertPath', label: 'TLS 证书路径(可选)', type: 'text', placeholder: '/path/to/cert.pem', target: 'config', configKey: 'cert.certPath' },
|
|
166
|
+
],
|
|
167
|
+
guide: {
|
|
168
|
+
intro: 'A2A 让本机 agent 与其它 agent 通过 HTTPS 互联:注册到 admin-workspace 目录后被 peer 发现,可收发消息与派发任务。启用时必须填写 Admin 服务地址与对外宣告地址。',
|
|
169
|
+
steps: [
|
|
170
|
+
'部署并启动 admin-workspace(提供 /api/login 与 /.well-known/jwks.json 的 HTTPS 服务),记录其地址',
|
|
171
|
+
'填写 Admin 服务地址(adminUrl)与对外宣告地址(advertiseUrl,HTTPS 域名,指向本机 A2A 端点)',
|
|
172
|
+
'保存配置(启用)并启动渠道:自动生成 agentId、密钥对并向 admin 提交注册',
|
|
173
|
+
'在 admin-workspace 审批注册后,本机 agent 即可被发现并接收消息/任务',
|
|
174
|
+
'TLS:advertiseUrl 为 HTTPS 时提供证书路径;也可由 Caddy 等反向代理终结 TLS',
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
144
178
|
};
|
|
145
179
|
|
|
146
180
|
// ── Per-channel lifecycle hooks ─────────────────────────────────────────────
|
|
@@ -171,6 +205,41 @@ const CHANNEL_IMPLS = {
|
|
|
171
205
|
return { ok: true };
|
|
172
206
|
},
|
|
173
207
|
},
|
|
208
|
+
a2a: {
|
|
209
|
+
// Config lives in ~/baize/components/a2a/config.json (D19 §5).
|
|
210
|
+
getStatus: () => {
|
|
211
|
+
const config = readChannelConfig('a2a');
|
|
212
|
+
const enabled = config.enabled === true || String(config.enabled) === 'true';
|
|
213
|
+
const adminUrl = String(config.adminUrl || '').trim();
|
|
214
|
+
const advertiseUrl = String(config.advertiseUrl || '').trim();
|
|
215
|
+
return {
|
|
216
|
+
configured: Boolean(enabled && adminUrl && advertiseUrl),
|
|
217
|
+
configSummary: enabled
|
|
218
|
+
? `admin: ${adminUrl}${advertiseUrl ? ` · advertise: ${advertiseUrl}` : ''}`
|
|
219
|
+
: '未启用',
|
|
220
|
+
service: pm2State('baize-a2a'),
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
verify: async (values) => {
|
|
224
|
+
const enabled = values.enabled === true || String(values.enabled) === 'true';
|
|
225
|
+
if (enabled) {
|
|
226
|
+
if (!String(values.adminUrl || '').trim()) return { ok: false, error: '启用 A2A 需要填写 Admin 服务地址(adminUrl)' };
|
|
227
|
+
if (!String(values.advertiseUrl || '').trim()) return { ok: false, error: '启用 A2A 需要填写对外宣告地址(advertiseUrl)' };
|
|
228
|
+
}
|
|
229
|
+
return { ok: true };
|
|
230
|
+
},
|
|
231
|
+
// Generic persist, but enabled must land as a real boolean (D19 §5 schema).
|
|
232
|
+
configure: async (values, deps) => {
|
|
233
|
+
const v = await CHANNEL_IMPLS.a2a.verify(values, deps);
|
|
234
|
+
if (!v.ok) return { success: false, error: v.error };
|
|
235
|
+
const normalized = { ...values };
|
|
236
|
+
if (normalized.enabled !== undefined && normalized.enabled !== null) {
|
|
237
|
+
normalized.enabled = normalized.enabled === true || String(normalized.enabled) === 'true';
|
|
238
|
+
}
|
|
239
|
+
persistGeneric('a2a', BUILTIN_CHANNELS.a2a.configSchema, normalized);
|
|
240
|
+
return { success: true, status: channelStatus({ ...BUILTIN_CHANNELS.a2a, installed: true }, deps) };
|
|
241
|
+
},
|
|
242
|
+
},
|
|
174
243
|
};
|
|
175
244
|
|
|
176
245
|
function builtinChannel(name) {
|
|
@@ -333,6 +402,35 @@ function upsertEnv(content, key, value) {
|
|
|
333
402
|
return `${content.trimEnd()}\n${line}\n`;
|
|
334
403
|
}
|
|
335
404
|
|
|
405
|
+
/**
|
|
406
|
+
* Generic persist: env keys → .env, config keys → dataDir/config.json.
|
|
407
|
+
* Shared by the default configure path and per-channel configure hooks
|
|
408
|
+
* (e.g. a2a normalizes enabled to a real boolean before persisting).
|
|
409
|
+
*/
|
|
410
|
+
function persistGeneric(name, schema, values) {
|
|
411
|
+
const env = readEnv();
|
|
412
|
+
let newEnv = env;
|
|
413
|
+
const config = readChannelConfig(name);
|
|
414
|
+
for (const f of schema) {
|
|
415
|
+
const value = values[f.key];
|
|
416
|
+
if (value == null || value === '') continue;
|
|
417
|
+
if (f.target === 'env' && f.envKey) {
|
|
418
|
+
newEnv = upsertEnv(newEnv, f.envKey, String(value).trim());
|
|
419
|
+
} else if (f.target === 'config' && f.configKey) {
|
|
420
|
+
// Preserve real booleans/numbers (D19 §5 config schema); env-style
|
|
421
|
+
// string coercion would turn enabled into "true".
|
|
422
|
+
const stored = typeof value === 'boolean' || typeof value === 'number' ? value : String(value).trim();
|
|
423
|
+
setDeep(config, f.configKey, stored);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (newEnv !== env) {
|
|
427
|
+
fs.mkdirSync(baizeDir(), { recursive: true });
|
|
428
|
+
fs.writeFileSync(path.join(baizeDir(), '.env'), newEnv, 'utf8');
|
|
429
|
+
}
|
|
430
|
+
fs.mkdirSync(path.dirname(channelConfigFile(name)), { recursive: true });
|
|
431
|
+
fs.writeFileSync(channelConfigFile(name), JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
432
|
+
}
|
|
433
|
+
|
|
336
434
|
// ── Configuration (schema-driven) ───────────────────────────────────────────
|
|
337
435
|
|
|
338
436
|
/**
|
|
@@ -360,22 +458,7 @@ export async function configureChannel(name, values = {}, deps = {}) {
|
|
|
360
458
|
if (!v.ok) return { success: false, error: v.error };
|
|
361
459
|
}
|
|
362
460
|
|
|
363
|
-
|
|
364
|
-
const env = readEnv();
|
|
365
|
-
let newEnv = env;
|
|
366
|
-
const config = readChannelConfig(name);
|
|
367
|
-
for (const f of schema) {
|
|
368
|
-
const value = values[f.key];
|
|
369
|
-
if (value == null || value === '') continue;
|
|
370
|
-
if (f.target === 'env' && f.envKey) newEnv = upsertEnv(newEnv, f.envKey, String(value).trim());
|
|
371
|
-
else if (f.target === 'config' && f.configKey) setDeep(config, f.configKey, String(value).trim());
|
|
372
|
-
}
|
|
373
|
-
if (newEnv !== env) {
|
|
374
|
-
fs.mkdirSync(baizeDir(), { recursive: true });
|
|
375
|
-
fs.writeFileSync(path.join(baizeDir(), '.env'), newEnv, 'utf8');
|
|
376
|
-
}
|
|
377
|
-
fs.mkdirSync(path.dirname(channelConfigFile(name)), { recursive: true });
|
|
378
|
-
fs.writeFileSync(channelConfigFile(name), JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
461
|
+
persistGeneric(name, schema, values);
|
|
379
462
|
|
|
380
463
|
return { success: true, status: channelStatus({ ...channel, installed: true }, deps) };
|
|
381
464
|
}
|