@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.
@@ -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 || error === "adopt_start_timeout" ? error : "lifecycle_error";
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",
@@ -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; bound_node_id: string | null; name: string | null; owner_user_id: string | null; permission_mode: string | null; alias: string | null }>(
75
- `SELECT t.user_id, t.bound_node_id, t.name, n.owner_user_id, n.permission_mode, n.alias
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 aliases = new Set<string>();
82
- if (row.alias) aliases.add(row.alias);
83
- if (row.name?.startsWith("node:")) aliases.add(row.name.slice(5));
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: row.bound_node_id,
87
- logKey: row.bound_node_id ?? `token:${tokenId}`,
88
- aliases: [...aliases],
89
- ownerUserId: row.owner_user_id || row.user_id || null,
90
- mode: normalizeMode(row.permission_mode),
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(() => {
@@ -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 bound = tokenId ? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", tokenId)?.bound_node_id : null;
799
- if (bound) return { kind: "node", id: bound };
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
 
@@ -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
- // This is the same decision expressed as a pure function so every credential
16
- // kind is a directly constructible test input rather than a hand-assembled
17
- // Request (the #503 convention in this codebase).
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.
@@ -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("UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE node_id = ?2 AND network_id = ?3", [row.task_content.slice(0, 200), node.node_id, row.network_id]);
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 > 10_000) throw new Error("invalid_task");
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 > 10_000) throw new Error("invalid_task");
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 nodeMayRename(resolved: { networkId: string | null; tokenName: string | null }, networkId: unknown, oldAlias: unknown): boolean {
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 === nodeTokenAlias(resolved);
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 = nodeTokenAlias(resolved);
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(10000),
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
- // Derive the calling alias from the token name (e.g., 'node:视频审查')
1032
- // so peer agents see the real sender instead of 'hub' on send_task.
1033
- const callerAlias = authCtx?.tokenName?.startsWith("node:")
1034
- ? authCtx.tokenName.slice("node:".length)
1035
- : (authCtx?.username || null);
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 (token bound to this alias) is node-scoped:
1208
- // at most one per (network, alias), newest wins. A node token watching some
1209
- // other alias stays a plain monitor and can never supersede that node.
1210
- if (authCtx.tokenName === `node:${sessionName}` && authCtx.networkId === scopedNetId) {
1211
- return createSSEStream(sessionName, scopedNetId, { node: nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId) });
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: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
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.slice(0, 200), targetAlias];
3692
- let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
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
+ }