@sleep2agi/commhub-server 0.9.0-preview.120 → 0.9.0-preview.121
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/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-acl-http.test.ts +2 -1
- package/src/api-host-supervisors-runtime-readiness.test.ts +13 -0
- package/src/auth.ts +8 -7
- package/src/codex-config-flags.ts +77 -0
- package/src/config-apply-validate.test.ts +33 -1
- package/src/config-apply-validate.ts +24 -0
- package/src/create-node-validate.test.ts +26 -1
- package/src/create-node-validate.ts +45 -1
- package/src/create-node-workdir.test.ts +13 -0
- package/src/daemon-provider-snapshots.test.ts +256 -0
- package/src/daemon-provider-snapshots.ts +226 -0
- package/src/db.ts +31 -0
- package/src/member-presence-http.test.ts +8 -4
- package/src/node-lifecycle-read-http.test.ts +13 -1
- package/src/node-lifecycle-read.ts +6 -0
- package/src/node-permissions.ts +2 -0
- package/src/report-status-host-schema.test.ts +24 -0
- package/src/runtime-readiness.ts +7 -0
- package/src/server.ts +40 -4
- package/src/shared/codex-inventory.ts +34 -0
- package/src/shared/daemon-provider-snapshot.ts +49 -0
- package/src/shared/provider-application.ts +24 -0
- package/src/tool-audience-http.test.ts +13 -1
- package/src/tool-audience.ts +2 -0
- package/src/tools.ts +47 -14
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
package/src/db.ts
CHANGED
|
@@ -320,6 +320,15 @@ db.exec(`
|
|
|
320
320
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
321
321
|
`);
|
|
322
322
|
|
|
323
|
+
// Human profile avatar. Additive for existing databases; old binaries ignore it.
|
|
324
|
+
try {
|
|
325
|
+
db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT");
|
|
326
|
+
} catch (e: any) {
|
|
327
|
+
// Both supported adapters report a duplicate column on the next startup.
|
|
328
|
+
// Do not send SQLite PRAGMA to PostgreSQL or swallow other migration errors.
|
|
329
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
330
|
+
}
|
|
331
|
+
|
|
323
332
|
// #261 P0-2 (2026-06-28): must_change_password flag. Added via ALTER
|
|
324
333
|
// (not in the CREATE block above) so it's a no-op on fresh DBs that
|
|
325
334
|
// already get the column via the CREATE path's full definition AND a
|
|
@@ -519,6 +528,28 @@ try {
|
|
|
519
528
|
}
|
|
520
529
|
|
|
521
530
|
// `node_config_updates` — pending + history. One row per dashboard write.
|
|
531
|
+
// #906: redacted daemon-owned inventory requests, never credential material.
|
|
532
|
+
db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_snapshots (
|
|
533
|
+
request_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, daemon_node_id TEXT NOT NULL,
|
|
534
|
+
requested_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
|
|
535
|
+
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, observed_at INTEGER,
|
|
536
|
+
snapshot_json TEXT, error TEXT
|
|
537
|
+
)`);
|
|
538
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_daemon_provider_pending
|
|
539
|
+
ON daemon_provider_snapshots(network_id, daemon_node_id, status)`);
|
|
540
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_daemon_provider_inflight
|
|
541
|
+
ON daemon_provider_snapshots(network_id, daemon_node_id) WHERE status='pending'`);
|
|
542
|
+
// One encrypted mutation payload per request; public receipt remains secret-free.
|
|
543
|
+
db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_writes (
|
|
544
|
+
request_id TEXT PRIMARY KEY, expected_revision INTEGER NOT NULL,
|
|
545
|
+
ciphertext BLOB NOT NULL, iv BLOB NOT NULL, tag BLOB NOT NULL
|
|
546
|
+
)`);
|
|
547
|
+
// Application intent is credential-free; secrets stay in the daemon's store.
|
|
548
|
+
db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_applications (
|
|
549
|
+
request_id TEXT PRIMARY KEY, expected_revision INTEGER NOT NULL,
|
|
550
|
+
selection_json TEXT NOT NULL
|
|
551
|
+
)`);
|
|
552
|
+
|
|
522
553
|
// At most one row per node may be in a non-terminal state at a time
|
|
523
554
|
// (single-flight enforced at the tool layer; this table just stores).
|
|
524
555
|
//
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// GET /api/networks/:id/humans 给每个人加 online / last_seen_at(判据 = 此刻有没有活着的用户流
|
|
4
4
|
// /events/users/me);用户流上推 member_presence。两个方向都测:
|
|
5
5
|
// 看得到:普通成员、受限成员、viewer;连上 → online=true,断开 → online=false + last_seen_at。
|
|
6
|
-
// 看不到:非成员 403
|
|
6
|
+
// 看不到:非成员 403;节点令牌只拿身份字段(含头像),无在线状态;别的网络的人收不到推送;自己收不到自己的。
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
9
9
|
import { mkdtempSync, rmSync } from "fs";
|
|
@@ -125,7 +125,8 @@ describe("GET /api/networks/:id/humans — online / last_seen_at", () => {
|
|
|
125
125
|
test("never connected since hub start → online=false, last_seen_at=null (unknown, not 'long ago')", async () => {
|
|
126
126
|
const r = await get(users.pres_alpha.token, `/api/networks/${NET}/humans`);
|
|
127
127
|
expect(r.status).toBe(200);
|
|
128
|
-
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
128
|
+
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["avatar_url", "display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
129
|
+
expect(r.body.humans.every((h: any) => h.avatar_url === null)).toBe(true);
|
|
129
130
|
const beta = r.body.humans.find((h: any) => h.user_id === users.pres_beta.userId);
|
|
130
131
|
expect(beta.online).toBe(false);
|
|
131
132
|
expect(beta.last_seen_at).toBeNull();
|
|
@@ -181,11 +182,14 @@ describe("who must NOT see presence", () => {
|
|
|
181
182
|
expect(r.body.humans).toBeUndefined();
|
|
182
183
|
});
|
|
183
184
|
|
|
184
|
-
test("node token →
|
|
185
|
+
test("node token → identity fields including avatar, no online / last_seen_at", async () => {
|
|
185
186
|
const beta = await openUserStream(users.pres_beta.token, NET);
|
|
186
187
|
const r = await get(nodeToken, `/api/networks/${NET}/humans`);
|
|
187
188
|
expect(r.status).toBe(200);
|
|
188
|
-
for (const h of r.body.humans)
|
|
189
|
+
for (const h of r.body.humans) {
|
|
190
|
+
expect(Object.keys(h).sort()).toEqual(["avatar_url", "display_name", "user_id", "username"]);
|
|
191
|
+
expect(h.avatar_url).toBeNull();
|
|
192
|
+
}
|
|
189
193
|
// 节点令牌也不能订阅用户流(既有行为,这里钉住:不能借它收 member_presence)。
|
|
190
194
|
expect((await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: auth(nodeToken) })).status).toBe(403);
|
|
191
195
|
beta.close();
|
|
@@ -60,8 +60,20 @@ test("public errors redact free text for granted viewers on every projection", a
|
|
|
60
60
|
"bin: ENOENT: /private/fixture/bin/anet on fixture-host",
|
|
61
61
|
"adopt_unknown_secret", "adopt_start_timeout: /private/fixture", "", "adopt_start_timeout", null,
|
|
62
62
|
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
63
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
64
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
65
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
66
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
67
|
+
"adopt_codex_identity_unproven",
|
|
68
|
+
"TUI session mismatch", "opencode_launch_health:TUI session mismatch", "opencode_tui_session_mismatch",
|
|
63
69
|
]) {
|
|
64
|
-
const
|
|
70
|
+
const publicCodes = ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed", "opencode_tui_session_mismatch",
|
|
71
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
72
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
73
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
74
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
75
|
+
"adopt_codex_identity_unproven"];
|
|
76
|
+
const expected = error === null || publicCodes.includes(error) ? error : "lifecycle_error";
|
|
65
77
|
for (const [kind, table, request] of [
|
|
66
78
|
["adopt", "node_daemon_bindings", "adopt_read_active"],
|
|
67
79
|
["start", "node_start_requests", "start_read_failed"],
|
|
@@ -7,6 +7,11 @@ const publicLifecycleErrors = new Set([
|
|
|
7
7
|
"codex_fork_confirmation_invalid", "codex_fork_recovery_unsupported", "codex_fork_cli_unsupported",
|
|
8
8
|
"codex_fork_result_invalid", "codex_fork_outcome_unknown",
|
|
9
9
|
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
10
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
11
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
12
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
13
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
14
|
+
"adopt_codex_identity_unproven",
|
|
10
15
|
"adopt_active_binding_required", "adopt_binding_unavailable",
|
|
11
16
|
"adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
|
|
12
17
|
"adopt_start_evidence_missing", "adopt_stop_evidence_missing",
|
|
@@ -27,6 +32,7 @@ const publicLifecycleErrors = new Set([
|
|
|
27
32
|
"adopt_tmux_socket_mismatch", "adopt_pane_invalid", "adopt_pane_unverified",
|
|
28
33
|
"adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
|
|
29
34
|
"adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
|
|
35
|
+
"opencode_tui_session_mismatch",
|
|
30
36
|
"adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
|
|
31
37
|
"adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
|
|
32
38
|
"adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
|
package/src/node-permissions.ts
CHANGED
|
@@ -246,6 +246,8 @@ export function streamVerdict(id: NodeIdentity, target: AgentRef | "network"): V
|
|
|
246
246
|
// ── MCP:每个工具归一类(test 钉住「每个注册的工具都归了类」,新工具忘了归类会红) ──
|
|
247
247
|
export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "schedule" | "node_write" | "human_only";
|
|
248
248
|
export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
249
|
+
daemon_provider_snapshot: "human_only",
|
|
250
|
+
pull_daemon_provider_snapshots: "always", ack_daemon_provider_snapshot: "always",
|
|
249
251
|
// 回复 / 上报 / 自己的收件箱 / 生命周期协议(节点自己的请求)—— 任何模式都能做
|
|
250
252
|
report_status: "always", report_completion: "always", get_inbox: "always", ack_inbox: "always",
|
|
251
253
|
mark_tasks_runtime_submitted: "always", mark_tasks_consumed: "always", send_reply: "always", send_peer_reply: "always", send_ack: "always",
|
|
@@ -149,6 +149,30 @@ describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽"
|
|
|
149
149
|
* 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
|
|
150
150
|
* 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
|
|
151
151
|
* 而不是「必须是某个值」。 */
|
|
152
|
+
describe("adoption_candidates ingest does not reject the heartbeat or keep secrets", () => {
|
|
153
|
+
const caps = () => schemas.report_status.config_snapshot;
|
|
154
|
+
const base = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
|
|
155
|
+
|
|
156
|
+
test("a clean row is kept, a bad sibling is dropped, and token keys are stripped", () => {
|
|
157
|
+
const parsed = caps().parse({ ...base, daemon_capabilities: { adopt_capable: true, adoption_candidates: [
|
|
158
|
+
{ node_id: "n_ok", alias: "演示", workdir: "/tmp/proj", runtime: "claude-agent-sdk", launch_hint: "stopped", token: "ntok_supersecret" },
|
|
159
|
+
{ node_id: "n_bad", alias: "x", workdir: "relative", launch_hint: "stopped" },
|
|
160
|
+
{ node_id: "n_hint", alias: "y", workdir: "/tmp/y", launch_hint: "nope" },
|
|
161
|
+
] } });
|
|
162
|
+
expect(parsed.daemon_capabilities.adoption_candidates.filter(Boolean)).toEqual([
|
|
163
|
+
{ node_id: "n_ok", alias: "演示", workdir: "/tmp/proj", runtime: "claude-agent-sdk", launch_hint: "stopped" },
|
|
164
|
+
]);
|
|
165
|
+
expect(JSON.stringify(parsed)).not.toContain("ntok_supersecret");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a non-array does not reject report_status and does not keep the payload", () => {
|
|
169
|
+
const parsed = caps().parse({ ...base, daemon_capabilities: { adopt_capable: true, adoption_candidates: { token: "ntok_supersecret" } } });
|
|
170
|
+
expect(parsed.daemon_capabilities.adoption_candidates).toBeUndefined();
|
|
171
|
+
expect(JSON.stringify(parsed)).not.toContain("ntok_supersecret");
|
|
172
|
+
expect(parsed.daemon_capabilities.adopt_capable).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
152
176
|
describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
|
|
153
177
|
const rs = () => schemas.report_status;
|
|
154
178
|
const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
|
package/src/runtime-readiness.ts
CHANGED
|
@@ -40,6 +40,9 @@ export interface RuntimeReadinessOut {
|
|
|
40
40
|
auth?: string;
|
|
41
41
|
network?: string;
|
|
42
42
|
shared_login_count?: number;
|
|
43
|
+
/** Copied only for runtime key `opencode-cli`. */
|
|
44
|
+
generation?: "v1" | "v2";
|
|
45
|
+
accepted?: boolean;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
const ENUMS: Record<string, readonly string[]> = {
|
|
@@ -76,6 +79,10 @@ export function sanitizeRuntimeReadiness(raw: unknown): Record<string, RuntimeRe
|
|
|
76
79
|
&& e.shared_login_count >= 0 && e.shared_login_count <= 100_000) {
|
|
77
80
|
entry.shared_login_count = e.shared_login_count;
|
|
78
81
|
}
|
|
82
|
+
if (rt === "opencode-cli" && (e.generation === "v1" || e.generation === "v2")) {
|
|
83
|
+
entry.generation = e.generation;
|
|
84
|
+
if (typeof e.accepted === "boolean") entry.accepted = e.accepted;
|
|
85
|
+
}
|
|
79
86
|
out[rt] = entry;
|
|
80
87
|
n++;
|
|
81
88
|
}
|
package/src/server.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { expireStaleOpenTasks } from "./task-stale-open.js";
|
|
|
32
32
|
import { flagOrphanTasks } from "./task-orphan.js";
|
|
33
33
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
34
34
|
import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
|
|
35
|
+
import { listAdoptionCandidates, snapshotReportsAdoptionDiscovery } from "./adoption-candidates.js";
|
|
35
36
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
36
37
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
37
38
|
import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
|
|
@@ -1455,6 +1456,30 @@ return Bun.serve({
|
|
|
1455
1456
|
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
|
|
1456
1457
|
}
|
|
1457
1458
|
|
|
1459
|
+
// Separate route: older Hubs must return unsupported, not silently ignore
|
|
1460
|
+
// avatar_url in their existing profile PUT and pretend that it was saved.
|
|
1461
|
+
if (url.pathname === "/api/auth/me/avatar" && req.method === "PUT") {
|
|
1462
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1463
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1464
|
+
const resolved = resolveToken(token);
|
|
1465
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1466
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1467
|
+
let body: unknown;
|
|
1468
|
+
try { body = await req.json(); } catch {
|
|
1469
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_json" }, { status: 400 }));
|
|
1470
|
+
}
|
|
1471
|
+
if (!body || typeof body !== "object" || Array.isArray(body)
|
|
1472
|
+
|| !Object.hasOwn(body, "avatar_url")
|
|
1473
|
+
|| Object.keys(body).some((key) => key !== "avatar_url")) {
|
|
1474
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_avatar_body", reason: "expected only avatar_url (string or null)" }, { status: 400 }));
|
|
1475
|
+
}
|
|
1476
|
+
const avatar = validateAvatarUrl((body as { avatar_url: unknown }).avatar_url);
|
|
1477
|
+
if (!avatar.ok) return withCors(req, Response.json({ ok: false, error: "invalid_avatar_url", reason: avatar.reason }, { status: 400 }));
|
|
1478
|
+
// No supplied target ID/alias; user identity comes exclusively from auth.
|
|
1479
|
+
db.run("UPDATE users SET avatar_url = ?1, updated_at = datetime('now') WHERE user_id = ?2", [avatar.value, resolved.user.user_id]);
|
|
1480
|
+
return withCors(req, Response.json({ ok: true, user_id: resolved.user.user_id, avatar_url: avatar.value }));
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1458
1483
|
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
1459
1484
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1460
1485
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
@@ -1473,7 +1498,7 @@ return Bun.serve({
|
|
|
1473
1498
|
db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
|
|
1474
1499
|
}
|
|
1475
1500
|
// Re-fetch
|
|
1476
|
-
const user = db.get<any>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
|
|
1501
|
+
const user = db.get<any>("SELECT user_id, username, display_name, avatar_url, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
|
|
1477
1502
|
return withCors(req, Response.json({ ok: true, user }));
|
|
1478
1503
|
} catch (e: any) {
|
|
1479
1504
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
@@ -2567,6 +2592,11 @@ return Bun.serve({
|
|
|
2567
2592
|
if (!lifecycleUser) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
2568
2593
|
return withCors(req, lifecycleRequestResponse(url, restScope));
|
|
2569
2594
|
}
|
|
2595
|
+
// Board #654: read-only hand-started adoption candidates. Not a binding.
|
|
2596
|
+
if (url.pathname === "/api/adoption-candidates" && req.method === "GET") {
|
|
2597
|
+
if (!lifecycleUser || !restAuth?.userId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
2598
|
+
return withCors(req, Response.json(listAdoptionCandidates(restScope, { userId: restAuth.userId, isAdmin })));
|
|
2599
|
+
}
|
|
2570
2600
|
|
|
2571
2601
|
const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
|
|
2572
2602
|
const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
|
|
@@ -4589,6 +4619,7 @@ return Bun.serve({
|
|
|
4589
4619
|
// upgraded past preview.55.
|
|
4590
4620
|
let snapCanCreate: boolean | undefined;
|
|
4591
4621
|
let snapAdoptCapable: boolean | undefined;
|
|
4622
|
+
let snapAdoptionDiscovery = false;
|
|
4592
4623
|
let snapBlockedReason: string | undefined;
|
|
4593
4624
|
// #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
|
|
4594
4625
|
// 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
|
|
@@ -4603,6 +4634,7 @@ return Bun.serve({
|
|
|
4603
4634
|
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
4604
4635
|
const caps = parsed?.daemon_capabilities;
|
|
4605
4636
|
if (typeof caps?.adopt_capable === "boolean") snapAdoptCapable = caps.adopt_capable;
|
|
4637
|
+
if (snapshotReportsAdoptionDiscovery(parsed)) snapAdoptionDiscovery = true;
|
|
4606
4638
|
if (caps && typeof caps.can_create_nodes === "boolean") {
|
|
4607
4639
|
snapCanCreate = caps.can_create_nodes;
|
|
4608
4640
|
}
|
|
@@ -4620,10 +4652,11 @@ return Bun.serve({
|
|
|
4620
4652
|
} catch { /* malformed */ }
|
|
4621
4653
|
}
|
|
4622
4654
|
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
|
|
4623
|
-
observedMsAgo: snapObservedMsAgo, readiness: snapReadiness, adoptCapable: snapAdoptCapable
|
|
4655
|
+
observedMsAgo: snapObservedMsAgo, readiness: snapReadiness, adoptCapable: snapAdoptCapable,
|
|
4656
|
+
adoptionDiscovery: snapAdoptionDiscovery };
|
|
4624
4657
|
})
|
|
4625
4658
|
.filter(({ role }) => role === "host_supervisor")
|
|
4626
|
-
.map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness, adoptCapable }) => {
|
|
4659
|
+
.map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness, adoptCapable, adoptionDiscovery }) => {
|
|
4627
4660
|
let online = false;
|
|
4628
4661
|
let lastSeenAt: string | null = null;
|
|
4629
4662
|
if (r.session_last_seen) {
|
|
@@ -4667,7 +4700,10 @@ return Bun.serve({
|
|
|
4667
4700
|
// Do not reveal even the new capability for a node the user cannot read.
|
|
4668
4701
|
const visibleParams: any[] = [r.node_id];
|
|
4669
4702
|
const visibleSql = addAgentNetworkScope("SELECT node_id FROM nodes WHERE node_id=?1", visibleParams, restScope, { alias: "alias", nodeId: "node_id" });
|
|
4670
|
-
if (db.get(visibleSql, ...visibleParams))
|
|
4703
|
+
if (db.get(visibleSql, ...visibleParams)) {
|
|
4704
|
+
out.adopt_capable = adoptCapable;
|
|
4705
|
+
if (adoptionDiscovery) out.adoption_discovery = true;
|
|
4706
|
+
}
|
|
4671
4707
|
}
|
|
4672
4708
|
// #1353 Fix ② — only emit the two capability keys when the
|
|
4673
4709
|
// daemon actually reported them. Undefined vs. false is a
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// #906 key-free existing Codex inventory. Identical public contract in each package.
|
|
2
|
+
export function validCodexInventory(v: any): boolean {
|
|
3
|
+
const fields = (o: any, allowed: string[]) => o && typeof o === 'object' && !Array.isArray(o)
|
|
4
|
+
&& Object.keys(o).every(k => allowed.includes(k));
|
|
5
|
+
const id = (s: any) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
|
|
6
|
+
const nullable = (s: any) => s === null || id(s);
|
|
7
|
+
if (!fields(v, ['observed_at', 'scope', 'rows', 'installation']) || !Number.isSafeInteger(v.observed_at) || v.observed_at <= 0
|
|
8
|
+
|| v.scope !== 'hub_bound_nodes' || !Array.isArray(v.rows) || v.rows.length > 200
|
|
9
|
+
|| new Set(v.rows.map((r: any) => r?.node_id)).size !== v.rows.length) return false;
|
|
10
|
+
if (v.installation !== undefined && (!fields(v.installation, ['status', 'version'])
|
|
11
|
+
|| !['found', 'missing', 'unknown'].includes(v.installation.status)
|
|
12
|
+
|| !(v.installation.version === null || (typeof v.installation.version === 'string' && /^\d+\.\d+\.\d+(?:[-+.][0-9A-Za-z.-]+)?$/.test(v.installation.version) && v.installation.version.length <= 64)))) return false;
|
|
13
|
+
for (const r of v.rows) {
|
|
14
|
+
if (!r || !id(r.node_id) || typeof r.alias !== 'string' || !/^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,99}$/u.test(r.alias)) return false;
|
|
15
|
+
if (['unavailable', 'not_codex'].includes(r.status)) {
|
|
16
|
+
if (!fields(r, ['node_id', 'alias', 'status'])) return false;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (r.status !== 'observed' || !fields(r, ['node_id', 'alias', 'status', 'runtime', 'home_ref', 'home_source',
|
|
20
|
+
'config_status', 'configured_provider', 'configured_model', 'provider_ids', 'auth_kind', 'credential_status',
|
|
21
|
+
'account_fingerprint', 'verification', 'effective_state', 'node_configured_model', 'config_revision'])) return false;
|
|
22
|
+
if (r.config_revision !== undefined && r.config_revision !== null && (typeof r.config_revision !== 'string' || !/^[a-f0-9]{64}$/.test(r.config_revision))) return false;
|
|
23
|
+
if (!nullable(r.runtime) || !/^[a-f0-9]{16}$/.test(r.home_ref)
|
|
24
|
+
|| !['host_default', 'config.codexHome', 'node-codex-home', 'config.env'].includes(r.home_source)
|
|
25
|
+
|| !['read', 'missing', 'unreadable'].includes(r.config_status) || !nullable(r.configured_provider)
|
|
26
|
+
|| !nullable(r.configured_model) || !nullable(r.node_configured_model)
|
|
27
|
+
|| !Array.isArray(r.provider_ids) || r.provider_ids.length > 101 || !r.provider_ids.every(id)
|
|
28
|
+
|| !['unknown', 'chatgpt', 'api_key'].includes(r.auth_kind)
|
|
29
|
+
|| !['unknown', 'present', 'unreadable'].includes(r.credential_status)
|
|
30
|
+
|| !(r.account_fingerprint === null || /^[a-f0-9]{16}$/.test(r.account_fingerprint))
|
|
31
|
+
|| r.verification !== 'not_checked' || r.effective_state !== 'not_checked') return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// #906 public Provider snapshot contract. Mirrored across separately published
|
|
2
|
+
// server/agent-node packages; drift is tested. No secret-shaped extension fields.
|
|
3
|
+
import { validCodexInventory } from './codex-inventory.js';
|
|
4
|
+
export function validateDaemonProviderSnapshot(value: unknown, networkId: string, daemonId: string): unknown {
|
|
5
|
+
const fail = (): never => { throw new Error("invalid_provider_snapshot"); };
|
|
6
|
+
const obj = (v: any, fields: string[]) => {
|
|
7
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(v).some(k => !fields.includes(k))) fail();
|
|
8
|
+
return v;
|
|
9
|
+
};
|
|
10
|
+
const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v);
|
|
11
|
+
const unique = (xs: any[], key: string) => { if (new Set(xs.map(x => x[key])).size !== xs.length) fail(); };
|
|
12
|
+
const arr = (v: any, max: number, empty = false): any[] => {
|
|
13
|
+
if (!Array.isArray(v) || (!empty && !v.length) || v.length > max) fail();
|
|
14
|
+
return v;
|
|
15
|
+
};
|
|
16
|
+
const s = obj(value, ["network_id", "daemon_node_id", "revision", "source", "providers", "codex_inventory"]);
|
|
17
|
+
if (s.codex_inventory !== undefined && !validCodexInventory(s.codex_inventory)) fail();
|
|
18
|
+
if (s.network_id !== networkId || s.daemon_node_id !== daemonId || s.source !== "daemon"
|
|
19
|
+
|| !Number.isSafeInteger(s.revision) || s.revision < 0) fail();
|
|
20
|
+
const providers = arr(s.providers, 100, true);
|
|
21
|
+
unique(providers, "id");
|
|
22
|
+
for (const p of providers) {
|
|
23
|
+
obj(p, ["id", "label", "enabled", "runtimes"]);
|
|
24
|
+
if (!id(p.id) || typeof p.label !== "string" || !p.label.trim() || p.label.length > 100
|
|
25
|
+
|| /[\x00-\x1f\x7f]/.test(p.label) || typeof p.enabled !== "boolean") fail();
|
|
26
|
+
const runtimes = arr(p.runtimes, 8); unique(runtimes, "runtime");
|
|
27
|
+
for (const r of runtimes) {
|
|
28
|
+
obj(r, ["runtime", "auth"]); if (r.runtime !== "codex-tui") fail();
|
|
29
|
+
const auths = arr(r.auth, 32); unique(auths, "id");
|
|
30
|
+
for (const a of auths) {
|
|
31
|
+
obj(a, ["id", "kind", "models", "baseUrl", "credential_present", "verification", "application"]);
|
|
32
|
+
if (!id(a.id) || a.verification !== "not_checked" || a.application !== "not_applied") fail();
|
|
33
|
+
const models = arr(a.models, 200);
|
|
34
|
+
if (new Set(models).size !== models.length || models.some(m => typeof m !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(m))) fail();
|
|
35
|
+
if (a.kind === "chatgpt") {
|
|
36
|
+
if (p.id !== "openai" || a.baseUrl !== undefined || a.credential_present !== false) fail();
|
|
37
|
+
} else if (a.kind === "api_key") {
|
|
38
|
+
if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
|
|
39
|
+
let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
|
|
40
|
+
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) fail();
|
|
41
|
+
} else fail();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// JSON roundtrip detaches the validated object from mutable caller references.
|
|
46
|
+
const encoded = JSON.stringify(s);
|
|
47
|
+
if (new TextEncoder().encode(encoded).byteLength > 1024 * 1024) fail();
|
|
48
|
+
return JSON.parse(encoded);
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// #906 public application protocol. No paths, endpoints or credentials.
|
|
2
|
+
export type ProviderApplicationSelection = {
|
|
3
|
+
node_id: string; node_revision: string; provider_id: string; auth_id: string;
|
|
4
|
+
model: string; confirm_restart: true;
|
|
5
|
+
};
|
|
6
|
+
const fields = (v: any, names: string[]) => v && typeof v === 'object' && !Array.isArray(v)
|
|
7
|
+
&& Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k));
|
|
8
|
+
const id = (v: any) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(v);
|
|
9
|
+
const hash = (v: any) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
10
|
+
export function validProviderApplicationSelection(v: any): v is ProviderApplicationSelection {
|
|
11
|
+
return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart'])
|
|
12
|
+
&& id(v.node_id) && hash(v.node_revision) && id(v.provider_id) && id(v.auth_id) && id(v.model)
|
|
13
|
+
&& v.confirm_restart === true;
|
|
14
|
+
}
|
|
15
|
+
export function validProviderApplicationReceipt(v: any, requestId: string, revision: number,
|
|
16
|
+
selection: ProviderApplicationSelection): boolean {
|
|
17
|
+
return fields(v, ['status', 'request_id', 'node_id', 'provider_id', 'auth_id', 'model', 'runtime_provider',
|
|
18
|
+
'node_revision', 'provider_revision', 'verification', 'applied_at'])
|
|
19
|
+
&& v.status === 'applied' && v.request_id === requestId && v.node_id === selection.node_id
|
|
20
|
+
&& v.provider_id === selection.provider_id && v.auth_id === selection.auth_id && v.model === selection.model
|
|
21
|
+
&& typeof v.runtime_provider === 'string' && /^anet_[a-f0-9]{20}$/.test(v.runtime_provider)
|
|
22
|
+
&& hash(v.node_revision) && v.provider_revision === revision && v.verification === 'runtime_confirmed'
|
|
23
|
+
&& Number.isSafeInteger(v.applied_at) && v.applied_at > 0 && v.applied_at <= Date.now() + 30_000;
|
|
24
|
+
}
|
|
@@ -197,7 +197,19 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
|
197
197
|
// user 58 / 56,950 B → 57,163 B (+213). start_node adds 213 B, report_status 169 B;
|
|
198
198
|
// hidden pull/ack schemas account for the remaining 948 B. Keep strict receipt validation
|
|
199
199
|
// and unchanged audiences; budget only this measured addition (~130/520 B node/all margin).
|
|
200
|
-
|
|
200
|
+
// Board #654 adds report_status adoption_candidates (node-only schema, no new tool).
|
|
201
|
+
// Same tree, field removed → restored: all 87 / 79,078 B → 79,591 B (+513);
|
|
202
|
+
// node 61 / 62,433 B → 62,946 B (+513); user 58 / 57,228 B unchanged
|
|
203
|
+
// (report_status is not on the user list). Budget only this measured addition
|
|
204
|
+
// (~154/509 B node/all margin).
|
|
205
|
+
// #906 registers daemon_provider_snapshot (user) plus pull/ack (protocol; omitted
|
|
206
|
+
// from the default node and user lists). Measured on this tree after the
|
|
207
|
+
// description trims already in the change: all 90 / 80,307 B; node 61 / 62,556 B;
|
|
208
|
+
// user 59 / 57,372 B. Node stays under 63,100 (544 B) and user under 57,500 (128 B).
|
|
209
|
+
// `all` is 207 B over 80,100. The two protocol descriptions are 179 B together, so
|
|
210
|
+
// shortening them cannot pay for their schemas. Raise only `all` to 80,800
|
|
211
|
+
// (493 B margin, same order as the previous all margin).
|
|
212
|
+
const CEILINGS = { node: 63_100, user: 57_500, all: 80_800 } as const;
|
|
201
213
|
test("node / user / all", async () => {
|
|
202
214
|
const node = await listTools(T.node_normal);
|
|
203
215
|
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
|
+
daemon_provider_snapshot: "user",
|
|
27
|
+
pull_daemon_provider_snapshots: "protocol", ack_daemon_provider_snapshot: "protocol",
|
|
26
28
|
request_adopt_node: "user", unadopt_node: "user",
|
|
27
29
|
get_adopt_request: "protocol", ack_adopt_request: "protocol",
|
|
28
30
|
// ── 节点:用户令牌一律 network_token_required ──
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
2
3
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
3
4
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
5
|
+
import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
|
|
4
6
|
import { forkRecoveryRequestSchema, forkRecoveryResultSchema, publicForkResult } from "./codex-fork-contract.js";
|
|
5
7
|
import { lockNodeAlias } from "./node-token-ownership.js";
|
|
6
8
|
import { clearNodeFromAgentTeams, agentTeamWhoami } from "./agent-teams.js";
|
|
@@ -39,6 +41,7 @@ import {
|
|
|
39
41
|
FLAG_KEYS,
|
|
40
42
|
validateFlagValue,
|
|
41
43
|
validateFlagsForRuntime,
|
|
44
|
+
validateOpenCodeV2ProviderModel,
|
|
42
45
|
ValidationError,
|
|
43
46
|
} from "./create-node-validate.js";
|
|
44
47
|
import {
|
|
@@ -843,6 +846,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
843
846
|
daemon_capabilities: z.object({
|
|
844
847
|
codex_fork_recovery: z.object({ protocol: z.literal(1), cli_supported: z.boolean() }).optional().catch(undefined),
|
|
845
848
|
adopt_capable: z.boolean().optional(),
|
|
849
|
+
adoption_candidates: adoptionCandidatesReportSchema,
|
|
846
850
|
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
847
851
|
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
848
852
|
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
@@ -3196,7 +3200,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3196
3200
|
callerNetworkId: string | null,
|
|
3197
3201
|
): { row: any | null; sec1Ok: boolean } => {
|
|
3198
3202
|
const row = db.get<any>(
|
|
3199
|
-
"SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
3203
|
+
"SELECT node_id, alias, network_id, runtime, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
3200
3204
|
nodeId,
|
|
3201
3205
|
);
|
|
3202
3206
|
if (!row) return { row: null, sec1Ok: false };
|
|
@@ -3326,7 +3330,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3326
3330
|
};
|
|
3327
3331
|
}
|
|
3328
3332
|
|
|
3329
|
-
const validationFail = validatePatch(model, flags, channels);
|
|
3333
|
+
const validationFail = validatePatch(model, flags, channels, node.runtime);
|
|
3330
3334
|
if (validationFail) {
|
|
3331
3335
|
return {
|
|
3332
3336
|
content: [{
|
|
@@ -4113,6 +4117,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4113
4117
|
const resolveCallerDaemonTokenBound = () =>
|
|
4114
4118
|
_resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
4115
4119
|
|
|
4120
|
+
const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
|
|
4121
|
+
const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
|
4122
|
+
server.tool(
|
|
4123
|
+
"daemon_provider_snapshot", "Admin Provider config. refresh/put/apply: id=daemon; read: id=request. put: revision/provider; apply: revision/provider={node_id,node_revision,provider_id,auth_id,model,confirm_restart:true}.",
|
|
4124
|
+
{ action: z.enum(["refresh", "read", "put", "apply"]), id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), revision: z.number().int().min(0).optional(), provider: z.unknown().optional() },
|
|
4125
|
+
async ({ action, id, network_id, revision, provider }) => {
|
|
4126
|
+
const human = providerHuman(network_id);
|
|
4127
|
+
if (action === "read") return providerReply(getProviderSnapshot(human, id));
|
|
4128
|
+
const daemon_node_id = id;
|
|
4129
|
+
const result = action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
|
|
4130
|
+
: action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
|
|
4131
|
+
if (result.ok) {
|
|
4132
|
+
const target = db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2", daemon_node_id, human.networkId!);
|
|
4133
|
+
if (target) pushEvent(target.alias, { type: "daemon_provider_snapshot", request_id: result.request_id }, human.networkId!);
|
|
4134
|
+
}
|
|
4135
|
+
return providerReply(result);
|
|
4136
|
+
},
|
|
4137
|
+
);
|
|
4138
|
+
server.tool(
|
|
4139
|
+
"pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/apply capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional() },
|
|
4140
|
+
async ({ accept_writes, accept_applications }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications)),
|
|
4141
|
+
);
|
|
4142
|
+
server.tool(
|
|
4143
|
+
"ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
|
|
4144
|
+
{ request_id: z.string().min(1).max(200), snapshot: z.unknown().optional(), failed: z.boolean().optional(), applied_revision: z.number().int().min(0).optional(), application: z.unknown().optional() },
|
|
4145
|
+
async ({ request_id, snapshot, failed, applied_revision, application }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application)),
|
|
4146
|
+
);
|
|
4147
|
+
|
|
4116
4148
|
// Helper — map a ValidationError thrown from create-node-validate
|
|
4117
4149
|
// into the MCP-tool-call JSON reply shape.
|
|
4118
4150
|
const validationFailReply = (e: unknown) => {
|
|
@@ -4132,7 +4164,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4132
4164
|
// join on api_tokens.revoked_at.
|
|
4133
4165
|
server.tool(
|
|
4134
4166
|
"list_host_supervisors",
|
|
4135
|
-
"List
|
|
4167
|
+
"List network daemons: status, runtimes, secret-key allowlist and telemetry (member-redacted).",
|
|
4136
4168
|
{
|
|
4137
4169
|
network_id: z.string().max(200).optional(),
|
|
4138
4170
|
},
|
|
@@ -4268,7 +4300,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4268
4300
|
// row (metadata only — no env_blob in SQL), pushEvent doorbell.
|
|
4269
4301
|
server.tool(
|
|
4270
4302
|
"create_node",
|
|
4271
|
-
"Create a node on
|
|
4303
|
+
"Create/start a node on the target daemon; the child reports back to Hub.",
|
|
4272
4304
|
{
|
|
4273
4305
|
daemon_node_id: z.string().min(1).max(200),
|
|
4274
4306
|
node_spec: z.object({
|
|
@@ -4438,6 +4470,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4438
4470
|
validateFlagValue(k, v);
|
|
4439
4471
|
}
|
|
4440
4472
|
validateFlagsForRuntime(node_spec.runtime, node_spec.flags);
|
|
4473
|
+
validateOpenCodeV2ProviderModel(node_spec.runtime, node_spec.model, node_spec.flags);
|
|
4441
4474
|
} catch (e) {
|
|
4442
4475
|
return validationFailReply(e);
|
|
4443
4476
|
}
|
|
@@ -4582,7 +4615,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4582
4615
|
// any still-pending requests bound to its own daemon token.
|
|
4583
4616
|
server.tool(
|
|
4584
4617
|
"list_my_pending_create_requests",
|
|
4585
|
-
"Daemon
|
|
4618
|
+
"Daemon pulls its pending creates after reconnect.",
|
|
4586
4619
|
{},
|
|
4587
4620
|
async () => {
|
|
4588
4621
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
@@ -4614,7 +4647,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4614
4647
|
// 只返回绑定到本 daemon + 本网络的 pending 行。
|
|
4615
4648
|
server.tool(
|
|
4616
4649
|
"list_my_pending_lifecycle_requests",
|
|
4617
|
-
"Daemon
|
|
4650
|
+
"Daemon pulls its pending stop/delete/start requests after reconnect.",
|
|
4618
4651
|
{},
|
|
4619
4652
|
async () => {
|
|
4620
4653
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
@@ -5178,7 +5211,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5178
5211
|
|
|
5179
5212
|
server.tool(
|
|
5180
5213
|
"stop_node",
|
|
5181
|
-
"Stop
|
|
5214
|
+
"Stop child, preserving config. Restart via restart_node. Omitted daemon_node_id resolves from child's create request.",
|
|
5182
5215
|
{
|
|
5183
5216
|
...NODE_ID_ALIAS_FIELDS,
|
|
5184
5217
|
// PR2 prereq: dashboard rarely knows daemon_node_id directly.
|
|
@@ -5407,7 +5440,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5407
5440
|
// consumer of its own, so restart_node's self-doorbell cannot revive it.
|
|
5408
5441
|
server.tool(
|
|
5409
5442
|
"start_node",
|
|
5410
|
-
"Start
|
|
5443
|
+
"Start stopped child; omitted daemon_node_id resolves from its create request.",
|
|
5411
5444
|
{
|
|
5412
5445
|
...NODE_ID_ALIAS_FIELDS,
|
|
5413
5446
|
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
@@ -5629,7 +5662,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5629
5662
|
// token + network.
|
|
5630
5663
|
server.tool(
|
|
5631
5664
|
"list_my_children",
|
|
5632
|
-
"Daemon
|
|
5665
|
+
"Daemon lists child alias/node_id pairs to rebuild its map.",
|
|
5633
5666
|
{},
|
|
5634
5667
|
async () => {
|
|
5635
5668
|
const callerDaemon = resolveCallerDaemonTokenBound(); // board #674: token-bound snapshot, not a lease
|
|
@@ -5675,7 +5708,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5675
5708
|
const adoptionReply = (result: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(result) }] });
|
|
5676
5709
|
server.tool(
|
|
5677
5710
|
"request_adopt_node",
|
|
5678
|
-
"
|
|
5711
|
+
"Owner/admin requests adoption; target daemon verifies locally before binding. No signals.",
|
|
5679
5712
|
{ node_id: z.string().min(1).max(200), daemon_node_id: z.string().min(1).max(200), workdir: z.string().min(1).max(4096) },
|
|
5680
5713
|
async args => adoptionReply(requestAdopt(adoptionHuman(), args)),
|
|
5681
5714
|
);
|
|
@@ -5984,7 +6017,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5984
6017
|
// §2.3.3 — list_providers (viewer+; never returns secret VALUES).
|
|
5985
6018
|
server.tool(
|
|
5986
6019
|
"list_providers",
|
|
5987
|
-
"List
|
|
6020
|
+
"List network Provider/model registry, never secrets. Not daemon inventory.",
|
|
5988
6021
|
{ network_id: z.string().max(200).optional() },
|
|
5989
6022
|
async ({ network_id: clientNetId }) => {
|
|
5990
6023
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
@@ -6011,7 +6044,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6011
6044
|
// §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
|
|
6012
6045
|
server.tool(
|
|
6013
6046
|
"probe_provider_model",
|
|
6014
|
-
"
|
|
6047
|
+
"Admin+ dispatches daemon probe; ephemeral secret pulled via get_probe_request.",
|
|
6015
6048
|
{
|
|
6016
6049
|
provider_id: z.string().min(1).max(200),
|
|
6017
6050
|
model_name: z.string().min(1).max(100),
|
|
@@ -6077,7 +6110,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6077
6110
|
// §2.3.6 — get_probe_results (viewer+; matrix renderer source).
|
|
6078
6111
|
server.tool(
|
|
6079
6112
|
"get_probe_results",
|
|
6080
|
-
"Query probe history
|
|
6113
|
+
"Query probe history filtered by provider/model/daemon.",
|
|
6081
6114
|
{
|
|
6082
6115
|
provider_id: z.string().max(200).optional(),
|
|
6083
6116
|
model_name: z.string().max(100).optional(),
|
|
@@ -6105,7 +6138,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6105
6138
|
// §2.3.7 daemon-facing — get_probe_request.
|
|
6106
6139
|
server.tool(
|
|
6107
6140
|
"get_probe_request",
|
|
6108
|
-
"Daemon pulls
|
|
6141
|
+
"Daemon pulls pending probe on SSE probe_provider.",
|
|
6109
6142
|
{ probe_id: z.string().min(1).max(200) },
|
|
6110
6143
|
async ({ probe_id }) => {
|
|
6111
6144
|
const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|