@sleep2agi/commhub-server 0.9.0-preview.126 → 0.9.0-preview.127
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.127",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -7,7 +7,7 @@ import { db } from './db';
|
|
|
7
7
|
import { registerTools } from './tools';
|
|
8
8
|
import { vaultGet } from './vault';
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
-
import { reconcileProviderSnapshots } from '../../agent-node/src/runtime/daemon-provider-snapshot';
|
|
10
|
+
import { reconcileProviderSnapshots, secureProviderTransport } from '../../agent-node/src/runtime/daemon-provider-snapshot';
|
|
11
11
|
import { DaemonProviderStore } from '../../agent-network/src/daemon-provider-store';
|
|
12
12
|
const net = 'net_hub907', user = 'u_hub907', daemonId = 'n_hub907', alias = 'daemon907', token = 'tok_hub907';
|
|
13
13
|
const root = mkdtempSync(join(tmpdir(), 'hub907-')), key = 'TEST-ONLY-shared-provider-key';
|
|
@@ -39,6 +39,29 @@ function client(kind = 'human') {
|
|
|
39
39
|
const provider = () => ({ id: 'shared', label: 'Shared model', enabled: true, protocol: 'openai-responses', baseUrl: 'https://provider.example/v1', models: ['fixture-model'], key });
|
|
40
40
|
const save = (human: ReturnType<typeof client>, value = provider(), revision = 0) => human('daemon_provider_snapshot', { action: 'hub_put', id: value.id, network_id: net, revision, provider: value });
|
|
41
41
|
const bind = (human: ReturnType<typeof client>, target = daemonId, revision = 1) => human('daemon_provider_snapshot', { action: 'hub_bind', network_id: net, id: 'shared', revision, provider: { daemon_node_id: target, daemon_revision: 0, confirm: true } });
|
|
42
|
+
test('HTTP Hub and API endpoint support save, explicit bind and exact private-store readback', async () => {
|
|
43
|
+
expect(secureProviderTransport('http://hub907.example.test')).toBe(true);
|
|
44
|
+
expect(secureProviderTransport('ftp://hub907.example.test')).toBe(false);
|
|
45
|
+
expect(secureProviderTransport('http://user:password@hub907.example.test')).toBe(false);
|
|
46
|
+
const human = client(), daemon = client('daemon');
|
|
47
|
+
const config = { ...provider(), baseUrl: 'http://provider.example/v1' };
|
|
48
|
+
const saved = await save(human, config); expect(saved.ok).toBe(true);
|
|
49
|
+
expect(saved.provider.baseUrl).toBe(config.baseUrl);
|
|
50
|
+
const queued = await bind(human); expect(queued.ok).toBe(true);
|
|
51
|
+
await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId,
|
|
52
|
+
serverUrl: 'http://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
|
|
53
|
+
const receipt = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
54
|
+
expect(receipt.status).toBe('succeeded');
|
|
55
|
+
expect(receipt.snapshot.providers[0].runtimes[0].auth[0].baseUrl).toBe(config.baseUrl);
|
|
56
|
+
expect(JSON.stringify([saved, queued, receipt])).not.toContain(key);
|
|
57
|
+
const store = new DaemonProviderStore(join(root, '.anet/nodes', alias));
|
|
58
|
+
await store.withApiKey({ revision: 1, providerId: queued.provider_reference.daemon_provider_id, authId: 'hub_key', model: 'fixture-model' }, async actual => {
|
|
59
|
+
expect(actual.baseUrl).toBe(config.baseUrl); expect(actual.key).toBe(key);
|
|
60
|
+
}); // Exact synthetic key in local callback, no external model request.
|
|
61
|
+
for (const baseUrl of ['ftp://provider.example/v1', 'http://user:pass@provider.example/v1', 'http://provider.example/v1?key=secret']) {
|
|
62
|
+
expect(await save(human, { ...provider(), id: 'invalid', baseUrl })).toMatchObject({ ok: false, error: 'invalid_hub_provider' });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
42
65
|
test('existing names-only vault key → Provider reference → actual daemon store, without re-entering key', async () => {
|
|
43
66
|
const human = client(), daemon = client('daemon');
|
|
44
67
|
expect(await human('upsert_network_secret', { network_id: net, key: 'SHARED_API_KEY', value: key })).toMatchObject({ ok: true });
|
|
@@ -24,7 +24,7 @@ function validConfig(value: any): value is Config {
|
|
|
24
24
|
|| !Array.isArray(value.models) || !value.models.length || value.models.length > 200
|
|
25
25
|
|| new Set(value.models).size !== value.models.length
|
|
26
26
|
|| value.models.some((m: unknown) => typeof m !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(m))) return false;
|
|
27
|
-
try { const u = new URL(value.baseUrl); return
|
|
27
|
+
try { const u = new URL(value.baseUrl); return ['https:', 'http:'].includes(u.protocol) && !u.username && !u.password && !u.search && !u.hash; }
|
|
28
28
|
catch { return false; }
|
|
29
29
|
}
|
|
30
30
|
type Row = { revision: number; config_json: string; secret_key_ref: string; updated_at: number };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Short-lived management lane, not task/message text and not a secret archive.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { db } from './db';
|
|
4
|
+
import { getUserNetworkRole } from './auth';
|
|
5
|
+
import { providerScanTargets } from './daemon-provider-snapshots';
|
|
6
|
+
import { validScopedSecretKey } from './shared/scoped-secret-contract';
|
|
7
|
+
type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
|
|
8
|
+
type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
|
|
9
|
+
type Request = { request_id: string; network_id: string; daemon_node_id: string; node_id: string | null;
|
|
10
|
+
action: 'list' | 'put' | 'delete'; key?: string; value?: string; revision?: number;
|
|
11
|
+
status: 'pending' | 'succeeded' | 'failed' | 'expired'; expires_at: number; snapshot?: any; error?: string };
|
|
12
|
+
const requests = new Map<string, Request>();
|
|
13
|
+
const denied = (error: string) => ({ ok: false as const, error });
|
|
14
|
+
function authorize(h: Human) {
|
|
15
|
+
return h.isNode || !h.userId ? denied('user_token_required') : !h.networkId
|
|
16
|
+
|| !['owner','admin'].includes(getUserNetworkRole(h.userId, h.networkId) ?? '') ? denied('secret_admin_required') : null;
|
|
17
|
+
}
|
|
18
|
+
function daemonSupported(network: string, daemon: string) {
|
|
19
|
+
const row = db.get<{ config_snapshot: string }>('SELECT config_snapshot FROM nodes WHERE network_id=?1 AND node_id=?2', network, daemon);
|
|
20
|
+
try { const s = JSON.parse(row?.config_snapshot ?? '{}'); return s.role === 'host_supervisor' && s.scoped_secrets_capable === true; } catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
function expire() {
|
|
23
|
+
for (const [id,r] of requests) {
|
|
24
|
+
if (r.expires_at < Date.now() && r.status === 'pending') { r.status = 'expired'; delete r.value; }
|
|
25
|
+
if (r.expires_at + 600_000 < Date.now()) requests.delete(id);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const timer = setInterval(expire, 10_000); timer.unref();
|
|
29
|
+
export function requestScopedSecrets(h: Human, daemon: string, node: string | null, action: Request['action'], key?: string, value?: string, revision?: number) {
|
|
30
|
+
const refusal = authorize(h); if (refusal) return refusal;
|
|
31
|
+
if (!daemonSupported(h.networkId!, daemon)) return denied('scoped_secrets_upgrade_required');
|
|
32
|
+
if (node !== null && !providerScanTargets(h.networkId!, daemon).some(t => t.node_id === node)) return denied('secret_target_not_bound');
|
|
33
|
+
if (action !== 'list' && (!validScopedSecretKey(key)
|
|
34
|
+
|| !Number.isSafeInteger(revision) || revision! < 0 || (action === 'put' && (typeof value !== 'string' || !value || Buffer.byteLength(value) > 8192 || value.includes('\0'))))) return denied('invalid_secret_input');
|
|
35
|
+
expire();
|
|
36
|
+
if ([...requests.values()].some(r => r.network_id === h.networkId && r.daemon_node_id === daemon && r.status === 'pending')) return denied('secret_store_busy');
|
|
37
|
+
if (requests.size >= 1000) return denied('secret_queue_busy');
|
|
38
|
+
const r: Request = { request_id: `dsk_${randomUUID()}`, network_id: h.networkId!, daemon_node_id: daemon,
|
|
39
|
+
node_id: node, action, ...(action === 'list' ? {} : { key, revision, ...(action === 'put' ? { value } : {}) }),
|
|
40
|
+
expires_at: Date.now() + 120_000, status: 'pending' };
|
|
41
|
+
requests.set(r.request_id, r);
|
|
42
|
+
return { ok: true as const, request_id: r.request_id, status: r.status, expires_at: r.expires_at };
|
|
43
|
+
}
|
|
44
|
+
export function getScopedSecrets(h: Human, id: string) {
|
|
45
|
+
const refusal = authorize(h); if (refusal) return refusal; expire();
|
|
46
|
+
const r = requests.get(id);
|
|
47
|
+
if (!r || r.network_id !== h.networkId) return denied('request_not_found');
|
|
48
|
+
return { ok: true as const, request_id: id, daemon_node_id: r.daemon_node_id, node_id: r.node_id,
|
|
49
|
+
status: r.status, ...(r.snapshot ? { snapshot: r.snapshot } : {}), ...(r.error ? { error: r.error } : {}) };
|
|
50
|
+
}
|
|
51
|
+
export function pullScopedSecrets(d: Daemon) {
|
|
52
|
+
if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
|
|
53
|
+
const targets = providerScanTargets(d.networkId, d.daemonNodeId);
|
|
54
|
+
return { ok: true, requests: [...requests.values()].filter(r => r.network_id === d.networkId && r.daemon_node_id === d.daemonNodeId && r.status === 'pending')
|
|
55
|
+
.flatMap(r => {
|
|
56
|
+
if (r.node_id !== null && !targets.some(t => t.node_id === r.node_id)) { r.status = 'failed'; r.error = 'secret_target_not_bound'; delete r.value; return []; }
|
|
57
|
+
const { status, snapshot, error, ...input } = r;
|
|
58
|
+
return [{ ...input, targets }];
|
|
59
|
+
}) };
|
|
60
|
+
}
|
|
61
|
+
export function ackScopedSecrets(d: Daemon, id: string, snapshot: any, failed = false) {
|
|
62
|
+
if (!d.ok || !daemonSupported(d.networkId, d.daemonNodeId)) return denied('caller_not_a_daemon'); expire();
|
|
63
|
+
const r = requests.get(id);
|
|
64
|
+
if (!r || r.network_id !== d.networkId || r.daemon_node_id !== d.daemonNodeId) return denied('request_not_found');
|
|
65
|
+
if (r.status !== 'pending') return { ok: r.status === 'succeeded' || r.status === 'failed', status: r.status };
|
|
66
|
+
if (!failed) {
|
|
67
|
+
if (!snapshot || Object.keys(snapshot).some(k => !['network_id','daemon_node_id','node_id','revision','activation','keys'].includes(k))
|
|
68
|
+
|| snapshot.network_id !== r.network_id || snapshot.daemon_node_id !== r.daemon_node_id || snapshot.node_id !== r.node_id
|
|
69
|
+
|| !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || snapshot.activation !== 'next_start'
|
|
70
|
+
|| !Array.isArray(snapshot.keys) || snapshot.keys.length > 256 || new Set(snapshot.keys.map((x:any)=>x.key)).size !== snapshot.keys.length
|
|
71
|
+
|| snapshot.keys.some((x:any) => !x || Object.keys(x).some(k=>!['key','source'].includes(k)) || !validScopedSecretKey(x.key)
|
|
72
|
+
|| !['daemon','node'].includes(x.source) || (r.node_id === null && x.source !== 'daemon'))
|
|
73
|
+
|| (r.action !== 'list' && snapshot.revision !== r.revision! + 1)
|
|
74
|
+
|| (r.action === 'put' && !snapshot.keys.some((x:any)=>x.key === r.key && x.source === (r.node_id ? 'node' : 'daemon')))) return denied('invalid_secret_receipt');
|
|
75
|
+
if (r.node_id !== null && !providerScanTargets(r.network_id,r.daemon_node_id).some(t=>t.node_id === r.node_id)) return denied('secret_target_not_bound');
|
|
76
|
+
r.snapshot = snapshot;
|
|
77
|
+
}
|
|
78
|
+
r.status = failed ? 'failed' : 'succeeded'; if (failed) r.error = 'secret_operation_unavailable'; delete r.value;
|
|
79
|
+
return { ok: true, status: r.status };
|
|
80
|
+
}
|
|
@@ -40,7 +40,7 @@ export function validateDaemonProviderSnapshot(value: unknown, networkId: string
|
|
|
40
40
|
if (a.account_id !== undefined) fail();
|
|
41
41
|
if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
|
|
42
42
|
let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
|
|
43
|
-
if (
|
|
43
|
+
if (!["https:", "http:"].includes(u.protocol) || u.username || u.password || u.search || u.hash) fail();
|
|
44
44
|
} else fail();
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { isReservedEnvKey } from './reserved-env';
|
|
2
|
+
export const validScopedSecretKey = (key: unknown): key is string => typeof key === 'string'
|
|
3
|
+
&& /^[A-Z_][A-Z0-9_]{0,127}$/.test(key) && !isReservedEnvKey(key)
|
|
4
|
+
&& !['CODEX_HOME','CLAUDE_CONFIG_DIR','XDG_CONFIG_HOME','XDG_DATA_HOME','TMUX','TMUX_PANE','TEMP','TMP'].includes(key)
|
|
5
|
+
&& !['COMMHUB_','ANET_','CLAUDE_CODE_'].some(p => key.startsWith(p));
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
3
3
|
import { readHubProviders, putHubProvider, bindHubProvider } from './hub-provider-config.js';
|
|
4
|
+
import { requestScopedSecrets, getScopedSecrets, pullScopedSecrets, ackScopedSecrets } from './scoped-secrets.js';
|
|
4
5
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
5
6
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
6
7
|
import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
|
|
@@ -841,6 +842,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
841
842
|
// discovery (#337 extracts this field). "host_supervisor" =
|
|
842
843
|
// anet daemon. Default-stripping zod would drop this otherwise.
|
|
843
844
|
role: z.string().max(64).optional().nullable(),
|
|
845
|
+
scoped_secrets_capable: z.boolean().optional(),
|
|
844
846
|
// RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
|
|
845
847
|
// `daemon_capabilities` (canonical shape per existing hub reads
|
|
846
848
|
// at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
|
|
@@ -4129,10 +4131,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4129
4131
|
const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
|
|
4130
4132
|
const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
|
4131
4133
|
server.tool(
|
|
4132
|
-
"daemon_provider_snapshot", "Provider
|
|
4133
|
-
{ action: z.enum(["refresh", "read", "put", "collect", "apply", "login", "hub_read", "hub_put", "hub_bind"]), 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(), include_discovery: z.boolean().optional() },
|
|
4134
|
+
"daemon_provider_snapshot", "Daemon Provider actions; read=id. Login provider={action,session_id?,label?}, no tokens. hub_*=owner catalog/distribute. secret_*=env persistence, provider={node_id?,key?,value?}; next full start.",
|
|
4135
|
+
{ action: z.enum(["refresh", "read", "put", "collect", "apply", "login", "hub_read", "hub_put", "hub_bind", "secret_list", "secret_put", "secret_delete", "secret_read"]), 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(), include_discovery: z.boolean().optional() },
|
|
4134
4136
|
async ({ action, id, network_id, revision, provider, include_discovery }) => {
|
|
4135
4137
|
const human = providerHuman(network_id);
|
|
4138
|
+
// Separate scoped-secret contract using the existing management lane.
|
|
4139
|
+
// No additional tools in model context; ordinary Provider actions unchanged.
|
|
4140
|
+
if (action === 'secret_read') return providerReply(getScopedSecrets(human, id));
|
|
4141
|
+
if (action === 'secret_list' || action === 'secret_put' || action === 'secret_delete') {
|
|
4142
|
+
const input = provider as { node_id?: string | null; key?: string; value?: string } | undefined;
|
|
4143
|
+
const result = requestScopedSecrets(human, id, input?.node_id ?? null, action === 'secret_list' ? 'list' : action === 'secret_put' ? 'put' : 'delete', input?.key, input?.value, revision);
|
|
4144
|
+
if (result.ok) {
|
|
4145
|
+
const target = db.get<{ alias: string }>('SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2', id, human.networkId!);
|
|
4146
|
+
if (target) pushEvent(target.alias, { type: 'scoped_secrets', request_id: result.request_id }, human.networkId!);
|
|
4147
|
+
}
|
|
4148
|
+
return providerReply(result);
|
|
4149
|
+
}
|
|
4136
4150
|
if (action === 'hub_read') return providerReply(readHubProviders(human, (provider as any)?.include_secret_refs === true));
|
|
4137
4151
|
if (action === 'hub_put') return providerReply(putHubProvider(human, revision!, provider));
|
|
4138
4152
|
if (action === 'hub_bind') {
|
|
@@ -4158,13 +4172,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4158
4172
|
},
|
|
4159
4173
|
);
|
|
4160
4174
|
server.tool(
|
|
4161
|
-
"pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/collect/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional(), accept_local_discovery: z.boolean().optional(), accept_collections: z.boolean().optional() },
|
|
4162
|
-
async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections }) =>
|
|
4175
|
+
"pull_daemon_provider_snapshots", "Daemon Provider work; advertise only implemented secure write/collect/apply/login capabilities.", { accept_writes: z.boolean().optional(), accept_applications: z.boolean().optional(), accept_logins: z.boolean().optional(), accept_local_discovery: z.boolean().optional(), accept_collections: z.boolean().optional(), accept_secrets: z.boolean().optional() },
|
|
4176
|
+
async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections, accept_secrets }) => {
|
|
4177
|
+
const caller = resolveCallerDaemonTokenBound();
|
|
4178
|
+
const result = pullProviderSnapshots(caller, accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections);
|
|
4179
|
+
return providerReply(result.ok && accept_secrets ? { ...result, secret_requests: pullScopedSecrets(caller) } : result);
|
|
4180
|
+
},
|
|
4163
4181
|
);
|
|
4164
4182
|
server.tool(
|
|
4165
4183
|
"ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
|
|
4166
4184
|
{ 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() },
|
|
4167
|
-
async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(
|
|
4185
|
+
async ({ request_id, snapshot, failed, applied_revision, application, login }) => providerReply(request_id.startsWith('dsk_')
|
|
4186
|
+
? ackScopedSecrets(resolveCallerDaemonTokenBound(), request_id, snapshot, failed)
|
|
4187
|
+
: ackProviderSnapshot(resolveCallerDaemonTokenBound(), request_id, snapshot, failed, applied_revision, application, login)),
|
|
4168
4188
|
);
|
|
4169
4189
|
|
|
4170
4190
|
// Helper — map a ValidationError thrown from create-node-validate
|