@sleep2agi/commhub-server 0.9.0-preview.109 → 0.9.0-preview.111
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/create-node-unicode-name.test.ts +118 -0
- package/src/create-node-validate.test.ts +19 -5
- package/src/create-node-validate.ts +14 -4
- package/src/create-node.ts +13 -6
- package/src/daemon-token-owner-http.test.ts +226 -0
- package/src/db.ts +3 -0
- package/src/node-lifecycle-read-http.test.ts +203 -0
- package/src/node-lifecycle-read.ts +99 -0
- 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/scheduled-tasks.ts +5 -3
- package/src/server.ts +28 -5
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -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 +49 -13
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";
|
|
@@ -27,6 +29,7 @@ import { pendingInboxCount } from "./inbox-count.js";
|
|
|
27
29
|
import { notifyExpiredTasks, type ExpiredTaskRow } from "./task-expiry-notice.js";
|
|
28
30
|
import { expireStaleOpenTasks } from "./task-stale-open.js";
|
|
29
31
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
32
|
+
import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
|
|
30
33
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
31
34
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
32
35
|
import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
|
|
@@ -792,7 +795,7 @@ function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T &
|
|
|
792
795
|
// ── REST input schema ───────────────────────────────
|
|
793
796
|
const TaskSchema = z.object({
|
|
794
797
|
alias: z.string().min(1).max(200),
|
|
795
|
-
task: z.string().min(1).max(
|
|
798
|
+
task: z.string().min(1).max(TASK_CONTENT_MAX),
|
|
796
799
|
priority: z.enum(["high", "normal", "low"]).default("normal"),
|
|
797
800
|
from: z.string().max(200).optional(),
|
|
798
801
|
network_id: z.string().max(200).optional(),
|
|
@@ -2455,6 +2458,16 @@ return Bun.serve({
|
|
|
2455
2458
|
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
2456
2459
|
}
|
|
2457
2460
|
|
|
2461
|
+
// #629: only actual user credentials gain lifecycle read metadata. Legacy
|
|
2462
|
+
// master/dev-open and network/daemon credentials keep their old surface.
|
|
2463
|
+
const lifecycleAuth = resolveRequestAuth(req, { allowQueryToken: false });
|
|
2464
|
+
const lifecycleUser = !!lifecycleAuth?.userId && !lifecycleAuth.networkId
|
|
2465
|
+
&& !requestToken(req, { allowQueryToken: false }).startsWith("ntok_");
|
|
2466
|
+
if (url.pathname === "/api/node-lifecycle-requests" && req.method === "GET") {
|
|
2467
|
+
if (!lifecycleUser) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
2468
|
+
return withCors(req, lifecycleRequestResponse(url, restScope));
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2458
2471
|
const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
|
|
2459
2472
|
const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
|
|
2460
2473
|
? { tokenId: sideActor.tokenId, networkId: sideActor.boundNetworkId, nodeId: sideActor.boundNodeId }
|
|
@@ -3677,8 +3690,8 @@ return Bun.serve({
|
|
|
3677
3690
|
// immediately, without waiting for the agent's report_status to
|
|
3678
3691
|
// arrive. Updating both `task` and `updated_at` is enough — we
|
|
3679
3692
|
// leave `status` to the agent (idle → working → idle).
|
|
3680
|
-
const touchParams: any[] = [body.task
|
|
3681
|
-
let touchSql =
|
|
3693
|
+
const touchParams: any[] = [sessionTaskPreview(body.task), targetAlias];
|
|
3694
|
+
let touchSql = `UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE alias = ?2`;
|
|
3682
3695
|
if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
|
|
3683
3696
|
db.run(touchSql, touchParams);
|
|
3684
3697
|
});
|
|
@@ -4468,6 +4481,7 @@ return Bun.serve({
|
|
|
4468
4481
|
// would silently start dimming every daemon that hasn't
|
|
4469
4482
|
// upgraded past preview.55.
|
|
4470
4483
|
let snapCanCreate: boolean | undefined;
|
|
4484
|
+
let snapAdoptCapable: boolean | undefined;
|
|
4471
4485
|
let snapBlockedReason: string | undefined;
|
|
4472
4486
|
// #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
|
|
4473
4487
|
// 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
|
|
@@ -4481,6 +4495,7 @@ return Bun.serve({
|
|
|
4481
4495
|
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
4482
4496
|
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
4483
4497
|
const caps = parsed?.daemon_capabilities;
|
|
4498
|
+
if (typeof caps?.adopt_capable === "boolean") snapAdoptCapable = caps.adopt_capable;
|
|
4484
4499
|
if (caps && typeof caps.can_create_nodes === "boolean") {
|
|
4485
4500
|
snapCanCreate = caps.can_create_nodes;
|
|
4486
4501
|
}
|
|
@@ -4498,10 +4513,10 @@ return Bun.serve({
|
|
|
4498
4513
|
} catch { /* malformed */ }
|
|
4499
4514
|
}
|
|
4500
4515
|
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
|
|
4501
|
-
observedMsAgo: snapObservedMsAgo, readiness: snapReadiness };
|
|
4516
|
+
observedMsAgo: snapObservedMsAgo, readiness: snapReadiness, adoptCapable: snapAdoptCapable };
|
|
4502
4517
|
})
|
|
4503
4518
|
.filter(({ role }) => role === "host_supervisor")
|
|
4504
|
-
.map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness }) => {
|
|
4519
|
+
.map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness, adoptCapable }) => {
|
|
4505
4520
|
let online = false;
|
|
4506
4521
|
let lastSeenAt: string | null = null;
|
|
4507
4522
|
if (r.session_last_seen) {
|
|
@@ -4541,6 +4556,12 @@ return Bun.serve({
|
|
|
4541
4556
|
allowed_secret_keys: secrets,
|
|
4542
4557
|
host_telemetry: telemetry,
|
|
4543
4558
|
};
|
|
4559
|
+
if (lifecycleUser && typeof adoptCapable === "boolean") {
|
|
4560
|
+
// Do not reveal even the new capability for a node the user cannot read.
|
|
4561
|
+
const visibleParams: any[] = [r.node_id];
|
|
4562
|
+
const visibleSql = addAgentNetworkScope("SELECT node_id FROM nodes WHERE node_id=?1", visibleParams, restScope, { alias: "alias", nodeId: "node_id" });
|
|
4563
|
+
if (db.get(visibleSql, ...visibleParams)) out.adopt_capable = adoptCapable;
|
|
4564
|
+
}
|
|
4544
4565
|
// #1353 Fix ② — only emit the two capability keys when the
|
|
4545
4566
|
// daemon actually reported them. Undefined vs. false is a
|
|
4546
4567
|
// real distinction here (see Fix ② comment above).
|
|
@@ -4657,6 +4678,7 @@ return Bun.serve({
|
|
|
4657
4678
|
const role = netRoles.get(r.network_id);
|
|
4658
4679
|
return role === "owner" || role === "admin";
|
|
4659
4680
|
};
|
|
4681
|
+
const management = lifecycleUser ? lifecycleProjections(restScope) : null;
|
|
4660
4682
|
const rows = rawRows.map(r => {
|
|
4661
4683
|
let role: string | null = null;
|
|
4662
4684
|
const snap = r.config_snapshot;
|
|
@@ -4680,6 +4702,7 @@ return Bun.serve({
|
|
|
4680
4702
|
role,
|
|
4681
4703
|
lifecycle_controllable,
|
|
4682
4704
|
lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
|
|
4705
|
+
...(management ? (management.get(r.node_id) ?? { managed: "none", adoption: null }) : {}),
|
|
4683
4706
|
viewer_can: { permission_mode: canSetPermissionMode(r) },
|
|
4684
4707
|
};
|
|
4685
4708
|
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
NODE_FOLDER_RE, NODE_NAME_CASES, LEGACY_NODE_NAME_RE,
|
|
6
|
+
checkNodeName, nodeDirNameFor, nodeFolderSlug, fnv1aHex,
|
|
7
|
+
} from "./node-name.js";
|
|
8
|
+
|
|
9
|
+
// Board #652 — one name rule for hub + daemon (+ the app's pinned copy).
|
|
10
|
+
|
|
11
|
+
describe("#652 checkNodeName — shared vectors", () => {
|
|
12
|
+
for (const c of NODE_NAME_CASES) {
|
|
13
|
+
test(`${JSON.stringify(c.input).slice(0, 24)} → ${c.ok ? "ok" : c.error}`, () => {
|
|
14
|
+
const r = checkNodeName(c.input);
|
|
15
|
+
expect(r.ok).toBe(c.ok);
|
|
16
|
+
if (!r.ok) {
|
|
17
|
+
expect(r.error).toBe(c.error!);
|
|
18
|
+
expect(r.message.length).toBeGreaterThan(0);
|
|
19
|
+
}
|
|
20
|
+
if (c.slug !== undefined) expect(nodeFolderSlug(c.input)).toBe(c.slug);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("the vector table really contains Chinese names that pass (old rule would refuse them)", () => {
|
|
25
|
+
const cjkOk = NODE_NAME_CASES.filter(c => c.ok && /\p{Script=Han}/u.test(c.input));
|
|
26
|
+
expect(cjkOk.length).toBeGreaterThan(0);
|
|
27
|
+
for (const c of cjkOk) expect(LEGACY_NODE_NAME_RE.test(c.input)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("#652 folder slug", () => {
|
|
32
|
+
test("every slug — incl. every accepted vector — matches the ASCII folder rule", () => {
|
|
33
|
+
for (const c of NODE_NAME_CASES.filter(x => x.ok)) {
|
|
34
|
+
const s = nodeFolderSlug(c.input);
|
|
35
|
+
expect(NODE_FOLDER_RE.test(s)).toBe(true);
|
|
36
|
+
expect(/^[\x21-\x7e]+$/.test(s)).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
test("non-ASCII → node-<6 hex>, deterministic", () => {
|
|
40
|
+
expect(nodeFolderSlug("测试")).toMatch(/^node-[0-9a-f]{6}$/);
|
|
41
|
+
expect(nodeFolderSlug("测试")).toBe(nodeFolderSlug(" 测试 "));
|
|
42
|
+
expect(nodeFolderSlug("测试")).not.toBe(nodeFolderSlug("测验"));
|
|
43
|
+
});
|
|
44
|
+
test("fnv1a known answers (pins the app reimplementation)", () => {
|
|
45
|
+
expect(fnv1aHex("")).toBe("811c9dc5");
|
|
46
|
+
expect(fnv1aHex("a")).toBe("e40c292c");
|
|
47
|
+
expect(fnv1aHex("foobar")).toBe("bf9cf968");
|
|
48
|
+
});
|
|
49
|
+
test("nodeDirNameFor keeps legacy names as-is (existing nodes never move)", () => {
|
|
50
|
+
for (const n of ["a", "demo-bot", "node_1", "my_agent"]) expect(nodeDirNameFor(n)).toBe(n);
|
|
51
|
+
expect(nodeDirNameFor("测试")).toBe(nodeFolderSlug("测试"));
|
|
52
|
+
expect(nodeDirNameFor("MyBot")).toBe("mybot");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("#652 hub/daemon node-name drift guard", () => {
|
|
57
|
+
test("byte-identical source files", () => {
|
|
58
|
+
const hub = readFileSync(join(import.meta.dir, "node-name.ts"), "utf-8");
|
|
59
|
+
const daemon = readFileSync(join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "node-name.ts"), "utf-8");
|
|
60
|
+
expect(daemon).toBe(hub);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Board #652 — node name (= network alias) rules and the ASCII folder slug derived from it.
|
|
2
|
+
//
|
|
3
|
+
// Single source of truth for hub + daemon. This file is byte-identical in
|
|
4
|
+
// server/src/shared/node-name.ts and agent-node/src/shared/node-name.ts
|
|
5
|
+
// (node-name-drift.test.ts fails the build on any difference), and the app
|
|
6
|
+
// (sleep2agi/agent-network-app) keeps a copy pinned to NODE_NAME_CASES below.
|
|
7
|
+
//
|
|
8
|
+
// Before #652 the name had to match /^[a-z][a-z0-9_-]{0,63}$/, so the app
|
|
9
|
+
// wizard refused 「测试」 while the real fleet runs Chinese aliases started by
|
|
10
|
+
// hand. House rule: the alias may be Chinese, the working directory may not.
|
|
11
|
+
// So the name is now any Unicode letters/digits, and every directory derived
|
|
12
|
+
// from it uses nodeFolderSlug() (or the legacy name, for names the old rule
|
|
13
|
+
// already accepted — see nodeDirNameFor).
|
|
14
|
+
|
|
15
|
+
/** The old rule. Every name it accepts keeps its old directory (nodeDirNameFor). */
|
|
16
|
+
export const LEGACY_NODE_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
17
|
+
|
|
18
|
+
/** Folder / slug rule: what a working directory or `.anet/nodes/<dir>` may be called. */
|
|
19
|
+
export const NODE_FOLDER_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
20
|
+
|
|
21
|
+
export const NODE_NAME_MAX_CHARS = 64;
|
|
22
|
+
|
|
23
|
+
// First char: a letter, a digit or `_` (never `-`: `anet node start -x` reads as a flag;
|
|
24
|
+
// never `.`: hidden files, `..`). Rest: letters, digits, combining marks (Devanagari,
|
|
25
|
+
// Thai… need them), `_`, `-`. Nothing else — no whitespace, `/ \ :`, `.`, quotes,
|
|
26
|
+
// `$`, backticks, control characters: the name ends up in argv, tmux session names
|
|
27
|
+
// (tmux rewrites `.` and `:`), JSON, logs and file names in the trash directory.
|
|
28
|
+
const NODE_NAME_RE = /^[\p{L}\p{N}_][\p{L}\p{M}\p{N}_-]*$/u;
|
|
29
|
+
|
|
30
|
+
export type NodeNameError =
|
|
31
|
+
| "empty"
|
|
32
|
+
| "too_long"
|
|
33
|
+
| "leading_dash"
|
|
34
|
+
| "forbidden_char"
|
|
35
|
+
| "not_nfc";
|
|
36
|
+
|
|
37
|
+
export type NodeNameCheck =
|
|
38
|
+
| { ok: true; name: string }
|
|
39
|
+
| { ok: false; error: NodeNameError; char?: string; message: string };
|
|
40
|
+
|
|
41
|
+
/** Trim + NFC. Callers store the returned `name`, not the raw input. */
|
|
42
|
+
export function normalizeNodeName(raw: string): string {
|
|
43
|
+
return raw.trim().normalize("NFC");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function describeChar(ch: string): string {
|
|
47
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
48
|
+
if (cp < 0x20 || cp === 0x7f) return `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
49
|
+
if (/\s/u.test(ch)) return "空格";
|
|
50
|
+
return ch;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check a node name. `raw` is trimmed first; the trimmed, NFC form must be
|
|
55
|
+
* 1..64 characters (code points) of letters / digits / `_` / `-`, not starting with `-`.
|
|
56
|
+
*/
|
|
57
|
+
export function checkNodeName(raw: unknown): NodeNameCheck {
|
|
58
|
+
if (typeof raw !== "string") return { ok: false, error: "empty", message: "名字不能为空" };
|
|
59
|
+
const trimmed = raw.trim();
|
|
60
|
+
if (trimmed.length === 0) return { ok: false, error: "empty", message: "名字不能为空" };
|
|
61
|
+
const chars = [...trimmed];
|
|
62
|
+
if (chars.length > NODE_NAME_MAX_CHARS) {
|
|
63
|
+
return { ok: false, error: "too_long", message: `名字最多 ${NODE_NAME_MAX_CHARS} 个字符` };
|
|
64
|
+
}
|
|
65
|
+
if (trimmed !== trimmed.normalize("NFC")) {
|
|
66
|
+
return { ok: false, error: "not_nfc", message: "名字需要是 Unicode NFC 规范形式" };
|
|
67
|
+
}
|
|
68
|
+
if (chars[0] === "-") {
|
|
69
|
+
return { ok: false, error: "leading_dash", char: "-", message: "名字不能以 - 开头" };
|
|
70
|
+
}
|
|
71
|
+
if (!NODE_NAME_RE.test(trimmed)) {
|
|
72
|
+
const bad = chars.find((ch, i) => !(i === 0 ? /^[\p{L}\p{N}_]$/u : /^[\p{L}\p{M}\p{N}_-]$/u).test(ch)) ?? chars[0]!;
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
error: "forbidden_char",
|
|
76
|
+
char: bad,
|
|
77
|
+
message: `名字里不能有「${describeChar(bad)}」:只能用文字、字母、数字、_ 和 -`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, name: trimmed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function isValidNodeName(raw: unknown): boolean {
|
|
84
|
+
return checkNodeName(raw).ok;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** FNV-1a 32-bit over the UTF-8 bytes, 8 hex digits. Tiny, dependency-free, and the
|
|
88
|
+
* app reimplements it byte for byte (no crypto module needed in the browser / RN). */
|
|
89
|
+
export function fnv1aHex(s: string): string {
|
|
90
|
+
const bytes = new TextEncoder().encode(s);
|
|
91
|
+
let h = 0x811c9dc5;
|
|
92
|
+
for (const b of bytes) {
|
|
93
|
+
h ^= b;
|
|
94
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
95
|
+
}
|
|
96
|
+
return h.toString(16).padStart(8, "0");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ASCII folder name for a node name. Pure-ASCII names are transliterated
|
|
101
|
+
* (lowercase, runs of anything outside [a-z0-9] become one `-`, trimmed, `node-`
|
|
102
|
+
* prefixed when they do not start with a letter); anything else — and anything the
|
|
103
|
+
* transliteration empties — becomes `node-<first 6 hex of fnv1a(name)>`.
|
|
104
|
+
* The result always matches NODE_FOLDER_RE.
|
|
105
|
+
*/
|
|
106
|
+
export function nodeFolderSlug(raw: string): string {
|
|
107
|
+
const name = normalizeNodeName(raw);
|
|
108
|
+
// eslint-disable-next-line no-control-regex
|
|
109
|
+
if (/^[\x00-\x7f]*$/.test(name)) {
|
|
110
|
+
let s = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
111
|
+
if (s && !/^[a-z]/.test(s)) s = `node-${s}`;
|
|
112
|
+
s = s.slice(0, NODE_NAME_MAX_CHARS).replace(/-+$/, "");
|
|
113
|
+
if (NODE_FOLDER_RE.test(s)) return s;
|
|
114
|
+
}
|
|
115
|
+
return `node-${fnv1aHex(name).slice(0, 6)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The directory under `.anet/nodes/` a daemon-created node lives in.
|
|
120
|
+
* Names the old rule accepted keep their old directory (= the name) so nothing that
|
|
121
|
+
* worked before #652 moves; every other name gets nodeFolderSlug(name).
|
|
122
|
+
*/
|
|
123
|
+
export function nodeDirNameFor(name: string): string {
|
|
124
|
+
return LEGACY_NODE_NAME_RE.test(name) ? name : nodeFolderSlug(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Shared test vectors — the app's copy must produce the same verdicts and slugs. */
|
|
128
|
+
export const NODE_NAME_CASES: ReadonlyArray<{ input: string; ok: boolean; error?: NodeNameError; slug?: string }> = [
|
|
129
|
+
{ input: "测试", ok: true, slug: "node-f78149" },
|
|
130
|
+
{ input: "研发助手A", ok: true, slug: "node-1d348f" },
|
|
131
|
+
{ input: "AB测试牛", ok: true, slug: "node-048250" },
|
|
132
|
+
{ input: "my-bot", ok: true, slug: "my-bot" },
|
|
133
|
+
{ input: "MyBot_2", ok: true, slug: "mybot-2" },
|
|
134
|
+
{ input: " spaced ", ok: true, slug: "spaced" },
|
|
135
|
+
{ input: "123", ok: true, slug: "node-123" },
|
|
136
|
+
{ input: "_x", ok: true, slug: "x" },
|
|
137
|
+
{ input: "Ünïcödé", ok: true, slug: "node-030667" },
|
|
138
|
+
{ input: "a".repeat(64), ok: true, slug: "a".repeat(64) },
|
|
139
|
+
{ input: "测".repeat(64), ok: true, slug: "node-50c3fb" },
|
|
140
|
+
{ input: "", ok: false, error: "empty" },
|
|
141
|
+
{ input: " ", ok: false, error: "empty" },
|
|
142
|
+
{ input: "a".repeat(65), ok: false, error: "too_long" },
|
|
143
|
+
{ input: "测".repeat(65), ok: false, error: "too_long" },
|
|
144
|
+
{ input: "-x", ok: false, error: "leading_dash" },
|
|
145
|
+
{ input: "e\u0301", ok: false, error: "not_nfc" },
|
|
146
|
+
{ input: ".hidden", ok: false, error: "forbidden_char" },
|
|
147
|
+
{ input: "..", ok: false, error: "forbidden_char" },
|
|
148
|
+
{ input: "a/b", ok: false, error: "forbidden_char" },
|
|
149
|
+
{ input: "a\\b", ok: false, error: "forbidden_char" },
|
|
150
|
+
{ input: "a:b", ok: false, error: "forbidden_char" },
|
|
151
|
+
{ input: "a.b", ok: false, error: "forbidden_char" },
|
|
152
|
+
{ input: "a b", ok: false, error: "forbidden_char" },
|
|
153
|
+
{ input: "a\u0000b", ok: false, error: "forbidden_char" },
|
|
154
|
+
{ input: "a\nb", ok: false, error: "forbidden_char" },
|
|
155
|
+
{ input: "a$b", ok: false, error: "forbidden_char" },
|
|
156
|
+
{ input: "a`b", ok: false, error: "forbidden_char" },
|
|
157
|
+
{ input: "a'b", ok: false, error: "forbidden_char" },
|
|
158
|
+
{ input: "a\"b", ok: false, error: "forbidden_char" },
|
|
159
|
+
{ input: "a;b", ok: false, error: "forbidden_char" },
|
|
160
|
+
{ input: "a*b", ok: false, error: "forbidden_char" },
|
|
161
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { SESSION_TASK_PREVIEW_MAX, TASK_CONTENT_MAX, sessionTaskPreview } from "./task-content-limit.js";
|
|
5
|
+
|
|
6
|
+
// Board #668 — the node reports the whole task so the hub can match it
|
|
7
|
+
// against tasks.content. Both sides must agree on the cap.
|
|
8
|
+
const HUB_PATH = join(import.meta.dir, "task-content-limit.ts");
|
|
9
|
+
const NODE_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "task-content-limit.ts");
|
|
10
|
+
|
|
11
|
+
describe("#668 — hub/node task-content-limit drift guard", () => {
|
|
12
|
+
test("byte-identical source files", () => {
|
|
13
|
+
expect(readFileSync(NODE_PATH, "utf-8")).toBe(readFileSync(HUB_PATH, "utf-8"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("constants equal at runtime", async () => {
|
|
17
|
+
const node = await import(NODE_PATH);
|
|
18
|
+
expect(node.TASK_CONTENT_MAX).toBe(TASK_CONTENT_MAX);
|
|
19
|
+
expect(node.SESSION_TASK_PREVIEW_MAX).toBe(SESSION_TASK_PREVIEW_MAX);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("preview is the first 200 chars", () => {
|
|
23
|
+
expect(SESSION_TASK_PREVIEW_MAX).toBe(200);
|
|
24
|
+
const long = "字".repeat(TASK_CONTENT_MAX);
|
|
25
|
+
expect(sessionTaskPreview(long)).toBe(long.slice(0, 200));
|
|
26
|
+
expect(sessionTaskPreview("short")).toBe("short");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Board #668 — task text limits shared by hub and node.
|
|
2
|
+
//
|
|
3
|
+
// This file is byte-identical in server/src/shared/task-content-limit.ts and
|
|
4
|
+
// agent-node/src/shared/task-content-limit.ts (task-content-limit-drift.test.ts
|
|
5
|
+
// fails the build on any difference).
|
|
6
|
+
//
|
|
7
|
+
// TASK_CONTENT_MAX is the cap every dispatch entry (send_task, REST /api/task,
|
|
8
|
+
// scheduled tasks) and report_status.task accept. The node reports the whole
|
|
9
|
+
// task text so the hub can match it against tasks.content and write
|
|
10
|
+
// started_at. If the dispatch cap grew without the node and report_status
|
|
11
|
+
// following, long tasks would silently stop matching again.
|
|
12
|
+
//
|
|
13
|
+
// SESSION_TASK_PREVIEW_MAX is what the hub keeps in sessions.task. That column
|
|
14
|
+
// is readable by get_all_status, the full /api/status and members who may see
|
|
15
|
+
// the agent but not its conversations, so only a short preview is stored —
|
|
16
|
+
// the full text is used for matching, never persisted on the session row.
|
|
17
|
+
|
|
18
|
+
export const TASK_CONTENT_MAX = 10_000;
|
|
19
|
+
|
|
20
|
+
export const SESSION_TASK_PREVIEW_MAX = 200;
|
|
21
|
+
|
|
22
|
+
/** The preview stored in sessions.task — same cut as the dispatch preview. */
|
|
23
|
+
export function sessionTaskPreview(task: string): string {
|
|
24
|
+
return task.slice(0, SESSION_TASK_PREVIEW_MAX);
|
|
25
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
3
3
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
4
|
+
import { lockNodeAlias } from "./node-token-ownership.js";
|
|
4
5
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
5
6
|
import { z } from "zod/v4";
|
|
6
7
|
import { nodeHealthSchema, normalizeNodeHealth, recordNodeHealth } from "./node-health-store.js";
|
|
@@ -9,6 +10,8 @@ import { noteModelAuthHealth } from "./model-auth-notify.js";
|
|
|
9
10
|
import { numberedAliasInClause, parseAliasFilter } from "./alias-filter.js";
|
|
10
11
|
import { createHash } from "node:crypto";
|
|
11
12
|
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
|
|
13
|
+
import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
|
|
14
|
+
import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
|
|
12
15
|
import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
|
|
13
16
|
import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js";
|
|
14
17
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
@@ -680,7 +683,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
680
683
|
resume_id: z.string().min(1).max(200).describe("Claude Code session UUID (unique per session)"),
|
|
681
684
|
alias: z.string().min(1).max(200).describe("Human-readable session name for dispatching (e.g. 指挥室/知识哥)"),
|
|
682
685
|
status: z.enum(["working", "idle", "blocked", "error", "waiting_input", "offline"]),
|
|
683
|
-
task: z.string().max(
|
|
686
|
+
task: z.string().max(TASK_CONTENT_MAX).optional().describe("Current task description (the hub stores only the first 200 chars on the session row; the full text is matched against tasks.content)"),
|
|
684
687
|
output: z.string().max(50000).optional().describe("Recent output (max 4000 chars stored)"),
|
|
685
688
|
score: z.number().min(0).max(10).optional().describe("Self-score 1-10"),
|
|
686
689
|
progress: z.number().min(0).max(100).optional().describe("Progress 0-100"),
|
|
@@ -1048,6 +1051,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1048
1051
|
) ?? null;
|
|
1049
1052
|
const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
|
|
1050
1053
|
(fresh ?? (handover?.[column] as T | null | undefined) ?? null);
|
|
1054
|
+
// #668 — the node reports the whole task so the tasks UPDATE below can match
|
|
1055
|
+
// tasks.content. sessions.task is readable far wider than the task row
|
|
1056
|
+
// (get_all_status, full /api/status, members who see the agent but not its
|
|
1057
|
+
// conversations), so only the dispatch-sized preview is stored here.
|
|
1058
|
+
const sessionTask = task == null ? null : sessionTaskPreview(task); // board668-session-preview
|
|
1051
1059
|
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
|
|
1052
1060
|
db.run(
|
|
1053
1061
|
`INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, external_schedules, peer_reply_inbox_capable, last_seen_at, updated_at)
|
|
@@ -1079,7 +1087,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1079
1087
|
external_schedules = COALESCE(?34, sessions.external_schedules),
|
|
1080
1088
|
peer_reply_inbox_capable = ?35,
|
|
1081
1089
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
1082
|
-
[resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus,
|
|
1090
|
+
[resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus, sessionTask, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
|
|
1083
1091
|
);
|
|
1084
1092
|
if (handover) {
|
|
1085
1093
|
// 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
|
|
@@ -1739,7 +1747,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1739
1747
|
"Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead (open tasks already on the target), target_busy, est_wait_minutes (rough, null = no history) and, when the queue is long, a warning: queued, never refused — consider an idle node.",
|
|
1740
1748
|
{
|
|
1741
1749
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
1742
|
-
task: z.string().min(1).max(
|
|
1750
|
+
task: z.string().min(1).max(TASK_CONTENT_MAX).describe("Task content"),
|
|
1743
1751
|
priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
|
|
1744
1752
|
context: z.string().max(10000).optional(),
|
|
1745
1753
|
from_session: z.string().max(200).optional(),
|
|
@@ -1771,8 +1779,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1771
1779
|
}
|
|
1772
1780
|
|
|
1773
1781
|
// Resolve parent_task_id: explicit > inferred (caller's most recent
|
|
1774
|
-
// delivered
|
|
1775
|
-
//
|
|
1782
|
+
// delivered inbox task). Inference is the safety net for when the LLM
|
|
1783
|
+
// forgets to pass parent_task_id.
|
|
1784
|
+
//
|
|
1785
|
+
// #668 — only 'delivered'. 'started' was never a tasks status. A task
|
|
1786
|
+
// the caller has acked or is running is NOT inferred: an unrelated
|
|
1787
|
+
// dispatch without parent_task_id would hang under it, the child's
|
|
1788
|
+
// reply would close it early (chainReplyToParent), and the node's own
|
|
1789
|
+
// reply would then be refused as reply_task_terminal.
|
|
1776
1790
|
//
|
|
1777
1791
|
// round5 F1 fix: the inference SELECT MUST be network-scoped. Without
|
|
1778
1792
|
// it, a caller in network B can pick up the parent_task_id of a
|
|
@@ -1782,7 +1796,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1782
1796
|
if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
|
|
1783
1797
|
try {
|
|
1784
1798
|
const recentParams: any[] = [from_session];
|
|
1785
|
-
let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered'
|
|
1799
|
+
let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered')"; // board668-parent-delivered-only
|
|
1786
1800
|
recentSql = addScope(recentSql, recentParams, effectiveNetId);
|
|
1787
1801
|
recentSql += " ORDER BY created_at DESC LIMIT 1";
|
|
1788
1802
|
const recent = db.get<{ task_id: string }>(recentSql, ...recentParams);
|
|
@@ -1930,8 +1944,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1930
1944
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
|
|
1931
1945
|
[id, fromNodeId, from_session, targetNodeId, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
|
|
1932
1946
|
);
|
|
1933
|
-
const touchParams: any[] = [task
|
|
1934
|
-
let touchSql =
|
|
1947
|
+
const touchParams: any[] = [sessionTaskPreview(task), targetAlias];
|
|
1948
|
+
let touchSql = `UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE alias = ?2`;
|
|
1935
1949
|
touchSql = addScope(touchSql, touchParams, effectiveNetId);
|
|
1936
1950
|
db.run(touchSql, touchParams);
|
|
1937
1951
|
// #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
|
|
@@ -4108,7 +4122,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4108
4122
|
{
|
|
4109
4123
|
daemon_node_id: z.string().min(1).max(200),
|
|
4110
4124
|
node_spec: z.object({
|
|
4111
|
-
|
|
4125
|
+
// #652 — 64 *code points* after trim is enforced by validateChildName (shared/node-name.ts);
|
|
4126
|
+
// zod's max counts UTF-16 units and surrounding whitespace, so it is only a coarse bound here.
|
|
4127
|
+
name: z.string().min(1).max(256),
|
|
4112
4128
|
runtime: z.string().min(1).max(64),
|
|
4113
4129
|
model: z.string().min(1).max(100).optional().nullable(),
|
|
4114
4130
|
flags: z.record(z.string(), z.unknown()).optional(),
|
|
@@ -4257,7 +4273,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4257
4273
|
// flag injection at the hub edge). Daemon repeats this; double
|
|
4258
4274
|
// layer per RFC §4.2.2.
|
|
4259
4275
|
try {
|
|
4260
|
-
|
|
4276
|
+
// #652 — store/forward the trimmed name the validator returns.
|
|
4277
|
+
node_spec.name = validateChildName(node_spec.name);
|
|
4261
4278
|
validateRuntime(node_spec.runtime);
|
|
4262
4279
|
validateModel(node_spec.model);
|
|
4263
4280
|
validateChannelsP1((node_spec as any).channels);
|
|
@@ -4350,7 +4367,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4350
4367
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
|
|
4351
4368
|
}
|
|
4352
4369
|
db.run(
|
|
4353
|
-
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
|
|
4370
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2)`,
|
|
4354
4371
|
[childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
|
|
4355
4372
|
);
|
|
4356
4373
|
|
|
@@ -6266,6 +6283,13 @@ export function trustedConfigSnapshotForNode(
|
|
|
6266
6283
|
}
|
|
6267
6284
|
|
|
6268
6285
|
export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
6286
|
+
return db.transaction(() => {
|
|
6287
|
+
if (input.callerNetworkId && input.alias) lockNodeAlias(input.callerNetworkId, input.alias, input.node_id ?? undefined);
|
|
6288
|
+
return upsertNodeIdentity(input);
|
|
6289
|
+
});
|
|
6290
|
+
}
|
|
6291
|
+
|
|
6292
|
+
function upsertNodeIdentity(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
6269
6293
|
if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
|
|
6270
6294
|
const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null; config_path: string | null }>(
|
|
6271
6295
|
"SELECT network_id, owner_user_id, alias, config_path FROM nodes WHERE node_id = ?1",
|
|
@@ -6277,8 +6301,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
6277
6301
|
// report only that exact node. Legacy unbound ntok rows remain compatible,
|
|
6278
6302
|
// but they can never consume owner-gated schedule intents.
|
|
6279
6303
|
if (input.callerTokenId) {
|
|
6280
|
-
const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
|
|
6281
|
-
"SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
|
|
6304
|
+
const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null; node_identity_epoch: number; name: string }>(
|
|
6305
|
+
"SELECT bound_node_id, user_id, network_id, node_identity_epoch, name FROM api_tokens WHERE token_id = ?1",
|
|
6282
6306
|
input.callerTokenId,
|
|
6283
6307
|
);
|
|
6284
6308
|
if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
|
|
@@ -6287,6 +6311,16 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
6287
6311
|
if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
|
|
6288
6312
|
return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
|
|
6289
6313
|
}
|
|
6314
|
+
// New name-only node tokens bind once, at their first registration. General
|
|
6315
|
+
// tokens renamed by report_status are deliberately NOT upgraded to epoch 2.
|
|
6316
|
+
if (token?.node_identity_epoch === 2 && !token.bound_node_id) {
|
|
6317
|
+
const conflict = db.get("SELECT node_id FROM nodes WHERE network_id=?1 AND alias=?2 AND node_id<>?3 LIMIT 1",
|
|
6318
|
+
callerNet, input.alias ?? "", input.node_id);
|
|
6319
|
+
if (conflict || token.name !== `node:${input.alias}` || token.user_id !== input.callerUserId || existing) {
|
|
6320
|
+
console.warn(`[commhub] 🚫 report_status node upsert refused (identity_mismatch): node_id=${input.node_id} first-binding conflict`);
|
|
6321
|
+
return { result: "refused", reason: "identity_mismatch", existingNet: existing?.network_id ?? null, callerNet };
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6290
6324
|
}
|
|
6291
6325
|
if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
|
|
6292
6326
|
return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
|
|
@@ -6351,6 +6385,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
6351
6385
|
callerNet ?? null,
|
|
6352
6386
|
],
|
|
6353
6387
|
);
|
|
6388
|
+
if (input.callerTokenId) db.run(`UPDATE api_tokens SET bound_node_id=?1
|
|
6389
|
+
WHERE token_id=?2 AND node_identity_epoch=2 AND bound_node_id IS NULL`, [input.node_id, input.callerTokenId]);
|
|
6354
6390
|
const trustedSnapshot = trustedConfigSnapshotForNode(
|
|
6355
6391
|
input.config_snapshot,
|
|
6356
6392
|
input.callerTokenId,
|