@sleep2agi/commhub-server 0.9.0-preview.44 → 0.9.0-preview.45

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/src/tools.ts CHANGED
@@ -61,6 +61,7 @@ import {
61
61
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
62
62
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
63
63
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
64
+ import { parseHubTimestamp } from "./hub-timestamp";
64
65
 
65
66
  function ts(): string {
66
67
  return new Date().toTimeString().slice(0, 8);
@@ -2487,9 +2488,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2487
2488
  ],
2488
2489
  );
2489
2490
  });
2490
- // #1459 — pushUserEvent 在没有订阅者时静默 return,而这个工具既不写
2491
- // inbox、也没有任何 user 级回读路径(/api/messages 只 SELECT FROM inbox)。
2492
- // 也就是说用户 dashboard 关着的时候这条消息就永久没了。
2491
+ // #1459 / #1563 — pushUserEvent 在没有订阅者时静默 return。
2492
+ // 🔴 这段注释原先写「本工具既不写 inbox、也没有 user 级回读路径 ⇒ dashboard
2493
+ // 关着时消息永久没了」。**那半句已经不成立** —— 上面 20 行就在
2494
+ // `INSERT INTO user_inbox`,且 `/api/messages?scope=user` 能回读
2495
+ // (desktop-message-seam.test.ts 有真实 send → 真实 GET 的端到端断言)。
2496
+ // 留着它会把 debug 未读数的人直接带向错误的一层。
2493
2497
  //
2494
2498
  // 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
2495
2499
  // 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
@@ -2998,6 +3002,240 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2998
3002
  },
2999
3003
  );
3000
3004
 
3005
+ // ── app#225 — 节点规则文件(AGENTS.md / CLAUDE.md)远程读写 ──
3006
+ //
3007
+ // 形状照抄 RFC-024 config-apply:客户端调 read_node_rules_file /
3008
+ // write_node_rules_file 落一行 node_rules_requests + 门铃 {type:"rules_file"};
3009
+ // 节点收到门铃后 get_rules_file_request 拉(和 get_config_update 同一道
3010
+ // F-A 闸:网络 token + alias),做完 ack_rules_file_request 回报;客户端
3011
+ // 轮询 get_rules_file_result。
3012
+ //
3013
+ // 🔴 安全(#225 验收第 5 条):整条链路**没有路径参数**。文件名由节点按
3014
+ // 自己的 RUNTIME 决定(claude → CLAUDE.md,其余 → AGENTS.md),目录固定是
3015
+ // 节点进程的 cwd(agent-node/src/runtime/rules-file.ts)。hub 只传 op +
3016
+ // content,客户端连文件名都指定不了,所以不存在「借路径参数写任意文件」。
3017
+ // 读和写都要 canWrite:规则文件是节点行为的一部分,读它等于读节点配置。
3018
+ const RULES_FILE_MAX_BYTES = 256 * 1024;
3019
+ const RULES_REQUEST_STALE_MS = 60_000;
3020
+
3021
+ const enqueueRulesFileRequest = (
3022
+ op: "read" | "write",
3023
+ a: { node_id?: string; child_node_id?: string; network_id?: string; content?: string },
3024
+ ) => {
3025
+ const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
3026
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3027
+ const nodeId = idArg.node_id;
3028
+ const effectiveNetId = getNetworkId(a.network_id);
3029
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} rules file`);
3030
+
3031
+ const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
3032
+ if (!node) {
3033
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
3034
+ }
3035
+ if (!sec1Ok) {
3036
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
3037
+ }
3038
+ if (op === "write") {
3039
+ if (typeof a.content !== "string") {
3040
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
3041
+ }
3042
+ const bytes = Buffer.byteLength(a.content, "utf8");
3043
+ if (bytes > RULES_FILE_MAX_BYTES) {
3044
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", bytes, max_bytes: RULES_FILE_MAX_BYTES }) }] };
3045
+ }
3046
+ }
3047
+
3048
+ // 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
3049
+ // 非终态,不回收就把这个节点锁死。
3050
+ const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
3051
+ "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') ORDER BY created_at DESC LIMIT 1",
3052
+ nodeId,
3053
+ );
3054
+ if (inFlight) {
3055
+ const age = Date.now() - (inFlight.pulled_at ?? inFlight.created_at);
3056
+ if (age <= RULES_REQUEST_STALE_MS) {
3057
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_in_flight", existing_request_id: inFlight.request_id, age_ms: age }) }] };
3058
+ }
3059
+ db.run(
3060
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3",
3061
+ [Date.now(), `superseded after ${age}ms (> ${RULES_REQUEST_STALE_MS}ms) — node did not answer`, inFlight.request_id],
3062
+ );
3063
+ }
3064
+
3065
+ const requestId = `rf_${uuidv4()}`;
3066
+ const networkId = node.network_id || "default";
3067
+ db.run(
3068
+ `INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
3069
+ [requestId, nodeId, networkId, op, op === "write" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3070
+ );
3071
+ pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
3072
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
3073
+ };
3074
+
3075
+ server.tool(
3076
+ "read_node_rules_file",
3077
+ "Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
3078
+ { ...NODE_ID_ALIAS_FIELDS, network_id: z.string().max(200).optional() },
3079
+ async ({ node_id, child_node_id, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, network_id }),
3080
+ );
3081
+
3082
+ server.tool(
3083
+ "write_node_rules_file",
3084
+ "Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
3085
+ {
3086
+ ...NODE_ID_ALIAS_FIELDS,
3087
+ content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
3088
+ network_id: z.string().max(200).optional(),
3089
+ },
3090
+ async ({ node_id, child_node_id, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, content, network_id }),
3091
+ );
3092
+
3093
+ server.tool(
3094
+ "get_rules_file_request",
3095
+ "Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
3096
+ {},
3097
+ async () => {
3098
+ // 同 get_config_update 的 F-A 闸:必须是网络 token + alias。
3099
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3100
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3101
+ }
3102
+ if (!callerAlias) {
3103
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3104
+ }
3105
+ const node = db.get<any>(
3106
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3107
+ callerAlias,
3108
+ enforceNetworkId,
3109
+ );
3110
+ if (!node) {
3111
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
3112
+ }
3113
+ const req = db.get<any>(
3114
+ "SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
3115
+ node.node_id,
3116
+ );
3117
+ if (!req) {
3118
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
3119
+ }
3120
+ db.run(
3121
+ "UPDATE node_rules_requests SET status = 'in_progress', pulled_at = ?1 WHERE request_id = ?2 AND status = 'pending'",
3122
+ [Date.now(), req.request_id],
3123
+ );
3124
+ return {
3125
+ content: [{
3126
+ type: "text" as const,
3127
+ text: JSON.stringify({
3128
+ ok: true,
3129
+ request: {
3130
+ request_id: req.request_id,
3131
+ op: req.op,
3132
+ ...(req.op === "write" ? { content: req.content ?? "" } : {}),
3133
+ },
3134
+ }),
3135
+ }],
3136
+ };
3137
+ },
3138
+ );
3139
+
3140
+ server.tool(
3141
+ "ack_rules_file_request",
3142
+ "Node reports the outcome of a rules-file request — done (with content for reads) or failed (with error). app#225.",
3143
+ {
3144
+ request_id: z.string().min(1).max(200),
3145
+ status: z.enum(["done", "failed"]),
3146
+ file_name: z.string().max(64).optional(),
3147
+ exists: z.boolean().optional(),
3148
+ content: z.string().max(RULES_FILE_MAX_BYTES).optional(),
3149
+ error: z.string().max(2000).optional(),
3150
+ },
3151
+ async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
3152
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3153
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3154
+ }
3155
+ if (!callerAlias) {
3156
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3157
+ }
3158
+ const node = db.get<any>(
3159
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3160
+ callerAlias,
3161
+ enforceNetworkId,
3162
+ );
3163
+ if (!node) {
3164
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
3165
+ }
3166
+ const req = db.get<any>(
3167
+ "SELECT request_id, node_id, status FROM node_rules_requests WHERE request_id = ?1",
3168
+ requestId,
3169
+ );
3170
+ // 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。
3171
+ if (!req || req.node_id !== node.node_id) {
3172
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
3173
+ }
3174
+ if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
3175
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
3176
+ }
3177
+ db.run(
3178
+ `UPDATE node_rules_requests SET status = ?1, acked_at = ?2, file_name = ?3, file_exists = ?4, result_content = ?5, error = ?6 WHERE request_id = ?7`,
3179
+ [
3180
+ status,
3181
+ Date.now(),
3182
+ fileName ?? null,
3183
+ typeof exists === "boolean" ? (exists ? 1 : 0) : null,
3184
+ status === "done" ? (content ?? null) : null,
3185
+ status === "failed" ? (ackError || "node reported failure without a reason") : null,
3186
+ requestId,
3187
+ ],
3188
+ );
3189
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, status }) }] };
3190
+ },
3191
+ );
3192
+
3193
+ server.tool(
3194
+ "get_rules_file_result",
3195
+ "Poll the outcome of a read_node_rules_file / write_node_rules_file request. Returns status pending|in_progress|done|failed|timeout, the file name the node used, and (for reads) the content. app#225.",
3196
+ { request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
3197
+ async ({ request_id: requestId, network_id: clientNetId }) => {
3198
+ const effectiveNetId = getNetworkId(clientNetId);
3199
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
3200
+ const row = db.get<any>(
3201
+ "SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at FROM node_rules_requests WHERE request_id = ?1",
3202
+ requestId,
3203
+ );
3204
+ // SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
3205
+ if (!row || row.network_id !== (effectiveNetId || "default")) {
3206
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
3207
+ }
3208
+ let status: string = row.status;
3209
+ const ageMs = Date.now() - (row.pulled_at ?? row.created_at);
3210
+ if ((status === "pending" || status === "in_progress") && ageMs > RULES_REQUEST_STALE_MS) {
3211
+ status = "timeout";
3212
+ db.run(
3213
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3 AND status IN ('pending', 'in_progress')",
3214
+ [Date.now(), `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`, requestId],
3215
+ );
3216
+ row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
3217
+ }
3218
+ return {
3219
+ content: [{
3220
+ type: "text" as const,
3221
+ text: JSON.stringify({
3222
+ ok: true,
3223
+ request_id: row.request_id,
3224
+ node_id: row.node_id,
3225
+ op: row.op,
3226
+ status,
3227
+ file_name: row.file_name ?? null,
3228
+ exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
3229
+ ...(status === "done" && row.op === "read" ? { content: row.result_content ?? "" } : {}),
3230
+ error: row.error ?? null,
3231
+ age_ms: ageMs,
3232
+ }),
3233
+ }],
3234
+ };
3235
+ },
3236
+ );
3237
+
3238
+
3001
3239
  server.tool(
3002
3240
  "restart_node",
3003
3241
  "Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
@@ -3322,8 +3560,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3322
3560
  "SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
3323
3561
  daemon_node_id,
3324
3562
  );
3325
- const t = sess?.last_seen_at ? Date.parse(sess.last_seen_at) : NaN;
3326
- if (Number.isFinite(t)) heartbeatAgeMs = Math.max(0, Date.now() - t);
3563
+ // #1650 — 这一列是 TEXT(`datetime('now')`),UTC 但不带时区标记。
3564
+ // Date.parse 会按**本机时区**解析,误差 = 主机偏移(生产 hub 在 UTC+8
3565
+ // 时这个诊断数字整整偏 8 小时)。走 parseHubTimestamp 显式按 UTC 解。
3566
+ const t = parseHubTimestamp(sess?.last_seen_at);
3567
+ if (t !== null) heartbeatAgeMs = Math.max(0, Date.now() - t);
3327
3568
  } catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
3328
3569
 
3329
3570
  // 🔴 三种取值,不能塌成两种:
@@ -3790,6 +4031,38 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3790
4031
  return row?.daemon_node_id ?? null;
3791
4032
  };
3792
4033
 
4034
+ /**
4035
+ * 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
4036
+ *
4037
+ * `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
4038
+ * ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
4039
+ * 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
4040
+ * daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
4041
+ * ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
4042
+ * 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
4043
+ * 是一条真的走得通的路。
4044
+ *
4045
+ * 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
4046
+ * **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
4047
+ * 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
4048
+ *
4049
+ * 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
4050
+ */
4051
+ const explainUnresolvableDaemon = (child_node_id: string) =>
4052
+ child_node_id.startsWith("node_")
4053
+ ? {
4054
+ ok: false, error: "daemon_not_resolvable",
4055
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
4056
+ }
4057
+ : {
4058
+ ok: false, error: "not_daemon_managed",
4059
+ message:
4060
+ `node ${child_node_id} was not created by a daemon (it was started by hand with ` +
4061
+ `\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
4062
+ `Run \`anet node stop <alias>\` on that machine instead. ` +
4063
+ `Daemon-created children have ids beginning with \`node_\`; this one does not.`,
4064
+ };
4065
+
3793
4066
  const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
3794
4067
  // §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
3795
4068
  // caller's scope. resolveReadScope returns 'denied' if the caller
@@ -4079,10 +4352,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4079
4352
  const child_node_id = idArg.node_id;
4080
4353
  const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4081
4354
  if (!resolved) {
4082
- return { content: [{ type: "text" as const, text: JSON.stringify({
4083
- ok: false, error: "daemon_not_resolvable",
4084
- message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
4085
- }) }] };
4355
+ return { content: [{ type: "text" as const,
4356
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4086
4357
  }
4087
4358
  const r = dispatchStopOrDelete(
4088
4359
  { action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
@@ -4111,10 +4382,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4111
4382
  const child_node_id = idArg.node_id;
4112
4383
  const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4113
4384
  if (!resolved) {
4114
- return { content: [{ type: "text" as const, text: JSON.stringify({
4115
- ok: false, error: "daemon_not_resolvable",
4116
- message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
4117
- }) }] };
4385
+ return { content: [{ type: "text" as const,
4386
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4118
4387
  }
4119
4388
  const r = dispatchStopOrDelete(
4120
4389
  { action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
@@ -24,6 +24,10 @@ let server: any;
24
24
  let userToken = "";
25
25
  let userNetworkId = "";
26
26
 
27
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
28
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
29
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
30
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
27
31
  beforeAll(async () => {
28
32
  // Bootstrap a real admin + token on the temp DB so the server can
29
33
  // resolve it via requireAuth (we don't want to short-circuit auth —
@@ -58,7 +62,7 @@ beforeAll(async () => {
58
62
  BASE = `http://127.0.0.1:${server.port}`;
59
63
  // Tiny settle so the listener is ready.
60
64
  await new Promise((r) => setTimeout(r, 100));
61
- });
65
+ }, 30_000);
62
66
 
63
67
  afterAll(() => {
64
68
  try { server?.stop?.(true); } catch {}