@sleep2agi/commhub-server 0.9.0-preview.122 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.122",
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);
@@ -1,6 +1,6 @@
1
1
  import "./require-explicit-test-db";
2
2
  import { afterAll, beforeEach, expect, test } from "bun:test";
3
- import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createHash } from 'node:crypto';
@@ -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);
@@ -320,15 +371,17 @@ test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public
320
371
  db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), nodeId]);
321
372
  const dir = join(root, ".anet/nodes", daemonAlias); mkdirSync(dir, { recursive: true, mode: 0o700 });
322
373
  writeFileSync(join(dir, "config.json"), JSON.stringify({ role: "host_supervisor", network_id: netId, node_id: nodeId }), { mode: 0o600 });
323
- for (const child of ['bound', 'neighbour']) {
374
+ for (const child of ['bound', 'neighbour', 'manual']) {
324
375
  const childId = `n_scan_${child}`;
325
376
  db.run('INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,?2,?2,?3,\'codex-app-server\')', [childId, child, netId]);
326
- 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 (?1,?2,?3,?4,?5,?6,\'active\',1,1)',
377
+ if (child !== 'manual') 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 (?1,?2,?3,?4,?5,?6,\'active\',1,1)',
327
378
  [`binding_scan_${child}`, netId, childId, child === 'bound' ? nodeId : 'another-daemon', root, owner.user!.user_id]);
328
379
  const home = join(root, '.anet/nodes', child, 'codex-home'); mkdirSync(home, { recursive: true, mode: 0o700 });
329
380
  writeFileSync(join(home, '../config.json'), JSON.stringify({ node_id: childId, alias: child, runtime: 'codex-app-server', network_id: netId }), { mode: 0o600 });
330
381
  writeFileSync(join(home, 'config.toml'), 'model_provider = "deepseek"\nmodel = "fixture-model"\n[model_providers.deepseek]\nenv_key = "TEST_API_KEY"\n', { mode: 0o600 });
331
382
  }
383
+ chmodSync(join(root, '.anet'), 0o700);
384
+ chmodSync(join(root, '.anet/nodes'), 0o775);
332
385
  const hub = (await import("./server")).bootServer({ port: 0, hostname: "127.0.0.1" });
333
386
  const url = `http://127.0.0.1:${hub.port}/mcp`;
334
387
  const rpc = (credential: string) => async (name: string, args: any) => {
@@ -352,6 +405,15 @@ test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public
352
405
  expect(read.status).toBe("succeeded"); expect(read.snapshot).toMatchObject({ network_id: netId, daemon_node_id: nodeId, revision: 0, source: "daemon", providers: [], codex_inventory: { scope: 'hub_bound_nodes' } });
353
406
  expect(read.snapshot.codex_inventory.rows).toHaveLength(1);
354
407
  expect(read.snapshot.codex_inventory.rows[0]).toMatchObject({ node_id: 'n_scan_bound', configured_provider: 'deepseek', configured_model: 'fixture-model', credential_status: 'unknown' });
408
+ expect(read.snapshot.codex_inventory.local_discovery).toBeUndefined(); // old client projection
409
+ const discovered = await human('daemon_provider_snapshot', { action: 'read', id: refresh.request_id, network_id: netId, include_discovery: true });
410
+ expect(discovered.snapshot.codex_inventory.local_discovery).toMatchObject({ status: 'observed', rows: [
411
+ { node_id: 'n_scan_manual', alias: 'manual', configured_model: 'fixture-model', config_revision: null },
412
+ ] });
413
+ expect(discovered.snapshot.codex_inventory.local_discovery.rows).toHaveLength(1); // other daemon filtered
414
+ const deniedApply = await human('daemon_provider_snapshot', { action: 'apply', id: nodeId, network_id: netId, revision: 0,
415
+ provider: { node_id: 'n_scan_manual', node_revision: 'a'.repeat(64), provider_id: 'deepseek', auth_id: 'key', model: 'fixture-model', confirm_restart: true } });
416
+ expect(deniedApply).toMatchObject({ ok: false, error: 'application_target_not_bound' });
355
417
  const foreign = await human('daemon_provider_snapshot', { action: 'refresh', id: nodeId, network_id: netId });
356
418
  const bad = structuredClone(read.snapshot); bad.codex_inventory.rows[0].node_id = 'n_scan_neighbour';
357
419
  expect((await daemon('ack_daemon_provider_snapshot', { request_id: foreign.request_id, snapshot: bad })).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);
@@ -156,18 +186,21 @@ export function requestProviderLogin(h: Human, daemonId: string, intent: unknown
156
186
  return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
157
187
  } catch { return denied('provider_snapshot_busy'); }
158
188
  }
159
- export function getProviderSnapshot(h: Human, requestId: string) {
189
+ export function getProviderSnapshot(h: Human, requestId: string, includeDiscovery = false) {
160
190
  const refusal = authorize(h); if (refusal) return refusal;
161
191
  expire(h.networkId!);
162
192
  const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2`, requestId, h.networkId!);
163
193
  if (!row) return denied("request_not_found");
194
+ const snapshot = !/^dp[al]_/.test(row.request_id) && row.snapshot_json ? JSON.parse(row.snapshot_json) : null;
195
+ // Old clients validate an exact contract. Their default projection stays unchanged.
196
+ if (!includeDiscovery && snapshot?.codex_inventory) delete snapshot.codex_inventory.local_discovery;
164
197
  return { ok: true, request_id: row.request_id, daemon_node_id: row.daemon_node_id, status: row.status,
165
198
  expires_at: row.expires_at, observed_at: row.observed_at, error: row.error,
166
- snapshot: !/^dp[al]_/.test(row.request_id) && row.snapshot_json ? JSON.parse(row.snapshot_json) : null,
199
+ snapshot,
167
200
  ...(row.request_id.startsWith('dpl_') ? { login: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}),
168
201
  ...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
169
202
  }
170
- export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false) {
203
+ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false, acceptDiscovery = false, acceptCollections = false) {
171
204
  if (!d.ok) return denied("caller_not_a_daemon");
172
205
  if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
173
206
  expire(d.networkId, d.daemonNodeId);
@@ -192,9 +225,16 @@ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApp
192
225
  }
193
226
  return [{ ...r, operation: 'apply', ...application!, alias: target.alias }];
194
227
  }
195
- if (!acceptWrites && db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', r.request_id)) return [];
196
228
  const write = privateWrite(r.request_id, d.networkId, d.daemonNodeId);
197
- return [write ? { ...r, operation: 'put', ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId) }];
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 } : {}) }];
198
238
  }) }; } catch { return denied('provider_write_unavailable'); }
199
239
  }
200
240
  export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown, login?: unknown) {
@@ -244,6 +284,16 @@ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unk
244
284
  const targets = new Map(providerScanTargets(d.networkId, d.daemonNodeId).map(n => [n.node_id, n.alias]));
245
285
  const rows = (safe as any).codex_inventory.rows;
246
286
  if (rows.length !== targets.size || rows.some((r: any) => targets.get(r.node_id) !== r.alias)) return denied('invalid_provider_snapshot');
287
+ const local = (safe as any).codex_inventory.local_discovery;
288
+ if (local) {
289
+ const registered = new Map(db.all<{ node_id: string; alias: string }>('SELECT node_id,alias FROM nodes WHERE network_id=?1', d.networkId).map(n => [n.node_id, n.alias]));
290
+ const ownership = buildControllableMap(
291
+ db.all<{ request_id: string; daemon_node_id: string }>('SELECT request_id,daemon_node_id FROM node_create_requests WHERE network_id=?1', d.networkId),
292
+ db.all<{ node_id: string; daemon_node_id: string }>("SELECT node_id,daemon_node_id FROM node_daemon_bindings WHERE network_id=?1 AND status='active'", d.networkId));
293
+ // Filesystem discovery is not control authority. Other-daemon and
294
+ // unregistered identities cannot enter this network's public receipt.
295
+ local.rows = local.rows.filter((r: any) => registered.get(r.node_id) === r.alias && !ownership.has(r.node_id));
296
+ }
247
297
  } catch { return denied('invalid_provider_snapshot'); }
248
298
  }
249
299
  try {
@@ -251,12 +301,20 @@ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unk
251
301
  if (write) {
252
302
  const actual = safe as any;
253
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 {
254
311
  const expected = validateWrite(write.provider, d.networkId, d.daemonNodeId);
255
312
  const saved = actual.providers.find((p: any) => p.id === expected.id);
256
313
  // CLI canonicalizes label and endpoint. No credential is compared or returned here.
257
314
  expected.label = expected.label.trim();
258
315
  for (const r of expected.runtimes) for (const a of r.auth) if (a.baseUrl) a.baseUrl = new URL(a.baseUrl).toString().replace(/\/$/, '');
259
316
  if (!isDeepStrictEqual(saved, expected)) return denied('provider_write_unconfirmed');
317
+ }
260
318
  }
261
319
  } catch { return denied('provider_write_unconfirmed'); }
262
320
  }
@@ -80,7 +80,8 @@ test("name-only issuance checks existing ownership atomically", async () => {
80
80
  });
81
81
 
82
82
  // Every daemon-facing handler must fail at the shared identity boundary,
83
- // not incidentally at request lookup or argument validation.
83
+ // not incidentally at request lookup or argument validation. The resolver
84
+ // mutation expects all 15 of these cases to go red together.
84
85
  const endpoints: [string, Record<string, unknown>][] = [
85
86
  ["list_my_pending_create_requests", {}], ["list_my_pending_lifecycle_requests", {}],
86
87
  ["get_create_request", { request_id: "cr_fixture_absent" }],
@@ -92,6 +93,8 @@ const endpoints: [string, Record<string, unknown>][] = [
92
93
  ["list_my_children", {}], ["get_adopt_request", {}], ["ack_adopt_request", { status: "adopted" }],
93
94
  ["get_probe_request", { probe_id: "probe_fixture_absent" }],
94
95
  ["ack_probe_request", { probe_id: "probe_fixture_absent", status: "ok", latency_ms: 1 }],
96
+ ["get_opencode_catalog_request", { request_id: "cat_fixture_absent" }],
97
+ ["ack_opencode_catalog", { request_id: "cat_fixture_absent", error: "unavailable" }],
95
98
  ];
96
99
  for (const [name, arguments_] of endpoints) {
97
100
  test(`daemon identity boundary: ${name}`, async () => {
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
+ }
@@ -26,9 +26,10 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
26
26
  * 加工具或加说明超过它,就在这里改数字并在 PR 里说为什么。
27
27
  */
28
28
  // Board #625: two human adoption tools; measured 58 tools / 56,615 B.
29
- // Hub #938 clone_node is audience both, so this user-token list grows.
30
- // Measured with #906's tools plus clone_node: 60 tools / 58,169 B. Ceiling keeps ~431 B.
31
- const TOOLS_LIST_MAX_BYTES = 58_600;
29
+ // Hub #938 clone_node is audience both (60 tools / 58,169 B). OpenCode V2
30
+ // catalog adds read_opencode_catalog and get_opencode_catalog on that user
31
+ // list. Measured together: 62 tools / 59,409 B. Ceiling keeps ~401 B.
32
+ const TOOLS_LIST_MAX_BYTES = 59_810;
32
33
  /** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
33
34
  const TOOL_MAX_BYTES = 9_000;
34
35
  /** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
@@ -254,6 +254,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
254
254
  get_config_update: "always", ack_config_update: "always", get_rules_file_request: "always", ack_rules_file_request: "always", get_rules_file_result: "always",
255
255
  get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
256
256
  get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
257
+ get_opencode_catalog_request: "always", ack_opencode_catalog: "always",
257
258
  list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
258
259
  request_adopt_node: "human_only", unadopt_node: "human_only",
259
260
  get_adopt_request: "always", ack_adopt_request: "always",
@@ -280,4 +281,5 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
280
281
  // 只有人能做
281
282
  upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
282
283
  projects_create: "human_only", projects_update: "human_only", review_skill: "human_only",
284
+ read_opencode_catalog: "human_only", get_opencode_catalog: "human_only",
283
285
  };