@sleep2agi/commhub-server 0.9.0-preview.64 → 0.9.0-preview.66

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.
@@ -45,7 +45,7 @@ const GOLDEN_RESPONSE_KEYS = {
45
45
  "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
46
46
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
47
47
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
48
- "external_schedules", "rules_file_capable", "skills_capable", "files_capable", "runtime", "host", "process_telemetry",
48
+ "external_schedules", "rules_file_capable", "skills_capable", "files_capable", "logs_capable", "runtime", "host", "process_telemetry",
49
49
  ],
50
50
  task: [
51
51
  "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
@@ -20,6 +20,7 @@ export const SESSION_REST_COLUMNS = [
20
20
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
21
21
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
22
22
  "external_schedules", "rules_file_capable", "skills_capable", "files_capable",
23
+ "logs_capable",
23
24
  ] as const;
24
25
 
25
26
  export const AUDIT_LOG_REST_COLUMNS = [
package/src/server.ts CHANGED
@@ -1707,6 +1707,7 @@ return Bun.serve({
1707
1707
  rules_file_capable: s.rules_file_capable === 1,
1708
1708
  skills_capable: s.skills_capable === 1,
1709
1709
  files_capable: s.files_capable === 1,
1710
+ logs_capable: s.logs_capable === 1,
1710
1711
  model: s.model ?? null,
1711
1712
  runtime: normalizeRuntime(s.agent),
1712
1713
  host: {
package/src/tools.ts CHANGED
@@ -64,7 +64,7 @@ import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
64
64
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
65
65
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
66
66
  import { parseHubTimestamp } from "./hub-timestamp";
67
- import { noteTerminalResultRead, sweepNodeRequestContent } from "./node-request-retention.js";
67
+ import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
68
68
 
69
69
  function ts(): string {
70
70
  return new Date().toTimeString().slice(0, 8);
@@ -730,8 +730,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
730
730
  skills_capable: z.literal(true).optional(),
731
731
  // Project folder view — same doorbell, ops files_list / file_read.
732
732
  files_capable: z.literal(true).optional(),
733
+ // Node run-log view — same doorbell, op logs_tail.
734
+ logs_capable: z.literal(true).optional(),
733
735
  },
734
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable }) => {
736
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable }) => {
735
737
  const effectiveNetId = getNetworkId(netId);
736
738
  const sessionNetId = effectiveNetId ?? "default";
737
739
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -943,6 +945,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
943
945
  if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
944
946
  db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
945
947
  }
948
+ if (logsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
949
+ db.run("UPDATE sessions SET logs_capable = 1 WHERE resume_id = ?1", [resume_id]);
950
+ }
946
951
  if (host || proc) {
947
952
  db.run(
948
953
  `INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
@@ -3201,11 +3206,43 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3201
3206
  return segs.join("/");
3202
3207
  };
3203
3208
 
3209
+ // Node run-log view — logs_tail rides the same queue + doorbell. `content`
3210
+ // carries the filter parameters as JSON (lines / level / grep / since_ts) and
3211
+ // never a path: the node reads only its own log directory and redacts before
3212
+ // it answers (agent-node/src/runtime/node-logs.ts). Stricter than the project
3213
+ // folder on the caller: user login only, and only the node's owner or a
3214
+ // network owner/admin — a member who may chat with a node does not get to read
3215
+ // its process log. Log bytes are purged on the first terminal read (see
3216
+ // get_rules_file_result) and by the retention sweep after LOGS_CONTENT_TTL_MS.
3217
+ const isLogsOp = (op: string) => op === "logs_tail";
3218
+ const LOGS_MAX_LINES = 2000;
3219
+ const LOGS_GREP_MAX = 200;
3220
+ const logsParamsJson = (p: { lines?: number; level?: string; grep?: string; since_ts?: number }): string =>
3221
+ JSON.stringify({
3222
+ lines: Math.min(Math.max(Math.floor(p.lines ?? 500), 1), LOGS_MAX_LINES),
3223
+ ...(p.level ? { level: p.level } : {}),
3224
+ ...(p.grep ? { grep: p.grep.slice(0, LOGS_GREP_MAX) } : {}),
3225
+ ...(typeof p.since_ts === "number" && p.since_ts > 0 ? { since_ts: p.since_ts } : {}),
3226
+ });
3227
+ // Owner of the node row, or owner/admin of the network. Legacy open mode (no
3228
+ // user identity on the connection) keeps canWrite's allow-all semantics.
3229
+ const canReadNodeLogs = (nodeId: string, networkId: string): boolean => {
3230
+ if (!enforceUserId) return true;
3231
+ const role = getUserNetworkRole(enforceUserId, networkId);
3232
+ if (role === "owner" || role === "admin") return true;
3233
+ if (nodeId.startsWith("session:")) return false;
3234
+ const row = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", nodeId);
3235
+ return !!row?.owner_user_id && row.owner_user_id === enforceUserId;
3236
+ };
3237
+
3204
3238
  const enqueueRulesFileRequest = (
3205
- op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read",
3239
+ op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read" | "logs_tail",
3206
3240
  a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
3207
3241
  ) => {
3208
3242
  const effectiveNetId = getNetworkId(a.network_id);
3243
+ if (isLogsOp(op) && callerTokenIsNetwork) {
3244
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_read_logs", message: "a node token cannot read another node's run log; use a user login" }) }] };
3245
+ }
3209
3246
  if (isFilesOp(op)) {
3210
3247
  if (callerTokenIsNetwork) {
3211
3248
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_browse_files", message: "a node token cannot browse another node's project folder; use a user login" }) }] };
@@ -3217,7 +3254,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3217
3254
  }
3218
3255
  a = { ...a, content: rel };
3219
3256
  }
3220
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
3257
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isLogsOp(op) ? "read node logs" : isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
3221
3258
  if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
3222
3259
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
3223
3260
  }
@@ -3247,7 +3284,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3247
3284
  node = byAlias;
3248
3285
  } else {
3249
3286
  const session = db.get<{ alias: string; network_id: string | null }>(
3250
- isFilesOp(op)
3287
+ isLogsOp(op)
3288
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND logs_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3289
+ : isFilesOp(op)
3251
3290
  ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3252
3291
  : isSkillsOp(op)
3253
3292
  ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
@@ -3256,6 +3295,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3256
3295
  scopeNet,
3257
3296
  );
3258
3297
  if (!session) {
3298
+ if (isLogsOp(op)) {
3299
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its run log (its agent-node may be too old)" }) }] };
3300
+ }
3259
3301
  if (isFilesOp(op)) {
3260
3302
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "files_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its project folder (its channel server may be too old)" }) }] };
3261
3303
  }
@@ -3267,6 +3309,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3267
3309
  }
3268
3310
  }
3269
3311
  const nodeId = node.node_id;
3312
+ if (isLogsOp(op) && !canReadNodeLogs(nodeId, node.network_id || "default")) {
3313
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_permission_denied", message: "only the node's owner or a network owner/admin can read its run log" }) }] };
3314
+ }
3270
3315
  if (op === "write") {
3271
3316
  if (typeof a.content !== "string") {
3272
3317
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
@@ -3282,7 +3327,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3282
3327
  // Separate single-flight lanes: the client opens the rules file and the
3283
3328
  // skills list together; one must not block the other.
3284
3329
  const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
3285
- isFilesOp(op)
3330
+ isLogsOp(op)
3331
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op = 'logs_tail' ORDER BY created_at DESC LIMIT 1"
3332
+ : isFilesOp(op)
3286
3333
  ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('files_list', 'file_read') ORDER BY created_at DESC LIMIT 1"
3287
3334
  : isSkillsOp(op)
3288
3335
  ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
@@ -3306,7 +3353,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3306
3353
  const networkId = node.network_id || "default";
3307
3354
  db.run(
3308
3355
  `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)`,
3309
- [requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3356
+ [requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) || isLogsOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3310
3357
  );
3311
3358
  pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
3312
3359
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
@@ -3382,6 +3429,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3382
3429
  async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
3383
3430
  );
3384
3431
 
3432
+ server.tool(
3433
+ "tail_node_logs",
3434
+ "Ask a node for the tail of its own agent-node run log (read-only, redacted on the node before it leaves). No path argument: the node reads only its own log directory. User logins only; the node's owner or a network owner/admin. Poll get_rules_file_result — content is JSON {files, lines:[{ts, level, text, key}], truncated, matched, now_ts}; it is handed out once and then purged.",
3435
+ {
3436
+ ...NODE_ID_ALIAS_FIELDS,
3437
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported logs_capable."),
3438
+ lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
3439
+ level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
3440
+ grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
3441
+ since_ts: z.number().int().min(0).optional().describe("Only lines at or after this epoch-ms timestamp (follow mode; the client de-duplicates by line key)."),
3442
+ network_id: z.string().max(200).optional(),
3443
+ },
3444
+ async ({ node_id, child_node_id, alias, lines, level, grep, since_ts, network_id }) =>
3445
+ enqueueRulesFileRequest("logs_tail", { node_id, child_node_id, alias, content: logsParamsJson({ lines, level, grep, since_ts }), network_id }),
3446
+ );
3447
+
3385
3448
  server.tool(
3386
3449
  "get_rules_file_request",
3387
3450
  "Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
@@ -3419,7 +3482,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3419
3482
  request: {
3420
3483
  request_id: req.request_id,
3421
3484
  op: req.op,
3422
- ...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) ? { content: req.content ?? "" } : {}),
3485
+ ...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) || isLogsOp(req.op) ? { content: req.content ?? "" } : {}),
3423
3486
  },
3424
3487
  }),
3425
3488
  }],
@@ -3460,7 +3523,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3460
3523
  if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
3461
3524
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
3462
3525
  }
3463
- if (typeof content === "string" && !isFilesOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
3526
+ if (typeof content === "string" && !isFilesOp(req.op) && !isLogsOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
3464
3527
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
3465
3528
  }
3466
3529
  db.run(
@@ -3492,7 +3555,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3492
3555
  );
3493
3556
  // SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
3494
3557
  // Project folder results: only the (user) token that asked may read them.
3495
- const foreignFilesRow = !!row && isFilesOp(row.op) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
3558
+ // Run-log results: same rule — only the token that asked.
3559
+ const foreignFilesRow = !!row && (isFilesOp(row.op) || isLogsOp(row.op)) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
3496
3560
  if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
3497
3561
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
3498
3562
  }
@@ -3511,6 +3575,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3511
3575
  // after the SEC-1 scope check above — a foreign caller can't stamp/purge.
3512
3576
  const terminal = status === "done" || status === "failed" || status === "timeout";
3513
3577
  const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
3578
+ // Run-log bytes are read-once: purge them in the same call that hands them
3579
+ // out (no grace window — the app never follows someone else's logs request,
3580
+ // it re-asks; see node-request-retention.ts).
3581
+ if (terminal && isLogsOp(row.op) && !purged) purgeLogsResultNow(requestId);
3514
3582
  return {
3515
3583
  content: [{
3516
3584
  type: "text" as const,