@sleep2agi/commhub-server 0.9.0-preview.110 → 0.9.0-preview.112
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/auth.ts +18 -14
- package/src/binding-generation-http.test.ts +121 -0
- package/src/create-node.ts +74 -23
- package/src/daemon-token-owner-http.test.ts +231 -0
- package/src/db.ts +3 -0
- package/src/node-caller-identity-http.test.ts +714 -0
- package/src/node-daemon-bindings.test.ts +1 -1
- package/src/node-lifecycle-read-http.test.ts +2 -1
- package/src/node-lifecycle-read.ts +1 -0
- package/src/node-permissions.ts +45 -12
- package/src/node-token-ownership.ts +36 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/requirement-events-http.test.ts +12 -1
- package/src/requirements-last-event-http.test.ts +6 -1
- package/src/requirements.ts +3 -7
- package/src/rest-identity.ts +29 -4
- package/src/scheduled-tasks.ts +5 -3
- package/src/server.ts +63 -21
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/tools.ts +91 -29
|
@@ -48,7 +48,7 @@ test("two-phase binding, daemon-bound pull, controllable map, adopted delete ref
|
|
|
48
48
|
expect(await call(handlers("daemon").get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: true, node_id: CHILD, alias: "adoption-child", workdir: "/workspace/project" });
|
|
49
49
|
expect(await call(handlers().get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: false });
|
|
50
50
|
expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true });
|
|
51
|
-
expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted" });
|
|
51
|
+
expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted", binding_request_id: r.request_id });
|
|
52
52
|
expect(buildControllableMap([], [{ node_id: CHILD, daemon_node_id: DAEMON }]).get(CHILD)).toBe(DAEMON);
|
|
53
53
|
expect(await call(handlers().delete_node, { child_node_id: CHILD, confirm_alias: "adoption-child", network_id: NET })).toMatchObject({ ok: false, error: "adopted_node_delete_unsupported" });
|
|
54
54
|
expect(db.get<any>("SELECT lifecycle_state FROM nodes WHERE node_id=?1", CHILD).lifecycle_state).toBe("active");
|
|
@@ -59,8 +59,9 @@ test("public errors redact free text for granted viewers on every projection", a
|
|
|
59
59
|
"not_my_child: daemon has no local record of fixture-node (fixture-id) — no children-map entry and no config at /private/fixture/fixture-node/config.json; refusing to signal processes by alias.",
|
|
60
60
|
"bin: ENOENT: /private/fixture/bin/anet on fixture-host",
|
|
61
61
|
"adopt_unknown_secret", "adopt_start_timeout: /private/fixture", "", "adopt_start_timeout", null,
|
|
62
|
+
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
62
63
|
]) {
|
|
63
|
-
const expected = error === null ||
|
|
64
|
+
const expected = error === null || ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed"].includes(error) ? error : "lifecycle_error";
|
|
64
65
|
for (const [kind, table, request] of [
|
|
65
66
|
["adopt", "node_daemon_bindings", "adopt_read_active"],
|
|
66
67
|
["start", "node_start_requests", "start_read_failed"],
|
|
@@ -3,6 +3,7 @@ import { addAgentNetworkScope, type RestNetworkScope } from "./network-scope.js"
|
|
|
3
3
|
|
|
4
4
|
// Exact public codes only; daemon exception text must never reach node viewers.
|
|
5
5
|
const publicLifecycleErrors = new Set([
|
|
6
|
+
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
6
7
|
"adopt_active_binding_required", "adopt_binding_unavailable",
|
|
7
8
|
"adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
|
|
8
9
|
"adopt_start_evidence_missing", "adopt_stop_evidence_missing",
|
package/src/node-permissions.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { db } from "./db.js";
|
|
18
18
|
import { canMessageAgent, type AgentRef } from "./agent-access.js";
|
|
19
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
19
20
|
|
|
20
21
|
export type NodePermissionMode = "normal" | "readonly" | "restricted";
|
|
21
22
|
export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
|
|
@@ -71,23 +72,55 @@ function normalizeMode(raw: unknown): NodePermissionMode {
|
|
|
71
72
|
/** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
|
|
72
73
|
export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
|
|
73
74
|
if (!tokenId || !networkId) return null;
|
|
74
|
-
const row = db.get<{ user_id: string | null;
|
|
75
|
-
`SELECT
|
|
76
|
-
FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
|
|
77
|
-
WHERE t.token_id = ?1 AND t.network_id = ?2`,
|
|
75
|
+
const row = db.get<{ user_id: string | null; name: string | null }>(
|
|
76
|
+
`SELECT user_id, name FROM api_tokens WHERE token_id = ?1 AND network_id = ?2`,
|
|
78
77
|
tokenId, networkId,
|
|
79
78
|
);
|
|
80
79
|
if (!row) return null;
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
if (
|
|
80
|
+
const resolved = resolveNodeCaller(tokenId);
|
|
81
|
+
// Revoked or not node-shaped: this lookup is not a second revocation gate.
|
|
82
|
+
if (!resolved.ok && resolved.reason === "not_a_node_token") {
|
|
83
|
+
const aliases = row.name?.startsWith("node:") ? [row.name.slice(5)] : [];
|
|
84
|
+
return {
|
|
85
|
+
networkId,
|
|
86
|
+
nodeId: null,
|
|
87
|
+
logKey: `token:${tokenId}`,
|
|
88
|
+
aliases,
|
|
89
|
+
ownerUserId: row.user_id,
|
|
90
|
+
mode: "normal",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!resolved.ok || resolved.networkId !== networkId) {
|
|
94
|
+
return {
|
|
95
|
+
networkId,
|
|
96
|
+
nodeId: null,
|
|
97
|
+
logKey: `token:${tokenId}`,
|
|
98
|
+
aliases: [],
|
|
99
|
+
ownerUserId: row.user_id,
|
|
100
|
+
mode: "normal",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (resolved.kind === "unregistered") {
|
|
104
|
+
return {
|
|
105
|
+
networkId,
|
|
106
|
+
nodeId: null,
|
|
107
|
+
logKey: `token:${tokenId}`,
|
|
108
|
+
aliases: resolved.alias ? [resolved.alias] : [],
|
|
109
|
+
ownerUserId: row.user_id,
|
|
110
|
+
mode: "normal",
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const node = db.get<{ owner_user_id: string | null; permission_mode: string | null }>(
|
|
114
|
+
`SELECT owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
|
|
115
|
+
resolved.nodeId, networkId,
|
|
116
|
+
);
|
|
84
117
|
return {
|
|
85
118
|
networkId,
|
|
86
|
-
nodeId:
|
|
87
|
-
logKey:
|
|
88
|
-
aliases: [
|
|
89
|
-
ownerUserId:
|
|
90
|
-
mode: normalizeMode(
|
|
119
|
+
nodeId: resolved.nodeId,
|
|
120
|
+
logKey: resolved.nodeId,
|
|
121
|
+
aliases: [resolved.alias],
|
|
122
|
+
ownerUserId: node?.owner_user_id || row.user_id || null,
|
|
123
|
+
mode: normalizeMode(node?.permission_mode),
|
|
91
124
|
};
|
|
92
125
|
}
|
|
93
126
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
// Call inside the caller's transaction. Serialize registration and issuance for
|
|
4
|
+
// this alias on PG; SQLite already serializes writes on its single connection.
|
|
5
|
+
export function lockNodeAlias(network: string, alias: string, nodeId?: string): void {
|
|
6
|
+
if (db.dialect !== "postgres") return;
|
|
7
|
+
db.get("SELECT pg_advisory_xact_lock(hashtext(?1))", JSON.stringify([network, "alias", alias]));
|
|
8
|
+
if (nodeId) db.get("SELECT pg_advisory_xact_lock(hashtext(?1))", JSON.stringify([network, "id", nodeId]));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function legacyNodeHolder(user: string, network: string, alias: string, nodeId: string): boolean {
|
|
12
|
+
return !!db.get(`SELECT token_id FROM api_tokens WHERE user_id=?1 AND network_id=?2
|
|
13
|
+
AND name=?3 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > datetime('now'))
|
|
14
|
+
AND (bound_node_id=?4 OR (bound_node_id IS NULL AND node_identity_epoch=0)) LIMIT 1`,
|
|
15
|
+
user, network, `node:${alias}`, nodeId);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve a name-only refresh to the existing identity, never claim its owner. */
|
|
19
|
+
export function checkNodeTokenClaim(user: string, network: string, alias: string, nodeId?: string): string | undefined {
|
|
20
|
+
lockNodeAlias(network, alias, nodeId);
|
|
21
|
+
const rows = db.all<{node_id: string; owner_user_id: string | null}>(
|
|
22
|
+
"SELECT node_id, owner_user_id FROM nodes WHERE network_id=?1 AND alias=?2", network, alias);
|
|
23
|
+
if (rows.length > 1 || rows.some(row => nodeId && row.node_id !== nodeId)) throw Error("node_owner_mismatch");
|
|
24
|
+
const row = rows[0];
|
|
25
|
+
// Explicit-ID ownerless claims retain auth.ts's node_owner_unclaimed error;
|
|
26
|
+
// name-only requests need holder evidence before resolving to that ID.
|
|
27
|
+
if (row && (row.owner_user_id ? row.owner_user_id !== user : !nodeId && !legacyNodeHolder(user, network, alias, row.node_id))) {
|
|
28
|
+
throw Error("node_owner_mismatch");
|
|
29
|
+
}
|
|
30
|
+
if (!row && db.get(`SELECT token_id FROM api_tokens WHERE network_id=?1 AND name=?2
|
|
31
|
+
AND user_id<>?3 AND node_identity_epoch=2 AND revoked_at IS NULL
|
|
32
|
+
AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1`, network, `node:${alias}`, user)) {
|
|
33
|
+
throw Error("node_owner_mismatch");
|
|
34
|
+
}
|
|
35
|
+
return nodeId ?? row?.node_id;
|
|
36
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// #667 — a restart from the right directory must clear the cwd warning the
|
|
2
|
+
// previous process left on the same resume_id. Hub stores
|
|
3
|
+
// `task = COALESCE(?11, sessions.task)`, so omitting task keeps the warning.
|
|
4
|
+
// Register sends task: "" once. Later idle reports omit task, so the
|
|
5
|
+
// description of a finished task stays. This boots a throwaway hub on
|
|
6
|
+
// port 0 (never 9200).
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { reportedTask } from "../../agent-node/src/project-dir-mismatch.ts";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-667-clear-"));
|
|
15
|
+
process.env.COMMHUB_DB = join(DIR, "hub.db");
|
|
16
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
17
|
+
process.env.HOST = "127.0.0.1";
|
|
18
|
+
|
|
19
|
+
const PW = "ProjectDirWarn123!x";
|
|
20
|
+
const stamp = Date.now();
|
|
21
|
+
const ROOT = "/work";
|
|
22
|
+
const NODE_DIR = "/work/.anet/nodes/demo-node";
|
|
23
|
+
const CONFIG = `${NODE_DIR}/config.json`;
|
|
24
|
+
|
|
25
|
+
let db: { get: (sql: string, ...params: unknown[]) => { task: string | null } | null };
|
|
26
|
+
let hub: { port: number; stop: (close?: boolean) => void } | null = null;
|
|
27
|
+
let BASE = "";
|
|
28
|
+
let NET = "";
|
|
29
|
+
let node: { alias: string; nodeId: string; token: string };
|
|
30
|
+
|
|
31
|
+
async function tool(token: string, name: string, args: Record<string, unknown>) {
|
|
32
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `Bearer ${token}`,
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
Accept: "application/json, text/event-stream",
|
|
38
|
+
"MCP-Protocol-Version": "2025-03-26",
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
41
|
+
});
|
|
42
|
+
const raw = await res.text();
|
|
43
|
+
const data = raw.split("\n").filter((line) => line.startsWith("data:"));
|
|
44
|
+
const payload = data.length ? JSON.parse(data.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
45
|
+
return JSON.parse(payload.result.content[0].text);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sessionTask(): string | null {
|
|
49
|
+
return db.get("SELECT task FROM sessions WHERE resume_id = ?1", `sdk-${node.nodeId}`)?.task ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
({ db } = await import("./db.js"));
|
|
54
|
+
const { createNetworkTokenForNode, register } = await import("./auth.js");
|
|
55
|
+
const boss = register(`cwd_boss_${stamp}`, PW);
|
|
56
|
+
expect(boss.ok).toBe(true);
|
|
57
|
+
NET = boss.network_id!;
|
|
58
|
+
const alias = `demo-node-${stamp}`;
|
|
59
|
+
const nodeId = `n_cwd_${stamp}`;
|
|
60
|
+
const minted = createNetworkTokenForNode(boss.user!.user_id, NET, alias, nodeId);
|
|
61
|
+
expect(minted.ok).toBe(true);
|
|
62
|
+
node = { alias, nodeId, token: minted.token! };
|
|
63
|
+
const mod = await import("./server.js");
|
|
64
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
65
|
+
expect(hub.port).not.toBe(9200);
|
|
66
|
+
expect(hub.port).toBeGreaterThan(0);
|
|
67
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
68
|
+
}, 30_000);
|
|
69
|
+
|
|
70
|
+
afterAll(() => {
|
|
71
|
+
try { hub?.stop(true); } catch { /* already stopped */ }
|
|
72
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch { /* temp dir */ }
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("#667 restart from the workspace root clears the cwd warning", () => {
|
|
76
|
+
test("wrong directory writes the warning; the right directory sends \"\" and the hub row is empty", async () => {
|
|
77
|
+
const resume = `sdk-${node.nodeId}`;
|
|
78
|
+
const wrong = reportedTask({ configPath: CONFIG, cwd: NODE_DIR, inFlight: 0, status: "idle" });
|
|
79
|
+
expect(wrong).toContain("目录不一致");
|
|
80
|
+
const started = await tool(node.token, "report_status", {
|
|
81
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: wrong,
|
|
82
|
+
});
|
|
83
|
+
expect(started.ok).toBe(true);
|
|
84
|
+
expect(sessionTask()).toBe(wrong);
|
|
85
|
+
|
|
86
|
+
const kept = await tool(node.token, "report_status", {
|
|
87
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET,
|
|
88
|
+
});
|
|
89
|
+
expect(kept.ok).toBe(true);
|
|
90
|
+
expect(sessionTask()).toBe(wrong);
|
|
91
|
+
|
|
92
|
+
const idle = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" });
|
|
93
|
+
expect(idle).toBeUndefined();
|
|
94
|
+
const cleared = idle ?? "";
|
|
95
|
+
expect(cleared).toBe("");
|
|
96
|
+
const fixed = await tool(node.token, "report_status", {
|
|
97
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: cleared,
|
|
98
|
+
});
|
|
99
|
+
expect(fixed.ok).toBe(true);
|
|
100
|
+
expect(sessionTask()).toBe("");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("after startup, a finished task's description stays on the hub", async () => {
|
|
104
|
+
const resume = `sdk-${node.nodeId}`;
|
|
105
|
+
const description = "BOARD667-FINISHED-TASK";
|
|
106
|
+
const startup = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" }) ?? "";
|
|
107
|
+
expect(startup).toBe("");
|
|
108
|
+
const registered = await tool(node.token, "report_status", {
|
|
109
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET, task: startup,
|
|
110
|
+
});
|
|
111
|
+
expect(registered.ok).toBe(true);
|
|
112
|
+
expect(sessionTask()).toBe("");
|
|
113
|
+
|
|
114
|
+
const working = reportedTask({
|
|
115
|
+
configPath: CONFIG, cwd: ROOT, inFlight: 1, status: "working", task: description,
|
|
116
|
+
});
|
|
117
|
+
expect(working).toBe(description);
|
|
118
|
+
const ran = await tool(node.token, "report_status", {
|
|
119
|
+
resume_id: resume, alias: node.alias, status: "working", node_id: node.nodeId, network_id: NET, task: working,
|
|
120
|
+
});
|
|
121
|
+
expect(ran.ok).toBe(true);
|
|
122
|
+
expect(sessionTask()).toBe(description);
|
|
123
|
+
|
|
124
|
+
const finished = reportedTask({ configPath: CONFIG, cwd: ROOT, inFlight: 0, status: "idle" });
|
|
125
|
+
expect(finished).toBeUndefined();
|
|
126
|
+
const done = await tool(node.token, "report_status", {
|
|
127
|
+
resume_id: resume, alias: node.alias, status: "idle", node_id: node.nodeId, network_id: NET,
|
|
128
|
+
});
|
|
129
|
+
expect(done.ok).toBe(true);
|
|
130
|
+
expect(sessionTask()).toBe(description);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -73,10 +73,21 @@ beforeAll(async () => {
|
|
|
73
73
|
// 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
|
|
74
74
|
db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
|
|
75
75
|
NODE = `n_ev_${stamp}`;
|
|
76
|
-
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "ev-node", "ev-node", NET]);
|
|
77
76
|
const ntok = createNetworkTokenForNode(admin.id, NET, "ev-node");
|
|
78
77
|
expect(ntok.ok).toBe(true);
|
|
79
78
|
nodeToken = ntok.token!;
|
|
79
|
+
// Real legacy-client order: mint by name, then register; never fill owner.
|
|
80
|
+
const registration = await fetch(`${BASE}/mcp`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { Authorization: `Bearer ${nodeToken}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
83
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: {
|
|
84
|
+
resume_id: `ev-session-${stamp}`, alias: "ev-node", status: "idle", node_id: NODE,
|
|
85
|
+
} } }),
|
|
86
|
+
});
|
|
87
|
+
expect(registration.status).toBe(200);
|
|
88
|
+
await registration.text();
|
|
89
|
+
expect(db.get<{owner_user_id: string | null}>("SELECT owner_user_id FROM nodes WHERE node_id=?1", NODE)?.owner_user_id).toBeNull();
|
|
90
|
+
expect(db.get<{bound_node_id: string | null}>("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", ntok.token_id!)?.bound_node_id).toBe(NODE);
|
|
80
91
|
}, 30_000);
|
|
81
92
|
|
|
82
93
|
afterAll(() => {
|
|
@@ -92,10 +92,15 @@ beforeAll(async () => {
|
|
|
92
92
|
// 看全部任务,但一个 Agent 都没授权:所有节点对他隐去
|
|
93
93
|
db.run("UPDATE network_members SET task_access = 'all', agent_access = 'granted' WHERE network_id = ?1 AND user_id = ?2", [NET, restricted.id]);
|
|
94
94
|
NODE = `n_le_${stamp}`;
|
|
95
|
-
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id) VALUES (?1, ?2, ?3, ?4)", [NODE, "le-node", "le-node", NET]);
|
|
96
95
|
const ntok = createNetworkTokenForNode(admin.id, NET, "le-node");
|
|
97
96
|
expect(ntok.ok).toBe(true);
|
|
98
97
|
nodeToken = ntok.token!;
|
|
98
|
+
// Real legacy-client order, with no owner backfill or administrator claim.
|
|
99
|
+
expect((await mcp(nodeToken, "report_status", {
|
|
100
|
+
resume_id: `le-session-${stamp}`, alias: "le-node", status: "idle", node_id: NODE,
|
|
101
|
+
})).data.ok).toBe(true);
|
|
102
|
+
expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id=?1", NODE).owner_user_id).toBeNull();
|
|
103
|
+
expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", ntok.token_id).bound_node_id).toBe(NODE);
|
|
99
104
|
}, 30_000);
|
|
100
105
|
|
|
101
106
|
afterAll(() => {
|
package/src/requirements.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
|
|
|
5
5
|
import { parsePeopleQuery, peopleDirectory, resolvePersonName } from "./requirements-people.js";
|
|
6
6
|
import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
|
|
7
7
|
import { db, logAudit } from "./db.js";
|
|
8
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
8
9
|
import { markGzipReusable } from "./http-gzip.js";
|
|
9
10
|
import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
|
|
10
11
|
import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
|
|
@@ -795,13 +796,8 @@ function actorOf(ctx: RequirementsRequestContext): Actor | null {
|
|
|
795
796
|
if (!ctx.auth) return null;
|
|
796
797
|
if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
|
|
797
798
|
const tokenId = ctx.auth.tokenId ?? null;
|
|
798
|
-
const
|
|
799
|
-
if (
|
|
800
|
-
const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
|
|
801
|
-
if (alias && ctx.auth.networkId) {
|
|
802
|
-
const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
|
|
803
|
-
if (node) return { kind: "node", id: node.node_id };
|
|
804
|
-
}
|
|
799
|
+
const resolved = tokenId ? resolveNodeCaller(tokenId) : null;
|
|
800
|
+
if (resolved?.ok && resolved.kind === "node") return { kind: "node", id: resolved.nodeId };
|
|
805
801
|
return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
|
|
806
802
|
}
|
|
807
803
|
|
package/src/rest-identity.ts
CHANGED
|
@@ -12,15 +12,20 @@
|
|
|
12
12
|
// task row and, once sender labels reach the copresence TUIs, rendered to a
|
|
13
13
|
// human as an attribution.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// Production network tokens pass tokenId and resolve through resolveNodeCaller.
|
|
16
|
+
// A token whose name is not `node:` (not_a_node_token, the registration token
|
|
17
|
+
// before report_status) falls back to nodeAliasForToken, same as main.
|
|
18
|
+
// tokenId omitted keeps the name-only shape used by the unit cases below.
|
|
19
|
+
|
|
20
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
18
21
|
|
|
19
22
|
export interface RestIdentityInput {
|
|
20
23
|
/** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
|
|
21
24
|
token: string;
|
|
22
25
|
/** `api_tokens.name`; for node tokens it is `node:<alias>`. */
|
|
23
26
|
tokenName: string | null | undefined;
|
|
27
|
+
/** Set on the production path. Network tokens then ignore tokenName as authority. */
|
|
28
|
+
tokenId?: string | null;
|
|
24
29
|
/** Username resolved from the authenticated token. */
|
|
25
30
|
authenticatedUsername?: string | null;
|
|
26
31
|
/** Caller-supplied `body.from`. */
|
|
@@ -46,8 +51,28 @@ export function nodeAliasForToken(token: string, tokenName: string | null | unde
|
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
|
|
49
|
-
const alias = nodeAliasForToken(input.token, input.tokenName);
|
|
50
54
|
const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
|
|
55
|
+
let alias: string | null;
|
|
56
|
+
if (input.token.startsWith("ntok_") && input.tokenId) {
|
|
57
|
+
const resolved = resolveNodeCaller(input.tokenId);
|
|
58
|
+
if (resolved.ok) {
|
|
59
|
+
alias = resolved.alias;
|
|
60
|
+
} else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
|
|
61
|
+
// Name is not `node:` yet. Main signs that as api, and refuses a claim.
|
|
62
|
+
// not_owner / bound_node_missing / ambiguous stay the hard reject.
|
|
63
|
+
alias = nodeAliasForToken(input.token, input.tokenName);
|
|
64
|
+
} else {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
error: "from_session_identity_mismatch",
|
|
68
|
+
message: "network token from_session does not match token-bound node alias",
|
|
69
|
+
tokenAlias: "",
|
|
70
|
+
requestedFromSession: requested,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
alias = nodeAliasForToken(input.token, input.tokenName);
|
|
75
|
+
}
|
|
51
76
|
|
|
52
77
|
// A network-bound token whose name we cannot resolve to an alias (`node:`,
|
|
53
78
|
// `node: `, or a name that was never node-scoped) is the degenerate case.
|
package/src/scheduled-tasks.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { db, logTaskEvent } from "./db.js";
|
|
2
|
+
import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
|
|
3
|
+
import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
|
|
2
4
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
3
5
|
import { assertNodeHealthy } from "./node-health-guard.js";
|
|
4
6
|
import { addNetworkScope, canRestWriteNetwork, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
@@ -366,7 +368,7 @@ export function dispatchScheduledOccurrence(row: ScheduledRow, scheduledFor: str
|
|
|
366
368
|
// Dispatch is not completion. Keep the run open until the exact bound
|
|
367
369
|
// task reaches replied/failed/cancelled/expired; db.ts owns that mirror.
|
|
368
370
|
db.run("UPDATE scheduled_task_runs SET task_id = ?1, status = ?2, completed_at = NULL WHERE run_id = ?3", [taskId, deliveryState, runId]);
|
|
369
|
-
db.run(
|
|
371
|
+
db.run(`UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3`, [sessionTaskPreview(row.task_content), node.node_id, row.network_id]);
|
|
370
372
|
db.run("UPDATE scheduled_tasks SET target_alias = ?1, last_run_at = ?2, updated_at = datetime('now') WHERE schedule_id = ?3", [node.alias, scheduledFor, row.schedule_id]);
|
|
371
373
|
if (advanceSchedule) advance({ ...row, target_alias: node.alias }, scheduledFor, advanceAfter);
|
|
372
374
|
createdTaskId = taskId;
|
|
@@ -517,7 +519,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
517
519
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
518
520
|
const content = typeof body.task === "string" ? body.task.trim() : "";
|
|
519
521
|
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
520
|
-
if (!content || content.length >
|
|
522
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
521
523
|
const priority = typeof body.priority === "string" ? body.priority : "normal";
|
|
522
524
|
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
523
525
|
const misfirePolicy = parseMisfirePolicy(body.misfire_policy);
|
|
@@ -604,7 +606,7 @@ export async function handleScheduledTaskRequest(ctx: ScheduledRequestContext):
|
|
|
604
606
|
const content = body.task === undefined ? row.task_content : String(body.task).trim();
|
|
605
607
|
const priority = body.priority === undefined ? row.priority : String(body.priority);
|
|
606
608
|
if (!name || name.length > 120) throw new Error("invalid_name");
|
|
607
|
-
if (!content || content.length >
|
|
609
|
+
if (!content || content.length > TASK_CONTENT_MAX) throw new Error("invalid_task");
|
|
608
610
|
if (!PRIORITIES.has(priority)) throw new Error("invalid_priority");
|
|
609
611
|
const target = body.target_node_id === undefined ? { node_id: row.target_node_id, alias: row.target_alias } : validateTarget(row.network_id, body.target_node_id);
|
|
610
612
|
const parsed = body.schedule === undefined
|
package/src/server.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
|
|
|
3
3
|
import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
4
4
|
import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
|
|
5
5
|
import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
|
|
6
|
+
import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
|
|
7
|
+
import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
|
|
6
8
|
import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
7
9
|
import { readNodeHealth } from "./node-health-store.js";
|
|
8
10
|
import { wantsAllTools } from "./tool-audience.js";
|
|
@@ -73,6 +75,7 @@ import {
|
|
|
73
75
|
TASK_REST_SELECT,
|
|
74
76
|
} from "./rest-projections.js";
|
|
75
77
|
import { resolveRestFromSession } from "./rest-identity.js";
|
|
78
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
76
79
|
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
77
80
|
import { diagnoseTask } from "./task-diagnostic.js";
|
|
78
81
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
@@ -195,12 +198,12 @@ function sweepStaleRateLimits(): void {
|
|
|
195
198
|
}
|
|
196
199
|
|
|
197
200
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
198
|
-
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false): McpServer {
|
|
201
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false, callerIdentityRejected = false): McpServer {
|
|
199
202
|
const server = new McpServer({
|
|
200
203
|
name: "commhub",
|
|
201
204
|
version: "0.5.0",
|
|
202
205
|
});
|
|
203
|
-
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools });
|
|
206
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools }, callerIdentityRejected);
|
|
204
207
|
return server;
|
|
205
208
|
}
|
|
206
209
|
|
|
@@ -270,6 +273,16 @@ function nodeSubscriberInfo(req: Request, server: any, tokenId: string | null, a
|
|
|
270
273
|
return { nodeId, instanceId, remote, userAgent: req.headers.get("user-agent")?.slice(0, 200) ?? null };
|
|
271
274
|
}
|
|
272
275
|
|
|
276
|
+
// The stream belongs to a node id, not a name. A session that never stored
|
|
277
|
+
// node_id still owns its stream when this network has exactly one nodes row
|
|
278
|
+
// for the alias. Null does not match null.
|
|
279
|
+
function streamOwnerNodeId(alias: string, networkId: string): string | null {
|
|
280
|
+
const fromSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, networkId)?.node_id ?? null;
|
|
281
|
+
if (fromSession) return fromSession;
|
|
282
|
+
const rows = db.all<{ node_id: string }>("SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", alias, networkId);
|
|
283
|
+
return rows.length === 1 ? rows[0].node_id : null;
|
|
284
|
+
}
|
|
285
|
+
|
|
273
286
|
function isLocalhostIP(ip: string): boolean {
|
|
274
287
|
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
|
|
275
288
|
}
|
|
@@ -308,17 +321,24 @@ function isHubAdminCredential(resolved: { networkId: string | null; user: { role
|
|
|
308
321
|
function nodeTokenAlias(resolved: { tokenName: string | null }): string | null {
|
|
309
322
|
return resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice("node:".length) : null;
|
|
310
323
|
}
|
|
311
|
-
function
|
|
324
|
+
function resolvedNodeAlias(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }): string | null {
|
|
325
|
+
if (!resolved.tokenId) return nodeTokenAlias(resolved);
|
|
326
|
+
const caller = resolveNodeCaller(resolved.tokenId);
|
|
327
|
+
if (!caller.ok) return null;
|
|
328
|
+
if (resolved.networkId && caller.networkId !== resolved.networkId) return null;
|
|
329
|
+
return caller.alias;
|
|
330
|
+
}
|
|
331
|
+
function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, networkId: unknown, oldAlias: unknown): boolean {
|
|
312
332
|
if (!resolved.networkId) return true;
|
|
313
|
-
return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias ===
|
|
333
|
+
return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === resolvedNodeAlias(resolved);
|
|
314
334
|
}
|
|
315
|
-
function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null }, txnId: unknown): boolean {
|
|
335
|
+
function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, txnId: unknown): boolean {
|
|
316
336
|
if (!resolved.networkId) return true;
|
|
317
337
|
if (typeof txnId !== "string") return false;
|
|
318
338
|
const txn = db.get<{ network_id: string; old_alias: string; new_alias: string }>("SELECT network_id, old_alias, new_alias FROM rename_txn WHERE txn_id = ?1", txnId);
|
|
319
339
|
if (!txn || txn.network_id !== resolved.networkId) return false;
|
|
320
340
|
// commit 前后令牌名可能已被改成新 alias(api_tokens.name 跟随改名),两个都算它自己。
|
|
321
|
-
const self =
|
|
341
|
+
const self = resolvedNodeAlias(resolved);
|
|
322
342
|
return self === txn.old_alias || self === txn.new_alias;
|
|
323
343
|
}
|
|
324
344
|
|
|
@@ -793,7 +813,7 @@ function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T &
|
|
|
793
813
|
// ── REST input schema ───────────────────────────────
|
|
794
814
|
const TaskSchema = z.object({
|
|
795
815
|
alias: z.string().min(1).max(200),
|
|
796
|
-
task: z.string().min(1).max(
|
|
816
|
+
task: z.string().min(1).max(TASK_CONTENT_MAX),
|
|
797
817
|
priority: z.enum(["high", "normal", "low"]).default("normal"),
|
|
798
818
|
from: z.string().max(200).optional(),
|
|
799
819
|
network_id: z.string().max(200).optional(),
|
|
@@ -1028,11 +1048,20 @@ return Bun.serve({
|
|
|
1028
1048
|
const token = requestToken(req);
|
|
1029
1049
|
const authCtx = resolveRequestAuth(req);
|
|
1030
1050
|
const enforceNetId = authCtx?.networkId || null;
|
|
1031
|
-
//
|
|
1032
|
-
//
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1051
|
+
// A resolved node token speaks as that node. A token whose name is not
|
|
1052
|
+
// `node:` (the registration network token) keeps the username. Only a
|
|
1053
|
+
// `node:` token refused as not_owner / bound_node_missing / ambiguous
|
|
1054
|
+
// is impersonation, and must not fall through to a client-supplied from.
|
|
1055
|
+
let callerAlias: string | null = authCtx?.username || null;
|
|
1056
|
+
let callerIdentityRejected = false;
|
|
1057
|
+
if (token?.startsWith("ntok_")) {
|
|
1058
|
+
const resolvedCaller = authCtx?.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
|
|
1059
|
+
if (resolvedCaller?.ok) callerAlias = resolvedCaller.alias;
|
|
1060
|
+
else if (resolvedCaller && resolvedCaller.reason !== "not_a_node_token") { // board679-plain-ntok
|
|
1061
|
+
callerAlias = null;
|
|
1062
|
+
callerIdentityRejected = true;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1036
1065
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
1037
1066
|
sessionIdGenerator: undefined,
|
|
1038
1067
|
});
|
|
@@ -1050,7 +1079,7 @@ return Bun.serve({
|
|
|
1050
1079
|
labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
|
|
1051
1080
|
}
|
|
1052
1081
|
// #478:tools/list 按调用者过滤;X-Anet-Tools: all / ?tools=all 时节点令牌也列出协议工具。
|
|
1053
|
-
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url));
|
|
1082
|
+
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url), callerIdentityRejected);
|
|
1054
1083
|
await mcpServer.connect(transport);
|
|
1055
1084
|
const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
|
|
1056
1085
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -1204,11 +1233,22 @@ return Bun.serve({
|
|
|
1204
1233
|
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
|
|
1205
1234
|
}));
|
|
1206
1235
|
if (streamDenied) return streamDenied;
|
|
1207
|
-
// #507 — the node's OWN stream
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
|
|
1211
|
-
|
|
1236
|
+
// #507 — the node's OWN stream is node-scoped: at most one per (network,
|
|
1237
|
+
// alias), newest wins. Own means the resolved node id equals this stream's
|
|
1238
|
+
// node id. A matching token name is not enough, and a monitor never supersedes.
|
|
1239
|
+
const resolvedCaller = authCtx.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
|
|
1240
|
+
const streamNodeId = streamOwnerNodeId(sessionName, scopedNetId);
|
|
1241
|
+
if (
|
|
1242
|
+
resolvedCaller?.ok
|
|
1243
|
+
&& resolvedCaller.kind === "node"
|
|
1244
|
+
&& resolvedCaller.nodeId
|
|
1245
|
+
&& streamNodeId
|
|
1246
|
+
&& resolvedCaller.nodeId === streamNodeId
|
|
1247
|
+
&& resolvedCaller.networkId === scopedNetId
|
|
1248
|
+
) {
|
|
1249
|
+
const info = nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId);
|
|
1250
|
+
if (!info.nodeId) info.nodeId = resolvedCaller.nodeId;
|
|
1251
|
+
return createSSEStream(sessionName, scopedNetId, { node: info });
|
|
1212
1252
|
}
|
|
1213
1253
|
return createSSEStream(sessionName, scopedNetId);
|
|
1214
1254
|
}
|
|
@@ -1393,8 +1433,9 @@ return Bun.serve({
|
|
|
1393
1433
|
// RFC-040:managed_department_ids = 我负责的部门 + 全部下级(空 = 不是负责人;viewer 恒空;节点令牌不是人,恒空)。
|
|
1394
1434
|
const networks = (resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks)
|
|
1395
1435
|
.map((n: any) => ({ ...n, managed_department_ids: resolved.networkId ? [] : managedDepartmentIds(n.network_id, resolved.user.user_id) }));
|
|
1436
|
+
const nodeCaller = resolved.networkId && resolved.tokenId ? resolveNodeCaller(resolved.tokenId) : null;
|
|
1396
1437
|
const credential = resolved.networkId
|
|
1397
|
-
? { kind: "node", network_id: resolved.networkId, node_alias:
|
|
1438
|
+
? { kind: "node", network_id: resolved.networkId, node_alias: nodeCaller?.ok ? nodeCaller.alias : null, acts_as_owner: false }
|
|
1398
1439
|
: { kind: "user" };
|
|
1399
1440
|
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
|
|
1400
1441
|
}
|
|
@@ -3544,6 +3585,7 @@ return Bun.serve({
|
|
|
3544
3585
|
const identity = resolveRestFromSession({
|
|
3545
3586
|
token: requestToken(req),
|
|
3546
3587
|
tokenName: restAuth?.tokenName,
|
|
3588
|
+
tokenId: restAuth?.tokenId,
|
|
3547
3589
|
authenticatedUsername: restAuth?.username,
|
|
3548
3590
|
requestedFrom: body.from,
|
|
3549
3591
|
});
|
|
@@ -3688,8 +3730,8 @@ return Bun.serve({
|
|
|
3688
3730
|
// immediately, without waiting for the agent's report_status to
|
|
3689
3731
|
// arrive. Updating both `task` and `updated_at` is enough — we
|
|
3690
3732
|
// leave `status` to the agent (idle → working → idle).
|
|
3691
|
-
const touchParams: any[] = [body.task
|
|
3692
|
-
let touchSql =
|
|
3733
|
+
const touchParams: any[] = [sessionTaskPreview(body.task), targetAlias];
|
|
3734
|
+
let touchSql = `UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE alias = ?2`;
|
|
3693
3735
|
if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
|
|
3694
3736
|
db.run(touchSql, touchParams);
|
|
3695
3737
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Board #668. A dispatch preview is what the hub shows before the node
|
|
2
|
+
// reports. Once the session is already working, that text is the task in
|
|
3
|
+
// flight — a newer message waits in the inbox and must not replace it.
|
|
4
|
+
// Idle and every other status still take the preview.
|
|
5
|
+
|
|
6
|
+
const PLACEHOLDER = /^\?\d+$/;
|
|
7
|
+
|
|
8
|
+
/** SQL expression for `sessions.task` on dispatch. `placeholder` is a `?N` bind. */
|
|
9
|
+
export function sessionTaskOnDispatch(placeholder: string): string {
|
|
10
|
+
if (!PLACEHOLDER.test(placeholder)) {
|
|
11
|
+
throw new Error("sessionTaskOnDispatch placeholder must be a ?N bind");
|
|
12
|
+
}
|
|
13
|
+
return `CASE WHEN status = 'working' THEN task ELSE ${placeholder} END`;
|
|
14
|
+
}
|