@sleep2agi/commhub-server 0.9.0-preview.65 → 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.
- package/package.json +1 -1
- package/src/db.ts +4 -1
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.ts +17 -1
- package/src/requirements-http.test.ts +205 -4
- package/src/requirements-migrate.ts +44 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +76 -0
- package/src/requirements.ts +255 -15
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -0
- package/src/server.ts +1 -0
- package/src/tools.ts +78 -10
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|