@sleep2agi/commhub-server 0.9.0-preview.106 → 0.9.0-preview.108
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/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +15 -1
- package/src/config-apply-validate.test.ts +9 -0
- package/src/config-apply-validate.ts +6 -2
- package/src/create-node-validate.test.ts +24 -0
- package/src/create-node-validate.ts +20 -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 +54 -61
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { beforeEach, afterAll, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
import { buildControllableMap } from "./node-lifecycle-controllable.js";
|
|
6
|
+
|
|
7
|
+
const NET = "net_adoption_test", USER = "u_adoption_test", CHILD = "n_adoption_child", DAEMON = "node_adoption_daemon";
|
|
8
|
+
function cleanup() {
|
|
9
|
+
for (const table of ["node_daemon_bindings", "node_stop_requests", "node_start_requests", "node_create_requests", "audit_log", "sessions", "nodes", "api_tokens", "network_members", "networks"])
|
|
10
|
+
db.run(`DELETE FROM ${table} WHERE network_id=?1`, [NET]);
|
|
11
|
+
db.run("DELETE FROM users WHERE user_id=?1", [USER]);
|
|
12
|
+
}
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
cleanup();
|
|
15
|
+
db.run("INSERT INTO users(user_id,username,password_hash,role) VALUES(?1,?1,'x','user')", [USER]);
|
|
16
|
+
db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,?1,?2)", [NET, USER]);
|
|
17
|
+
db.run("INSERT INTO network_members(user_id,network_id,role) VALUES(?1,?2,'owner')", [USER, NET]);
|
|
18
|
+
for (const [id, alias, snap] of [[DAEMON, "adoption-daemon", { role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }], [CHILD, "adoption-child", {}]] as const) {
|
|
19
|
+
db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state) VALUES(?1,?2,?2,?3,?4,'test-host',?5,'active')`, [id, alias, NET, USER, JSON.stringify(snap)]);
|
|
20
|
+
}
|
|
21
|
+
db.run("INSERT INTO sessions(resume_id,session_id,alias,network_id,status,last_seen_at) VALUES('adoption-session','adoption-session','adoption-daemon',?1,'idle',datetime('now'))", [NET]);
|
|
22
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES('adoption-token',?1,?2,'network','node:adoption-daemon','adoption-hash')", [USER, NET]);
|
|
23
|
+
});
|
|
24
|
+
afterAll(cleanup);
|
|
25
|
+
function handlers(kind: "user" | "daemon" | "stranger" = "user") {
|
|
26
|
+
const server = new McpServer({ name: "adoption-test", version: "0" }) as any;
|
|
27
|
+
const out: any = {}, original = server.tool.bind(server);
|
|
28
|
+
server.tool = (name: string, desc: string, schema: any, handler: any) => { out[name] = handler; return original(name, desc, schema, handler); };
|
|
29
|
+
registerTools(server, undefined, kind === "daemon" ? NET : null, kind === "user" ? USER : null, null, kind === "daemon", kind === "daemon" ? "adoption-token" : null);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
const call = async (handler: any, args: any = {}) => JSON.parse((await handler(args)).content[0].text);
|
|
33
|
+
const request = () => call(handlers().request_adopt_node, { node_id: CHILD, daemon_node_id: DAEMON, workdir: "/workspace/project" });
|
|
34
|
+
async function adopt() {
|
|
35
|
+
const r = await request(); expect(r.ok).toBe(true);
|
|
36
|
+
expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
|
|
37
|
+
return r.request_id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("unbound node_ cannot use an explicitly supplied daemon (witnessed red on main)", async () => {
|
|
41
|
+
db.run("UPDATE nodes SET node_id='node_unbound_child' WHERE node_id=?1", [CHILD]);
|
|
42
|
+
expect(await call(handlers().stop_node, { child_node_id: "node_unbound_child", daemon_node_id: DAEMON, network_id: NET })).toMatchObject({ ok: false, error: "daemon_not_resolvable" });
|
|
43
|
+
expect(db.all("SELECT * FROM node_stop_requests WHERE network_id=?1", NET)).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
test("two-phase binding, daemon-bound pull, controllable map, adopted delete refused", async () => {
|
|
46
|
+
const r = await request(); expect(r.ok).toBe(true);
|
|
47
|
+
expect(await call(handlers().stop_node, { child_node_id: CHILD, daemon_node_id: DAEMON, network_id: NET })).toMatchObject({ ok: false });
|
|
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
|
+
expect(await call(handlers().get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: false });
|
|
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" });
|
|
52
|
+
expect(buildControllableMap([], [{ node_id: CHILD, daemon_node_id: DAEMON }]).get(CHILD)).toBe(DAEMON);
|
|
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
|
+
expect(db.get<any>("SELECT lifecycle_state FROM nodes WHERE node_id=?1", CHILD).lifecycle_state).toBe("active");
|
|
55
|
+
});
|
|
56
|
+
test("adopted stop/start complete through existing protocol; revocation removes authority", async () => {
|
|
57
|
+
await adopt(); const u = handlers(), d = handlers("daemon");
|
|
58
|
+
const stop = await call(u.stop_node, { child_node_id: CHILD, network_id: NET }); expect(stop.ok).toBe(true);
|
|
59
|
+
expect((await call(d.get_stop_request, { request_id: stop.request_id })).ok).toBe(true);
|
|
60
|
+
expect((await call(d.ack_stop_request, { request_id: stop.request_id, status: "stopped" })).ok).toBe(true);
|
|
61
|
+
expect((await call(d.list_my_children)).children[0].lifecycle_state).toBe("stopped");
|
|
62
|
+
const start = await call(u.start_node, { child_node_id: CHILD, network_id: NET }); expect(start.ok).toBe(true);
|
|
63
|
+
expect((await call(d.get_start_request, { request_id: start.request_id })).ok).toBe(true);
|
|
64
|
+
expect((await call(d.ack_start_request, { request_id: start.request_id, status: "started", child_pid: 4321 })).ok).toBe(true);
|
|
65
|
+
expect((await call(u.unadopt_node, { node_id: CHILD })).ok).toBe(true);
|
|
66
|
+
expect((await call(d.list_my_children)).children).toHaveLength(0);
|
|
67
|
+
expect((await call(u.stop_node, { child_node_id: CHILD, daemon_node_id: DAEMON, network_id: NET })).ok).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
test("pending cancellation cannot be resurrected; duplicate pending refuses", async () => {
|
|
70
|
+
const r = await request(); expect(r.ok).toBe(true);
|
|
71
|
+
expect((await request()).error).toBe("node_already_managed");
|
|
72
|
+
expect((await call(handlers().unadopt_node, { node_id: CHILD })).ok).toBe(true);
|
|
73
|
+
expect((await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_pending");
|
|
74
|
+
});
|
|
75
|
+
test("human-only and owner/admin fail closed; admin exception audited", async () => {
|
|
76
|
+
const args = { node_id: CHILD, daemon_node_id: DAEMON, workdir: "/workspace/project" };
|
|
77
|
+
expect((await call(handlers("daemon").request_adopt_node, args)).ok).toBe(false);
|
|
78
|
+
expect((await call(handlers("stranger").request_adopt_node, args)).ok).toBe(false);
|
|
79
|
+
db.run("UPDATE network_members SET role='member' WHERE network_id=?1", [NET]);
|
|
80
|
+
db.run("UPDATE nodes SET owner_user_id=NULL WHERE node_id=?1", [CHILD]);
|
|
81
|
+
expect((await request()).error).toBe("adopt_forbidden");
|
|
82
|
+
db.run("UPDATE network_members SET role='owner' WHERE network_id=?1", [NET]);
|
|
83
|
+
expect((await request()).ok).toBe(true);
|
|
84
|
+
expect(db.get("SELECT action FROM audit_log WHERE action='adopt_by_admin' AND network_id=?1", NET)).not.toBeNull();
|
|
85
|
+
});
|
|
86
|
+
test("viewer owner cannot request; membership loss before ack cannot activate", async () => {
|
|
87
|
+
db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1", [NET]);
|
|
88
|
+
expect((await request()).error).toBe("adopt_forbidden");
|
|
89
|
+
db.run("UPDATE network_members SET role='owner' WHERE network_id=?1", [NET]);
|
|
90
|
+
const r = await request(); expect(r.ok).toBe(true);
|
|
91
|
+
db.run("DELETE FROM network_members WHERE network_id=?1", [NET]);
|
|
92
|
+
expect((await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("adopt_forbidden");
|
|
93
|
+
});
|
|
94
|
+
test("wrong daemon cannot pull/ack; refusal releases reservation; in-flight unadopt is refused", async () => {
|
|
95
|
+
const r = await request(); expect(r.ok).toBe(true);
|
|
96
|
+
db.run("UPDATE node_daemon_bindings SET daemon_node_id='other-daemon' WHERE request_id=?1", [r.request_id]);
|
|
97
|
+
const d = handlers("daemon");
|
|
98
|
+
expect((await call(d.get_adopt_request, { request_id: r.request_id })).error).toBe("request_not_found");
|
|
99
|
+
expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_found");
|
|
100
|
+
db.run("UPDATE node_daemon_bindings SET daemon_node_id=?1 WHERE request_id=?2", [DAEMON, r.request_id]);
|
|
101
|
+
expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "refused", error: "adopt_roots_not_configured" })).status).toBe("refused");
|
|
102
|
+
await adopt();
|
|
103
|
+
expect((await call(handlers().stop_node, { child_node_id: CHILD, network_id: NET })).ok).toBe(true);
|
|
104
|
+
expect((await call(handlers().unadopt_node, { node_id: CHILD })).error).toBe("node_lifecycle_in_flight");
|
|
105
|
+
});
|
|
106
|
+
test("old daemon, hostname mismatch, stale heartbeat and cross-network daemon rejected", async () => {
|
|
107
|
+
db.run("UPDATE nodes SET config_snapshot='null' WHERE node_id=?1", [DAEMON]);
|
|
108
|
+
expect((await request()).error).toBe("daemon_not_adopt_capable");
|
|
109
|
+
db.run("UPDATE nodes SET config_snapshot='{}' WHERE node_id=?1", [DAEMON]);
|
|
110
|
+
expect((await request()).error).toBe("daemon_not_adopt_capable");
|
|
111
|
+
db.run("UPDATE nodes SET config_snapshot=?1,hostname='different' WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }), DAEMON]);
|
|
112
|
+
expect((await request()).error).toBe("hostname_mismatch");
|
|
113
|
+
db.run("UPDATE nodes SET hostname='test-host' WHERE node_id=?1", [DAEMON]);
|
|
114
|
+
db.run("UPDATE sessions SET last_seen_at='2000-01-01 00:00:00' WHERE network_id=?1", [NET]);
|
|
115
|
+
expect((await request()).error).toBe("daemon_offline");
|
|
116
|
+
expect((await call(handlers().request_adopt_node, { node_id: CHILD, daemon_node_id: "other_network_daemon", workdir: "/workspace/project" })).error).toBe("daemon_not_found");
|
|
117
|
+
});
|
|
118
|
+
test("network boundaries apply to both human request and daemon pull/ack", async () => {
|
|
119
|
+
const r = await request(); expect(r.ok).toBe(true);
|
|
120
|
+
db.run("UPDATE node_daemon_bindings SET network_id='net_other_adoption_test' WHERE request_id=?1", [r.request_id]);
|
|
121
|
+
try {
|
|
122
|
+
const d = handlers("daemon");
|
|
123
|
+
expect((await call(d.get_adopt_request, { request_id: r.request_id })).error).toBe("request_not_found");
|
|
124
|
+
expect((await call(d.ack_adopt_request, { request_id: r.request_id, status: "adopted" })).error).toBe("request_not_found");
|
|
125
|
+
} finally { db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2", [NET, r.request_id]); }
|
|
126
|
+
db.run("UPDATE nodes SET network_id='net_other_adoption_test' WHERE node_id=?1", [DAEMON]);
|
|
127
|
+
try { expect((await request()).error).toBe("daemon_not_found"); }
|
|
128
|
+
finally { db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2", [NET, DAEMON]); }
|
|
129
|
+
db.run("UPDATE nodes SET network_id='net_other_adoption_test' WHERE node_id=?1", [CHILD]);
|
|
130
|
+
try { expect((await request()).error).toBe("adopt_forbidden"); }
|
|
131
|
+
finally { db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2", [NET, CHILD]); }
|
|
132
|
+
});
|
|
@@ -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>();
|