@sleep2agi/commhub-server 0.9.0-preview.33 → 0.9.0-preview.34
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 +7 -0
- package/src/rest-explicit-columns-http.test.ts +1 -0
- package/src/rest-projections.ts +1 -1
- package/src/task-consumption.test.ts +73 -0
- package/src/tools.ts +48 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.34",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/db.ts
CHANGED
|
@@ -200,6 +200,8 @@ db.exec(`
|
|
|
200
200
|
started_at TEXT,
|
|
201
201
|
runtime_submitted_at TEXT,
|
|
202
202
|
consumed_at TEXT,
|
|
203
|
+
thread_id TEXT,
|
|
204
|
+
turn_id TEXT,
|
|
203
205
|
completed_at TEXT,
|
|
204
206
|
expires_at TEXT
|
|
205
207
|
);
|
|
@@ -1157,6 +1159,11 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
|
|
|
1157
1159
|
// started_at (legacy report_status/content matching). Old binaries ignore them.
|
|
1158
1160
|
try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
|
|
1159
1161
|
try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
|
|
1162
|
+
// Exact vendor-runtime boundary for SideThread-capable clients. These values
|
|
1163
|
+
// are written only by the token-bound target node after attributable runtime
|
|
1164
|
+
// evidence; the Hub never derives them from task text or caller input.
|
|
1165
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); } catch {}
|
|
1166
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN turn_id TEXT"); } catch {}
|
|
1160
1167
|
|
|
1161
1168
|
// #1181 durable outbound terminal cursor. A task's created_at cannot be used
|
|
1162
1169
|
// as a recovery watermark because an old task may become terminal after newer
|
|
@@ -52,6 +52,7 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
52
52
|
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
53
53
|
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
|
|
54
54
|
"completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
|
|
55
|
+
"thread_id", "turn_id",
|
|
55
56
|
],
|
|
56
57
|
audit: [
|
|
57
58
|
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
package/src/rest-projections.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const TASK_REST_COLUMNS = [
|
|
|
36
36
|
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
37
37
|
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
38
38
|
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
|
|
39
|
-
"network_id", "parent_task_id", "meta_json",
|
|
39
|
+
"network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
|
|
40
40
|
] as const;
|
|
41
41
|
|
|
42
42
|
export const COMPLETION_REST_COLUMNS = [
|
|
@@ -92,6 +92,13 @@ function runtimeSubmittedAt(taskId: string): string | null {
|
|
|
92
92
|
)?.runtime_submitted_at ?? null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
function runtimeContext(taskId: string): { thread_id: string | null; turn_id: string | null } {
|
|
96
|
+
return db.get<{ thread_id: string | null; turn_id: string | null }>(
|
|
97
|
+
"SELECT thread_id, turn_id FROM tasks WHERE task_id = ?1",
|
|
98
|
+
taskId,
|
|
99
|
+
)!;
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
beforeEach(() => { cleanup(); seed(); });
|
|
96
103
|
afterAll(cleanup);
|
|
97
104
|
|
|
@@ -218,6 +225,72 @@ describe("task consumed_at identity and lifecycle", () => {
|
|
|
218
225
|
expect(listedTask?.consumed_at).toBe(timestamp);
|
|
219
226
|
});
|
|
220
227
|
|
|
228
|
+
test("token-bound consumed evidence persists and exposes an exact BTW boundary", async () => {
|
|
229
|
+
const taskId = await createTask("runtime emitted exact Codex context");
|
|
230
|
+
const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed;
|
|
231
|
+
const context = { task_id: taskId, thread_id: "thread_btw_exact", turn_id: "turn_btw_exact" };
|
|
232
|
+
|
|
233
|
+
const first = await call(handler, { task_ids: [taskId], task_contexts: [context] });
|
|
234
|
+
expect(first.ok).toBe(true);
|
|
235
|
+
expect(first.tasks[0]).toMatchObject(context);
|
|
236
|
+
expect(runtimeContext(taskId)).toEqual({
|
|
237
|
+
thread_id: context.thread_id,
|
|
238
|
+
turn_id: context.turn_id,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const listed = await call(toolsFor().list_tasks, { alias: NODE_A, limit: 20 });
|
|
242
|
+
expect(listed.tasks.find((task: any) => task.task_id === taskId)).toMatchObject(context);
|
|
243
|
+
|
|
244
|
+
const replay = await call(handler, { task_ids: [taskId], task_contexts: [context] });
|
|
245
|
+
expect(replay.ok).toBe(true);
|
|
246
|
+
const conflict = await call(handler, {
|
|
247
|
+
task_ids: [taskId],
|
|
248
|
+
task_contexts: [{ ...context, turn_id: "turn_sibling" }],
|
|
249
|
+
});
|
|
250
|
+
expect(conflict).toEqual({
|
|
251
|
+
ok: false,
|
|
252
|
+
error: "task_runtime_context_conflict",
|
|
253
|
+
task_id: taskId,
|
|
254
|
+
});
|
|
255
|
+
expect(runtimeContext(taskId)).toEqual({
|
|
256
|
+
thread_id: context.thread_id,
|
|
257
|
+
turn_id: context.turn_id,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("runtime context rejects user tokens, foreign tasks, and unrequested ids without partial writes", async () => {
|
|
262
|
+
const ownTask = await createTask("owned context", NODE_A);
|
|
263
|
+
const foreignTask = await createTask("foreign context", NODE_B);
|
|
264
|
+
const ownContext = { task_id: ownTask, thread_id: "thread_owned", turn_id: "turn_owned" };
|
|
265
|
+
const foreignContext = { task_id: foreignTask, thread_id: "thread_foreign", turn_id: "turn_foreign" };
|
|
266
|
+
|
|
267
|
+
expect(await call(toolsFor().mark_tasks_consumed, {
|
|
268
|
+
task_ids: [ownTask], task_contexts: [ownContext],
|
|
269
|
+
})).toEqual({ ok: false, error: "node_token_required" });
|
|
270
|
+
expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
|
|
271
|
+
task_ids: [ownTask, foreignTask], task_contexts: [ownContext, foreignContext],
|
|
272
|
+
})).toEqual({ ok: false, error: "task_not_owned", task_id: foreignTask });
|
|
273
|
+
expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
|
|
274
|
+
task_ids: [ownTask], task_contexts: [foreignContext],
|
|
275
|
+
})).toEqual({ ok: false, error: "task_context_not_requested", task_id: foreignTask });
|
|
276
|
+
expect(runtimeContext(ownTask)).toEqual({ thread_id: null, turn_id: null });
|
|
277
|
+
expect(runtimeContext(foreignTask)).toEqual({ thread_id: null, turn_id: null });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("submitted evidence cannot smuggle a runtime context", async () => {
|
|
281
|
+
const taskId = await createTask("submission has no authoritative turn yet");
|
|
282
|
+
const result = await call(
|
|
283
|
+
toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_runtime_submitted,
|
|
284
|
+
{
|
|
285
|
+
task_ids: [taskId],
|
|
286
|
+
task_contexts: [{ task_id: taskId, thread_id: "thread_early", turn_id: "turn_early" }],
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
expect(result).toEqual({ ok: false, error: "runtime_context_requires_consumed" });
|
|
290
|
+
expect(runtimeSubmittedAt(taskId)).toBeNull();
|
|
291
|
+
expect(runtimeContext(taskId)).toEqual({ thread_id: null, turn_id: null });
|
|
292
|
+
});
|
|
293
|
+
|
|
221
294
|
test("one runtime wake can mark an exact batch of owned tasks", async () => {
|
|
222
295
|
const firstTask = await createTask("batched wake first");
|
|
223
296
|
const secondTask = await createTask("batched wake second");
|
package/src/tools.ts
CHANGED
|
@@ -991,6 +991,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
991
991
|
const markTaskRuntimeEvidence = (
|
|
992
992
|
taskIds: string[],
|
|
993
993
|
level: "submitted" | "consumed",
|
|
994
|
+
taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
|
|
994
995
|
) => {
|
|
995
996
|
const task_ids = taskIds;
|
|
996
997
|
if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
|
|
@@ -1017,6 +1018,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1017
1018
|
};
|
|
1018
1019
|
}
|
|
1019
1020
|
|
|
1021
|
+
if (level !== "consumed" && taskContexts.length > 0) {
|
|
1022
|
+
return {
|
|
1023
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
|
|
1027
|
+
if (contextByTaskId.size !== taskContexts.length) {
|
|
1028
|
+
return {
|
|
1029
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
|
|
1033
|
+
if (foreignContext) {
|
|
1034
|
+
return {
|
|
1035
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1020
1039
|
const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
|
|
1021
1040
|
|
|
1022
1041
|
// Keep ownership preflight and every stamp in one SQLite transaction.
|
|
@@ -1031,8 +1050,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1031
1050
|
enforceNetworkId,
|
|
1032
1051
|
canonicalCaller,
|
|
1033
1052
|
);
|
|
1034
|
-
const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string }>(
|
|
1035
|
-
`SELECT task_id, to_node_id, to_name FROM tasks
|
|
1053
|
+
const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
|
|
1054
|
+
`SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
|
|
1036
1055
|
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1037
1056
|
enforceNetworkId,
|
|
1038
1057
|
...uniqueTaskIds,
|
|
@@ -1050,6 +1069,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1050
1069
|
if (rejectedTaskId) {
|
|
1051
1070
|
return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
|
|
1052
1071
|
}
|
|
1072
|
+
const conflictingContext = uniqueTaskIds.find((taskId) => {
|
|
1073
|
+
const context = contextByTaskId.get(taskId);
|
|
1074
|
+
if (!context) return false;
|
|
1075
|
+
const row = owned.get(taskId)!;
|
|
1076
|
+
return (row.thread_id !== null && row.thread_id !== context.thread_id)
|
|
1077
|
+
|| (row.turn_id !== null && row.turn_id !== context.turn_id);
|
|
1078
|
+
});
|
|
1079
|
+
if (conflictingContext) {
|
|
1080
|
+
return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
|
|
1081
|
+
}
|
|
1053
1082
|
|
|
1054
1083
|
for (const taskId of uniqueTaskIds) {
|
|
1055
1084
|
const row = owned.get(taskId)!;
|
|
@@ -1059,12 +1088,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1059
1088
|
const ownerValue = row.to_node_id ?? row.to_name;
|
|
1060
1089
|
let updateResult;
|
|
1061
1090
|
if (level === "consumed") {
|
|
1091
|
+
const context = contextByTaskId.get(taskId);
|
|
1062
1092
|
updateResult = db.run(
|
|
1063
1093
|
`UPDATE tasks SET
|
|
1064
1094
|
runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
|
|
1065
|
-
consumed_at = COALESCE(consumed_at, datetime('now'))
|
|
1095
|
+
consumed_at = COALESCE(consumed_at, datetime('now')),
|
|
1096
|
+
thread_id = COALESCE(thread_id, ?4),
|
|
1097
|
+
turn_id = COALESCE(turn_id, ?5)
|
|
1066
1098
|
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1067
|
-
[enforceNetworkId, taskId, ownerValue],
|
|
1099
|
+
[enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
|
|
1068
1100
|
);
|
|
1069
1101
|
} else {
|
|
1070
1102
|
updateResult = db.run(
|
|
@@ -1083,8 +1115,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1083
1115
|
task_id: string;
|
|
1084
1116
|
runtime_submitted_at: string;
|
|
1085
1117
|
consumed_at: string | null;
|
|
1118
|
+
thread_id: string | null;
|
|
1119
|
+
turn_id: string | null;
|
|
1086
1120
|
}>(
|
|
1087
|
-
`SELECT task_id, runtime_submitted_at, consumed_at FROM tasks
|
|
1121
|
+
`SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
|
|
1088
1122
|
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1089
1123
|
enforceNetworkId,
|
|
1090
1124
|
...uniqueTaskIds,
|
|
@@ -1102,20 +1136,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1102
1136
|
|
|
1103
1137
|
const taskRuntimeEvidenceSchema = {
|
|
1104
1138
|
task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
|
|
1139
|
+
task_contexts: z.array(z.object({
|
|
1140
|
+
task_id: z.string().min(1).max(200),
|
|
1141
|
+
thread_id: z.string().min(1).max(500),
|
|
1142
|
+
turn_id: z.string().min(1).max(500),
|
|
1143
|
+
}).strict()).max(100).optional(),
|
|
1105
1144
|
};
|
|
1106
1145
|
|
|
1107
1146
|
server.tool(
|
|
1108
1147
|
"mark_tasks_runtime_submitted",
|
|
1109
1148
|
"Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
|
|
1110
1149
|
taskRuntimeEvidenceSchema,
|
|
1111
|
-
async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "submitted"),
|
|
1150
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
|
|
1112
1151
|
);
|
|
1113
1152
|
|
|
1114
1153
|
server.tool(
|
|
1115
1154
|
"mark_tasks_consumed",
|
|
1116
1155
|
"Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
|
|
1117
1156
|
taskRuntimeEvidenceSchema,
|
|
1118
|
-
async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "consumed"),
|
|
1157
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
|
|
1119
1158
|
);
|
|
1120
1159
|
|
|
1121
1160
|
// ═══════════════════════════════════════════
|
|
@@ -2031,7 +2070,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2031
2070
|
const tasks = db.all(
|
|
2032
2071
|
`SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
|
|
2033
2072
|
t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
|
|
2034
|
-
t.consumed_at, t.completed_at, e.terminal_seq
|
|
2073
|
+
t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
|
|
2035
2074
|
FROM task_terminal_events e
|
|
2036
2075
|
JOIN tasks t ON t.task_id = e.task_id
|
|
2037
2076
|
WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
|
|
@@ -2048,7 +2087,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2048
2087
|
has_more: tasks.length === limit,
|
|
2049
2088
|
}) }] };
|
|
2050
2089
|
}
|
|
2051
|
-
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, completed_at FROM tasks WHERE 1=1";
|
|
2090
|
+
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
|
|
2052
2091
|
const params: any[] = [];
|
|
2053
2092
|
sql = addReadScope(sql, params, readScope);
|
|
2054
2093
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|