@sleep2agi/commhub-server 0.9.0-preview.120 → 0.9.0-preview.122
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/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- 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 +359 -0
- package/src/daemon-provider-snapshots.ts +269 -0
- package/src/daemon-token-owner-http.test.ts +3 -1
- package/src/db.ts +45 -0
- package/src/mcp-context-cost-http.test.ts +3 -1
- 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 +3 -1
- 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-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/tool-audience-http.test.ts +15 -2
- package/src/tool-audience.ts +3 -1
- package/src/tools.ts +233 -19
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
|
@@ -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,31 @@
|
|
|
1
|
+
// #906 managed login transport: only explicit actions and secret-free projections.
|
|
2
|
+
export type LoginIntent = { action: 'start' | 'read' | 'cancel' | 'save' | 'accounts'; session_id?: string; label?: string };
|
|
3
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
4
|
+
const object = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const keys = (v: any, names: string[]) => Object.keys(v).every(k => names.includes(k));
|
|
6
|
+
export function validLoginIntent(v: any): v is LoginIntent {
|
|
7
|
+
return object(v) && keys(v, ['action','session_id','label'])
|
|
8
|
+
&& ['start','read','cancel','save','accounts'].includes(v.action)
|
|
9
|
+
&& (v.action === 'accounts' ? v.session_id === undefined : typeof v.session_id === 'string' && uuid.test(v.session_id))
|
|
10
|
+
&& (v.action === 'save' ? typeof v.label === 'string' && !!v.label.trim() && v.label.length <= 100 && !/[\x00-\x1f\x7f]/.test(v.label) : v.label === undefined);
|
|
11
|
+
}
|
|
12
|
+
export function validLoginReceipt(v: any, intent: LoginIntent, network: string, daemon: string): boolean {
|
|
13
|
+
if (!object(v)) return false;
|
|
14
|
+
if (intent.action === 'accounts') return keys(v, ['accounts']) && Array.isArray(v.accounts) && v.accounts.length <= 100
|
|
15
|
+
&& v.accounts.every((a: any) => object(a) && keys(a, ['version','network_id','daemon_node_id','account_id','label','created_at','auth_state'])
|
|
16
|
+
&& a.version === 1 && a.network_id === network && a.daemon_node_id === daemon
|
|
17
|
+
&& typeof a.account_id === 'string' && uuid.test(a.account_id)
|
|
18
|
+
&& typeof a.label === 'string' && a.label.length <= 100
|
|
19
|
+
&& Number.isSafeInteger(a.created_at) && a.auth_state === 'not_checked');
|
|
20
|
+
if (!keys(v, ['request_id','status','error','challenge','account_id']) || v.request_id !== intent.session_id
|
|
21
|
+
|| !['idle','starting','awaiting_user','verifying','authenticated','cancelled','failed'].includes(v.status)) return false;
|
|
22
|
+
if (v.error !== undefined && !['login_start_failed','login_rejected','account_read_failed','login_process_exited','login_expired'].includes(v.error)) return false;
|
|
23
|
+
if (v.account_id !== undefined && (v.status !== 'authenticated' || typeof v.account_id !== 'string' || !uuid.test(v.account_id))) return false;
|
|
24
|
+
if (intent.action === 'cancel' && v.status !== 'cancelled') return false;
|
|
25
|
+
if (intent.action === 'save' && (v.status !== 'authenticated' || !v.account_id)) return false;
|
|
26
|
+
if (v.status === 'awaiting_user') return object(v.challenge) && keys(v.challenge, ['loginId','verificationUrl','userCode'])
|
|
27
|
+
&& typeof v.challenge.loginId === 'string' && uuid.test(v.challenge.loginId)
|
|
28
|
+
&& v.challenge.verificationUrl === 'https://auth.openai.com/codex/device'
|
|
29
|
+
&& typeof v.challenge.userCode === 'string' && /^[A-Z0-9-]{4,32}$/i.test(v.challenge.userCode);
|
|
30
|
+
return v.challenge === undefined;
|
|
31
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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", "account_id", "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 || typeof a.credential_present !== 'boolean') fail();
|
|
37
|
+
if (a.account_id !== undefined && (typeof a.account_id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(a.account_id))) fail();
|
|
38
|
+
if (a.credential_present && !a.account_id) fail();
|
|
39
|
+
} else if (a.kind === "api_key") {
|
|
40
|
+
if (a.account_id !== undefined) fail();
|
|
41
|
+
if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
|
|
42
|
+
let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
|
|
43
|
+
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) fail();
|
|
44
|
+
} else fail();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// JSON roundtrip detaches the validated object from mutable caller references.
|
|
49
|
+
const encoded = JSON.stringify(s);
|
|
50
|
+
if (new TextEncoder().encode(encoded).byteLength > 1024 * 1024) fail();
|
|
51
|
+
return JSON.parse(encoded);
|
|
52
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
account_id?: string;
|
|
6
|
+
};
|
|
7
|
+
const fields = (v: any, names: string[]) => v && typeof v === 'object' && !Array.isArray(v)
|
|
8
|
+
&& Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k));
|
|
9
|
+
const id = (v: any) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(v);
|
|
10
|
+
const hash = (v: any) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
11
|
+
const account = (v: any) => typeof v === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(v);
|
|
12
|
+
export function validProviderApplicationSelection(v: any): v is ProviderApplicationSelection {
|
|
13
|
+
return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart',
|
|
14
|
+
...(v?.account_id !== undefined ? ['account_id'] : [])])
|
|
15
|
+
&& (v.account_id === undefined || (v.provider_id === 'openai' && account(v.account_id)))
|
|
16
|
+
&& id(v.node_id) && hash(v.node_revision) && id(v.provider_id) && id(v.auth_id) && id(v.model)
|
|
17
|
+
&& v.confirm_restart === true;
|
|
18
|
+
}
|
|
19
|
+
export function validProviderApplicationReceipt(v: any, requestId: string, revision: number,
|
|
20
|
+
selection: ProviderApplicationSelection): boolean {
|
|
21
|
+
return fields(v, ['status', 'request_id', 'node_id', 'provider_id', 'auth_id', 'model', 'runtime_provider',
|
|
22
|
+
'node_revision', 'provider_revision', 'verification', 'applied_at', ...(selection.account_id ? ['account_id'] : [])])
|
|
23
|
+
&& v.status === 'applied' && v.request_id === requestId && v.node_id === selection.node_id
|
|
24
|
+
&& v.provider_id === selection.provider_id && v.auth_id === selection.auth_id && v.model === selection.model
|
|
25
|
+
&& (selection.account_id ? v.runtime_provider === 'openai' && v.account_id === selection.account_id
|
|
26
|
+
: typeof v.runtime_provider === 'string' && /^anet_[a-f0-9]{20}$/.test(v.runtime_provider))
|
|
27
|
+
&& hash(v.node_revision) && v.provider_revision === revision && v.verification === 'runtime_confirmed'
|
|
28
|
+
&& Number.isSafeInteger(v.applied_at) && v.applied_at > 0 && v.applied_at <= Date.now() + 30_000;
|
|
29
|
+
}
|
|
@@ -163,7 +163,7 @@ describe("what each caller sees", () => {
|
|
|
163
163
|
|
|
164
164
|
test("node modes: readonly / restricted drop broadcast + node writes + human-only; enforce drops human-only for normal nodes", async () => {
|
|
165
165
|
const ro = new Set((await listTools(T.node_readonly)).names);
|
|
166
|
-
for (const n of ["broadcast", "update_node_config", "create_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
|
|
166
|
+
for (const n of ["broadcast", "update_node_config", "create_node", "clone_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
|
|
167
167
|
for (const n of ["send_task", "requirements_update", "report_status", "get_all_status"]) expect([n, ro.has(n)]).toEqual([n, true]); // 看参数才拒的照列
|
|
168
168
|
expect(new Set((await listTools(T.node_normal)).names).has("upsert_provider")).toBe(true); // log 模式:调得通,照列
|
|
169
169
|
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
@@ -197,7 +197,20 @@ 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).
|
|
206
|
+
// Measured there: all 90 / 80,307 B; node 61 / 62,556 B; user 59 / 57,372 B,
|
|
207
|
+
// ceilings 80,800 / 63,100 / 57,500.
|
|
208
|
+
// Hub #938 adds clone_node (audience both, so it lands on the node list and
|
|
209
|
+
// the user list) plus optional expect_clone on get_create_request (protocol,
|
|
210
|
+
// so only the full list). Measured on this combined tree:
|
|
211
|
+
// all 91 / 81,228 B; node 62 / 63,281 B; user 60 / 58,169 B.
|
|
212
|
+
// Ceilings keep about 400 B of margin and nothing else.
|
|
213
|
+
const CEILINGS = { node: 63_700, user: 58_600, all: 81_700 } as const;
|
|
201
214
|
test("node / user / all", async () => {
|
|
202
215
|
const node = await listTools(T.node_normal);
|
|
203
216
|
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 ──
|
|
@@ -51,7 +53,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
51
53
|
send_desktop_message: "both", broadcast: "both", get_completions: "both",
|
|
52
54
|
update_node_config: "both", read_node_rules_file: "both", write_node_rules_file: "both",
|
|
53
55
|
list_node_skills: "both", read_node_skill: "both", get_rules_file_result: "both",
|
|
54
|
-
restart_node: "both", list_host_supervisors: "both", create_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
|
|
56
|
+
restart_node: "both", list_host_supervisors: "both", create_node: "both", clone_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
|
|
55
57
|
upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
|
|
56
58
|
list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
|
|
57
59
|
requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, requestProviderLogin, 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 {
|
|
@@ -52,6 +55,7 @@ import {
|
|
|
52
55
|
auditCreateNodeStrict,
|
|
53
56
|
resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
|
|
54
57
|
} from "./create-node.js";
|
|
58
|
+
import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
|
|
55
59
|
import {
|
|
56
60
|
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
57
61
|
VaultError,
|
|
@@ -843,6 +847,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
843
847
|
daemon_capabilities: z.object({
|
|
844
848
|
codex_fork_recovery: z.object({ protocol: z.literal(1), cli_supported: z.boolean() }).optional().catch(undefined),
|
|
845
849
|
adopt_capable: z.boolean().optional(),
|
|
850
|
+
adoption_candidates: adoptionCandidatesReportSchema,
|
|
846
851
|
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
847
852
|
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
848
853
|
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
@@ -3196,7 +3201,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3196
3201
|
callerNetworkId: string | null,
|
|
3197
3202
|
): { row: any | null; sec1Ok: boolean } => {
|
|
3198
3203
|
const row = db.get<any>(
|
|
3199
|
-
"SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
3204
|
+
"SELECT node_id, alias, network_id, runtime, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
3200
3205
|
nodeId,
|
|
3201
3206
|
);
|
|
3202
3207
|
if (!row) return { row: null, sec1Ok: false };
|
|
@@ -3326,7 +3331,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3326
3331
|
};
|
|
3327
3332
|
}
|
|
3328
3333
|
|
|
3329
|
-
const validationFail = validatePatch(model, flags, channels);
|
|
3334
|
+
const validationFail = validatePatch(model, flags, channels, node.runtime);
|
|
3330
3335
|
if (validationFail) {
|
|
3331
3336
|
return {
|
|
3332
3337
|
content: [{
|
|
@@ -4113,6 +4118,35 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4113
4118
|
const resolveCallerDaemonTokenBound = () =>
|
|
4114
4119
|
_resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
4115
4120
|
|
|
4121
|
+
const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
|
|
4122
|
+
const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
|
4123
|
+
server.tool(
|
|
4124
|
+
"daemon_provider_snapshot", "Admin Provider: refresh/put/apply/login use daemon id; read uses request id. put: revision+provider; apply: revision+node selection; login: provider={action:start|read|cancel|save|accounts,session_id?,label?}. No login tokens.",
|
|
4125
|
+
{ action: z.enum(["refresh", "read", "put", "apply", "login"]), 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() },
|
|
4126
|
+
async ({ action, id, network_id, revision, provider }) => {
|
|
4127
|
+
const human = providerHuman(network_id);
|
|
4128
|
+
if (action === "read") return providerReply(getProviderSnapshot(human, id));
|
|
4129
|
+
const daemon_node_id = id;
|
|
4130
|
+
const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
|
|
4131
|
+
: action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
|
|
4132
|
+
: action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
|
|
4133
|
+
if (result.ok) {
|
|
4134
|
+
const target = db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2", daemon_node_id, human.networkId!);
|
|
4135
|
+
if (target) pushEvent(target.alias, { type: "daemon_provider_snapshot", request_id: result.request_id }, human.networkId!);
|
|
4136
|
+
}
|
|
4137
|
+
return providerReply(result);
|
|
4138
|
+
},
|
|
4139
|
+
);
|
|
4140
|
+
server.tool(
|
|
4141
|
+
"pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional() },
|
|
4142
|
+
async ({ accept_writes, accept_applications, accept_logins }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins)),
|
|
4143
|
+
);
|
|
4144
|
+
server.tool(
|
|
4145
|
+
"ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
|
|
4146
|
+
{ 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(), login: z.unknown().optional() },
|
|
4147
|
+
async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
|
|
4148
|
+
);
|
|
4149
|
+
|
|
4116
4150
|
// Helper — map a ValidationError thrown from create-node-validate
|
|
4117
4151
|
// into the MCP-tool-call JSON reply shape.
|
|
4118
4152
|
const validationFailReply = (e: unknown) => {
|
|
@@ -4132,7 +4166,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4132
4166
|
// join on api_tokens.revoked_at.
|
|
4133
4167
|
server.tool(
|
|
4134
4168
|
"list_host_supervisors",
|
|
4135
|
-
"List
|
|
4169
|
+
"List network daemons: status, runtimes, secret-key allowlist and telemetry (member-redacted).",
|
|
4136
4170
|
{
|
|
4137
4171
|
network_id: z.string().max(200).optional(),
|
|
4138
4172
|
},
|
|
@@ -4268,7 +4302,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4268
4302
|
// row (metadata only — no env_blob in SQL), pushEvent doorbell.
|
|
4269
4303
|
server.tool(
|
|
4270
4304
|
"create_node",
|
|
4271
|
-
"Create a node on
|
|
4305
|
+
"Create/start a node on the target daemon; the child reports back to Hub.",
|
|
4272
4306
|
{
|
|
4273
4307
|
daemon_node_id: z.string().min(1).max(200),
|
|
4274
4308
|
node_spec: z.object({
|
|
@@ -4438,6 +4472,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4438
4472
|
validateFlagValue(k, v);
|
|
4439
4473
|
}
|
|
4440
4474
|
validateFlagsForRuntime(node_spec.runtime, node_spec.flags);
|
|
4475
|
+
validateOpenCodeV2ProviderModel(node_spec.runtime, node_spec.model, node_spec.flags);
|
|
4441
4476
|
} catch (e) {
|
|
4442
4477
|
return validationFailReply(e);
|
|
4443
4478
|
}
|
|
@@ -4577,12 +4612,162 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4577
4612
|
},
|
|
4578
4613
|
);
|
|
4579
4614
|
|
|
4615
|
+
// Hub #938 — clone a child that already lives on a daemon into a new
|
|
4616
|
+
// node on THAT daemon. New alias, new ntok_. Session copy is done by
|
|
4617
|
+
// the daemon from the source config on disk; this tool only records
|
|
4618
|
+
// the policy and the runtime's session capability (see clone-node.ts).
|
|
4619
|
+
server.tool(
|
|
4620
|
+
"clone_node",
|
|
4621
|
+
"Clone a daemon child into a new node on the same daemon. New name and new token. copy_session defaults to true. workdir_policy new_empty (default) or share_source. Hub #938.",
|
|
4622
|
+
{
|
|
4623
|
+
source_node_id: z.string().min(1).max(200),
|
|
4624
|
+
name: z.string().min(1).max(256),
|
|
4625
|
+
copy_session: z.boolean().optional(),
|
|
4626
|
+
workdir_policy: z.enum(["new_empty", "share_source"]).optional(),
|
|
4627
|
+
daemon_node_id: z.string().min(1).max(200).optional(),
|
|
4628
|
+
network_id: z.string().max(200).optional(),
|
|
4629
|
+
},
|
|
4630
|
+
async ({ source_node_id, name, copy_session, workdir_policy, daemon_node_id, network_id: clientNetId }) => {
|
|
4631
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4632
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
4633
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
4634
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
4635
|
+
: null;
|
|
4636
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
4637
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
|
|
4638
|
+
}
|
|
4639
|
+
const { row: source, sec1Ok } = resolveTargetNode(source_node_id, effectiveNetId);
|
|
4640
|
+
if (!source) {
|
|
4641
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: source_node_id }) }] };
|
|
4642
|
+
}
|
|
4643
|
+
if (!sec1Ok) {
|
|
4644
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
4645
|
+
}
|
|
4646
|
+
const createRow = db.get<{ daemon_node_id: string; runtime: string; model: string | null; flags_json: string; child_name: string }>(
|
|
4647
|
+
`SELECT daemon_node_id, runtime, model, flags_json, child_name
|
|
4648
|
+
FROM node_create_requests
|
|
4649
|
+
WHERE child_node_id = ?1 AND network_id = ?2 AND status = 'succeeded'
|
|
4650
|
+
ORDER BY created_at DESC LIMIT 1`,
|
|
4651
|
+
source_node_id, source.network_id || effectiveNetId || "default",
|
|
4652
|
+
);
|
|
4653
|
+
const net = source.network_id || effectiveNetId || "default";
|
|
4654
|
+
const nameRows = db.all<{ alias: string | null; node_name: string | null }>(
|
|
4655
|
+
`SELECT alias, node_name FROM nodes WHERE network_id = ?1`,
|
|
4656
|
+
net,
|
|
4657
|
+
);
|
|
4658
|
+
const existingNames = nameRows.flatMap((r) => [r.alias, r.node_name].filter((x): x is string => !!x));
|
|
4659
|
+
const owning = createRow?.daemon_node_id ?? "";
|
|
4660
|
+
const inflight = owning
|
|
4661
|
+
? db.all<{ child_name: string }>(
|
|
4662
|
+
`SELECT child_name FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered')`,
|
|
4663
|
+
owning,
|
|
4664
|
+
).map((r) => r.child_name)
|
|
4665
|
+
: [];
|
|
4666
|
+
let policy: ReturnType<typeof parseWorkdirPolicy>;
|
|
4667
|
+
try { policy = parseWorkdirPolicy(workdir_policy); }
|
|
4668
|
+
catch (e) { return validationFailReply(e); }
|
|
4669
|
+
const facts = factsFromRows({
|
|
4670
|
+
node: source,
|
|
4671
|
+
createRow: createRow ?? null,
|
|
4672
|
+
existingNames,
|
|
4673
|
+
inflightNames: inflight,
|
|
4674
|
+
});
|
|
4675
|
+
let decision;
|
|
4676
|
+
try {
|
|
4677
|
+
decision = decideClone(facts, {
|
|
4678
|
+
newName: name,
|
|
4679
|
+
copySession: copy_session !== false,
|
|
4680
|
+
workdirPolicy: policy,
|
|
4681
|
+
requestedDaemonId: daemon_node_id ?? null,
|
|
4682
|
+
});
|
|
4683
|
+
} catch (e) {
|
|
4684
|
+
return validationFailReply(e);
|
|
4685
|
+
}
|
|
4686
|
+
if (!decision.ok) {
|
|
4687
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: decision.error, ...decision.detail }) }] };
|
|
4688
|
+
}
|
|
4689
|
+
const { row: daemon, sec1Ok: daemonOk } = resolveTargetNode(decision.daemonNodeId, effectiveNetId);
|
|
4690
|
+
if (!daemon || !daemonOk) {
|
|
4691
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id: decision.daemonNodeId }) }] };
|
|
4692
|
+
}
|
|
4693
|
+
if (!enforceUserId) {
|
|
4694
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
|
|
4695
|
+
}
|
|
4696
|
+
const childToken = generateNetworkToken();
|
|
4697
|
+
const childTokenId = generateId("tok");
|
|
4698
|
+
const requestId = newRequestId();
|
|
4699
|
+
const networkIdForChild = daemon.network_id || net;
|
|
4700
|
+
const cloneMeta = {
|
|
4701
|
+
source_node_id,
|
|
4702
|
+
source_alias: facts.alias,
|
|
4703
|
+
copy_session: decision.copySession,
|
|
4704
|
+
workdir_policy: decision.workdirPolicy,
|
|
4705
|
+
channels: decision.channels,
|
|
4706
|
+
};
|
|
4707
|
+
db.run(
|
|
4708
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2)`,
|
|
4709
|
+
[childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${decision.name}`, "network", "child", requestId],
|
|
4710
|
+
);
|
|
4711
|
+
putPendingEnvBlob({
|
|
4712
|
+
request_id: requestId,
|
|
4713
|
+
daemon_node_id: decision.daemonNodeId,
|
|
4714
|
+
env_blob: {},
|
|
4715
|
+
child_token: childToken,
|
|
4716
|
+
child_token_id: childTokenId,
|
|
4717
|
+
});
|
|
4718
|
+
db.run(
|
|
4719
|
+
`INSERT INTO node_create_requests
|
|
4720
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, clone_json)
|
|
4721
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, '[]', 'pending', ?8, ?9, ?10, ?11)`,
|
|
4722
|
+
[
|
|
4723
|
+
requestId, decision.daemonNodeId, decision.name, networkIdForChild,
|
|
4724
|
+
decision.runtime, decision.model, JSON.stringify(decision.flags),
|
|
4725
|
+
childTokenId, Date.now(), callerTokenId || "unknown", JSON.stringify(cloneMeta),
|
|
4726
|
+
],
|
|
4727
|
+
);
|
|
4728
|
+
pushEvent(daemon.alias, { type: "clone_node", request_id: requestId }, networkIdForChild);
|
|
4729
|
+
auditCreateNode({
|
|
4730
|
+
action: "clone_node_dispatch_attempted",
|
|
4731
|
+
user_id: enforceUserId,
|
|
4732
|
+
network_id: networkIdForChild,
|
|
4733
|
+
target_id: requestId,
|
|
4734
|
+
detail: {
|
|
4735
|
+
daemon_node_id: decision.daemonNodeId,
|
|
4736
|
+
source_node_id,
|
|
4737
|
+
child_name: decision.name,
|
|
4738
|
+
runtime: decision.runtime,
|
|
4739
|
+
copy_session: decision.copySession,
|
|
4740
|
+
workdir_policy: decision.workdirPolicy,
|
|
4741
|
+
session_mode: decision.sessionContract.mode,
|
|
4742
|
+
},
|
|
4743
|
+
});
|
|
4744
|
+
return {
|
|
4745
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
4746
|
+
ok: true,
|
|
4747
|
+
request_id: requestId,
|
|
4748
|
+
daemon_node_id: decision.daemonNodeId,
|
|
4749
|
+
child_name: decision.name,
|
|
4750
|
+
copy_session: decision.copySession,
|
|
4751
|
+
workdir_policy: decision.workdirPolicy,
|
|
4752
|
+
session_contract: decision.sessionContract,
|
|
4753
|
+
contract: {
|
|
4754
|
+
token: "new ntok_ minted for the clone; the source token is not copied",
|
|
4755
|
+
workdir: decision.workdirPolicy === "new_empty"
|
|
4756
|
+
? "new empty directory under the source workdir's parent (or <daemon cwd>/clone-work when the source has no project workdir)"
|
|
4757
|
+
: "share the source workdir (explicit)",
|
|
4758
|
+
session: decision.sessionContract.detail,
|
|
4759
|
+
},
|
|
4760
|
+
}) }],
|
|
4761
|
+
};
|
|
4762
|
+
},
|
|
4763
|
+
);
|
|
4764
|
+
|
|
4580
4765
|
// #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
|
|
4581
4766
|
// intentionally best-effort; after a daemon connects or reconnects, it pulls
|
|
4582
4767
|
// any still-pending requests bound to its own daemon token.
|
|
4583
4768
|
server.tool(
|
|
4584
4769
|
"list_my_pending_create_requests",
|
|
4585
|
-
"Daemon
|
|
4770
|
+
"Daemon pulls its pending creates after reconnect.",
|
|
4586
4771
|
{},
|
|
4587
4772
|
async () => {
|
|
4588
4773
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
@@ -4595,10 +4780,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4595
4780
|
WHERE daemon_node_id = ?1
|
|
4596
4781
|
AND network_id = ?2
|
|
4597
4782
|
AND status = 'pending'
|
|
4783
|
+
AND COALESCE(clone_json, '') = ''
|
|
4598
4784
|
ORDER BY created_at ASC`,
|
|
4599
4785
|
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4600
4786
|
);
|
|
4601
|
-
|
|
4787
|
+
// Hub #938 — clone rows stay out of `requests` so an older daemon
|
|
4788
|
+
// that replays every pending create does not spawn a blank node.
|
|
4789
|
+
const clone_requests = db.all<{ request_id: string; child_name: string; created_at: number }>(
|
|
4790
|
+
`SELECT request_id, child_name, created_at
|
|
4791
|
+
FROM node_create_requests
|
|
4792
|
+
WHERE daemon_node_id = ?1
|
|
4793
|
+
AND network_id = ?2
|
|
4794
|
+
AND status = 'pending'
|
|
4795
|
+
AND COALESCE(clone_json, '') != ''
|
|
4796
|
+
ORDER BY created_at ASC`,
|
|
4797
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4798
|
+
);
|
|
4799
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4800
|
+
ok: true,
|
|
4801
|
+
count: rows.length,
|
|
4802
|
+
requests: rows,
|
|
4803
|
+
clone_count: clone_requests.length,
|
|
4804
|
+
clone_requests,
|
|
4805
|
+
}) }] };
|
|
4602
4806
|
},
|
|
4603
4807
|
);
|
|
4604
4808
|
|
|
@@ -4614,7 +4818,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4614
4818
|
// 只返回绑定到本 daemon + 本网络的 pending 行。
|
|
4615
4819
|
server.tool(
|
|
4616
4820
|
"list_my_pending_lifecycle_requests",
|
|
4617
|
-
"Daemon
|
|
4821
|
+
"Daemon pulls its pending stop/delete/start requests after reconnect.",
|
|
4618
4822
|
{},
|
|
4619
4823
|
async () => {
|
|
4620
4824
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
@@ -4666,21 +4870,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4666
4870
|
// isn't a host_supervisor, reject.
|
|
4667
4871
|
server.tool(
|
|
4668
4872
|
"get_create_request",
|
|
4669
|
-
"Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
|
|
4873
|
+
"Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026. expect_clone pulls a clone_node row.",
|
|
4670
4874
|
{
|
|
4671
4875
|
request_id: z.string().min(1).max(200),
|
|
4876
|
+
expect_clone: z.boolean().optional(),
|
|
4672
4877
|
},
|
|
4673
|
-
async ({ request_id }) => {
|
|
4878
|
+
async ({ request_id, expect_clone }) => {
|
|
4674
4879
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4675
4880
|
if (!callerDaemon.ok) {
|
|
4676
4881
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4677
4882
|
}
|
|
4678
4883
|
|
|
4679
|
-
const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
|
|
4680
|
-
`SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
|
|
4884
|
+
const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string; clone_json: string | null }>(
|
|
4885
|
+
`SELECT request_id, daemon_node_id, status, child_token_id, network_id, clone_json FROM node_create_requests WHERE request_id = ?1`,
|
|
4681
4886
|
request_id,
|
|
4682
4887
|
);
|
|
4683
4888
|
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4889
|
+
const isClone = typeof row.clone_json === "string" && row.clone_json.trim().length > 0;
|
|
4890
|
+
// Do not consume the env blob or flip status when the wrong handler asks.
|
|
4891
|
+
if (isClone && expect_clone !== true) {
|
|
4892
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "clone_request" }) }] };
|
|
4893
|
+
}
|
|
4894
|
+
if (!isClone && expect_clone === true) {
|
|
4895
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_a_clone" }) }] };
|
|
4896
|
+
}
|
|
4684
4897
|
// §4.1.4 — strict daemon binding by token-derived node_id.
|
|
4685
4898
|
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4686
4899
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
@@ -4730,6 +4943,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4730
4943
|
},
|
|
4731
4944
|
child_token: blob.child_token,
|
|
4732
4945
|
env_blob: blob.env_blob,
|
|
4946
|
+
...(isClone ? { clone: JSON.parse(row.clone_json as string) } : {}),
|
|
4733
4947
|
}) }],
|
|
4734
4948
|
};
|
|
4735
4949
|
},
|
|
@@ -5178,7 +5392,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5178
5392
|
|
|
5179
5393
|
server.tool(
|
|
5180
5394
|
"stop_node",
|
|
5181
|
-
"Stop
|
|
5395
|
+
"Stop child, preserving config. Restart via restart_node. Omitted daemon_node_id resolves from child's create request.",
|
|
5182
5396
|
{
|
|
5183
5397
|
...NODE_ID_ALIAS_FIELDS,
|
|
5184
5398
|
// PR2 prereq: dashboard rarely knows daemon_node_id directly.
|
|
@@ -5407,7 +5621,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5407
5621
|
// consumer of its own, so restart_node's self-doorbell cannot revive it.
|
|
5408
5622
|
server.tool(
|
|
5409
5623
|
"start_node",
|
|
5410
|
-
"Start
|
|
5624
|
+
"Start stopped child; omitted daemon_node_id resolves from its create request.",
|
|
5411
5625
|
{
|
|
5412
5626
|
...NODE_ID_ALIAS_FIELDS,
|
|
5413
5627
|
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
@@ -5629,7 +5843,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5629
5843
|
// token + network.
|
|
5630
5844
|
server.tool(
|
|
5631
5845
|
"list_my_children",
|
|
5632
|
-
"Daemon
|
|
5846
|
+
"Daemon lists child alias/node_id pairs to rebuild its map.",
|
|
5633
5847
|
{},
|
|
5634
5848
|
async () => {
|
|
5635
5849
|
const callerDaemon = resolveCallerDaemonTokenBound(); // board #674: token-bound snapshot, not a lease
|
|
@@ -5675,7 +5889,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5675
5889
|
const adoptionReply = (result: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(result) }] });
|
|
5676
5890
|
server.tool(
|
|
5677
5891
|
"request_adopt_node",
|
|
5678
|
-
"
|
|
5892
|
+
"Owner/admin requests adoption; target daemon verifies locally before binding. No signals.",
|
|
5679
5893
|
{ 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
5894
|
async args => adoptionReply(requestAdopt(adoptionHuman(), args)),
|
|
5681
5895
|
);
|
|
@@ -5984,7 +6198,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5984
6198
|
// §2.3.3 — list_providers (viewer+; never returns secret VALUES).
|
|
5985
6199
|
server.tool(
|
|
5986
6200
|
"list_providers",
|
|
5987
|
-
"List
|
|
6201
|
+
"List network Provider/model registry, never secrets. Not daemon inventory.",
|
|
5988
6202
|
{ network_id: z.string().max(200).optional() },
|
|
5989
6203
|
async ({ network_id: clientNetId }) => {
|
|
5990
6204
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
@@ -6011,7 +6225,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6011
6225
|
// §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
|
|
6012
6226
|
server.tool(
|
|
6013
6227
|
"probe_provider_model",
|
|
6014
|
-
"
|
|
6228
|
+
"Admin+ dispatches daemon probe; ephemeral secret pulled via get_probe_request.",
|
|
6015
6229
|
{
|
|
6016
6230
|
provider_id: z.string().min(1).max(200),
|
|
6017
6231
|
model_name: z.string().min(1).max(100),
|
|
@@ -6077,7 +6291,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6077
6291
|
// §2.3.6 — get_probe_results (viewer+; matrix renderer source).
|
|
6078
6292
|
server.tool(
|
|
6079
6293
|
"get_probe_results",
|
|
6080
|
-
"Query probe history
|
|
6294
|
+
"Query probe history filtered by provider/model/daemon.",
|
|
6081
6295
|
{
|
|
6082
6296
|
provider_id: z.string().max(200).optional(),
|
|
6083
6297
|
model_name: z.string().max(100).optional(),
|
|
@@ -6105,7 +6319,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6105
6319
|
// §2.3.7 daemon-facing — get_probe_request.
|
|
6106
6320
|
server.tool(
|
|
6107
6321
|
"get_probe_request",
|
|
6108
|
-
"Daemon pulls
|
|
6322
|
+
"Daemon pulls pending probe on SSE probe_provider.",
|
|
6109
6323
|
{ probe_id: z.string().min(1).max(200) },
|
|
6110
6324
|
async ({ probe_id }) => {
|
|
6111
6325
|
const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|