@sleep2agi/commhub-server 0.9.0-preview.105 → 0.9.0-preview.107
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/api-host-supervisors-runtime-readiness.test.ts +182 -0
- package/src/config-apply-validate.test.ts +9 -0
- package/src/config-apply-validate.ts +6 -2
- package/src/create-node-validate.test.ts +60 -1
- package/src/create-node-validate.ts +46 -3
- package/src/db.ts +20 -0
- package/src/mcp-context-cost-http.test.ts +2 -1
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-store.ts +32 -3
- package/src/node-id-alias.test.ts +3 -1
- package/src/node-lifecycle-controllable.ts +2 -0
- package/src/node-permissions.ts +2 -0
- package/src/runtime-readiness.ts +83 -0
- package/src/server.ts +14 -3
- package/src/stop-delete-node.test.ts +8 -12
- package/src/tool-audience-http.test.ts +3 -1
- package/src/tool-audience.ts +2 -0
- package/src/tools.ts +55 -60
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { db, uuidv4 } from "./db.js";
|
|
2
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
3
|
+
import { pushEvent } from "./push.js";
|
|
4
|
+
import { canRestWriteNetwork } from "./network-scope.js";
|
|
5
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
6
|
+
|
|
7
|
+
type Node = { node_id: string; alias: string; network_id: string; owner_user_id: string | null; hostname: string | null; config_snapshot: string | null };
|
|
8
|
+
type Binding = { request_id: string; network_id: string; node_id: string; daemon_node_id: string; workdir: string; requested_by: string; status: string; error: string | null };
|
|
9
|
+
type Human = { userId?: string | null; networkId?: string | null; isNode: boolean; canWrite: (networkId: string) => boolean };
|
|
10
|
+
type Daemon = { ok: true; daemonNodeId: string; networkId: string } | { ok: false; error: string };
|
|
11
|
+
const fail = (error: string) => ({ ok: false as const, error });
|
|
12
|
+
const snapshot = (node: Node) => {
|
|
13
|
+
try { const value = JSON.parse(node.config_snapshot || "{}"); return value && typeof value === "object" && !Array.isArray(value) ? value : {}; }
|
|
14
|
+
catch { return {}; }
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createdDaemon(nodeId: string): string | null {
|
|
18
|
+
if (!nodeId.startsWith("node_")) return null;
|
|
19
|
+
return db.get<{ daemon_node_id: string }>("SELECT daemon_node_id FROM node_create_requests WHERE request_id=?1", `cr_${nodeId.slice(5)}`)?.daemon_node_id ?? null;
|
|
20
|
+
}
|
|
21
|
+
export function activeBinding(nodeId: string): Binding | null {
|
|
22
|
+
return db.get<Binding>("SELECT * FROM node_daemon_bindings WHERE node_id=?1 AND status='active'", nodeId);
|
|
23
|
+
}
|
|
24
|
+
export function resolveManagedDaemon(nodeId: string): string | null {
|
|
25
|
+
return createdDaemon(nodeId) ?? activeBinding(nodeId)?.daemon_node_id ?? null;
|
|
26
|
+
}
|
|
27
|
+
function humanNode(ctx: Human, nodeId: string): Node | null {
|
|
28
|
+
if (ctx.isNode || !ctx.userId) return null;
|
|
29
|
+
const node = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", nodeId);
|
|
30
|
+
if (!node || (ctx.networkId && ctx.networkId !== node.network_id) || !ctx.canWrite(node.network_id)) return null;
|
|
31
|
+
const role = getUserNetworkRole(ctx.userId, node.network_id);
|
|
32
|
+
return node.owner_user_id === ctx.userId || role === "owner" || role === "admin" ? node : null;
|
|
33
|
+
}
|
|
34
|
+
function audit(binding: Binding, action: string, actor: string) {
|
|
35
|
+
db.run(`INSERT INTO audit_log(user_id,action,target_type,target_id,detail,network_id)
|
|
36
|
+
VALUES(?1,?2,'node_daemon_binding',?3,?4,?5)`,
|
|
37
|
+
[actor, action, binding.request_id, JSON.stringify({ node_id: binding.node_id, daemon_node_id: binding.daemon_node_id }), binding.network_id]);
|
|
38
|
+
}
|
|
39
|
+
function daemonReady(daemon: Node | null, node: Node): string | null {
|
|
40
|
+
if (!daemon || daemon.network_id !== node.network_id) return "daemon_not_found";
|
|
41
|
+
const snap = snapshot(daemon);
|
|
42
|
+
if (snap.role !== "host_supervisor" || snap.daemon_capabilities?.adopt_capable !== true) return "daemon_not_adopt_capable";
|
|
43
|
+
if (!node.hostname || daemon.hostname !== node.hostname) return "hostname_mismatch";
|
|
44
|
+
const session = db.get<{ last_seen_at: string }>(`SELECT last_seen_at FROM sessions WHERE alias=?1 AND network_id=?2`, daemon.alias, daemon.network_id);
|
|
45
|
+
const seen = parseDbTimestampMs(session?.last_seen_at ?? "");
|
|
46
|
+
if (!Number.isFinite(seen) || Date.now() - seen > 300_000) return "daemon_offline";
|
|
47
|
+
const token = db.get(`SELECT token_id FROM api_tokens WHERE name=?1 AND network_id=?2 AND revoked_at IS NULL`, `node:${daemon.alias}`, daemon.network_id);
|
|
48
|
+
return token ? null : "daemon_offline";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function requestAdopt(ctx: Human, args: { node_id: string; daemon_node_id: string; workdir: string }) {
|
|
52
|
+
if (ctx.isNode || !ctx.userId) return fail("user_token_required");
|
|
53
|
+
if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
|
|
54
|
+
const node = humanNode(ctx, args.node_id);
|
|
55
|
+
if (!node) return fail("adopt_forbidden");
|
|
56
|
+
if (snapshot(node).role === "host_supervisor") return fail("cannot_adopt_daemon");
|
|
57
|
+
if (createdDaemon(node.node_id)) return fail("node_already_managed");
|
|
58
|
+
if (!args.workdir.trim() || /[\x00-\x1f]/.test(args.workdir)) return fail("invalid_workdir");
|
|
59
|
+
const daemon = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", args.daemon_node_id);
|
|
60
|
+
const refused = daemonReady(daemon, node);
|
|
61
|
+
if (refused) return fail(refused);
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const binding: Binding = { ...args, request_id: `adopt_${uuidv4()}`, network_id: node.network_id, requested_by: ctx.userId, status: "pending", error: null };
|
|
64
|
+
const inserted = db.transaction(() => {
|
|
65
|
+
const result = db.run(`INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at)
|
|
66
|
+
VALUES(?1,?2,?3,?4,?5,?6,'pending',?7,?7) ON CONFLICT DO NOTHING`,
|
|
67
|
+
[binding.request_id, binding.network_id, node.node_id, args.daemon_node_id, args.workdir, ctx.userId, now]);
|
|
68
|
+
if (!result.changes) return false;
|
|
69
|
+
audit(binding, node.owner_user_id === ctx.userId ? "adopt_requested" : "adopt_by_admin", ctx.userId!);
|
|
70
|
+
return true;
|
|
71
|
+
});
|
|
72
|
+
if (!inserted) return fail("node_already_managed");
|
|
73
|
+
pushEvent(daemon!.alias, { type: "adopt_node", request_id: binding.request_id }, node.network_id);
|
|
74
|
+
return { ok: true, request_id: binding.request_id, status: "pending" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function daemonBinding(caller: Daemon, requestId: string): Binding | null {
|
|
78
|
+
if (!caller.ok) return null;
|
|
79
|
+
return db.get<Binding>(`SELECT * FROM node_daemon_bindings WHERE request_id=?1 AND daemon_node_id=?2 AND network_id=?3`, requestId, caller.daemonNodeId, caller.networkId);
|
|
80
|
+
}
|
|
81
|
+
export function getAdopt(caller: Daemon, requestId: string) {
|
|
82
|
+
if (!caller.ok) return fail(caller.error);
|
|
83
|
+
const binding = daemonBinding(caller, requestId);
|
|
84
|
+
if (!binding) return fail("request_not_found");
|
|
85
|
+
if (binding.status !== "pending") return fail("request_not_pending");
|
|
86
|
+
const node = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1 AND network_id=?2", binding.node_id, binding.network_id);
|
|
87
|
+
if (!node) return fail("node_not_found");
|
|
88
|
+
return { ok: true, ...binding, alias: node.alias };
|
|
89
|
+
}
|
|
90
|
+
export function ackAdopt(caller: Daemon, args: { request_id: string; status: "adopted" | "refused"; error?: string }) {
|
|
91
|
+
if (!caller.ok) return fail(caller.error);
|
|
92
|
+
if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
|
|
93
|
+
const binding = daemonBinding(caller, args.request_id);
|
|
94
|
+
if (!binding) return fail("request_not_found");
|
|
95
|
+
return db.transaction(() => {
|
|
96
|
+
// Ownership/membership can change while local verification is in flight.
|
|
97
|
+
if (args.status === "adopted") {
|
|
98
|
+
const requester: Human = { userId: binding.requested_by, networkId: binding.network_id, isNode: false,
|
|
99
|
+
canWrite: networkId => canRestWriteNetwork({ userId: binding.requested_by, networkId: null }, networkId, false) };
|
|
100
|
+
const node = humanNode(requester, binding.node_id);
|
|
101
|
+
if (!node || createdDaemon(binding.node_id)) return fail("adopt_forbidden");
|
|
102
|
+
const refusal = daemonReady(db.get<Node>("SELECT * FROM nodes WHERE node_id=?1", binding.daemon_node_id), node);
|
|
103
|
+
if (refusal) return fail(refusal);
|
|
104
|
+
}
|
|
105
|
+
const changed = db.run(`UPDATE node_daemon_bindings SET status=?1,error=?2,updated_at=?3 WHERE request_id=?4 AND status='pending'`,
|
|
106
|
+
[args.status === "adopted" ? "active" : "refused", args.error ?? null, Date.now(), args.request_id]);
|
|
107
|
+
if (!changed.changes) return fail("request_not_pending");
|
|
108
|
+
audit(binding, `adopt_${args.status}`, caller.daemonNodeId);
|
|
109
|
+
return { ok: true, status: args.status === "adopted" ? "active" : "refused" };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export function unadopt(ctx: Human, nodeId: string) {
|
|
113
|
+
if (ctx.isNode || !ctx.userId) return fail("user_token_required");
|
|
114
|
+
if (db.transactionalFeaturesRefusal) return fail("atomic_transactions_required");
|
|
115
|
+
const node = humanNode(ctx, nodeId);
|
|
116
|
+
if (!node) return fail("adopt_forbidden");
|
|
117
|
+
const binding = db.get<Binding>("SELECT * FROM node_daemon_bindings WHERE node_id=?1 AND status IN ('pending','active')", nodeId);
|
|
118
|
+
if (!binding) return fail("binding_not_found");
|
|
119
|
+
// Do not reassign authority while a daemon may already be executing a lifecycle command.
|
|
120
|
+
for (const table of ["node_stop_requests", "node_start_requests"]) {
|
|
121
|
+
if (db.get(`SELECT request_id FROM ${table} WHERE child_node_id=?1 AND network_id=?2 AND status IN ('pending','delivered')`, nodeId, node.network_id)) return fail("node_lifecycle_in_flight");
|
|
122
|
+
}
|
|
123
|
+
db.transaction(() => {
|
|
124
|
+
db.run("UPDATE node_daemon_bindings SET status='revoked',updated_at=?1 WHERE request_id=?2", [Date.now(), binding.request_id]);
|
|
125
|
+
audit(binding, "unadopted", ctx.userId!);
|
|
126
|
+
});
|
|
127
|
+
const daemon = db.get<Node>("SELECT * FROM nodes WHERE node_id=?1 AND network_id=?2", binding.daemon_node_id, node.network_id);
|
|
128
|
+
if (daemon) pushEvent(daemon.alias, { type: "unadopt_node", node_id: nodeId, request_id: binding.request_id }, node.network_id);
|
|
129
|
+
return { ok: true, status: "revoked" };
|
|
130
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// #594 step 1 — report_status.health.codex_login (non-secret codex login fingerprint +
|
|
2
|
+
// "shared with N other nodes on this host"). Additive on the #448 health channel.
|
|
3
|
+
//
|
|
4
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/node-health-codex-login.db bun test src/node-health-codex-login.test.ts
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
7
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { db } from "./db.js";
|
|
10
|
+
import { registerTools } from "./tools.js";
|
|
11
|
+
import { clearNodeHealthStore, readNodeHealth } from "./node-health-store.js";
|
|
12
|
+
import { degradedLayers } from "./node-health-guard.js";
|
|
13
|
+
|
|
14
|
+
const NET = "net_health_codex_login_t";
|
|
15
|
+
const OWNER = "u_hcl_owner";
|
|
16
|
+
const NODE_A = "hcl-node-a";
|
|
17
|
+
const NODE_B = "hcl-node-b";
|
|
18
|
+
const NODE_A_ID = "node_hcl_a";
|
|
19
|
+
const NODE_B_ID = "node_hcl_b";
|
|
20
|
+
const T_A = "tok_hcl_node_a";
|
|
21
|
+
const T_B = "tok_hcl_node_b";
|
|
22
|
+
|
|
23
|
+
function cleanup() {
|
|
24
|
+
for (const t of ["sessions", "nodes", "api_tokens", "network_members", "networks"]) {
|
|
25
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
26
|
+
}
|
|
27
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [OWNER]); } catch {}
|
|
28
|
+
clearNodeHealthStore();
|
|
29
|
+
}
|
|
30
|
+
function seed() {
|
|
31
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [OWNER, OWNER]);
|
|
32
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
|
|
33
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [OWNER, NET]);
|
|
34
|
+
for (const [id, alias, tok] of [[NODE_A_ID, NODE_A, T_A], [NODE_B_ID, NODE_B, T_B]]) {
|
|
35
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`, [id, alias, alias, NET, OWNER]);
|
|
36
|
+
db.run(`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`, [tok, OWNER, NET, `node:${alias}`, `hash_${tok}`, id]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function connectAs(alias: string, tokenId: string) {
|
|
41
|
+
const server = new McpServer({ name: "health-test", version: "1" });
|
|
42
|
+
registerTools(server, undefined, NET, OWNER, alias, true, tokenId);
|
|
43
|
+
const client = new Client({ name: "health-client", version: "1" });
|
|
44
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
45
|
+
await server.connect(st);
|
|
46
|
+
await client.connect(ct);
|
|
47
|
+
const call = async (args: Record<string, unknown>) => {
|
|
48
|
+
const r: any = await client.callTool({ name: "report_status", arguments: args });
|
|
49
|
+
return { isError: r.isError === true, body: JSON.parse(r.content?.[0]?.text ?? "null") };
|
|
50
|
+
};
|
|
51
|
+
return { call, close: async () => { await client.close(); await server.close(); } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LOGIN = { fingerprint: "1a2b3c4d", shared_with: 2, shared_home_with: 1, codex_home: "/srv/ws/.anet/nodes/a/codex-home" };
|
|
55
|
+
|
|
56
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
57
|
+
afterAll(cleanup);
|
|
58
|
+
|
|
59
|
+
describe("#594 report_status.health.codex_login passthrough", () => {
|
|
60
|
+
test("well-formed codex_login is kept next to the #448 layers and readable", async () => {
|
|
61
|
+
const a = await connectAs(NODE_A, T_A);
|
|
62
|
+
try {
|
|
63
|
+
const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { bridge: "ok", model_auth: "ok", codex_login: LOGIN } });
|
|
64
|
+
expect(r.isError).toBe(false);
|
|
65
|
+
} finally { await a.close(); }
|
|
66
|
+
expect(readNodeHealth(NET, NODE_A)?.health.codex_login).toEqual(LOGIN);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("codex_login alone (codex-sdk runtime, no #448 monitor) is accepted", async () => {
|
|
70
|
+
const a = await connectAs(NODE_A, T_A);
|
|
71
|
+
try {
|
|
72
|
+
await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { codex_login: { fingerprint: "deadbeef", shared_with: 0 } } });
|
|
73
|
+
} finally { await a.close(); }
|
|
74
|
+
expect(readNodeHealth(NET, NODE_A)?.health.codex_login).toEqual({ fingerprint: "deadbeef", shared_with: 0 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("🔴 a shared login is NOT a degraded layer — dispatch must not refuse because of it", () => {
|
|
78
|
+
expect(degradedLayers({ bridge: "ok", model_auth: "ok", codex_login: LOGIN } as any)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("🔴 anything that is not an 8-hex fingerprint is dropped (a token pasted there never gets stored), report still accepted", async () => {
|
|
82
|
+
const a = await connectAs(NODE_A, T_A);
|
|
83
|
+
const bads = [
|
|
84
|
+
{ fingerprint: "FAKE-REFRESH-TOKEN-should-never-be-stored", shared_with: 1 },
|
|
85
|
+
{ fingerprint: "a".repeat(64), shared_with: 1 },
|
|
86
|
+
{ fingerprint: "1A2B3C4D", shared_with: 1 },
|
|
87
|
+
{ fingerprint: "1a2b3c4d", shared_with: -1 },
|
|
88
|
+
{ fingerprint: "1a2b3c4d", shared_with: 1.5 },
|
|
89
|
+
{ fingerprint: "1a2b3c4d" },
|
|
90
|
+
"1a2b3c4d",
|
|
91
|
+
];
|
|
92
|
+
try {
|
|
93
|
+
for (const bad of bads) {
|
|
94
|
+
const r = await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { bridge: "ok", model_auth: "ok", codex_login: bad } });
|
|
95
|
+
expect(r.isError).toBe(false);
|
|
96
|
+
const h = readNodeHealth(NET, NODE_A)?.health;
|
|
97
|
+
expect(h?.model_auth).toBe("ok");
|
|
98
|
+
expect(h?.codex_login).toBeUndefined();
|
|
99
|
+
expect(JSON.stringify(h)).not.toContain("FAKE-REFRESH-TOKEN");
|
|
100
|
+
}
|
|
101
|
+
} finally { await a.close(); }
|
|
102
|
+
expect(db.get<{ s: string }>("SELECT status AS s FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.s).toBe("idle");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("bad optional sub-fields degrade alone: control characters in codex_home are dropped, the rest kept", async () => {
|
|
106
|
+
const a = await connectAs(NODE_A, T_A);
|
|
107
|
+
try {
|
|
108
|
+
await a.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { codex_login: { fingerprint: "1a2b3c4d", shared_with: 1, shared_home_with: "x", codex_home: "/x\n; rm -rf /" } } });
|
|
109
|
+
} finally { await a.close(); }
|
|
110
|
+
expect(readNodeHealth(NET, NODE_A)?.health.codex_login).toEqual({ fingerprint: "1a2b3c4d", shared_with: 1 });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("another node's token cannot set this node's codex_login", async () => {
|
|
114
|
+
const b = await connectAs(NODE_B, T_B);
|
|
115
|
+
try {
|
|
116
|
+
await b.call({ resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, health: { codex_login: LOGIN } });
|
|
117
|
+
} finally { await b.close(); }
|
|
118
|
+
expect(readNodeHealth(NET, NODE_A)).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/node-health-store.ts
CHANGED
|
@@ -27,9 +27,35 @@ export const nodeHealthSchema = z.object({
|
|
|
27
27
|
reason: z.string().max(40),
|
|
28
28
|
}).optional().catch(undefined),
|
|
29
29
|
model_auth: z.enum(["ok", "revoked", "expired", "unknown"]).optional().catch(undefined),
|
|
30
|
+
// #594 —— codex 节点的登录指纹 + 本机另有几台节点持有同一份登录(见下方 codexLoginSchema)。
|
|
31
|
+
// 🔴 线上 schema 故意是 unknown:report_status 的 JSON schema 每个节点每次 tools/list 都要下发,
|
|
32
|
+
// #476 给它定了字节上限(tool-audience-http.test.ts)。严格校验在 recordNodeHealth 里做,效果相同:
|
|
33
|
+
// 不合规 → 这一格当没报,整份 report 照收。
|
|
34
|
+
codex_login: z.unknown().optional(),
|
|
30
35
|
}).optional().catch(undefined);
|
|
31
36
|
|
|
32
|
-
|
|
37
|
+
// #594 —— health.codex_login 的严格形状。纯展示/提示,**不是** degraded 层:共用登录今天还能跑,
|
|
38
|
+
// 只是迟早互相顶掉(#1918);派发不因它拒绝。fingerprint 必须是 8 位小写 hex(sha256(refresh_token)
|
|
39
|
+
// 截断,不是凭据)—— 节点误把 token 塞进来也存不下;计数是小的非负整数;codex_home 是节点自己的路径
|
|
40
|
+
// (给客户端拼 `CODEX_HOME=<codex_home> codex login --device-auth`),拒控制字符。
|
|
41
|
+
// 可选格各自降级;必需格(fingerprint / shared_with)不合规 → 整格丢弃。
|
|
42
|
+
export const codexLoginSchema = z.object({
|
|
43
|
+
fingerprint: z.string().regex(/^[0-9a-f]{8}$/),
|
|
44
|
+
shared_with: z.number().int().min(0).max(100_000),
|
|
45
|
+
shared_home_with: z.number().int().min(0).max(100_000).optional().catch(undefined),
|
|
46
|
+
codex_home: z.string().max(1024).regex(/^[^\u0000-\u001f\u007f]*$/).optional().catch(undefined),
|
|
47
|
+
});
|
|
48
|
+
export type CodexLoginHealth = z.infer<typeof codexLoginSchema>;
|
|
49
|
+
|
|
50
|
+
export type NodeHealth = Omit<NonNullable<z.infer<typeof nodeHealthSchema>>, "codex_login"> & { codex_login?: CodexLoginHealth };
|
|
51
|
+
|
|
52
|
+
/** 线上收到的 health → 存的那份:codex_login 按严格形状过一遍,不合规就去掉这一格。 */
|
|
53
|
+
export function normalizeNodeHealth(raw: NonNullable<z.infer<typeof nodeHealthSchema>>): NodeHealth {
|
|
54
|
+
const { codex_login: rawLogin, ...rest } = raw;
|
|
55
|
+
if (rawLogin === undefined) return rest;
|
|
56
|
+
const parsed = codexLoginSchema.safeParse(rawLogin);
|
|
57
|
+
return parsed.success ? { ...rest, codex_login: parsed.data } : rest;
|
|
58
|
+
}
|
|
33
59
|
|
|
34
60
|
const store = new Map<string, { health: NodeHealth; at: number }>();
|
|
35
61
|
const keyOf = (networkId: string | null | undefined, alias: string) => `${networkId ?? "default"}\0${alias}`;
|
|
@@ -48,9 +74,12 @@ export function nodeHealthNextExpiryAt(now = Date.now()): number {
|
|
|
48
74
|
return next;
|
|
49
75
|
}
|
|
50
76
|
|
|
51
|
-
export function recordNodeHealth(
|
|
77
|
+
export function recordNodeHealth(
|
|
78
|
+
networkId: string | null | undefined, alias: string,
|
|
79
|
+
health: NodeHealth | NonNullable<z.infer<typeof nodeHealthSchema>>, now = Date.now(),
|
|
80
|
+
): void {
|
|
52
81
|
version++;
|
|
53
|
-
store.set(keyOf(networkId, alias), { health, at: now });
|
|
82
|
+
store.set(keyOf(networkId, alias), { health: normalizeNodeHealth(health), at: now });
|
|
54
83
|
// 有界:按 TTL 顺手清掉过期的,别让改过名/删掉的节点在内存里攒着。
|
|
55
84
|
if (store.size > 256) {
|
|
56
85
|
for (const [k, v] of store) if (now - v.at > NODE_HEALTH_TTL_MS) store.delete(k);
|
|
@@ -168,7 +168,9 @@ describe("app#196 —— 「解析不到 daemon」的两种情况必须给不同
|
|
|
168
168
|
const r = await call("stop_node", { node_id: DAEMON_CHILD });
|
|
169
169
|
expect(r.ok).toBe(false);
|
|
170
170
|
expect(r.error).toBe("daemon_not_resolvable");
|
|
171
|
-
|
|
171
|
+
// Board #625: an explicit daemon id no longer substitutes for authority.
|
|
172
|
+
expect(r.message).not.toContain("pass daemon_node_id explicitly");
|
|
173
|
+
expect(r.message).toContain("adopt this node");
|
|
172
174
|
});
|
|
173
175
|
|
|
174
176
|
test("delete_node 走同一条判别(两个调用点不能只改一个)", async () => {
|
|
@@ -24,8 +24,10 @@ export function childNodeIdForCreateRequest(requestId: unknown): string | null {
|
|
|
24
24
|
/** 由创建记录建出「child node_id → daemon node_id」的映射。 */
|
|
25
25
|
export function buildControllableMap(
|
|
26
26
|
createRequests: Array<{ request_id?: unknown; daemon_node_id?: unknown }>,
|
|
27
|
+
bindings: Array<{ node_id: string; daemon_node_id: string }> = [],
|
|
27
28
|
): Map<string, string> {
|
|
28
29
|
const m = new Map<string, string>();
|
|
30
|
+
for (const binding of bindings) m.set(binding.node_id, binding.daemon_node_id);
|
|
29
31
|
for (const cr of createRequests) {
|
|
30
32
|
const child = childNodeIdForCreateRequest(cr?.request_id);
|
|
31
33
|
if (!child) continue;
|
package/src/node-permissions.ts
CHANGED
|
@@ -220,6 +220,8 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
|
220
220
|
get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
|
|
221
221
|
get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
|
|
222
222
|
list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
|
|
223
|
+
request_adopt_node: "human_only", unadopt_node: "human_only",
|
|
224
|
+
get_adopt_request: "always", ack_adopt_request: "always",
|
|
223
225
|
send_desktop_message: "always", // 发给人的私信,不是派活
|
|
224
226
|
// 读
|
|
225
227
|
get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// #622 —— daemon 逐 runtime 自检结果(`config_snapshot.daemon_capabilities.runtime_readiness`)。
|
|
2
|
+
//
|
|
3
|
+
// 上报侧:agent-node/src/runtime/runtime-readiness.ts。这里两件事:
|
|
4
|
+
// 1. report_status 的 zod 形状 —— **收得宽**:任何一格坏都只丢这一格(或整个
|
|
5
|
+
// runtime_readiness),绝不拒掉整份 report(同 create_capability_observed_ms_ago
|
|
6
|
+
// 的教训:zod 对象里一个已知字段验证失败 = 整份被拒 = 节点在 hub 上失联)。
|
|
7
|
+
// 2. /api/host-supervisors 读取时再消毒一次(库里可能存着任何旧形状)。
|
|
8
|
+
//
|
|
9
|
+
// 向后兼容:旧 daemon 不报 ⇒ 响应里**没有**这个键(不是 {}、不是 null)。
|
|
10
|
+
|
|
11
|
+
import { z } from "zod/v4";
|
|
12
|
+
|
|
13
|
+
export const READINESS_STATES = ["ready", "missing_cli", "not_logged_in", "no_network", "unknown"] as const;
|
|
14
|
+
export type ReadinessState = typeof READINESS_STATES[number];
|
|
15
|
+
|
|
16
|
+
const MAX_RUNTIMES = 16;
|
|
17
|
+
const MAX_REASON = 600;
|
|
18
|
+
|
|
19
|
+
const MAX_JSON_BYTES = 16 * 1024;
|
|
20
|
+
|
|
21
|
+
/** report_status 里 `daemon_capabilities.runtime_readiness` 的 schema。永不拒整份 report。
|
|
22
|
+
*
|
|
23
|
+
* 🔴 故意只验外形(对象、键数、总字节),逐格消毒放在读取侧 `sanitizeRuntimeReadiness`:
|
|
24
|
+
* 这份 schema 会进每个节点的 `tools/list`(#476 按角色的字节天花板),逐字段声明
|
|
25
|
+
* 一遍要多花 ~500 B;而读取侧本来就必须再消毒一次(库里可能存着任何旧形状)。
|
|
26
|
+
* 总字节上限挡住一个异常 daemon 往库里塞大对象。 */
|
|
27
|
+
export const runtimeReadinessSchema = z
|
|
28
|
+
.record(z.string().max(64), z.record(z.string(), z.unknown()))
|
|
29
|
+
.refine((r) => Object.keys(r).length <= MAX_RUNTIMES && JSON.stringify(r).length <= MAX_JSON_BYTES)
|
|
30
|
+
.optional()
|
|
31
|
+
.catch(undefined);
|
|
32
|
+
|
|
33
|
+
export interface RuntimeReadinessOut {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
state: ReadinessState;
|
|
36
|
+
reason: string;
|
|
37
|
+
version?: string;
|
|
38
|
+
checked_at?: string;
|
|
39
|
+
cli?: string;
|
|
40
|
+
auth?: string;
|
|
41
|
+
network?: string;
|
|
42
|
+
shared_login_count?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ENUMS: Record<string, readonly string[]> = {
|
|
46
|
+
cli: ["found", "missing", "bundled", "unknown"],
|
|
47
|
+
auth: ["present", "absent", "not_required", "unknown"],
|
|
48
|
+
network: ["reachable", "unreachable", "skipped"],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** 读取侧消毒。返回 undefined 表示「daemon 没报」—— 调用方据此**不输出**这个键。 */
|
|
52
|
+
export function sanitizeRuntimeReadiness(raw: unknown): Record<string, RuntimeReadinessOut> | undefined {
|
|
53
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
54
|
+
const out: Record<string, RuntimeReadinessOut> = {};
|
|
55
|
+
let n = 0;
|
|
56
|
+
for (const [rt, v] of Object.entries(raw as Record<string, unknown>)) {
|
|
57
|
+
if (n >= MAX_RUNTIMES) break;
|
|
58
|
+
if (typeof rt !== "string" || rt.length === 0 || rt.length > 64) continue;
|
|
59
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) continue;
|
|
60
|
+
const e = v as Record<string, unknown>;
|
|
61
|
+
const state: ReadinessState = (READINESS_STATES as readonly string[]).includes(e.state as string)
|
|
62
|
+
? e.state as ReadinessState
|
|
63
|
+
: "unknown";
|
|
64
|
+
const entry: RuntimeReadinessOut = {
|
|
65
|
+
// ok 只在 state=ready 时可能为真:不让一个自相矛盾的上报朝「没问题」方向错。
|
|
66
|
+
ok: e.ok === true && state === "ready",
|
|
67
|
+
state,
|
|
68
|
+
reason: typeof e.reason === "string" ? e.reason.slice(0, MAX_REASON) : "",
|
|
69
|
+
};
|
|
70
|
+
if (typeof e.version === "string" && e.version.length > 0) entry.version = e.version.slice(0, 64);
|
|
71
|
+
if (typeof e.checked_at === "string" && e.checked_at.length > 0) entry.checked_at = e.checked_at.slice(0, 40);
|
|
72
|
+
for (const k of ["cli", "auth", "network"] as const) {
|
|
73
|
+
if (typeof e[k] === "string" && ENUMS[k].includes(e[k] as string)) entry[k] = e[k] as string;
|
|
74
|
+
}
|
|
75
|
+
if (typeof e.shared_login_count === "number" && Number.isInteger(e.shared_login_count)
|
|
76
|
+
&& e.shared_login_count >= 0 && e.shared_login_count <= 100_000) {
|
|
77
|
+
entry.shared_login_count = e.shared_login_count;
|
|
78
|
+
}
|
|
79
|
+
out[rt] = entry;
|
|
80
|
+
n++;
|
|
81
|
+
}
|
|
82
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
83
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildServeErrorResponse } from "./serve-error.js";
|
|
2
|
+
import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
|
|
2
3
|
import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
3
4
|
import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
|
|
4
5
|
import { dispatchQueueInfo, queueDepthByNode, queueDepthKey } from "./task-queue-ahead.js";
|
|
@@ -4474,6 +4475,7 @@ return Bun.serve({
|
|
|
4474
4475
|
// 🔴 不是 clamp 成边界值:clamp 会把一个坏值变成一个**看起来正常的年龄**,
|
|
4475
4476
|
// 而这一格存在的全部意义就是让人分辨新鲜和陈旧。
|
|
4476
4477
|
let snapObservedMsAgo: number | undefined;
|
|
4478
|
+
let snapReadiness: ReturnType<typeof sanitizeRuntimeReadiness>;
|
|
4477
4479
|
if (r.config_snapshot) {
|
|
4478
4480
|
try {
|
|
4479
4481
|
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
@@ -4491,13 +4493,15 @@ return Bun.serve({
|
|
|
4491
4493
|
&& rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000) {
|
|
4492
4494
|
snapObservedMsAgo = rawAge;
|
|
4493
4495
|
}
|
|
4496
|
+
// #622 —— 逐 runtime 就绪度;旧 daemon 不报 ⇒ undefined ⇒ 响应里没有这个键。
|
|
4497
|
+
snapReadiness = sanitizeRuntimeReadiness(caps?.runtime_readiness);
|
|
4494
4498
|
} catch { /* malformed */ }
|
|
4495
4499
|
}
|
|
4496
4500
|
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
|
|
4497
|
-
observedMsAgo: snapObservedMsAgo };
|
|
4501
|
+
observedMsAgo: snapObservedMsAgo, readiness: snapReadiness };
|
|
4498
4502
|
})
|
|
4499
4503
|
.filter(({ role }) => role === "host_supervisor")
|
|
4500
|
-
.map(({ row: r, canCreate, blockedReason, observedMsAgo }) => {
|
|
4504
|
+
.map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness }) => {
|
|
4501
4505
|
let online = false;
|
|
4502
4506
|
let lastSeenAt: string | null = null;
|
|
4503
4507
|
if (r.session_last_seen) {
|
|
@@ -4562,6 +4566,10 @@ return Bun.serve({
|
|
|
4562
4566
|
out.create_capability_observed_ms_ago = observedMsAgo;
|
|
4563
4567
|
}
|
|
4564
4568
|
}
|
|
4569
|
+
// #622 —— 逐 runtime 实测就绪度(CLI / 登录存在性 / 网络 / codex 共享登录数)。
|
|
4570
|
+
// 只在 daemon 真的报了时出现;**不影响** can_create_nodes(旧 app 依赖它的语义)。
|
|
4571
|
+
// 内容不含路径与凭据(上报侧只发存在性 + 版本号 + 通用修法),所以不按角色脱敏。
|
|
4572
|
+
if (readiness) out.runtime_readiness = readiness;
|
|
4565
4573
|
// app「新建节点」确认页的默认工作目录根。出现即表示该 daemon 认 node_spec.workdir;
|
|
4566
4574
|
// 缺席(老 daemon / 老 hub 的 zod 剥掉了它)时 app 藏起那一行、不发 workdir。
|
|
4567
4575
|
// 🔴 它是那台机器的家目录路径 —— 与 ip_internal 同级,只给 admin/owner。
|
|
@@ -4628,7 +4636,10 @@ return Bun.serve({
|
|
|
4628
4636
|
const crParams: any[] = [];
|
|
4629
4637
|
let crSql = "SELECT request_id, daemon_node_id FROM node_create_requests WHERE 1=1";
|
|
4630
4638
|
crSql = addNetworkScope(crSql, crParams, restScope);
|
|
4631
|
-
|
|
4639
|
+
const bindingParams: any[] = [];
|
|
4640
|
+
const bindingSql = addNetworkScope("SELECT node_id,daemon_node_id FROM node_daemon_bindings WHERE status='active'", bindingParams, restScope);
|
|
4641
|
+
return buildControllableMap(db.all<Record<string, any>>(crSql, ...crParams),
|
|
4642
|
+
db.all<{ node_id: string; daemon_node_id: string }>(bindingSql, ...bindingParams));
|
|
4632
4643
|
} catch {
|
|
4633
4644
|
// 表不存在/查询失败 ⇒ 全部按不可控处理,不让这一格拖垮整个列表
|
|
4634
4645
|
return new Map<string, string>();
|
|
@@ -184,6 +184,12 @@ function setupAlphaNetwork() {
|
|
|
184
184
|
seedMembership(USER_A_ID, NET_A, "admin");
|
|
185
185
|
seedDaemon(NET_A, DAEMON_A_ID, DAEMON_A_ALIAS, USER_A_ID, DAEMON_A_TOK);
|
|
186
186
|
seedChild(NET_A, CHILD_A_ID, CHILD_A_ALIAS, USER_A_ID, CHILD_TOK_A_ID);
|
|
187
|
+
// Board #625: happy paths must carry actual creation authority, not merely a node_ id.
|
|
188
|
+
db.run(
|
|
189
|
+
`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
|
|
190
|
+
VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
|
|
191
|
+
[`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
|
|
192
|
+
);
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
// ── stop_node ──────────────────────────────────────────────────────
|
|
@@ -418,12 +424,7 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
|
|
|
418
424
|
// f3 合入后错 daemon 会 sweep 空 + ack stopped → hub 假收敛 stopped 而 child 仍在跑。
|
|
419
425
|
test("#1448 finding-4: stop/delete reject a wrong same-network daemon_node_id (daemon_child_mismatch)", async () => {
|
|
420
426
|
setupAlphaNetwork();
|
|
421
|
-
//
|
|
422
|
-
db.run(
|
|
423
|
-
`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
|
|
424
|
-
VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
|
|
425
|
-
[`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
|
|
426
|
-
);
|
|
427
|
+
// setupAlphaNetwork carries the creation authority.
|
|
427
428
|
// 一个同网、存在、但没创建 CHILD_A 的 daemon
|
|
428
429
|
const DAEMON_B = "node_sd_daemon_b";
|
|
429
430
|
seedDaemon(NET_A, DAEMON_B, "sd-daemon-b", USER_A_ID, "tok_sd_daemon_b");
|
|
@@ -454,12 +455,7 @@ describe("get_stop_request + ack_stop_request — daemon flow", () => {
|
|
|
454
455
|
expect(readNode(HAND_ID)?.lifecycle_state).toBe("active");
|
|
455
456
|
const rows = db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM node_stop_requests WHERE child_node_id = ?1`, HAND_ID);
|
|
456
457
|
expect(Number(rows?.n ?? 0)).toBe(0);
|
|
457
|
-
// 回归钉:
|
|
458
|
-
db.run(
|
|
459
|
-
`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,model,flags_json,env_keys,status,created_at,created_by_token,child_node_id)
|
|
460
|
-
VALUES(?1,?2,?3,?4,'claude-agent-sdk','x','{}','[]','succeeded',1,'t',?5)`,
|
|
461
|
-
[`cr_${CHILD_A_ID.slice(5)}`, DAEMON_A_ID, CHILD_A_ALIAS, NET_A, CHILD_A_ID],
|
|
462
|
-
);
|
|
458
|
+
// 回归钉:setupAlphaNetwork 中有 create 记录的 child 照常可停。
|
|
463
459
|
expect((await call(u.stop_node, { child_node_id: CHILD_A_ID, network_id: NET_A })).ok).toBe(true);
|
|
464
460
|
});
|
|
465
461
|
|
|
@@ -182,7 +182,9 @@ describe("what each caller sees", () => {
|
|
|
182
182
|
describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
183
183
|
// 全部工具(= 改动前每个调用者拿到的)71–72 KB;改动后按角色。改数字时在 PR 里写为什么。
|
|
184
184
|
// 量出来(#478 合入时):全部 74 个工具 71,559 B;节点 52 个 57,632 B(−19%);人 56 个 54,780 B(−23%)。留 ~1.5 KB 余量。
|
|
185
|
-
|
|
185
|
+
// Board #625 adds 2 human tools + 2 hidden daemon protocol tools. Measured:
|
|
186
|
+
// all 78 / 73,969 B; user 58 / 56,615 B; node remains 52 / 58,320 B.
|
|
187
|
+
const CEILINGS = { node: 59_000, user: 57_500, all: 75_000 } as const;
|
|
186
188
|
test("node / user / all", async () => {
|
|
187
189
|
const node = await listTools(T.node_normal);
|
|
188
190
|
const user = await listTools(T.owner);
|
package/src/tool-audience.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { NODE_TOOL_CLASS, nodeIdentity, nodePermissionsFlag, broadcastVerdict, h
|
|
|
23
23
|
export type ToolAudience = "both" | "node" | "protocol" | "user";
|
|
24
24
|
|
|
25
25
|
export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
26
|
+
request_adopt_node: "user", unadopt_node: "user",
|
|
27
|
+
get_adopt_request: "protocol", ack_adopt_request: "protocol",
|
|
26
28
|
// ── 节点:用户令牌一律 network_token_required ──
|
|
27
29
|
report_status: "node",
|
|
28
30
|
// ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
|