@sleep2agi/commhub-server 0.9.0-preview.124 → 0.9.0-preview.125
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/bin/commhub.ts +7 -0
- package/package.json +1 -1
- package/src/hub-provider-config.test.ts +37 -0
- package/src/hub-provider-config.ts +18 -11
- package/src/server.ts +4 -0
- package/src/tools.ts +1 -1
package/bin/commhub.ts
CHANGED
|
@@ -119,6 +119,13 @@ for (let i = 0; i < args.length; i++) {
|
|
|
119
119
|
console.log(HELP);
|
|
120
120
|
process.exit(0);
|
|
121
121
|
} else if (arg === "--version" || arg === "-v") {
|
|
122
|
+
// Set by bin/anet-bin.ts before this file is imported. A compiled binary
|
|
123
|
+
// has no package.json beside import.meta.url (/$bunfs). npm leaves it unset.
|
|
124
|
+
const embedded = process.env.ANET_EMBEDDED_COMMHUB_VERSION?.trim() ?? "";
|
|
125
|
+
if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(embedded)) {
|
|
126
|
+
console.log(embedded);
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
122
129
|
const pkg = (await import("../package.json", { with: { type: "json" } })).default as { version: string };
|
|
123
130
|
console.log(pkg.version);
|
|
124
131
|
process.exit(0);
|
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.125",
|
|
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",
|
|
@@ -5,8 +5,10 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { db } from './db';
|
|
7
7
|
import { registerTools } from './tools';
|
|
8
|
+
import { vaultGet } from './vault';
|
|
8
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
10
|
import { reconcileProviderSnapshots } from '../../agent-node/src/runtime/daemon-provider-snapshot';
|
|
11
|
+
import { DaemonProviderStore } from '../../agent-network/src/daemon-provider-store';
|
|
10
12
|
const net = 'net_hub907', user = 'u_hub907', daemonId = 'n_hub907', alias = 'daemon907', token = 'tok_hub907';
|
|
11
13
|
const root = mkdtempSync(join(tmpdir(), 'hub907-')), key = 'TEST-ONLY-shared-provider-key';
|
|
12
14
|
function cleanup() {
|
|
@@ -37,6 +39,41 @@ function client(kind = 'human') {
|
|
|
37
39
|
const provider = () => ({ id: 'shared', label: 'Shared model', enabled: true, protocol: 'openai-responses', baseUrl: 'https://provider.example/v1', models: ['fixture-model'], key });
|
|
38
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 });
|
|
39
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('existing names-only vault key → Provider reference → actual daemon store, without re-entering key', async () => {
|
|
43
|
+
const human = client(), daemon = client('daemon');
|
|
44
|
+
expect(await human('upsert_network_secret', { network_id: net, key: 'SHARED_API_KEY', value: key })).toMatchObject({ ok: true });
|
|
45
|
+
const { key: ignored, ...config } = provider();
|
|
46
|
+
const saved = await save(human, { ...config, secretKeyRef: 'SHARED_API_KEY' } as any);
|
|
47
|
+
expect(saved.provider).toMatchObject({ secret_key_ref: 'SHARED_API_KEY', credential_present: true });
|
|
48
|
+
expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).toBe('SHARED_API_KEY');
|
|
49
|
+
const legacy = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net });
|
|
50
|
+
expect(legacy.providers[0].secret_key_ref).toBeUndefined(); // older strict client shape unchanged
|
|
51
|
+
const modern = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net, provider: { include_secret_refs: true } });
|
|
52
|
+
expect(modern.secret_reference_supported).toBe(true); expect(modern.providers[0].secret_key_ref).toBe('SHARED_API_KEY');
|
|
53
|
+
expect(JSON.stringify([saved, legacy, modern])).not.toContain(key);
|
|
54
|
+
const queued = await bind(human); expect(queued.ok).toBe(true);
|
|
55
|
+
await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId, serverUrl: 'https://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
|
|
56
|
+
const receipt = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
57
|
+
expect(receipt.status).toBe('succeeded'); expect(receipt.snapshot.providers[0].runtimes[0].auth[0].credential_present).toBe(true);
|
|
58
|
+
const store = new DaemonProviderStore(join(root, '.anet/nodes', alias));
|
|
59
|
+
await store.withApiKey({ revision: receipt.snapshot.revision, providerId: queued.provider_reference.daemon_provider_id, authId: 'hub_key', model: 'fixture-model' }, async profile => {
|
|
60
|
+
expect(profile.key).toBe(key); expect(profile.baseUrl).toBe(config.baseUrl);
|
|
61
|
+
}); // synthetic local callback only; no model/vendor request
|
|
62
|
+
expect(JSON.stringify(receipt)).not.toContain(key);
|
|
63
|
+
// Switching back to Provider-owned plaintext never changes the reusable named key.
|
|
64
|
+
await save(human, { ...provider(), key: 'TEST-ONLY-provider-specific' }, 1);
|
|
65
|
+
expect(vaultGet(net, 'SHARED_API_KEY')).toBe(key);
|
|
66
|
+
expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).not.toBe('SHARED_API_KEY');
|
|
67
|
+
});
|
|
68
|
+
test('missing or ambiguous references do not save; explicit ref replacement updates the stored reference', async () => {
|
|
69
|
+
const human = client(); const { key: ignored, ...config } = provider();
|
|
70
|
+
expect(await save(human, { ...config, secretKeyRef: 'MISSING_KEY' } as any)).toMatchObject({ ok: false, error: 'provider_secret_reference_missing' });
|
|
71
|
+
expect(await save(human, { ...provider(), secretKeyRef: 'SHARED_API_KEY' } as any)).toMatchObject({ ok: false, error: 'invalid_provider_secret_reference' });
|
|
72
|
+
await save(human);
|
|
73
|
+
await human('upsert_network_secret', { network_id: net, key: 'SECOND_API_KEY', value: 'TEST-ONLY-second-key' });
|
|
74
|
+
expect((await save(human, { ...config, secretKeyRef: 'SECOND_API_KEY' } as any, 1)).provider.secret_key_ref).toBe('SECOND_API_KEY');
|
|
75
|
+
expect(db.get<any>('SELECT secret_key_ref FROM hub_provider_configs WHERE network_id=?1', net).secret_key_ref).toBe('SECOND_API_KEY');
|
|
76
|
+
});
|
|
40
77
|
test('actual Hub tool atomically saves encrypted Provider; metadata read and conflict never echo key', async () => {
|
|
41
78
|
const human = client(); const result = await save(human); expect(result.ok).toBe(true);
|
|
42
79
|
expect(result.provider).toMatchObject({ id: 'shared', revision: 1, source: 'hub', credential_present: true, compatible_runtimes: ['codex-tui'] });
|
|
@@ -16,7 +16,7 @@ function auth(h: Human, write: boolean, owner = false) {
|
|
|
16
16
|
}
|
|
17
17
|
function validConfig(value: any): value is Config {
|
|
18
18
|
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
19
|
-
|| Object.keys(value).some(k => !['id', 'label', 'enabled', 'protocol', 'baseUrl', 'models', 'key'].includes(k))
|
|
19
|
+
|| Object.keys(value).some(k => !['id', 'label', 'enabled', 'protocol', 'baseUrl', 'models', 'key', 'secretKeyRef'].includes(k))
|
|
20
20
|
|| !idValid(value.id) || typeof value.label !== 'string' || !value.label.trim() || value.label.length > 100
|
|
21
21
|
|| /[\x00-\x1f\x7f]/.test(value.label) || typeof value.enabled !== 'boolean'
|
|
22
22
|
|| !['openai-responses', 'openai-chat-completions', 'anthropic-messages'].includes(value.protocol)
|
|
@@ -31,36 +31,43 @@ type Row = { revision: number; config_json: string; secret_key_ref: string; upda
|
|
|
31
31
|
function row(networkId: string, id: string) {
|
|
32
32
|
return db.get<Row>('SELECT revision,config_json,secret_key_ref,updated_at FROM hub_provider_configs WHERE network_id=?1 AND provider_id=?2', networkId, id);
|
|
33
33
|
}
|
|
34
|
-
function publicRow(networkId: string, stored: Row) {
|
|
34
|
+
function publicRow(networkId: string, stored: Row, exposeRef = false) {
|
|
35
35
|
const config = JSON.parse(stored.config_json) as Config;
|
|
36
|
-
return { ...config, revision: stored.revision, updated_at: stored.updated_at, source: 'hub',
|
|
36
|
+
return { ...config, ...(exposeRef ? { secret_key_ref: stored.secret_key_ref } : {}), revision: stored.revision, updated_at: stored.updated_at, source: 'hub',
|
|
37
37
|
credential_present: vaultListKeys(networkId).includes(stored.secret_key_ref), verification: 'not_checked',
|
|
38
38
|
compatible_runtimes: config.protocol === 'openai-responses' ? ['codex-tui'] : [] };
|
|
39
39
|
}
|
|
40
|
-
export function readHubProviders(h: Human) {
|
|
40
|
+
export function readHubProviders(h: Human, exposeRefs = false) {
|
|
41
41
|
const refusal = auth(h, false); if (refusal) return refusal;
|
|
42
42
|
const rows = db.all<Row>('SELECT revision,config_json,secret_key_ref,updated_at FROM hub_provider_configs WHERE network_id=?1 ORDER BY provider_id', h.networkId!);
|
|
43
|
-
return { ok: true as const, network_id: h.networkId!, source: 'hub' as const,
|
|
43
|
+
return { ok: true as const, network_id: h.networkId!, source: 'hub' as const,
|
|
44
|
+
...(exposeRefs ? { secret_reference_supported: true } : {}), providers: rows.map(r => publicRow(h.networkId!, r, exposeRefs)) };
|
|
44
45
|
}
|
|
45
46
|
export function putHubProvider(h: Human, revision: number, input: unknown) {
|
|
46
47
|
// This operation stores/rotates a shared secret: same OWNER boundary as vault writes.
|
|
47
48
|
const refusal = auth(h, true, true); if (refusal) return refusal;
|
|
48
49
|
if (!Number.isSafeInteger(revision) || revision < 0 || revision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
|
|
49
50
|
if (!validConfig(input)) return denied('invalid_hub_provider');
|
|
50
|
-
const value = input as Config & { key?: string };
|
|
51
|
+
const value = input as Config & { key?: string; secretKeyRef?: string };
|
|
51
52
|
if (value.key !== undefined && (typeof value.key !== 'string' || !value.key || value.key.length > 8192 || /[\s\x00-\x1f\x7f]/.test(value.key))) return denied('invalid_provider_key');
|
|
53
|
+
if (value.secretKeyRef !== undefined && (typeof value.secretKeyRef !== 'string' || !/^[A-Z][A-Z0-9_]{0,63}$/.test(value.secretKeyRef)
|
|
54
|
+
|| value.key !== undefined)) return denied('invalid_provider_secret_reference');
|
|
55
|
+
if (value.secretKeyRef && !vaultListKeys(h.networkId!).includes(value.secretKeyRef)) return denied('provider_secret_reference_missing');
|
|
52
56
|
const old = row(h.networkId!, value.id);
|
|
53
57
|
if ((old?.revision ?? 0) !== revision) return denied('hub_provider_revision_conflict');
|
|
54
58
|
const previous = old ? JSON.parse(old.config_json) as Config : null;
|
|
55
|
-
if (!value.key && (!old || previous?.baseUrl !== value.baseUrl || previous?.protocol !== value.protocol)) return denied('provider_key_required');
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
if (!value.key && !value.secretKeyRef && (!old || previous?.baseUrl !== value.baseUrl || previous?.protocol !== value.protocol)) return denied('provider_key_required');
|
|
60
|
+
// A new plaintext key belongs to this Provider. Never overwrite a separately
|
|
61
|
+
// named shared key merely because the previous config referenced it.
|
|
62
|
+
const privateRef = `HUB_PROVIDER_${createHash('sha256').update(JSON.stringify([h.networkId, value.id])).digest('hex').slice(0, 32).toUpperCase()}`;
|
|
63
|
+
const ref = value.secretKeyRef ?? (value.key ? privateRef : old!.secret_key_ref);
|
|
64
|
+
const { key, secretKeyRef, ...config } = value;
|
|
58
65
|
try {
|
|
59
66
|
db.transaction(() => {
|
|
60
67
|
if ((row(h.networkId!, value.id)?.revision ?? 0) !== revision) throw new Error('hub_provider_revision_conflict');
|
|
61
68
|
if (key) vaultUpsert(h.networkId!, ref, key);
|
|
62
69
|
db.run(`INSERT INTO hub_provider_configs(network_id,provider_id,revision,config_json,secret_key_ref,updated_at) VALUES(?1,?2,?3,?4,?5,?6)
|
|
63
|
-
ON CONFLICT(network_id,provider_id) DO UPDATE SET revision=excluded.revision,config_json=excluded.config_json,updated_at=excluded.updated_at`,
|
|
70
|
+
ON CONFLICT(network_id,provider_id) DO UPDATE SET revision=excluded.revision,config_json=excluded.config_json,secret_key_ref=excluded.secret_key_ref,updated_at=excluded.updated_at`,
|
|
64
71
|
[h.networkId!, value.id, revision + 1, JSON.stringify(config), ref, Date.now()]);
|
|
65
72
|
// Cancel still-pending dispatches when the source is edited/rotated/disabled.
|
|
66
73
|
// Already installed copies are NOT remotely revoked by this change.
|
|
@@ -69,7 +76,7 @@ export function putHubProvider(h: Human, revision: number, input: unknown) {
|
|
|
69
76
|
db.run(`DELETE FROM daemon_provider_writes WHERE request_id IN (SELECT b.request_id FROM hub_provider_bindings b
|
|
70
77
|
JOIN daemon_provider_snapshots s ON s.request_id=b.request_id WHERE b.network_id=?1 AND b.provider_id=?2 AND s.status!='pending')`, [h.networkId!, value.id]);
|
|
71
78
|
});
|
|
72
|
-
return { ok: true as const, network_id: h.networkId!, provider: publicRow(h.networkId!, row(h.networkId!, value.id)
|
|
79
|
+
return { ok: true as const, network_id: h.networkId!, provider: publicRow(h.networkId!, row(h.networkId!, value.id)!, secretKeyRef !== undefined) };
|
|
73
80
|
} catch (error) { return denied(error instanceof VaultError ? error.code : 'hub_provider_save_failed'); }
|
|
74
81
|
}
|
|
75
82
|
export function bindHubProvider(h: Human, input: { id: string; revision: number; daemon_node_id: string; daemon_revision: number; confirm: boolean }) {
|
package/src/server.ts
CHANGED
|
@@ -134,7 +134,11 @@ if (AUTH_TOKEN) {
|
|
|
134
134
|
const MCP_LABEL_MAX_BYTES = 1024 * 1024;
|
|
135
135
|
|
|
136
136
|
// Read version from package.json so banners and /health stay in sync.
|
|
137
|
+
// ANET_EMBEDDED_COMMHUB_VERSION is set only by the standalone anet binary
|
|
138
|
+
// (bin/anet-bin.ts). npm/bunx leaves it unset and keeps the disk read.
|
|
137
139
|
const SERVER_VERSION = (() => {
|
|
140
|
+
const embedded = process.env.ANET_EMBEDDED_COMMHUB_VERSION?.trim() ?? "";
|
|
141
|
+
if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(embedded)) return embedded;
|
|
138
142
|
try {
|
|
139
143
|
const url = new URL("../package.json", import.meta.url);
|
|
140
144
|
return JSON.parse(require("fs").readFileSync(url, "utf8")).version || "?";
|
package/src/tools.ts
CHANGED
|
@@ -4133,7 +4133,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4133
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
4134
|
async ({ action, id, network_id, revision, provider, include_discovery }) => {
|
|
4135
4135
|
const human = providerHuman(network_id);
|
|
4136
|
-
if (action === 'hub_read') return providerReply(readHubProviders(human));
|
|
4136
|
+
if (action === 'hub_read') return providerReply(readHubProviders(human, (provider as any)?.include_secret_refs === true));
|
|
4137
4137
|
if (action === 'hub_put') return providerReply(putHubProvider(human, revision!, provider));
|
|
4138
4138
|
if (action === 'hub_bind') {
|
|
4139
4139
|
const selection = provider as { daemon_node_id?: string; daemon_revision?: number; confirm?: boolean } | undefined;
|