@sleep2agi/commhub-server 0.9.0-preview.123 → 0.9.0-preview.124
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/create-node-validate.ts +11 -1
- package/src/daemon-provider-snapshots.test.ts +51 -0
- package/src/daemon-provider-snapshots.ts +49 -4
- package/src/db.ts +13 -0
- package/src/hub-provider-config.test.ts +79 -0
- package/src/hub-provider-config.ts +98 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/tools.ts +18 -5
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.124",
|
|
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",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { isReservedEnvKey } from "./shared/reserved-env.js";
|
|
11
11
|
import { checkNodeName } from "./shared/node-name.js";
|
|
12
12
|
import { CODEX_CONFIG_ONLY_FLAG_KEYS, validateCodexConfigFlag } from "./codex-config-flags.js";
|
|
13
|
+
import { validProviderInitialization } from './shared/provider-initialization.js';
|
|
13
14
|
|
|
14
15
|
export class ValidationError extends Error {
|
|
15
16
|
constructor(public code: string, public detail?: Record<string, unknown>) {
|
|
@@ -57,6 +58,7 @@ export const FLAG_KEYS = [
|
|
|
57
58
|
"permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout",
|
|
58
59
|
"copresence", "opencodeGeneration", "opencodeUnsafeTools",
|
|
59
60
|
"approvalPolicy", "sandboxMode", "skipGitRepoCheck", "copresenceFullAccess", "modelReasoningEffort",
|
|
61
|
+
"daemonProvider",
|
|
60
62
|
] as const;
|
|
61
63
|
|
|
62
64
|
/** #584 —— 只有这些 runtime 认 `flags.copresence`。目前只有 codex-app-server 的共存靠 config 字段
|
|
@@ -142,6 +144,9 @@ export function isValidTimeoutMs(v: unknown): v is number {
|
|
|
142
144
|
|
|
143
145
|
export function validateFlagValue(k: string, v: unknown): void {
|
|
144
146
|
switch (k) {
|
|
147
|
+
case 'daemonProvider':
|
|
148
|
+
if (!validProviderInitialization(v)) throw new ValidationError('flag_value_invalid', { field: k });
|
|
149
|
+
return;
|
|
145
150
|
case "opencodeGeneration":
|
|
146
151
|
if (v !== "v1" && v !== "v2") throw new ValidationError("flag_value_invalid", { field: k, reason: "must be v1 or v2" });
|
|
147
152
|
return;
|
|
@@ -188,6 +193,9 @@ export function validateFlagValue(k: string, v: unknown): void {
|
|
|
188
193
|
* 在别的 runtime 上收下它而什么都不做,就是又一个「选了共存、拿到无头」。 */
|
|
189
194
|
export function validateFlagsForRuntime(runtime: string, flags: Record<string, unknown> | undefined | null): void {
|
|
190
195
|
if (!flags) return;
|
|
196
|
+
if (flags.daemonProvider !== undefined && (runtime !== 'codex-app-server' || flags.copresence !== true)) {
|
|
197
|
+
throw new ValidationError('flag_not_applicable_to_runtime', { field: 'daemonProvider', runtime, applicable: ['codex-app-server copresence'] });
|
|
198
|
+
}
|
|
191
199
|
// Strict flags envelope: old Hub/daemon reject these keys rather than dropping
|
|
192
200
|
// unknown node_spec fields. Opt-in must be explicit on this create request.
|
|
193
201
|
for (const field of ["opencodeGeneration", "opencodeUnsafeTools"]) {
|
|
@@ -386,6 +394,8 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
|
386
394
|
validateRuntime(spec.runtime);
|
|
387
395
|
validateModel(spec.model);
|
|
388
396
|
validateChannelsP1(spec.channels);
|
|
397
|
+
if (spec.flags?.daemonProvider !== undefined && (!validProviderInitialization(spec.flags.daemonProvider)
|
|
398
|
+
|| spec.model !== spec.flags.daemonProvider.model)) throw new ValidationError('provider_create_model_mismatch');
|
|
389
399
|
|
|
390
400
|
const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime];
|
|
391
401
|
if (spec.model) args.push("--model", spec.model);
|
|
@@ -398,7 +408,7 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
|
|
|
398
408
|
// `anet node create --copresence` 是布尔开关,不吃值。
|
|
399
409
|
if (k === "copresence") { if (v === true) args.push("--copresence"); continue; }
|
|
400
410
|
if (k === "opencodeUnsafeTools") { if (v === true) args.push("--opencode-unsafe-tools"); continue; }
|
|
401
|
-
if (CODEX_CONFIG_ONLY_FLAG_KEYS.has(k)) continue;
|
|
411
|
+
if (CODEX_CONFIG_ONLY_FLAG_KEYS.has(k) || k === 'daemonProvider') continue;
|
|
402
412
|
args.push(`--${kebab(k)}`, String(v));
|
|
403
413
|
}
|
|
404
414
|
validateFlagsForRuntime(spec.runtime, spec.flags);
|
|
@@ -13,6 +13,7 @@ import { registerTools } from "./tools";
|
|
|
13
13
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
14
|
import { reconcileProviderSnapshots, readLocalProviderSnapshot } from "../../agent-node/src/runtime/daemon-provider-snapshot";
|
|
15
15
|
import { DaemonProviderStore } from "../../agent-network/src/daemon-provider-store";
|
|
16
|
+
import { providerNodeRevision } from '../../agent-network/src/daemon-provider-application';
|
|
16
17
|
import { register, createNetworkTokenForNode } from "./auth";
|
|
17
18
|
|
|
18
19
|
const net = "net_dps906", user = "u_dps906", node = "node_dps906", token = "token_dps906", alias = "daemon906";
|
|
@@ -218,6 +219,56 @@ test('write core chain: encrypted Hub request → target daemon CLI → durable
|
|
|
218
219
|
expect((await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 1, provider })).error).toBe('provider_admin_required');
|
|
219
220
|
});
|
|
220
221
|
|
|
222
|
+
test('collection core chain: bound source → capable daemon CLI → durable replay → redacted Provider', async () => {
|
|
223
|
+
process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32);
|
|
224
|
+
const work = mkdtempSync(join(root, 'collect-')), dir = join(work, '.anet/nodes', alias);
|
|
225
|
+
const childId = 'n_collect906', childAlias = 'source906', source = join(work, '.anet/nodes', childAlias);
|
|
226
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 }); mkdirSync(join(source, 'codex-home'), { recursive: true, mode: 0o700 });
|
|
227
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
|
|
228
|
+
const key = 'TEST-ONLY-collect-private-key';
|
|
229
|
+
writeFileSync(join(source, 'config.json'), JSON.stringify({ node_id: childId, alias: childAlias, network_id: net,
|
|
230
|
+
runtime: 'codex-app-server', codexHome: join(source, 'codex-home'), env: { TEST_PROVIDER_KEY: { _envRef: 'TEST_PROVIDER_KEY' } } }), { mode: 0o600 });
|
|
231
|
+
writeFileSync(join(source, '.env'), `TEST_PROVIDER_KEY=${key}\n`, { mode: 0o600 });
|
|
232
|
+
writeFileSync(join(source, 'codex-home/config.toml'), 'model_provider = "deepseek"\nmodel = "fixture-model"\n[model_providers.deepseek]\nbase_url = "https://api.example.test/v1"\nenv_key = "TEST_PROVIDER_KEY"\nwire_api = "responses"\n', { mode: 0o600 });
|
|
233
|
+
db.run("INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,?2,?2,?3,'codex-app-server')", [childId, childAlias, net]);
|
|
234
|
+
db.run("INSERT INTO node_daemon_bindings (request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES ('binding_collect906',?1,?2,?3,?4,?5,'active',1,1)", [net, childId, node, work, user]);
|
|
235
|
+
const selection = { node_id: childId, alias: childAlias, node_revision: providerNodeRevision(source), provider_id: 'deepseek', auth_id: 'imported', label: 'DeepSeek' };
|
|
236
|
+
const before = providerNodeRevision(source), human = client(), daemon = client('daemon');
|
|
237
|
+
const requestArgs = { action: 'collect', id: node, network_id: net, revision: 0, provider: selection };
|
|
238
|
+
expect((await daemon('daemon_provider_snapshot', requestArgs)).error).toBe('user_token_required');
|
|
239
|
+
expect((await human('daemon_provider_snapshot', { ...requestArgs, provider: { ...selection, alias: 'another' } })).error).toBe('collection_target_not_bound');
|
|
240
|
+
expect((await human('daemon_provider_snapshot', { ...requestArgs, provider: { ...selection, key } })).error).toBe('invalid_provider_collection');
|
|
241
|
+
const r = await human('daemon_provider_snapshot', requestArgs); expect(r.ok).toBe(true);
|
|
242
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_writes: true })).requests).toEqual([]);
|
|
243
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_collections: true })).requests[0]).toMatchObject({ operation: 'collect', selection });
|
|
244
|
+
let drop = true;
|
|
245
|
+
const deps = { workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {},
|
|
246
|
+
callCommHub: async (name: string, args: any) => {
|
|
247
|
+
expect(JSON.stringify(args)).not.toContain(key);
|
|
248
|
+
if (name === 'ack_daemon_provider_snapshot' && drop) { drop = false; throw Error('lost acknowledgement'); }
|
|
249
|
+
return daemon(name, args);
|
|
250
|
+
} };
|
|
251
|
+
await reconcileProviderSnapshots(deps);
|
|
252
|
+
expect(new DaemonProviderStore(dir).list().revision).toBe(1);
|
|
253
|
+
await reconcileProviderSnapshots(deps);
|
|
254
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
|
|
255
|
+
expect(read.status).toBe('succeeded'); expect(read.snapshot.revision).toBe(1);
|
|
256
|
+
expect(read.snapshot.providers[0].runtimes[0].auth[0]).toMatchObject({ id: 'imported', kind: 'api_key', models: ['fixture-model'], credential_present: true, verification: 'not_checked', application: 'not_applied' });
|
|
257
|
+
expect(JSON.stringify(read)).not.toContain(key);
|
|
258
|
+
expect(db.get('SELECT * FROM daemon_provider_writes WHERE request_id=?1', r.request_id)).toBeNull();
|
|
259
|
+
expect(providerNodeRevision(source)).toBe(before); // source config/session/credential is never changed
|
|
260
|
+
const stale = await human('daemon_provider_snapshot', { ...requestArgs, revision: 1, provider: { ...selection, auth_id: 'second' } });
|
|
261
|
+
db.run("UPDATE node_daemon_bindings SET daemon_node_id='other' WHERE request_id='binding_collect906'");
|
|
262
|
+
expect((await daemon('pull_daemon_provider_snapshots', { accept_collections: true })).requests).toEqual([]);
|
|
263
|
+
expect((await human('daemon_provider_snapshot', { action: 'read', id: stale.request_id, network_id: net })).error).toBe('collection_target_not_bound');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('collection intent contract mirrors CLI, daemon and Hub exactly', () => {
|
|
267
|
+
const hub = readFileSync(new URL('./shared/provider-collection.ts', import.meta.url), 'utf8');
|
|
268
|
+
expect(hub).toBe(readFileSync(new URL('../../agent-node/src/shared/provider-collection.ts', import.meta.url), 'utf8'));
|
|
269
|
+
expect(hub).toBe(readFileSync(new URL('../../agent-network/src/shared/provider-collection.ts', import.meta.url), 'utf8'));
|
|
270
|
+
});
|
|
271
|
+
|
|
221
272
|
test("human authorization precedes dispatch; unknown, nodes and non-admins cannot request", async () => {
|
|
222
273
|
expect((await client("anonymous")("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node })).error).toBe("user_token_required");
|
|
223
274
|
expect((await client("daemon")("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node })).ok).toBe(false);
|
|
@@ -9,6 +9,7 @@ import { encryptSecret, decryptSecret, VaultError } from './vault';
|
|
|
9
9
|
import { buildControllableMap } from './node-lifecycle-controllable';
|
|
10
10
|
import { validProviderApplicationSelection, validProviderApplicationReceipt, type ProviderApplicationSelection } from './shared/provider-application';
|
|
11
11
|
import { validLoginIntent, validLoginReceipt } from './shared/daemon-provider-login';
|
|
12
|
+
import { validProviderCollection, type ProviderCollection } from './shared/provider-collection';
|
|
12
13
|
|
|
13
14
|
export function providerScanTargets(networkId: string, daemonId: string) {
|
|
14
15
|
const ownership = buildControllableMap(
|
|
@@ -95,8 +96,37 @@ function privateWrite(requestId: string, networkId: string, daemonId: string) {
|
|
|
95
96
|
if (!row) return null;
|
|
96
97
|
const payload = JSON.parse(decryptSecret({ ciphertext: Buffer.from(row.ciphertext), iv: Buffer.from(row.iv), tag: Buffer.from(row.tag) }));
|
|
97
98
|
if (payload.network_id !== networkId || payload.daemon_node_id !== daemonId) throw new Error('scope_mismatch');
|
|
99
|
+
if (payload.operation === 'collect') {
|
|
100
|
+
if (!validProviderCollection(payload.selection)) throw Error('invalid_provider_collection');
|
|
101
|
+
return { expected_revision: row.expected_revision, operation: 'collect' as const, selection: payload.selection };
|
|
102
|
+
}
|
|
98
103
|
validateWrite(payload.provider, networkId, daemonId);
|
|
99
|
-
return { expected_revision: row.expected_revision, provider: payload.provider };
|
|
104
|
+
return { expected_revision: row.expected_revision, operation: 'put' as const, provider: payload.provider };
|
|
105
|
+
}
|
|
106
|
+
function boundCollectionTarget(networkId: string, daemonId: string, selection: ProviderCollection) {
|
|
107
|
+
return selection.node_id !== daemonId && providerScanTargets(networkId, daemonId)
|
|
108
|
+
.some(n => n.node_id === selection.node_id && n.alias === selection.alias);
|
|
109
|
+
}
|
|
110
|
+
export function requestProviderCollection(h: Human, daemonId: string, revision: number, selection: unknown) {
|
|
111
|
+
const refusal = authorize(h); if (refusal) return refusal;
|
|
112
|
+
if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
|
|
113
|
+
if (!Number.isSafeInteger(revision) || revision < 0 || revision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
|
|
114
|
+
if (!validProviderCollection(selection)) return denied('invalid_provider_collection');
|
|
115
|
+
try {
|
|
116
|
+
if (!boundCollectionTarget(h.networkId!, daemonId, selection)) return denied('collection_target_not_bound');
|
|
117
|
+
expire(h.networkId!, daemonId);
|
|
118
|
+
if (db.get("SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending'", h.networkId!, daemonId)) return denied('provider_snapshot_busy');
|
|
119
|
+
// Reuse the short-lived write queue, but no credential is supplied to Hub.
|
|
120
|
+
const enc = encryptSecret(JSON.stringify({ network_id: h.networkId, daemon_node_id: daemonId, operation: 'collect', selection }));
|
|
121
|
+
const requestId = `dps_${randomUUID()}`, now = Date.now();
|
|
122
|
+
db.transaction(() => {
|
|
123
|
+
db.run("INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at) VALUES (?1,?2,?3,?4,'pending',?5,?6)",
|
|
124
|
+
[requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
|
|
125
|
+
db.run('INSERT INTO daemon_provider_writes (request_id,expected_revision,ciphertext,iv,tag) VALUES (?1,?2,?3,?4,?5)',
|
|
126
|
+
[requestId, revision, enc.ciphertext, enc.iv, enc.tag]);
|
|
127
|
+
});
|
|
128
|
+
return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
|
|
129
|
+
} catch (error) { return denied(error instanceof VaultError ? error.code : 'provider_collection_unavailable'); }
|
|
100
130
|
}
|
|
101
131
|
function applicationIntent(requestId: string) {
|
|
102
132
|
const row = db.get<{ expected_revision: number; selection_json: string }>('SELECT * FROM daemon_provider_applications WHERE request_id=?1', requestId);
|
|
@@ -170,7 +200,7 @@ export function getProviderSnapshot(h: Human, requestId: string, includeDiscover
|
|
|
170
200
|
...(row.request_id.startsWith('dpl_') ? { login: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}),
|
|
171
201
|
...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
|
|
172
202
|
}
|
|
173
|
-
export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false, acceptDiscovery = false) {
|
|
203
|
+
export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false, acceptDiscovery = false, acceptCollections = false) {
|
|
174
204
|
if (!d.ok) return denied("caller_not_a_daemon");
|
|
175
205
|
if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
|
|
176
206
|
expire(d.networkId, d.daemonNodeId);
|
|
@@ -195,9 +225,16 @@ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApp
|
|
|
195
225
|
}
|
|
196
226
|
return [{ ...r, operation: 'apply', ...application!, alias: target.alias }];
|
|
197
227
|
}
|
|
198
|
-
if (!acceptWrites && db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', r.request_id)) return [];
|
|
199
228
|
const write = privateWrite(r.request_id, d.networkId, d.daemonNodeId);
|
|
200
|
-
|
|
229
|
+
if (write?.operation === 'collect') {
|
|
230
|
+
if (!acceptCollections) return []; // old write-capable daemons cannot consume collection as put/scan
|
|
231
|
+
if (!boundCollectionTarget(d.networkId, d.daemonNodeId, write.selection)) {
|
|
232
|
+
db.run("UPDATE daemon_provider_snapshots SET status='failed',error='collection_target_not_bound',observed_at=?1 WHERE request_id=?2 AND status='pending'", [Date.now(), r.request_id]);
|
|
233
|
+
db.run('DELETE FROM daemon_provider_writes WHERE request_id=?1', [r.request_id]);
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
} else if (write && !acceptWrites) return [];
|
|
237
|
+
return [write ? { ...r, ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId), ...(acceptDiscovery ? { discover_local: true } : {}) }];
|
|
201
238
|
}) }; } catch { return denied('provider_write_unavailable'); }
|
|
202
239
|
}
|
|
203
240
|
export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown, login?: unknown) {
|
|
@@ -264,12 +301,20 @@ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unk
|
|
|
264
301
|
if (write) {
|
|
265
302
|
const actual = safe as any;
|
|
266
303
|
if (appliedRevision !== write.expected_revision + 1 || actual.revision !== appliedRevision) return denied('provider_write_unconfirmed');
|
|
304
|
+
if (write.operation === 'collect') {
|
|
305
|
+
if (!boundCollectionTarget(d.networkId, d.daemonNodeId, write.selection)) return denied('collection_target_not_bound');
|
|
306
|
+
const saved = actual.providers.find((p: any) => p.id === write.selection.provider_id);
|
|
307
|
+
const auth = saved?.runtimes.find((r: any) => r.runtime === 'codex-tui')?.auth.find((a: any) => a.id === write.selection.auth_id);
|
|
308
|
+
if (saved?.label !== write.selection.label.trim() || auth?.kind !== 'api_key' || auth.credential_present !== true
|
|
309
|
+
|| auth.models.length !== 1 || auth.verification !== 'not_checked' || auth.application !== 'not_applied') return denied('provider_write_unconfirmed');
|
|
310
|
+
} else {
|
|
267
311
|
const expected = validateWrite(write.provider, d.networkId, d.daemonNodeId);
|
|
268
312
|
const saved = actual.providers.find((p: any) => p.id === expected.id);
|
|
269
313
|
// CLI canonicalizes label and endpoint. No credential is compared or returned here.
|
|
270
314
|
expected.label = expected.label.trim();
|
|
271
315
|
for (const r of expected.runtimes) for (const a of r.auth) if (a.baseUrl) a.baseUrl = new URL(a.baseUrl).toString().replace(/\/$/, '');
|
|
272
316
|
if (!isDeepStrictEqual(saved, expected)) return denied('provider_write_unconfirmed');
|
|
317
|
+
}
|
|
273
318
|
}
|
|
274
319
|
} catch { return denied('provider_write_unconfirmed'); }
|
|
275
320
|
}
|
package/src/db.ts
CHANGED
|
@@ -936,6 +936,19 @@ db.exec(`
|
|
|
936
936
|
);
|
|
937
937
|
CREATE INDEX IF NOT EXISTS idx_providers_network ON providers(network_id);
|
|
938
938
|
`);
|
|
939
|
+
// #907 network-owned Provider configuration. Keys remain in network_secrets.
|
|
940
|
+
db.exec(`
|
|
941
|
+
CREATE TABLE IF NOT EXISTS hub_provider_configs (
|
|
942
|
+
network_id TEXT NOT NULL, provider_id TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
943
|
+
config_json TEXT NOT NULL, secret_key_ref TEXT NOT NULL, updated_at INTEGER NOT NULL,
|
|
944
|
+
PRIMARY KEY(network_id, provider_id)
|
|
945
|
+
);
|
|
946
|
+
CREATE TABLE IF NOT EXISTS hub_provider_bindings (
|
|
947
|
+
request_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, provider_id TEXT NOT NULL,
|
|
948
|
+
provider_revision INTEGER NOT NULL, daemon_node_id TEXT NOT NULL,
|
|
949
|
+
daemon_provider_id TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
950
|
+
);
|
|
951
|
+
`);
|
|
939
952
|
db.exec(`
|
|
940
953
|
CREATE TABLE IF NOT EXISTS provider_models (
|
|
941
954
|
model_id TEXT PRIMARY KEY,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import './require-explicit-test-db';
|
|
2
|
+
import { afterAll, beforeEach, expect, test } from 'bun:test';
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { db } from './db';
|
|
7
|
+
import { registerTools } from './tools';
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { reconcileProviderSnapshots } from '../../agent-node/src/runtime/daemon-provider-snapshot';
|
|
10
|
+
const net = 'net_hub907', user = 'u_hub907', daemonId = 'n_hub907', alias = 'daemon907', token = 'tok_hub907';
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), 'hub907-')), key = 'TEST-ONLY-shared-provider-key';
|
|
12
|
+
function cleanup() {
|
|
13
|
+
for (const t of ['daemon_provider_writes', 'daemon_provider_applications', 'daemon_provider_logins']) db.run(`DELETE FROM ${t} WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)`, [net]);
|
|
14
|
+
for (const t of ['hub_provider_bindings', 'hub_provider_configs', 'network_secrets', 'daemon_provider_snapshots', 'nodes', 'api_tokens', 'network_members']) db.run(`DELETE FROM ${t} WHERE network_id=?1`, [net]);
|
|
15
|
+
db.run('DELETE FROM networks WHERE network_id=?1', [net]); db.run('DELETE FROM users WHERE user_id=?1', [user]);
|
|
16
|
+
}
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
cleanup(); rmSync(join(root, '.anet'), { recursive: true, force: true });
|
|
19
|
+
process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32);
|
|
20
|
+
db.run("INSERT INTO users(user_id,username,password_hash,role) VALUES(?1,?1,'x','user')", [user]);
|
|
21
|
+
db.run('INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,?1,?2)', [net, user]);
|
|
22
|
+
db.run("INSERT INTO network_members(user_id,network_id,role) VALUES(?1,?2,'owner')", [user, net]);
|
|
23
|
+
db.run('INSERT INTO nodes(node_id,node_name,alias,network_id,config_snapshot) VALUES(?1,?2,?2,?3,?4)', [daemonId, alias, net, JSON.stringify({ role: 'host_supervisor' })]);
|
|
24
|
+
db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES(?1,?2,?3,'network',?4,?5)", [token, user, net, `node:${alias}`, `hash_${token}`]);
|
|
25
|
+
const dir = join(root, '.anet/nodes', alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
26
|
+
writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', node_id: daemonId, network_id: net }), { mode: 0o600 });
|
|
27
|
+
});
|
|
28
|
+
afterAll(() => { cleanup(); rmSync(root, { recursive: true, force: true }); });
|
|
29
|
+
function client(kind = 'human') {
|
|
30
|
+
const handlers: Record<string, any> = {}, server = new McpServer({ name: 'test907', version: '0' }) as any;
|
|
31
|
+
const tool = server.tool.bind(server), register = server.registerTool.bind(server);
|
|
32
|
+
server.tool = (name: string, desc: any, shape: any, handler: any) => { handlers[name] = handler; return tool(name, desc, shape, handler); };
|
|
33
|
+
server.registerTool = (name: string, config: any, handler: any) => { handlers[name] = handler; return register(name, config, handler); };
|
|
34
|
+
registerTools(server, undefined, kind === 'daemon' ? net : null, kind === 'anonymous' ? null : user, alias, kind === 'daemon', kind === 'daemon' ? token : null);
|
|
35
|
+
return async (name: string, args: any = {}) => JSON.parse((await handlers[name](args)).content[0].text);
|
|
36
|
+
}
|
|
37
|
+
const provider = () => ({ id: 'shared', label: 'Shared model', enabled: true, protocol: 'openai-responses', baseUrl: 'https://provider.example/v1', models: ['fixture-model'], key });
|
|
38
|
+
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
|
+
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 } });
|
|
40
|
+
test('actual Hub tool atomically saves encrypted Provider; metadata read and conflict never echo key', async () => {
|
|
41
|
+
const human = client(); const result = await save(human); expect(result.ok).toBe(true);
|
|
42
|
+
expect(result.provider).toMatchObject({ id: 'shared', revision: 1, source: 'hub', credential_present: true, compatible_runtimes: ['codex-tui'] });
|
|
43
|
+
const read = await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: net }); expect(read.providers).toHaveLength(1);
|
|
44
|
+
expect(JSON.stringify([result, read])).not.toContain(key);
|
|
45
|
+
expect(await save(human)).toMatchObject({ ok: false, error: 'hub_provider_revision_conflict' });
|
|
46
|
+
const secret = db.get<any>('SELECT ciphertext FROM network_secrets WHERE network_id=?1', net);
|
|
47
|
+
expect(Buffer.from(secret.ciphertext).toString()).not.toContain(key);
|
|
48
|
+
});
|
|
49
|
+
test('Hub → actual daemon queue → pinned CLI store → redacted receipt, not runtime application', async () => {
|
|
50
|
+
const human = client(), daemon = client('daemon'); await save(human);
|
|
51
|
+
const queued = await bind(human); expect(queued.ok).toBe(true); expect(queued.status).toBe('pending');
|
|
52
|
+
expect(JSON.stringify(queued)).not.toContain(key);
|
|
53
|
+
await reconcileProviderSnapshots({ workDir: root, alias, networkId: net, daemonId, serverUrl: 'https://hub907.example.test', callCommHub: daemon, warn: message => console.warn(message) });
|
|
54
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
55
|
+
expect(read.status).toBe('succeeded'); expect(read.snapshot.revision).toBe(1);
|
|
56
|
+
expect(read.snapshot.providers[0]).toMatchObject({ id: queued.provider_reference.daemon_provider_id, label: 'Hub · Shared model' });
|
|
57
|
+
expect(read.snapshot.providers[0].runtimes[0].auth[0]).toMatchObject({ id: 'hub_key', models: ['fixture-model'], application: 'not_applied', verification: 'not_checked' });
|
|
58
|
+
expect(JSON.stringify(read)).not.toContain(key);
|
|
59
|
+
});
|
|
60
|
+
test('scope/role boundaries: no node or anonymous writes, no viewer distribution or foreign daemon', async () => {
|
|
61
|
+
expect(await save(client('daemon'))).toMatchObject({ ok: false, error: 'user_token_required' });
|
|
62
|
+
expect(await save(client('anonymous'))).toMatchObject({ ok: false, error: 'user_token_required' });
|
|
63
|
+
const human = client(); await save(human);
|
|
64
|
+
expect(await human('daemon_provider_snapshot', { action: 'hub_read', id: 'hub', network_id: 'foreign' })).toMatchObject({ ok: false, error: 'provider_access_denied' });
|
|
65
|
+
expect(await bind(human, 'foreign')).toMatchObject({ ok: false, error: 'daemon_not_found' });
|
|
66
|
+
db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1 AND user_id=?2", [net, user]);
|
|
67
|
+
expect(await bind(human)).toMatchObject({ ok: false, error: 'provider_access_denied' });
|
|
68
|
+
expect(await save(human)).toMatchObject({ ok: false, error: 'provider_owner_required' });
|
|
69
|
+
});
|
|
70
|
+
test('protocol mismatch never queues Codex; editing source cancels pending distribution, not installed copies', async () => {
|
|
71
|
+
const human = client(); await save(human, { ...provider(), protocol: 'openai-chat-completions' });
|
|
72
|
+
expect(await bind(human)).toMatchObject({ ok: false, error: 'runtime_protocol_not_supported' });
|
|
73
|
+
await save(human, provider(), 1);
|
|
74
|
+
const queued = await bind(human, daemonId, 2);
|
|
75
|
+
expect(queued.ok).toBe(true); await save(human, { ...provider(), enabled: false }, 2);
|
|
76
|
+
const read = await human('daemon_provider_snapshot', { action: 'read', id: queued.request_id, network_id: net });
|
|
77
|
+
expect(read).toMatchObject({ status: 'failed', error: 'hub_provider_source_changed' });
|
|
78
|
+
expect(db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', queued.request_id)).toBeNull();
|
|
79
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// #907: real network-scoped catalog + explicit dispatch; not a Provider proxy.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { db } from './db';
|
|
4
|
+
import { getUserNetworkRole } from './auth';
|
|
5
|
+
import { vaultUpsert, vaultGet, vaultListKeys, VaultError } from './vault';
|
|
6
|
+
import { requestProviderWrite } from './daemon-provider-snapshots';
|
|
7
|
+
type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
|
|
8
|
+
type Config = { id: string; label: string; enabled: boolean; protocol: 'openai-responses' | 'openai-chat-completions' | 'anthropic-messages'; baseUrl: string; models: string[] };
|
|
9
|
+
const denied = (error: string) => ({ ok: false as const, error });
|
|
10
|
+
const idValid = (id: unknown) => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(id);
|
|
11
|
+
function auth(h: Human, write: boolean, owner = false) {
|
|
12
|
+
if (h.isNode || !h.userId || !h.networkId) return denied('user_token_required');
|
|
13
|
+
const role = getUserNetworkRole(h.userId, h.networkId);
|
|
14
|
+
if (!role || (owner && role !== 'owner') || (write && !['owner', 'admin'].includes(role))) return denied(owner ? 'provider_owner_required' : 'provider_access_denied');
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function validConfig(value: any): value is Config {
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
19
|
+
|| Object.keys(value).some(k => !['id', 'label', 'enabled', 'protocol', 'baseUrl', 'models', 'key'].includes(k))
|
|
20
|
+
|| !idValid(value.id) || typeof value.label !== 'string' || !value.label.trim() || value.label.length > 100
|
|
21
|
+
|| /[\x00-\x1f\x7f]/.test(value.label) || typeof value.enabled !== 'boolean'
|
|
22
|
+
|| !['openai-responses', 'openai-chat-completions', 'anthropic-messages'].includes(value.protocol)
|
|
23
|
+
|| typeof value.baseUrl !== 'string' || value.baseUrl.length > 2048
|
|
24
|
+
|| !Array.isArray(value.models) || !value.models.length || value.models.length > 200
|
|
25
|
+
|| new Set(value.models).size !== value.models.length
|
|
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 u.protocol === 'https:' && !u.username && !u.password && !u.search && !u.hash; }
|
|
28
|
+
catch { return false; }
|
|
29
|
+
}
|
|
30
|
+
type Row = { revision: number; config_json: string; secret_key_ref: string; updated_at: number };
|
|
31
|
+
function row(networkId: string, id: string) {
|
|
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
|
+
}
|
|
34
|
+
function publicRow(networkId: string, stored: Row) {
|
|
35
|
+
const config = JSON.parse(stored.config_json) as Config;
|
|
36
|
+
return { ...config, revision: stored.revision, updated_at: stored.updated_at, source: 'hub',
|
|
37
|
+
credential_present: vaultListKeys(networkId).includes(stored.secret_key_ref), verification: 'not_checked',
|
|
38
|
+
compatible_runtimes: config.protocol === 'openai-responses' ? ['codex-tui'] : [] };
|
|
39
|
+
}
|
|
40
|
+
export function readHubProviders(h: Human) {
|
|
41
|
+
const refusal = auth(h, false); if (refusal) return refusal;
|
|
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, providers: rows.map(r => publicRow(h.networkId!, r)) };
|
|
44
|
+
}
|
|
45
|
+
export function putHubProvider(h: Human, revision: number, input: unknown) {
|
|
46
|
+
// This operation stores/rotates a shared secret: same OWNER boundary as vault writes.
|
|
47
|
+
const refusal = auth(h, true, true); if (refusal) return refusal;
|
|
48
|
+
if (!Number.isSafeInteger(revision) || revision < 0 || revision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
|
|
49
|
+
if (!validConfig(input)) return denied('invalid_hub_provider');
|
|
50
|
+
const value = input as Config & { key?: string };
|
|
51
|
+
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');
|
|
52
|
+
const old = row(h.networkId!, value.id);
|
|
53
|
+
if ((old?.revision ?? 0) !== revision) return denied('hub_provider_revision_conflict');
|
|
54
|
+
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
|
+
const ref = old?.secret_key_ref ?? `HUB_PROVIDER_${createHash('sha256').update(JSON.stringify([h.networkId, value.id])).digest('hex').slice(0, 32).toUpperCase()}`;
|
|
57
|
+
const { key, ...config } = value;
|
|
58
|
+
try {
|
|
59
|
+
db.transaction(() => {
|
|
60
|
+
if ((row(h.networkId!, value.id)?.revision ?? 0) !== revision) throw new Error('hub_provider_revision_conflict');
|
|
61
|
+
if (key) vaultUpsert(h.networkId!, ref, key);
|
|
62
|
+
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`,
|
|
64
|
+
[h.networkId!, value.id, revision + 1, JSON.stringify(config), ref, Date.now()]);
|
|
65
|
+
// Cancel still-pending dispatches when the source is edited/rotated/disabled.
|
|
66
|
+
// Already installed copies are NOT remotely revoked by this change.
|
|
67
|
+
db.run(`UPDATE daemon_provider_snapshots SET status='failed',error='hub_provider_source_changed'
|
|
68
|
+
WHERE status='pending' AND request_id IN (SELECT request_id FROM hub_provider_bindings WHERE network_id=?1 AND provider_id=?2)`, [h.networkId!, value.id]);
|
|
69
|
+
db.run(`DELETE FROM daemon_provider_writes WHERE request_id IN (SELECT b.request_id FROM hub_provider_bindings b
|
|
70
|
+
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
|
+
});
|
|
72
|
+
return { ok: true as const, network_id: h.networkId!, provider: publicRow(h.networkId!, row(h.networkId!, value.id)!) };
|
|
73
|
+
} catch (error) { return denied(error instanceof VaultError ? error.code : 'hub_provider_save_failed'); }
|
|
74
|
+
}
|
|
75
|
+
export function bindHubProvider(h: Human, input: { id: string; revision: number; daemon_node_id: string; daemon_revision: number; confirm: boolean }) {
|
|
76
|
+
const refusal = auth(h, true); if (refusal) return refusal;
|
|
77
|
+
if (!input || !idValid(input.id) || input.confirm !== true || !Number.isSafeInteger(input.revision)
|
|
78
|
+
|| !Number.isSafeInteger(input.daemon_revision) || input.daemon_revision < 0) return denied('invalid_hub_provider_binding');
|
|
79
|
+
const stored = row(h.networkId!, input.id);
|
|
80
|
+
if (!stored || stored.revision !== input.revision) return denied('hub_provider_revision_conflict');
|
|
81
|
+
const config = JSON.parse(stored.config_json) as Config;
|
|
82
|
+
if (!config.enabled) return denied('hub_provider_disabled');
|
|
83
|
+
// Never silently treat Chat Completions/Anthropic as Codex Responses.
|
|
84
|
+
if (config.protocol !== 'openai-responses') return denied('runtime_protocol_not_supported');
|
|
85
|
+
const targetId = `hub_${createHash('sha256').update(JSON.stringify([h.networkId, input.id])).digest('hex').slice(0, 24)}`;
|
|
86
|
+
try {
|
|
87
|
+
const key = vaultGet(h.networkId!, stored.secret_key_ref); if (!key) return denied('provider_key_missing');
|
|
88
|
+
const queued = requestProviderWrite(h, input.daemon_node_id, input.daemon_revision, { id: targetId,
|
|
89
|
+
label: `Hub · ${config.label}`.slice(0, 100), enabled: true, runtimes: [{ runtime: 'codex-tui', auth: [
|
|
90
|
+
{ id: 'hub_key', kind: 'api_key', models: config.models, baseUrl: config.baseUrl, key },
|
|
91
|
+
] }] });
|
|
92
|
+
if (!queued.ok) return queued;
|
|
93
|
+
db.run(`INSERT INTO hub_provider_bindings(request_id,network_id,provider_id,provider_revision,daemon_node_id,daemon_provider_id,created_at)
|
|
94
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7)`, [queued.request_id, h.networkId!, input.id, input.revision, input.daemon_node_id, targetId, Date.now()]);
|
|
95
|
+
return { ...queued, daemon_node_id: input.daemon_node_id,
|
|
96
|
+
provider_reference: { network_id: h.networkId!, id: input.id, revision: input.revision, daemon_provider_id: targetId } };
|
|
97
|
+
} catch (error) { return denied(error instanceof VaultError ? error.code : 'hub_provider_binding_failed'); }
|
|
98
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// #906 intent contains identifiers only. API keys remain on the owning daemon.
|
|
2
|
+
export type ProviderCollection = {
|
|
3
|
+
node_id: string; alias: string; node_revision: string;
|
|
4
|
+
provider_id: string; auth_id: string; label: string;
|
|
5
|
+
};
|
|
6
|
+
const id = (v: unknown) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v);
|
|
7
|
+
export function validProviderCollection(v: any): v is ProviderCollection {
|
|
8
|
+
return !!v && typeof v === 'object' && !Array.isArray(v)
|
|
9
|
+
&& Object.keys(v).every(k => ['node_id', 'alias', 'node_revision', 'provider_id', 'auth_id', 'label'].includes(k))
|
|
10
|
+
&& id(v.node_id) && id(v.provider_id) && id(v.auth_id)
|
|
11
|
+
&& typeof v.alias === 'string' && /^[\p{L}\p{N}][\p{L}\p{N}_.-]{0,99}$/u.test(v.alias)
|
|
12
|
+
&& typeof v.node_revision === 'string' && /^[a-f0-9]{64}$/.test(v.node_revision)
|
|
13
|
+
&& typeof v.label === 'string' && !!v.label.trim() && v.label.length <= 100 && !/[\x00-\x1f\x7f]/.test(v.label);
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type ProviderInitialization = { revision: number; provider_id: string; auth_id: string; model: string };
|
|
2
|
+
export function validProviderInitialization(value: any): value is ProviderInitialization {
|
|
3
|
+
const id = (v: unknown) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(v);
|
|
4
|
+
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
5
|
+
&& Object.keys(value).every(key => ['revision', 'provider_id', 'auth_id', 'model'].includes(key))
|
|
6
|
+
&& Number.isSafeInteger(value.revision) && value.revision >= 0 && id(value.provider_id) && id(value.auth_id)
|
|
7
|
+
&& typeof value.model === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(value.model);
|
|
8
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
|
+
import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
3
|
+
import { readHubProviders, putHubProvider, bindHubProvider } from './hub-provider-config.js';
|
|
3
4
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
4
5
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
5
6
|
import { adoptionCandidatesReportSchema } from "./adoption-candidates.js";
|
|
@@ -4128,13 +4129,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4128
4129
|
const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
|
|
4129
4130
|
const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
|
|
4130
4131
|
server.tool(
|
|
4131
|
-
"daemon_provider_snapshot", "
|
|
4132
|
-
{ 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(), include_discovery: z.boolean().optional() },
|
|
4132
|
+
"daemon_provider_snapshot", "Provider: daemon refresh/put/collect/apply/login; read=request id. login provider={action:start|read|cancel|save|accounts,session_id?,label?}. hub_read=catalog; hub_put=owner config; hub_bind=id+revision,provider={daemon_node_id,daemon_revision,confirm:true}. No login tokens.",
|
|
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() },
|
|
4133
4134
|
async ({ action, id, network_id, revision, provider, include_discovery }) => {
|
|
4134
4135
|
const human = providerHuman(network_id);
|
|
4136
|
+
if (action === 'hub_read') return providerReply(readHubProviders(human));
|
|
4137
|
+
if (action === 'hub_put') return providerReply(putHubProvider(human, revision!, provider));
|
|
4138
|
+
if (action === 'hub_bind') {
|
|
4139
|
+
const selection = provider as { daemon_node_id?: string; daemon_revision?: number; confirm?: boolean } | undefined;
|
|
4140
|
+
const result = bindHubProvider(human, { id, revision: revision!, daemon_node_id: selection?.daemon_node_id!, daemon_revision: selection?.daemon_revision!, confirm: selection?.confirm! });
|
|
4141
|
+
if (result.ok && 'request_id' in result) {
|
|
4142
|
+
const target = db.get<{ alias: string }>('SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2', selection?.daemon_node_id!, human.networkId!);
|
|
4143
|
+
if (target) pushEvent(target.alias, { type: 'daemon_provider_snapshot', request_id: result.request_id }, human.networkId!);
|
|
4144
|
+
}
|
|
4145
|
+
return providerReply(result);
|
|
4146
|
+
}
|
|
4135
4147
|
if (action === "read") return providerReply(getProviderSnapshot(human, id, include_discovery));
|
|
4136
4148
|
const daemon_node_id = id;
|
|
4137
4149
|
const result = action === 'login' ? requestProviderLogin(human, daemon_node_id, provider)
|
|
4150
|
+
: action === 'collect' ? requestProviderCollection(human, daemon_node_id, revision!, provider)
|
|
4138
4151
|
: action === 'apply' ? requestProviderApplication(human, daemon_node_id, revision!, provider)
|
|
4139
4152
|
: action === 'put' ? requestProviderWrite(human, daemon_node_id, revision!, provider) : requestProviderSnapshot(human, daemon_node_id);
|
|
4140
4153
|
if (result.ok) {
|
|
@@ -4145,8 +4158,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4145
4158
|
},
|
|
4146
4159
|
);
|
|
4147
4160
|
server.tool(
|
|
4148
|
-
"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(), accept_local_discovery: z.boolean().optional() },
|
|
4149
|
-
async ({ accept_writes, accept_applications, accept_logins, accept_local_discovery }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins, accept_local_discovery)),
|
|
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 }) => providerReply(pullProviderSnapshots(resolveCallerDaemonTokenBound(), accept_writes, accept_applications, accept_logins, accept_local_discovery, accept_collections)),
|
|
4150
4163
|
);
|
|
4151
4164
|
server.tool(
|
|
4152
4165
|
"ack_daemon_provider_snapshot", "Target daemon acknowledges redacted Provider configuration readback; fixed failure code only.",
|