cli-swarm 7.0.18 → 7.0.25
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/README.md +16 -1
- package/broker.mjs +301 -0
- package/cli.mjs +54 -12
- package/installer.mjs +57 -154
- package/package.json +8 -3
- package/skill/SKILL.md +25 -18
- package/skill/references/autocoord.md +33 -0
- package/skill/skill.json +2 -2
- package/swarm-coordinator-fs.mjs +265 -0
- package/swarm-coordinator-model.mjs +272 -0
- package/swarm-coordinator.mjs +500 -0
package/README.md
CHANGED
|
@@ -6,11 +6,20 @@ swarm — 智能体蜂群编排 skill(CLI.Tax 发布)。
|
|
|
6
6
|
- 项目 JSON 任务派单/认领/回传 + 红绿灯状态 + 进度/错误汇报
|
|
7
7
|
- 固定运维智能体:心跳检测、回收卡死智能体、派新智能体继承任务续跑
|
|
8
8
|
- 固定安全守卫智能体:注入/危险指令检测、异常警报
|
|
9
|
+
- 固定协调智能体:`.coord/` 任务卡、冲突扫描、文件/构建锁、基线握手、依赖等待、超时与死锁处置
|
|
9
10
|
|
|
10
11
|
```bash
|
|
11
12
|
npx cli-swarm@latest install
|
|
12
13
|
```
|
|
13
14
|
|
|
15
|
+
本地 AutoCoord 首次调用:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cli-swarm local capabilities /absolute/repository/path
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
其余操作从 stdin 接收 capabilities 返回 Schema 对应的 JSON;协调事实只写入 `.coord/`,不依赖对话上下文。
|
|
22
|
+
|
|
14
23
|
|
|
15
24
|
也可以直接从 CLI.Tax 对象存储安装(与站点「安装命令」一致):
|
|
16
25
|
|
|
@@ -20,4 +29,10 @@ npx https://cli.tax/cli-downloads/clitax-zj7fTPVh4p.tgz install
|
|
|
20
29
|
|
|
21
30
|
Source: https://github.com/88208555/swarm-clitax.git
|
|
22
31
|
|
|
23
|
-
|
|
32
|
+
## 受限调用与自动评价
|
|
33
|
+
|
|
34
|
+
使用 `npx cli-swarm@latest invoke <operation> '<JSON对象>'`,或让 IDE 以 JSON stdin 调用 `npx cli-swarm@latest broker`。broker 本身只需要 Brain Client HTTPS、受限身份文件和显式传入路径,不需要完整磁盘访问。要保证 IDE 看不到 token,必须把 broker 作为独立低权限账户或沙箱服务运行并只暴露受限 IPC;同一系统账户下的 `0600` 不能隔离 IDE 与 broker。
|
|
35
|
+
|
|
36
|
+
Brain Client 服务端在同一次 runtime 请求的事务中绑定真实响应、生成并持久化权威评分与评语,再返回已提交回执。broker 只验证 `feedbackReceiptId`、`feedbackInvocationId` 和权威摘要,不发起第二次评价写入,也不生成分数或评语。`not-reported`、验证不完整、P0/P1 findings、`blocked` 或 `failed` 都不得生成好评;缺凭证、缺回执、摘要不匹配、响应非法或 HTTP 失败都会显式失败。
|
|
37
|
+
|
|
38
|
+
本地 CLI 不提供手工评分或评语提交命令,人类不能选择技能分数或填写技能评价。日常聊天不属于评价协议。
|
package/broker.mjs
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { constants } from 'node:fs'
|
|
3
|
+
import { lstat, open } from 'node:fs/promises'
|
|
4
|
+
import { resolve, win32 } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export const LOOKUP_TIMEOUT_MS = 8000
|
|
7
|
+
export const CALL_TIMEOUT_MS = 120_000
|
|
8
|
+
const FEEDBACK_API_PATH = '/api/v1/telemetry/skill-usage'
|
|
9
|
+
const TOKEN_FILE_ENV = 'CLITAX_BRAIN_CLIENT_TOKEN_FILE'
|
|
10
|
+
const TOKEN_FILE_VERSION = 'member-brain.client-token-file/1.0'
|
|
11
|
+
const AUTH_SCHEME = 'BrainClient'
|
|
12
|
+
const TOKEN_FILE_MAX_BYTES = 16_384
|
|
13
|
+
const POSIX_TOKEN_FILE_MODE = 0o600
|
|
14
|
+
const WINDOWS_BROKER_DIRECTORY = ['CLI.Tax', 'broker']
|
|
15
|
+
const FEEDBACK_COMMENT_MAX = 500
|
|
16
|
+
const EVALUATION_DURATION_MAX = 86_400_000
|
|
17
|
+
const SCORE_MIN = 0
|
|
18
|
+
const SCORE_MAX = 100
|
|
19
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
|
20
|
+
const INVOCATION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
21
|
+
const DIGEST_PATTERN = /^[0-9a-f]{64}$/
|
|
22
|
+
const PROTOCOL_STATUSES = new Set(['succeeded', 'blocked', 'failed'])
|
|
23
|
+
const VALIDATION_STATES = new Set(['passed', 'failed', 'incomplete'])
|
|
24
|
+
const EVALUATION_SCHEMA = 'skill-automatic-evaluation/1.0'
|
|
25
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
|
|
26
|
+
const REQUEST_SCHEMA_PATTERN = /^([A-Za-z0-9.-]+\.skill)\.request\/([0-9]+\.[0-9]+)$/
|
|
27
|
+
|
|
28
|
+
function asObject(value, label) {
|
|
29
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
30
|
+
throw new Error(`${label} must be an object`)
|
|
31
|
+
}
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requiredString(value, label) {
|
|
36
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
37
|
+
if (!text) throw new Error(`${label} is required`)
|
|
38
|
+
return text
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function boundedInteger(value, label) {
|
|
42
|
+
if (!Number.isFinite(value) || value < 0 || !Number.isSafeInteger(value)) {
|
|
43
|
+
throw new Error(`${label} must be a non-negative safe integer`)
|
|
44
|
+
}
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function insideWindowsDirectory(candidate, directory) {
|
|
49
|
+
const relative = win32.relative(directory, candidate)
|
|
50
|
+
return relative === '' || (!relative.startsWith('..\\') && relative !== '..' && !win32.isAbsolute(relative))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function brainClientTokenPath(environment, platform = process.platform) {
|
|
54
|
+
const configured = requiredString(environment[TOKEN_FILE_ENV], TOKEN_FILE_ENV)
|
|
55
|
+
if (platform !== 'win32') return resolve(configured)
|
|
56
|
+
if (!win32.isAbsolute(configured)) {
|
|
57
|
+
throw new Error('Windows Brain Client token file path must be absolute')
|
|
58
|
+
}
|
|
59
|
+
const localAppData = requiredString(environment.LOCALAPPDATA, 'LOCALAPPDATA')
|
|
60
|
+
const brokerDirectory = win32.resolve(localAppData, ...WINDOWS_BROKER_DIRECTORY)
|
|
61
|
+
const candidate = win32.resolve(configured)
|
|
62
|
+
if (!insideWindowsDirectory(candidate, brokerDirectory)) {
|
|
63
|
+
throw new Error(`Windows Brain Client token file must be inside ${brokerDirectory}`)
|
|
64
|
+
}
|
|
65
|
+
return candidate
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function assertTokenFileStatus(status, platform, currentUserId) {
|
|
69
|
+
if (!status.isFile() || status.size < 1 || status.size > TOKEN_FILE_MAX_BYTES) {
|
|
70
|
+
throw new Error('Brain Client token file must be a non-empty restricted file')
|
|
71
|
+
}
|
|
72
|
+
if (platform === 'win32') return
|
|
73
|
+
if (!Number.isInteger(currentUserId)) {
|
|
74
|
+
throw new Error('Brain Client token file ownership cannot be verified')
|
|
75
|
+
}
|
|
76
|
+
if (status.uid !== currentUserId || (status.mode & 0o777) !== POSIX_TOKEN_FILE_MODE) {
|
|
77
|
+
throw new Error('Brain Client token file must be owned by the current user with mode 0600')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseTokenFile(source) {
|
|
82
|
+
let tokenFile
|
|
83
|
+
try {
|
|
84
|
+
tokenFile = asObject(JSON.parse(source), 'Brain Client token file')
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error('Brain Client token file must contain valid JSON')
|
|
87
|
+
}
|
|
88
|
+
const expectedKeys = ['authorizationScheme', 'endpoint', 'schemaVersion', 'token']
|
|
89
|
+
if (Object.keys(tokenFile).sort().join('\n') !== expectedKeys.join('\n')) {
|
|
90
|
+
throw new Error('Brain Client token file contains unknown or missing fields')
|
|
91
|
+
}
|
|
92
|
+
return tokenFile
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function brainClientAuthorization(context, environment, dependencies = {}) {
|
|
96
|
+
const platform = dependencies.platform ?? process.platform
|
|
97
|
+
const tokenFilePath = brainClientTokenPath(environment, platform)
|
|
98
|
+
const inspectPath = dependencies.lstat ?? lstat
|
|
99
|
+
const openPath = dependencies.open ?? open
|
|
100
|
+
const currentUserId = platform === 'win32'
|
|
101
|
+
? null
|
|
102
|
+
: (dependencies.getuid ?? process.getuid)?.()
|
|
103
|
+
const linkStatus = await inspectPath(tokenFilePath)
|
|
104
|
+
if (linkStatus.isSymbolicLink()) throw new Error('Brain Client token file cannot be a symlink')
|
|
105
|
+
const noFollow = platform === 'win32' ? 0 : (constants.O_NOFOLLOW ?? 0)
|
|
106
|
+
const handle = await openPath(tokenFilePath, constants.O_RDONLY | noFollow)
|
|
107
|
+
try {
|
|
108
|
+
const status = await handle.stat()
|
|
109
|
+
assertTokenFileStatus(status, platform, currentUserId)
|
|
110
|
+
const tokenFile = parseTokenFile(await handle.readFile('utf8'))
|
|
111
|
+
const endpoint = new URL(requiredString(tokenFile.endpoint, 'Brain Client endpoint'))
|
|
112
|
+
if (tokenFile.schemaVersion !== TOKEN_FILE_VERSION
|
|
113
|
+
|| tokenFile.authorizationScheme !== AUTH_SCHEME
|
|
114
|
+
|| endpoint.origin !== new URL(context.endpoint).origin
|
|
115
|
+
|| endpoint.pathname !== FEEDBACK_API_PATH || endpoint.search || endpoint.hash
|
|
116
|
+
|| endpoint.username || endpoint.password
|
|
117
|
+
|| !TOKEN_PATTERN.test(tokenFile.token)) {
|
|
118
|
+
throw new Error('Brain Client token file authority is invalid')
|
|
119
|
+
}
|
|
120
|
+
return `${AUTH_SCHEME} ${tokenFile.token}`
|
|
121
|
+
} finally {
|
|
122
|
+
await handle.close()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function canonicalJson(value) {
|
|
127
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') {
|
|
128
|
+
return JSON.stringify(value)
|
|
129
|
+
}
|
|
130
|
+
if (typeof value === 'number') {
|
|
131
|
+
if (!Number.isFinite(value)) throw new Error('automatic evaluation contains a non-finite number')
|
|
132
|
+
return JSON.stringify(value)
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
|
135
|
+
const record = asObject(value, 'automatic evaluation')
|
|
136
|
+
return `{${Object.keys(record).sort().map((key) => (
|
|
137
|
+
`${JSON.stringify(key)}:${canonicalJson(record[key])}`
|
|
138
|
+
)).join(',')}}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function expectedResponseSchema(requestSchema) {
|
|
142
|
+
const matched = requiredString(requestSchema, 'skill request schemaVersion')
|
|
143
|
+
.match(REQUEST_SCHEMA_PATTERN)
|
|
144
|
+
if (!matched) throw new Error('skill request schemaVersion is invalid')
|
|
145
|
+
return `${matched[1]}.response/${matched[2]}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function protocolResponse(protocolValue, requestEnvelope) {
|
|
149
|
+
const protocol = asObject(protocolValue, 'skill protocol response')
|
|
150
|
+
const responseSchema = expectedResponseSchema(requestEnvelope.schemaVersion)
|
|
151
|
+
if (protocol.schemaVersion !== responseSchema) {
|
|
152
|
+
throw new Error('skill protocol response schemaVersion does not match the request')
|
|
153
|
+
}
|
|
154
|
+
if (protocol.requestId !== requestEnvelope.requestId) {
|
|
155
|
+
throw new Error('skill protocol response requestId does not match the request')
|
|
156
|
+
}
|
|
157
|
+
const status = requiredString(protocol.status, 'skill protocol status')
|
|
158
|
+
if (!PROTOCOL_STATUSES.has(status)) {
|
|
159
|
+
throw new Error('skill protocol status must be succeeded, blocked, or failed')
|
|
160
|
+
}
|
|
161
|
+
return { protocol, responseSchema, status }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function authoritativeEvaluation(value, expected) {
|
|
165
|
+
const evaluation = asObject(value, 'server automatic evaluation')
|
|
166
|
+
const expectedKeys = [
|
|
167
|
+
'digest', 'durationMs', 'findingCount', 'operation', 'p0Count', 'p1Count', 'p2Count',
|
|
168
|
+
'requestId', 'responseSchemaVersion', 'schemaVersion', 'score', 'status', 'userComment',
|
|
169
|
+
'validation',
|
|
170
|
+
]
|
|
171
|
+
if (Object.keys(evaluation).sort().join('\n') !== expectedKeys.join('\n')) {
|
|
172
|
+
throw new Error('server automatic evaluation contains unknown or missing fields')
|
|
173
|
+
}
|
|
174
|
+
if (evaluation.schemaVersion !== EVALUATION_SCHEMA
|
|
175
|
+
|| evaluation.operation !== expected.operation
|
|
176
|
+
|| evaluation.requestId !== expected.requestId
|
|
177
|
+
|| evaluation.responseSchemaVersion !== expected.responseSchema
|
|
178
|
+
|| evaluation.status !== expected.status
|
|
179
|
+
|| !VALIDATION_STATES.has(evaluation.validation)
|
|
180
|
+
|| typeof evaluation.userComment !== 'string' || !evaluation.userComment.trim()
|
|
181
|
+
|| Buffer.byteLength(evaluation.userComment, 'utf8') > FEEDBACK_COMMENT_MAX
|
|
182
|
+
|| typeof evaluation.digest !== 'string' || !DIGEST_PATTERN.test(evaluation.digest)) {
|
|
183
|
+
throw new Error('server automatic evaluation authority is invalid')
|
|
184
|
+
}
|
|
185
|
+
for (const field of ['durationMs', 'findingCount', 'p0Count', 'p1Count', 'p2Count', 'score']) {
|
|
186
|
+
boundedInteger(evaluation[field], `server automatic evaluation ${field}`)
|
|
187
|
+
}
|
|
188
|
+
if (evaluation.score < SCORE_MIN || evaluation.score > SCORE_MAX
|
|
189
|
+
|| evaluation.durationMs > EVALUATION_DURATION_MAX
|
|
190
|
+
|| evaluation.findingCount < evaluation.p0Count + evaluation.p1Count + evaluation.p2Count) {
|
|
191
|
+
throw new Error('server automatic evaluation bounds are invalid')
|
|
192
|
+
}
|
|
193
|
+
if ((evaluation.status !== 'succeeded' || evaluation.validation !== 'passed'
|
|
194
|
+
|| evaluation.p0Count > 0 || evaluation.p1Count > 0) && evaluation.score >= 60) {
|
|
195
|
+
throw new Error('server automatic evaluation cannot report a positive score')
|
|
196
|
+
}
|
|
197
|
+
const { digest, ...core } = evaluation
|
|
198
|
+
const actualDigest = createHash('sha256').update(canonicalJson(core)).digest('hex')
|
|
199
|
+
if (digest !== actualDigest) throw new Error('server automatic evaluation digest is invalid')
|
|
200
|
+
return evaluation
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function responsePayload(response, label) {
|
|
204
|
+
try {
|
|
205
|
+
return asObject(await response.json(), label)
|
|
206
|
+
} catch {
|
|
207
|
+
throw new Error(`${label} is not valid JSON (HTTP ${response.status})`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function invocationRequest(context, operation, input) {
|
|
212
|
+
const normalizedOperation = requiredString(operation, 'skill operation')
|
|
213
|
+
if (!IDENTIFIER_PATTERN.test(normalizedOperation)) {
|
|
214
|
+
throw new Error('skill operation is invalid')
|
|
215
|
+
}
|
|
216
|
+
expectedResponseSchema(context.schemaVersion)
|
|
217
|
+
return {
|
|
218
|
+
schemaVersion: context.schemaVersion,
|
|
219
|
+
requestId: `${context.runtimeCode}-${randomUUID()}`,
|
|
220
|
+
operation: normalizedOperation,
|
|
221
|
+
input: asObject(input, 'skill operation input'),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function invokeOfficialSkill(context, operation, input, dependencies) {
|
|
226
|
+
const environment = asObject(dependencies.environment, 'broker environment')
|
|
227
|
+
if (typeof dependencies.request !== 'function') {
|
|
228
|
+
throw new Error('broker request dependency is required')
|
|
229
|
+
}
|
|
230
|
+
const authorization = await brainClientAuthorization(context, environment, dependencies.credentialAccess)
|
|
231
|
+
const requestEnvelope = invocationRequest(context, operation, input)
|
|
232
|
+
let response
|
|
233
|
+
try {
|
|
234
|
+
response = await dependencies.request(context.endpoint, {
|
|
235
|
+
method: 'POST',
|
|
236
|
+
headers: { 'Content-Type': 'application/json', Authorization: authorization },
|
|
237
|
+
body: JSON.stringify({ input: requestEnvelope }),
|
|
238
|
+
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
239
|
+
})
|
|
240
|
+
} catch {
|
|
241
|
+
throw new Error(`${context.displayName} ${operation} invocation failed`)
|
|
242
|
+
}
|
|
243
|
+
const payload = await responsePayload(response, `${context.displayName} ${operation} response`)
|
|
244
|
+
if (!response.ok || payload.ok !== true) {
|
|
245
|
+
throw new Error(`${context.displayName} ${operation} failed: HTTP ${response.status}`)
|
|
246
|
+
}
|
|
247
|
+
const invocationId = payload.feedbackInvocationId
|
|
248
|
+
if (typeof invocationId !== 'string' || !INVOCATION_PATTERN.test(invocationId)) {
|
|
249
|
+
throw new Error(`${context.displayName} ${operation} response is missing a valid feedbackInvocationId`)
|
|
250
|
+
}
|
|
251
|
+
const feedbackReceiptId = payload.feedbackReceiptId
|
|
252
|
+
const feedbackRequestId = payload.feedbackRequestId
|
|
253
|
+
if (typeof feedbackReceiptId !== 'string' || !INVOCATION_PATTERN.test(feedbackReceiptId)
|
|
254
|
+
|| feedbackRequestId !== `automatic-${invocationId}`) {
|
|
255
|
+
throw new Error(`${context.displayName} ${operation} response is missing a committed feedback receipt`)
|
|
256
|
+
}
|
|
257
|
+
const protocolAuthority = protocolResponse(payload.output, requestEnvelope)
|
|
258
|
+
const evaluation = authoritativeEvaluation(payload.feedbackEvaluation, {
|
|
259
|
+
operation: requestEnvelope.operation,
|
|
260
|
+
requestId: requestEnvelope.requestId,
|
|
261
|
+
responseSchema: protocolAuthority.responseSchema,
|
|
262
|
+
status: protocolAuthority.status,
|
|
263
|
+
})
|
|
264
|
+
const feedback = {
|
|
265
|
+
id: feedbackReceiptId,
|
|
266
|
+
requestId: feedbackRequestId,
|
|
267
|
+
duplicated: false,
|
|
268
|
+
}
|
|
269
|
+
return { response: payload, invocationId, evaluation, feedback }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function callOfficialSkill(context, operation, input, dependencies) {
|
|
273
|
+
return (await invokeOfficialSkill(context, operation, input, dependencies)).response
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function invokeCommandInput(args) {
|
|
277
|
+
const operation = requiredString(args[1], 'skill operation')
|
|
278
|
+
const source = args.slice(2).join(' ').trim()
|
|
279
|
+
if (!source) return { operation, input: {} }
|
|
280
|
+
try {
|
|
281
|
+
return { operation, input: asObject(JSON.parse(source), 'skill operation input') }
|
|
282
|
+
} catch {
|
|
283
|
+
throw new Error('skill operation input must be a JSON object')
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function brokerCommandInput(source) {
|
|
288
|
+
let parsed
|
|
289
|
+
try {
|
|
290
|
+
parsed = asObject(JSON.parse(source), 'broker request')
|
|
291
|
+
} catch {
|
|
292
|
+
throw new Error('broker request must be a JSON object')
|
|
293
|
+
}
|
|
294
|
+
if (Object.keys(parsed).some((key) => !['operation', 'input'].includes(key))) {
|
|
295
|
+
throw new Error('broker request contains unknown fields')
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
operation: requiredString(parsed.operation, 'skill operation'),
|
|
299
|
+
input: asObject(parsed.input, 'skill operation input'),
|
|
300
|
+
}
|
|
301
|
+
}
|
package/cli.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { dirname } from 'node:path'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { stdin } from 'node:process'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { dispatchOfficialSkillCli, runIntakeHandshake } from './installer.mjs'
|
|
6
|
+
import { executeCoordinatorOperation } from './swarm-coordinator.mjs'
|
|
7
|
+
|
|
8
|
+
const MAX_LOCAL_STDIN_BYTES = 1_048_576
|
|
5
9
|
|
|
6
10
|
const INTAKE_QUESTIONS = [
|
|
7
11
|
{
|
|
@@ -30,14 +34,52 @@ const INTAKE_QUESTIONS = [
|
|
|
30
34
|
},
|
|
31
35
|
]
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
37
|
+
async function readLocalInput() {
|
|
38
|
+
const chunks = []
|
|
39
|
+
let bytes = 0
|
|
40
|
+
for await (const chunk of stdin) {
|
|
41
|
+
bytes += chunk.length
|
|
42
|
+
if (bytes > MAX_LOCAL_STDIN_BYTES) throw new Error('coordinator input exceeds 1 MiB')
|
|
43
|
+
chunks.push(chunk)
|
|
44
|
+
}
|
|
45
|
+
const source = Buffer.concat(chunks).toString('utf8').trim()
|
|
46
|
+
const input = source ? JSON.parse(source) : {}
|
|
47
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
48
|
+
throw new Error('coordinator input must be a JSON object')
|
|
49
|
+
}
|
|
50
|
+
return input
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runLocalCoordinator(args) {
|
|
54
|
+
const operation = args[0]?.trim()
|
|
55
|
+
const repositoryRoot = args[1]?.trim()
|
|
56
|
+
if (!operation || !repositoryRoot) throw new Error('usage: cli-swarm local <operation> <repositoryRoot>')
|
|
57
|
+
return executeCoordinatorOperation(operation, repositoryRoot, await readLocalInput())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cliPath = fileURLToPath(import.meta.url)
|
|
61
|
+
if (process.argv[1] && resolve(process.argv[1]) === cliPath && process.argv[2] === 'local') {
|
|
62
|
+
try {
|
|
63
|
+
console.log(JSON.stringify(await runLocalCoordinator(process.argv.slice(3))))
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const code = error instanceof Error && typeof error.code === 'string'
|
|
66
|
+
? error.code : 'SWARM_COORD_FAILED'
|
|
67
|
+
console.error(JSON.stringify({ status: 'failed', code,
|
|
68
|
+
message: error instanceof Error ? error.message : String(error) }))
|
|
69
|
+
process.exitCode = 1
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
await dispatchOfficialSkillCli({
|
|
73
|
+
packageRoot: dirname(cliPath),
|
|
74
|
+
runCommand: (context) => runIntakeHandshake(context, {
|
|
75
|
+
questions: INTAKE_QUESTIONS,
|
|
76
|
+
outputFile: 'SWARM-REQUIREMENTS.json',
|
|
77
|
+
afterCapabilities(output) {
|
|
78
|
+
const instruction = output.nextStep?.instruction
|
|
79
|
+
if (typeof instruction === 'string' && instruction.trim()) console.log(instruction)
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { runLocalCoordinator }
|
package/installer.mjs
CHANGED
|
@@ -2,29 +2,33 @@
|
|
|
2
2
|
* 八个官方技能共用这一份安装器。packages/*-cli/installer.mjs 必须与本文件字节一致。
|
|
3
3
|
* 禁止第二套超时、第二套版本来源、第二套 bin 名。
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { cp, lstat, mkdir, open, rm, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
6
|
+
import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
8
7
|
import { dirname, join, resolve } from 'node:path'
|
|
9
8
|
import { stdin, stdout } from 'node:process'
|
|
10
9
|
import { createInterface } from 'node:readline/promises'
|
|
11
10
|
import { fileURLToPath } from 'node:url'
|
|
11
|
+
import {
|
|
12
|
+
LOOKUP_TIMEOUT_MS,
|
|
13
|
+
brokerCommandInput,
|
|
14
|
+
invokeCommandInput,
|
|
15
|
+
invokeOfficialSkill,
|
|
16
|
+
} from './broker.mjs'
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
CALL_TIMEOUT_MS,
|
|
20
|
+
LOOKUP_TIMEOUT_MS,
|
|
21
|
+
authoritativeEvaluation,
|
|
22
|
+
brainClientAuthorization,
|
|
23
|
+
brainClientTokenPath,
|
|
24
|
+
brokerCommandInput,
|
|
25
|
+
callOfficialSkill,
|
|
26
|
+
invokeCommandInput,
|
|
27
|
+
invokeOfficialSkill,
|
|
28
|
+
} from './broker.mjs'
|
|
12
29
|
|
|
13
|
-
export const LOOKUP_TIMEOUT_MS = 8000
|
|
14
|
-
export const CALL_TIMEOUT_MS = 120_000
|
|
15
30
|
const INSTALL_META = 'install-meta.json'
|
|
16
|
-
const
|
|
17
|
-
const BRAIN_CLIENT_TOKEN_FILE_ENV = 'CLITAX_BRAIN_CLIENT_TOKEN_FILE'
|
|
18
|
-
const BRAIN_CLIENT_TOKEN_FILE_VERSION = 'member-brain.client-token-file/1.0'
|
|
19
|
-
const BRAIN_CLIENT_AUTH_SCHEME = 'BrainClient'
|
|
20
|
-
const BRAIN_CLIENT_TOKEN_FILE_MAX_BYTES = 16_384
|
|
21
|
-
const BRAIN_CLIENT_TOKEN_FILE_MODE = 0o600
|
|
22
|
-
const FEEDBACK_COMMENT_MAX = 500
|
|
23
|
-
const FEEDBACK_SCORE_MIN = 0
|
|
24
|
-
const FEEDBACK_SCORE_MAX = 100
|
|
25
|
-
const BRAIN_CLIENT_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/
|
|
26
|
-
const FEEDBACK_INVOCATION_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
27
|
-
const FEEDBACK_SCORE_PATTERN = /^(?:0|[1-9]\d{0,2})$/
|
|
31
|
+
const BROKER_STDIN_MAX_BYTES = 1_048_576
|
|
28
32
|
|
|
29
33
|
function asObject(value, label) {
|
|
30
34
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -94,138 +98,6 @@ export async function fetchLatestVersion(context) {
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
export async function callOfficialSkill(context, operation, input) {
|
|
98
|
-
const requestId = `${context.npmName}-${Date.now()}`
|
|
99
|
-
const response = await fetch(context.endpoint, {
|
|
100
|
-
method: 'POST',
|
|
101
|
-
headers: { 'Content-Type': 'application/json' },
|
|
102
|
-
body: JSON.stringify({
|
|
103
|
-
input: {
|
|
104
|
-
schemaVersion: context.schemaVersion,
|
|
105
|
-
requestId,
|
|
106
|
-
operation,
|
|
107
|
-
input,
|
|
108
|
-
},
|
|
109
|
-
}),
|
|
110
|
-
signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
|
|
111
|
-
})
|
|
112
|
-
let payload
|
|
113
|
-
try {
|
|
114
|
-
payload = await response.json()
|
|
115
|
-
} catch {
|
|
116
|
-
throw new Error(`${context.displayName} ${operation} failed: non-JSON response (HTTP ${response.status}). Check ${context.endpoint}.`)
|
|
117
|
-
}
|
|
118
|
-
if (!response.ok || payload?.ok !== true) {
|
|
119
|
-
const message = payload?.error?.message
|
|
120
|
-
if (typeof message !== 'string' || !message.trim()) {
|
|
121
|
-
throw new Error(`${context.displayName} ${operation} failed: HTTP ${response.status}`)
|
|
122
|
-
}
|
|
123
|
-
throw new Error(`${context.displayName} ${operation} failed: ${message}`)
|
|
124
|
-
}
|
|
125
|
-
return payload
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function feedbackCommandInput(args) {
|
|
129
|
-
const invocationId = requiredString(args[1], 'feedback invocation id')
|
|
130
|
-
if (!FEEDBACK_INVOCATION_PATTERN.test(invocationId)) {
|
|
131
|
-
throw new Error('feedback invocation id must be the UUID returned by a real skill response')
|
|
132
|
-
}
|
|
133
|
-
const scoreText = requiredString(args[2], 'feedback score')
|
|
134
|
-
if (!FEEDBACK_SCORE_PATTERN.test(scoreText)) {
|
|
135
|
-
throw new Error(`feedback score must be an integer between ${FEEDBACK_SCORE_MIN} and ${FEEDBACK_SCORE_MAX}`)
|
|
136
|
-
}
|
|
137
|
-
const score = Number(scoreText)
|
|
138
|
-
if (!Number.isInteger(score) || score < FEEDBACK_SCORE_MIN || score > FEEDBACK_SCORE_MAX) {
|
|
139
|
-
throw new Error(`feedback score must be between ${FEEDBACK_SCORE_MIN} and ${FEEDBACK_SCORE_MAX}`)
|
|
140
|
-
}
|
|
141
|
-
const userComment = args.slice(3).join(' ').trim()
|
|
142
|
-
if (!userComment) throw new Error('feedback comment is required')
|
|
143
|
-
if (userComment.length > FEEDBACK_COMMENT_MAX) {
|
|
144
|
-
throw new Error(`feedback comment must be at most ${FEEDBACK_COMMENT_MAX} characters`)
|
|
145
|
-
}
|
|
146
|
-
return { invocationId, score, userComment }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async function brainClientAuthorization(context, environment) {
|
|
150
|
-
const configuredPath = typeof environment[BRAIN_CLIENT_TOKEN_FILE_ENV] === 'string'
|
|
151
|
-
? environment[BRAIN_CLIENT_TOKEN_FILE_ENV].trim() : ''
|
|
152
|
-
if (!configuredPath) throw new Error(`${BRAIN_CLIENT_TOKEN_FILE_ENV} is required`)
|
|
153
|
-
if (process.platform === 'win32' || typeof process.getuid !== 'function') {
|
|
154
|
-
throw new Error('Brain Client token file ownership cannot be verified')
|
|
155
|
-
}
|
|
156
|
-
const tokenFilePath = resolve(configuredPath)
|
|
157
|
-
const linkStatus = await lstat(tokenFilePath)
|
|
158
|
-
if (linkStatus.isSymbolicLink()) throw new Error('Brain Client token file cannot be a symlink')
|
|
159
|
-
const handle = await open(tokenFilePath, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
160
|
-
try {
|
|
161
|
-
const status = await handle.stat()
|
|
162
|
-
if (!status.isFile() || status.uid !== process.getuid()
|
|
163
|
-
|| (status.mode & 0o777) !== BRAIN_CLIENT_TOKEN_FILE_MODE
|
|
164
|
-
|| status.size < 1 || status.size > BRAIN_CLIENT_TOKEN_FILE_MAX_BYTES) {
|
|
165
|
-
throw new Error('Brain Client token file must be owned by the current user with mode 0600')
|
|
166
|
-
}
|
|
167
|
-
const tokenFile = asObject(JSON.parse(await handle.readFile('utf8')), 'Brain Client token file')
|
|
168
|
-
const expectedKeys = ['authorizationScheme', 'endpoint', 'schemaVersion', 'token']
|
|
169
|
-
if (Object.keys(tokenFile).sort().join('\n') !== expectedKeys.join('\n')) {
|
|
170
|
-
throw new Error('Brain Client token file contains unknown or missing fields')
|
|
171
|
-
}
|
|
172
|
-
const endpoint = new URL(requiredString(tokenFile.endpoint, 'Brain Client endpoint'))
|
|
173
|
-
if (tokenFile.schemaVersion !== BRAIN_CLIENT_TOKEN_FILE_VERSION
|
|
174
|
-
|| tokenFile.authorizationScheme !== BRAIN_CLIENT_AUTH_SCHEME
|
|
175
|
-
|| endpoint.origin !== new URL(context.endpoint).origin
|
|
176
|
-
|| endpoint.pathname !== FEEDBACK_API_PATH || endpoint.search || endpoint.hash
|
|
177
|
-
|| endpoint.username || endpoint.password
|
|
178
|
-
|| !BRAIN_CLIENT_TOKEN_PATTERN.test(tokenFile.token)) {
|
|
179
|
-
throw new Error('Brain Client token file authority is invalid')
|
|
180
|
-
}
|
|
181
|
-
return `${BRAIN_CLIENT_AUTH_SCHEME} ${tokenFile.token}`
|
|
182
|
-
} finally {
|
|
183
|
-
await handle.close()
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function submitOfficialSkillFeedback(context, args, environment, request) {
|
|
188
|
-
const input = feedbackCommandInput(args)
|
|
189
|
-
const authorization = await brainClientAuthorization(context, environment)
|
|
190
|
-
const requestId = `${context.runtimeCode}-${randomUUID()}`
|
|
191
|
-
let response
|
|
192
|
-
try {
|
|
193
|
-
response = await request(new URL(FEEDBACK_API_PATH, context.endpoint), {
|
|
194
|
-
method: 'POST',
|
|
195
|
-
headers: {
|
|
196
|
-
'Content-Type': 'application/json',
|
|
197
|
-
Authorization: authorization,
|
|
198
|
-
},
|
|
199
|
-
body: JSON.stringify({
|
|
200
|
-
requestId,
|
|
201
|
-
skillId: context.runtimeCode,
|
|
202
|
-
invocationId: input.invocationId,
|
|
203
|
-
score: input.score,
|
|
204
|
-
userComment: input.userComment,
|
|
205
|
-
}),
|
|
206
|
-
signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS),
|
|
207
|
-
})
|
|
208
|
-
} catch {
|
|
209
|
-
throw new Error('cli.tax feedback request failed')
|
|
210
|
-
}
|
|
211
|
-
let payload
|
|
212
|
-
try {
|
|
213
|
-
payload = asObject(await response.json(), 'cli.tax feedback response')
|
|
214
|
-
} catch (error) {
|
|
215
|
-
if (error instanceof Error && error.message.startsWith('cli.tax feedback response')) throw error
|
|
216
|
-
throw new Error(`cli.tax feedback failed: non-JSON response (HTTP ${response.status})`)
|
|
217
|
-
}
|
|
218
|
-
if (!response.ok || payload.ok !== true) {
|
|
219
|
-
throw new Error(`cli.tax feedback failed: HTTP ${response.status}`)
|
|
220
|
-
}
|
|
221
|
-
if (payload.requestId !== requestId || typeof payload.id !== 'string'
|
|
222
|
-
|| !FEEDBACK_INVOCATION_PATTERN.test(payload.id)
|
|
223
|
-
|| typeof payload.duplicated !== 'boolean') {
|
|
224
|
-
throw new Error('cli.tax feedback response authority is invalid')
|
|
225
|
-
}
|
|
226
|
-
return { id: payload.id, requestId, duplicated: payload.duplicated }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
101
|
export async function installOfficialSkill(context, explicit) {
|
|
230
102
|
const target = installTarget(context.skillName, explicit)
|
|
231
103
|
await mkdir(target, { recursive: true })
|
|
@@ -283,21 +155,52 @@ export function defaultUsage(context, extraLines) {
|
|
|
283
155
|
` npx ${context.npmName}@latest check [directory]`,
|
|
284
156
|
' Check whether the installed skill has a newer version.',
|
|
285
157
|
` npx ${context.npmName}@latest run`,
|
|
286
|
-
|
|
158
|
+
" Run this skill's applicability or onboarding flow; only a real HTTP invocation can trigger automatic evaluation.",
|
|
159
|
+
` npx ${context.npmName}@latest invoke <operation> <JSON-object>`,
|
|
160
|
+
' Invoke through the restricted local broker; a valid real HTTP invocation submits one authority-bound evaluation.',
|
|
161
|
+
` npx ${context.npmName}@latest broker`,
|
|
162
|
+
' Read one {"operation":"...","input":{...}} request from JSON stdin.',
|
|
163
|
+
'Credential: CLITAX_BRAIN_CLIENT_TOKEN_FILE (the broker reads it; never pass the token).',
|
|
287
164
|
`Endpoint: ${context.endpoint}`,
|
|
288
165
|
]
|
|
289
166
|
if (extraLines?.length) lines.push('', ...extraLines)
|
|
290
167
|
return lines.join('\n')
|
|
291
168
|
}
|
|
292
169
|
|
|
170
|
+
function brokerDependencies() {
|
|
171
|
+
return { environment: process.env, request: fetch }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function readBrokerSource(input) {
|
|
175
|
+
let source = ''
|
|
176
|
+
for await (const chunk of input) {
|
|
177
|
+
source += chunk
|
|
178
|
+
if (Buffer.byteLength(source, 'utf8') > BROKER_STDIN_MAX_BYTES) {
|
|
179
|
+
throw new Error(`broker request must be at most ${BROKER_STDIN_MAX_BYTES} bytes`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!source.trim()) throw new Error('broker request is required on stdin')
|
|
183
|
+
return source
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function runBrokerInvocation(context, commandInput) {
|
|
187
|
+
const invocation = await invokeOfficialSkill(
|
|
188
|
+
context, commandInput.operation, commandInput.input, brokerDependencies(),
|
|
189
|
+
)
|
|
190
|
+
console.log(JSON.stringify(invocation))
|
|
191
|
+
return invocation
|
|
192
|
+
}
|
|
193
|
+
|
|
293
194
|
export async function runIntakeHandshake(context, spec) {
|
|
294
|
-
const
|
|
195
|
+
const invocation = await invokeOfficialSkill(context, 'capabilities', {}, brokerDependencies())
|
|
196
|
+
const capabilities = invocation.response
|
|
295
197
|
const output = capabilities.output && typeof capabilities.output === 'object' ? capabilities.output : {}
|
|
296
198
|
const skill = output.skill && typeof output.skill === 'object' ? output.skill : {}
|
|
297
199
|
const version = typeof skill.version === 'string' && skill.version.trim()
|
|
298
200
|
? skill.version.trim()
|
|
299
201
|
: context.skillVersion
|
|
300
202
|
console.log(`${context.displayName} ${version}`)
|
|
203
|
+
console.log(`Automatic feedback accepted: ${invocation.feedback.id}`)
|
|
301
204
|
if (typeof spec.afterCapabilities === 'function') spec.afterCapabilities(output)
|
|
302
205
|
const readline = createInterface({ input: stdin, output: stdout })
|
|
303
206
|
const answers = []
|
|
@@ -340,9 +243,9 @@ export async function dispatchOfficialSkillCli(options) {
|
|
|
340
243
|
if (command === 'install') await installOfficialSkill(context, argument)
|
|
341
244
|
else if (command === 'check') await checkOfficialSkill(context, argument)
|
|
342
245
|
else if (command === 'run') await options.runCommand(context)
|
|
343
|
-
else if (command === '
|
|
344
|
-
|
|
345
|
-
|
|
246
|
+
else if (command === 'invoke') await runBrokerInvocation(context, invokeCommandInput(args))
|
|
247
|
+
else if (command === 'broker') {
|
|
248
|
+
await runBrokerInvocation(context, brokerCommandInput(await readBrokerSource(stdin)))
|
|
346
249
|
}
|
|
347
250
|
else if (command === 'help' || command === '--help' || command === '-h') {
|
|
348
251
|
console.log(options.usage ? options.usage(context) : defaultUsage(context, options.extraUsageLines))
|