@sleep2agi/commhub-server 0.9.0-preview.121 → 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/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/daemon-provider-snapshots.test.ts +103 -0
- package/src/daemon-provider-snapshots.ts +49 -6
- package/src/daemon-token-owner-http.test.ts +3 -1
- package/src/db.ts +14 -0
- package/src/mcp-context-cost-http.test.ts +3 -1
- package/src/node-permissions.ts +1 -1
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +5 -2
- package/src/shared/provider-application.ts +8 -3
- package/src/tool-audience-http.test.ts +10 -9
- package/src/tool-audience.ts +1 -1
- package/src/tools.ts +194 -13
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
2
|
+
import { requestProviderSnapshot, requestProviderWrite, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
3
3
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
4
4
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
5
5
|
import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
auditCreateNodeStrict,
|
|
56
56
|
resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
|
|
57
57
|
} from "./create-node.js";
|
|
58
|
+
import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
|
|
58
59
|
import {
|
|
59
60
|
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
60
61
|
VaultError,
|
|
@@ -4120,13 +4121,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4120
4121
|
const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
|
|
4121
4122
|
const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
|
4122
4123
|
server.tool(
|
|
4123
|
-
"daemon_provider_snapshot", "Admin Provider
|
|
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() },
|
|
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() },
|
|
4125
4126
|
async ({ action, id, network_id, revision, provider }) => {
|
|
4126
4127
|
const human = providerHuman(network_id);
|
|
4127
4128
|
if (action === "read") return providerReply(getProviderSnapshot(human, id));
|
|
4128
4129
|
const daemon_node_id = id;
|
|
4129
|
-
const result = action === '
|
|
4130
|
+
const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
|
|
4131
|
+
: action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
|
|
4130
4132
|
: action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
|
|
4131
4133
|
if (result.ok) {
|
|
4132
4134
|
const target = db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2", daemon_node_id, human.networkId!);
|
|
@@ -4136,13 +4138,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4136
4138
|
},
|
|
4137
4139
|
);
|
|
4138
4140
|
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
|
+
"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)),
|
|
4141
4143
|
);
|
|
4142
4144
|
server.tool(
|
|
4143
4145
|
"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
|
+
{ 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)),
|
|
4146
4148
|
);
|
|
4147
4149
|
|
|
4148
4150
|
// Helper — map a ValidationError thrown from create-node-validate
|
|
@@ -4610,6 +4612,156 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4610
4612
|
},
|
|
4611
4613
|
);
|
|
4612
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
|
+
|
|
4613
4765
|
// #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
|
|
4614
4766
|
// intentionally best-effort; after a daemon connects or reconnects, it pulls
|
|
4615
4767
|
// any still-pending requests bound to its own daemon token.
|
|
@@ -4628,10 +4780,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4628
4780
|
WHERE daemon_node_id = ?1
|
|
4629
4781
|
AND network_id = ?2
|
|
4630
4782
|
AND status = 'pending'
|
|
4783
|
+
AND COALESCE(clone_json, '') = ''
|
|
4631
4784
|
ORDER BY created_at ASC`,
|
|
4632
4785
|
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4633
4786
|
);
|
|
4634
|
-
|
|
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
|
+
}) }] };
|
|
4635
4806
|
},
|
|
4636
4807
|
);
|
|
4637
4808
|
|
|
@@ -4699,21 +4870,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4699
4870
|
// isn't a host_supervisor, reject.
|
|
4700
4871
|
server.tool(
|
|
4701
4872
|
"get_create_request",
|
|
4702
|
-
"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.",
|
|
4703
4874
|
{
|
|
4704
4875
|
request_id: z.string().min(1).max(200),
|
|
4876
|
+
expect_clone: z.boolean().optional(),
|
|
4705
4877
|
},
|
|
4706
|
-
async ({ request_id }) => {
|
|
4878
|
+
async ({ request_id, expect_clone }) => {
|
|
4707
4879
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4708
4880
|
if (!callerDaemon.ok) {
|
|
4709
4881
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4710
4882
|
}
|
|
4711
4883
|
|
|
4712
|
-
const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
|
|
4713
|
-
`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`,
|
|
4714
4886
|
request_id,
|
|
4715
4887
|
);
|
|
4716
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
|
+
}
|
|
4717
4897
|
// §4.1.4 — strict daemon binding by token-derived node_id.
|
|
4718
4898
|
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4719
4899
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
@@ -4763,6 +4943,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4763
4943
|
},
|
|
4764
4944
|
child_token: blob.child_token,
|
|
4765
4945
|
env_blob: blob.env_blob,
|
|
4946
|
+
...(isClone ? { clone: JSON.parse(row.clone_json as string) } : {}),
|
|
4766
4947
|
}) }],
|
|
4767
4948
|
};
|
|
4768
4949
|
},
|