@sleep2agi/commhub-server 0.9.0-preview.121 → 0.9.0-preview.123

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.
@@ -1,9 +1,13 @@
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';
7
+ import { EventEmitter } from 'node:events';
8
+ import { CodexManagedLogin } from '../../agent-node/src/runtime/codex-managed-login';
9
+ import { DaemonCodexLogins } from '../../agent-node/src/runtime/daemon-codex-logins';
10
+ import { executeProviderLogin, closeProviderLogins } from '../../agent-node/src/runtime/daemon-provider-login';
7
11
  import { db } from "./db";
8
12
  import { registerTools } from "./tools";
9
13
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -14,6 +18,7 @@ import { register, createNetworkTokenForNode } from "./auth";
14
18
  const net = "net_dps906", user = "u_dps906", node = "node_dps906", token = "token_dps906", alias = "daemon906";
15
19
  const root = mkdtempSync(join(tmpdir(), "dps906-"));
16
20
  function cleanup() {
21
+ db.run('DELETE FROM daemon_provider_logins WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
17
22
  db.run('DELETE FROM daemon_provider_applications WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
18
23
  db.run('DELETE FROM daemon_provider_writes WHERE request_id IN (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1)', [net]);
19
24
  db.run('DELETE FROM node_daemon_bindings WHERE network_id=?1', [net]);
@@ -44,6 +49,90 @@ function client(kind = "human", network = net) {
44
49
  const snapshot = () => ({ network_id: net, daemon_node_id: node, revision: 0, source: "daemon", providers: [] });
45
50
  const request = () => client()("daemon_provider_snapshot", { action: "refresh", network_id: net, id: node });
46
51
 
52
+ test('saved ChatGPT account binding traverses Hub and compiled daemon CLI without credentials', async () => {
53
+ process.env.ANET_HUB_SECRET_VAULT_KEY = '42'.repeat(32);
54
+ const work = mkdtempSync(join(root, 'bind-')), dir = join(work, '.anet/nodes', alias);
55
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
56
+ writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
57
+ const prepared = new DaemonProviderStore(dir).prepareLoginDirectory();
58
+ const home = join(prepared.root, 'login-fixture'); mkdirSync(home, { mode: 0o700 });
59
+ const account = '11111111-1111-4111-8111-111111111111';
60
+ writeFileSync(join(home, 'anet-account.json'), JSON.stringify({ version: 1, network_id: net, daemon_node_id: node, account_id: account }), { mode: 0o600 });
61
+ writeFileSync(join(home, 'auth.json'), JSON.stringify({ auth_mode: 'chatgpt', tokens: { access_token: 'TEST-ONLY-access', refresh_token: 'TEST-ONLY-refresh' } }), { mode: 0o600 });
62
+ const provider = { id: 'openai', label: 'OpenAI', enabled: true, runtimes: [{ runtime: 'codex-tui', auth: [
63
+ { id: 'work', kind: 'chatgpt', account_id: account, models: ['model-fixture'] },
64
+ ] }] };
65
+ const human = client(), daemon = client('daemon');
66
+ const r = await human('daemon_provider_snapshot', { action: 'put', id: node, network_id: net, revision: 0, provider });
67
+ expect(r.ok).toBe(true);
68
+ await reconcileProviderSnapshots({ workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {}, callCommHub: daemon });
69
+ const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
70
+ expect(read.status).toBe('succeeded');
71
+ expect(read.snapshot.providers[0].runtimes[0].auth[0]).toMatchObject({ account_id: account, credential_present: true, verification: 'not_checked', application: 'not_applied' });
72
+ expect(JSON.stringify(read)).not.toContain('TEST-ONLY-access');
73
+ expect(JSON.stringify(read)).not.toContain('TEST-ONLY-refresh');
74
+ expect(JSON.stringify(read)).not.toContain(home);
75
+ });
76
+
77
+ test('managed login: admin intent → capable target daemon → session manager → bounded public receipt', async () => {
78
+ const work = mkdtempSync(join(root, 'login-'));
79
+ const dir = join(work, '.anet/nodes', alias); mkdirSync(dir, { recursive: true, mode: 0o700 });
80
+ writeFileSync(join(dir, 'config.json'), JSON.stringify({ role: 'host_supervisor', network_id: net, node_id: node }), { mode: 0o600 });
81
+ const prepared = new DaemonProviderStore(dir).prepareLoginDirectory();
82
+ const session = 'b86c3ca5-5022-434d-bc9f-aa1c82ecf419';
83
+ class Rpc extends EventEmitter {
84
+ account: unknown = null;
85
+ async request<R>(method: string): Promise<R> {
86
+ return (method === 'account/read' ? { account: this.account } : { type: 'chatgptDeviceCode', loginId: session,
87
+ verificationUrl: 'https://auth.openai.com/codex/device', userCode: 'TEST-ONLY' }) as R;
88
+ }
89
+ async close() {}
90
+ }
91
+ const rpc = new Rpc(); let home = '';
92
+ const logins = new DaemonCodexLogins(prepared.root, { network_id: net, daemon_node_id: node }, async () => {
93
+ home = mkdtempSync(join(prepared.root, 'login-'));
94
+ const login = new CodexManagedLogin(rpc);
95
+ return { home, login, discard: async () => { await login.close(); rmSync(home, { recursive: true, force: true }); } };
96
+ });
97
+ const human = client(), daemon = client('daemon');
98
+ const deps = { workDir: work, alias, networkId: net, daemonId: node, serverUrl: 'https://hub.example.test', warn: () => {},
99
+ callCommHub: daemon, login: (intent: any) => executeProviderLogin(logins, intent) };
100
+ const command = (intent: any) => human('daemon_provider_snapshot', { action: 'login', id: node, network_id: net, provider: intent });
101
+ const read = (id: string) => human('daemon_provider_snapshot', { action: 'read', id, network_id: net });
102
+ try {
103
+ expect((await daemon('daemon_provider_snapshot', { action: 'login', id: node, provider: { action: 'start', session_id: session } })).ok).toBe(false);
104
+ expect((await command({ action: 'start', session_id: session, token: 'TEST-ONLY' })).ok).toBe(false);
105
+ const start = await command({ action: 'start', session_id: session });
106
+ expect(start.ok).toBe(true);
107
+ expect((await daemon('pull_daemon_provider_snapshots', { accept_writes: true, accept_applications: true })).requests).toEqual([]);
108
+ expect((await client('daemon', 'other')('pull_daemon_provider_snapshots', { accept_logins: true })).ok).toBe(false);
109
+ expect((await daemon('ack_daemon_provider_snapshot', { request_id: start.request_id, login: { request_id: session, status: 'authenticated', token: 'TEST-ONLY' } })).ok).toBe(false);
110
+ await reconcileProviderSnapshots(deps);
111
+ const challenge = await read(start.request_id);
112
+ expect(challenge.status).toBe('succeeded');
113
+ expect(challenge.login.status).toBe('awaiting_user');
114
+ expect(challenge.snapshot).toBeNull();
115
+ writeFileSync(join(home, 'auth.json'), '{"TEST_ONLY":"SECRET"}', { mode: 0o600 });
116
+ rpc.account = { type: 'chatgpt', email: 'PRIVATE', token: 'SECRET' };
117
+ rpc.emit('account/login/completed', { loginId: session, success: true });
118
+ await Promise.resolve();
119
+ const save = await command({ action: 'save', session_id: session, label: 'Work' });
120
+ await reconcileProviderSnapshots(deps);
121
+ expect((await read(save.request_id)).login.account_id).toBeString();
122
+ const list = await command({ action: 'accounts' });
123
+ // Listing uses the actual daemon handler and compiled pinned CLI bootstrap,
124
+ // not the injected OAuth fixture. It does not start a login or run inference.
125
+ await reconcileProviderSnapshots({ ...deps, login: undefined });
126
+ const result = await read(list.request_id);
127
+ expect(result.login.accounts).toHaveLength(1);
128
+ expect(result.login.accounts[0].auth_state).toBe('not_checked');
129
+ expect(JSON.stringify(result)).not.toMatch(/SECRET|PRIVATE|auth.json/);
130
+ db.run('UPDATE daemon_provider_snapshots SET expires_at=?1 WHERE request_id=?2', [Date.now() - 1, start.request_id]);
131
+ expect((await read(start.request_id)).login).toBeNull();
132
+ expect(db.get<any>('SELECT snapshot_json FROM daemon_provider_snapshots WHERE request_id=?1', start.request_id).snapshot_json).toBeNull();
133
+ } finally { await logins.close(); await closeProviderLogins(); }
134
+ });
135
+
47
136
  test('application transport: scoped intent, capability gate, exact public receipt and changed binding refusal', async () => {
48
137
  const human = client(), daemon = client('daemon');
49
138
  const child = 'node_apply906';
@@ -71,6 +160,20 @@ test('application transport: scoped intent, capability gate, exact public receip
71
160
  const read = await human('daemon_provider_snapshot', { action: 'read', id: r.request_id, network_id: net });
72
161
  expect(read.status).toBe('succeeded'); expect(read.application).toEqual(receipt); expect(read.snapshot).toBeNull();
73
162
  expect(db.get('SELECT * FROM daemon_provider_applications WHERE request_id=?1', r.request_id)).toBeNull();
163
+ const accountId = 'aaaaaaaa-1111-4111-8111-111111111111';
164
+ const officialSelection = { ...selection, provider_id: 'openai', auth_id: 'work-login', account_id: accountId };
165
+ const official = await human('daemon_provider_snapshot', { ...args, provider: officialSelection });
166
+ expect(official.ok).toBe(true);
167
+ const officialDispatch = await daemon('pull_daemon_provider_snapshots', { accept_applications: true });
168
+ expect(officialDispatch.requests[0].selection).toEqual(officialSelection);
169
+ const officialReceipt = { ...receipt, request_id: official.request_id, provider_id: 'openai', auth_id: 'work-login',
170
+ account_id: accountId, runtime_provider: 'openai', applied_at: Date.now() };
171
+ for (const wrong of [{ ...officialReceipt, account_id: 'bbbbbbbb-1111-4111-8111-111111111111' },
172
+ { ...officialReceipt, account_id: undefined }, { ...officialReceipt, runtime_provider: receipt.runtime_provider }]) {
173
+ expect((await daemon('ack_daemon_provider_snapshot', { request_id: official.request_id, application: wrong })).error).toBe('invalid_application_receipt');
174
+ }
175
+ expect((await daemon('ack_daemon_provider_snapshot', { request_id: official.request_id, application: officialReceipt })).status).toBe('succeeded');
176
+ expect((await human('daemon_provider_snapshot', { action: 'read', id: official.request_id, network_id: net })).application).toEqual(officialReceipt);
74
177
  const stale = await human('daemon_provider_snapshot', args);
75
178
  db.run("UPDATE node_daemon_bindings SET daemon_node_id='other' WHERE request_id='binding_apply906'");
76
179
  expect((await daemon('pull_daemon_provider_snapshots', { accept_applications: true })).requests).toEqual([]);
@@ -217,15 +320,17 @@ test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public
217
320
  db.run("UPDATE nodes SET config_snapshot=?1 WHERE node_id=?2", [JSON.stringify({ role: "host_supervisor" }), nodeId]);
218
321
  const dir = join(root, ".anet/nodes", daemonAlias); mkdirSync(dir, { recursive: true, mode: 0o700 });
219
322
  writeFileSync(join(dir, "config.json"), JSON.stringify({ role: "host_supervisor", network_id: netId, node_id: nodeId }), { mode: 0o600 });
220
- for (const child of ['bound', 'neighbour']) {
323
+ for (const child of ['bound', 'neighbour', 'manual']) {
221
324
  const childId = `n_scan_${child}`;
222
325
  db.run('INSERT INTO nodes (node_id,node_name,alias,network_id,runtime) VALUES (?1,?2,?2,?3,\'codex-app-server\')', [childId, child, netId]);
223
- 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)',
326
+ 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)',
224
327
  [`binding_scan_${child}`, netId, childId, child === 'bound' ? nodeId : 'another-daemon', root, owner.user!.user_id]);
225
328
  const home = join(root, '.anet/nodes', child, 'codex-home'); mkdirSync(home, { recursive: true, mode: 0o700 });
226
329
  writeFileSync(join(home, '../config.json'), JSON.stringify({ node_id: childId, alias: child, runtime: 'codex-app-server', network_id: netId }), { mode: 0o600 });
227
330
  writeFileSync(join(home, 'config.toml'), 'model_provider = "deepseek"\nmodel = "fixture-model"\n[model_providers.deepseek]\nenv_key = "TEST_API_KEY"\n', { mode: 0o600 });
228
331
  }
332
+ chmodSync(join(root, '.anet'), 0o700);
333
+ chmodSync(join(root, '.anet/nodes'), 0o775);
229
334
  const hub = (await import("./server")).bootServer({ port: 0, hostname: "127.0.0.1" });
230
335
  const url = `http://127.0.0.1:${hub.port}/mcp`;
231
336
  const rpc = (credential: string) => async (name: string, args: any) => {
@@ -249,6 +354,15 @@ test("real HTTP authentication -> daemon handler -> pinned CLI -> scoped public
249
354
  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' } });
250
355
  expect(read.snapshot.codex_inventory.rows).toHaveLength(1);
251
356
  expect(read.snapshot.codex_inventory.rows[0]).toMatchObject({ node_id: 'n_scan_bound', configured_provider: 'deepseek', configured_model: 'fixture-model', credential_status: 'unknown' });
357
+ expect(read.snapshot.codex_inventory.local_discovery).toBeUndefined(); // old client projection
358
+ const discovered = await human('daemon_provider_snapshot', { action: 'read', id: refresh.request_id, network_id: netId, include_discovery: true });
359
+ expect(discovered.snapshot.codex_inventory.local_discovery).toMatchObject({ status: 'observed', rows: [
360
+ { node_id: 'n_scan_manual', alias: 'manual', configured_model: 'fixture-model', config_revision: null },
361
+ ] });
362
+ expect(discovered.snapshot.codex_inventory.local_discovery.rows).toHaveLength(1); // other daemon filtered
363
+ const deniedApply = await human('daemon_provider_snapshot', { action: 'apply', id: nodeId, network_id: netId, revision: 0,
364
+ provider: { node_id: 'n_scan_manual', node_revision: 'a'.repeat(64), provider_id: 'deepseek', auth_id: 'key', model: 'fixture-model', confirm_restart: true } });
365
+ expect(deniedApply).toMatchObject({ ok: false, error: 'application_target_not_bound' });
252
366
  const foreign = await human('daemon_provider_snapshot', { action: 'refresh', id: nodeId, network_id: netId });
253
367
  const bad = structuredClone(read.snapshot); bad.codex_inventory.rows[0].node_id = 'n_scan_neighbour';
254
368
  expect((await daemon('ack_daemon_provider_snapshot', { request_id: foreign.request_id, snapshot: bad })).ok).toBe(false);
@@ -8,6 +8,7 @@ import { validateDaemonProviderSnapshot } from "./shared/daemon-provider-snapsho
8
8
  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
+ import { validLoginIntent, validLoginReceipt } from './shared/daemon-provider-login';
11
12
 
12
13
  export function providerScanTargets(networkId: string, daemonId: string) {
13
14
  const ownership = buildControllableMap(
@@ -38,6 +39,9 @@ function daemonExists(networkId: string, nodeId: string): boolean {
38
39
  try { return JSON.parse(node?.config_snapshot || "{}").role === "host_supervisor"; } catch { return false; }
39
40
  }
40
41
  function expire(networkId: string, daemonId?: string) {
42
+ // Device-code instructions are short-lived; do not leave them in audit history.
43
+ db.run(`UPDATE daemon_provider_snapshots SET status='expired', snapshot_json=NULL, error='request_expired'
44
+ WHERE network_id=?1 AND request_id LIKE 'dpl_%' AND expires_at <= ?2`, [networkId, Date.now()]);
41
45
  db.run(`UPDATE daemon_provider_snapshots SET status='expired', error='request_expired'
42
46
  WHERE network_id=?1 AND status='pending' AND expires_at <= ?2
43
47
  AND (?3 IS NULL OR daemon_node_id=?3)`, [networkId, Date.now(), daemonId ?? null]);
@@ -45,6 +49,8 @@ function expire(networkId: string, daemonId?: string) {
45
49
  (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
46
50
  db.run(`DELETE FROM daemon_provider_applications WHERE request_id IN
47
51
  (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
52
+ db.run(`DELETE FROM daemon_provider_logins WHERE request_id IN
53
+ (SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
48
54
  db.run(`DELETE FROM daemon_provider_snapshots WHERE network_id=?1 AND expires_at < ?2`, [networkId, Date.now() - 7 * 86400_000]);
49
55
  }
50
56
  /** Validate the complete public projection before encrypting the private input. */
@@ -56,8 +62,10 @@ function validateWrite(provider: any, networkId: string, daemonId: string) {
56
62
  if (a.kind === 'chatgpt' && key !== undefined) throw new Error('invalid_provider');
57
63
  if (key !== undefined && (typeof key !== 'string' || !key || key.length > 8192 || /[\s\x00-\x1f\x7f]/.test(key))) throw new Error('invalid_provider');
58
64
  // Only CLI write fields may be supplied; public-state flags are not writable.
59
- if (Object.keys(a).some(k => !['id','kind','models','baseUrl','key'].includes(k))) throw new Error('invalid_provider');
60
- return { ...rest, credential_present: a.kind === 'api_key', verification: 'not_checked', application: 'not_applied' };
65
+ if (Object.keys(a).some(k => !['id','kind','models','baseUrl','key','account_id'].includes(k))) throw new Error('invalid_provider');
66
+ // A bound login write must be confirmed present by its owning daemon;
67
+ // this projection is the expected receipt, not an independent Hub claim.
68
+ return { ...rest, credential_present: a.kind === 'api_key' || (a.kind === 'chatgpt' && a.account_id !== undefined), verification: 'not_checked', application: 'not_applied' };
61
69
  }),
62
70
  })) };
63
71
  validateDaemonProviderSnapshot({ network_id: networkId, daemon_node_id: daemonId, source: 'daemon', revision: 0, providers: [publicProvider] }, networkId, daemonId);
@@ -133,23 +141,49 @@ export function requestProviderSnapshot(h: Human, daemonId: string) {
133
141
  if (!accepted) return denied("provider_snapshot_busy");
134
142
  return { ok: true as const, request_id: accepted.request_id, status: "pending", expires_at: accepted.expires_at };
135
143
  }
136
- export function getProviderSnapshot(h: Human, requestId: string) {
144
+ export function requestProviderLogin(h: Human, daemonId: string, intent: unknown) {
145
+ const refusal = authorize(h); if (refusal) return refusal;
146
+ if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
147
+ if (!validLoginIntent(intent)) return denied('invalid_login_intent');
148
+ expire(h.networkId!, daemonId);
149
+ const requestId = `dpl_${randomUUID()}`, now = Date.now();
150
+ try {
151
+ db.transaction(() => {
152
+ db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
153
+ VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
154
+ db.run('INSERT INTO daemon_provider_logins (request_id,intent_json) VALUES (?1,?2)', [requestId, JSON.stringify(intent)]);
155
+ });
156
+ return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
157
+ } catch { return denied('provider_snapshot_busy'); }
158
+ }
159
+ export function getProviderSnapshot(h: Human, requestId: string, includeDiscovery = false) {
137
160
  const refusal = authorize(h); if (refusal) return refusal;
138
161
  expire(h.networkId!);
139
162
  const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2`, requestId, h.networkId!);
140
163
  if (!row) return denied("request_not_found");
164
+ const snapshot = !/^dp[al]_/.test(row.request_id) && row.snapshot_json ? JSON.parse(row.snapshot_json) : null;
165
+ // Old clients validate an exact contract. Their default projection stays unchanged.
166
+ if (!includeDiscovery && snapshot?.codex_inventory) delete snapshot.codex_inventory.local_discovery;
141
167
  return { ok: true, request_id: row.request_id, daemon_node_id: row.daemon_node_id, status: row.status,
142
168
  expires_at: row.expires_at, observed_at: row.observed_at, error: row.error,
143
- snapshot: !row.request_id.startsWith('dpa_') && row.snapshot_json ? JSON.parse(row.snapshot_json) : null,
169
+ snapshot,
170
+ ...(row.request_id.startsWith('dpl_') ? { login: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}),
144
171
  ...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
145
172
  }
146
- export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false) {
173
+ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false, acceptDiscovery = false) {
147
174
  if (!d.ok) return denied("caller_not_a_daemon");
148
175
  if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
149
176
  expire(d.networkId, d.daemonNodeId);
150
177
  const requests = db.all<{ request_id: string; expires_at: number }>(`SELECT request_id,expires_at FROM daemon_provider_snapshots
151
178
  WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at LIMIT 20`, d.networkId, d.daemonNodeId);
152
179
  try { return { ok: true, requests: requests.flatMap<Record<string, unknown>>(r => {
180
+ if (r.request_id.startsWith('dpl_')) {
181
+ if (!acceptLogins) return [];
182
+ const row = db.get<{ intent_json: string }>('SELECT intent_json FROM daemon_provider_logins WHERE request_id=?1', r.request_id);
183
+ const intent = row && JSON.parse(row.intent_json);
184
+ if (!validLoginIntent(intent)) throw Error('invalid_login_intent');
185
+ return [{ ...r, operation: 'login', intent }];
186
+ }
153
187
  if (r.request_id.startsWith('dpa_')) {
154
188
  if (!acceptApplications) return []; // old daemons must not consume applications as scans
155
189
  const application = applicationIntent(r.request_id);
@@ -163,16 +197,28 @@ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApp
163
197
  }
164
198
  if (!acceptWrites && db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', r.request_id)) return [];
165
199
  const write = privateWrite(r.request_id, d.networkId, d.daemonNodeId);
166
- return [write ? { ...r, operation: 'put', ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId) }];
200
+ return [write ? { ...r, operation: 'put', ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId), ...(acceptDiscovery ? { discover_local: true } : {}) }];
167
201
  }) }; } catch { return denied('provider_write_unavailable'); }
168
202
  }
169
- export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown) {
203
+ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown, login?: unknown) {
170
204
  if (!d.ok || !daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
171
205
  expire(d.networkId, d.daemonNodeId);
172
206
  const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2 AND daemon_node_id=?3`, requestId, d.networkId, d.daemonNodeId);
173
207
  if (!row) return denied("request_not_found");
174
208
  // First accepted receipt wins; reconnect replay cannot rewrite prior evidence.
175
209
  if (row.status !== "pending") return { ok: row.status === "succeeded" || row.status === "failed", status: row.status };
210
+ if (requestId.startsWith('dpl_')) {
211
+ if (snapshot !== undefined || application !== undefined || appliedRevision !== undefined || (failed && login !== undefined)) return denied('invalid_login_receipt');
212
+ const intentRow = db.get<{ intent_json: string }>('SELECT intent_json FROM daemon_provider_logins WHERE request_id=?1', requestId);
213
+ const intent = intentRow && JSON.parse(intentRow.intent_json);
214
+ if (!failed && (!validLoginIntent(intent) || !validLoginReceipt(login, intent, d.networkId, d.daemonNodeId))) return denied('invalid_login_receipt');
215
+ db.run(`UPDATE daemon_provider_snapshots SET status=?1,snapshot_json=?2,error=?3,observed_at=?4
216
+ WHERE request_id=?5 AND status='pending'`, [failed ? 'failed' : 'succeeded', failed ? null : JSON.stringify(login),
217
+ failed ? 'provider_login_unavailable' : null, Date.now(), requestId]);
218
+ db.run('DELETE FROM daemon_provider_logins WHERE request_id=?1', [requestId]);
219
+ return { ok: true, status: failed ? 'failed' : 'succeeded' };
220
+ }
221
+ if (login !== undefined) return denied('invalid_login_receipt');
176
222
  if (requestId.startsWith('dpa_')) {
177
223
  if (snapshot !== undefined || appliedRevision !== undefined || (failed && application !== undefined)) return denied('invalid_application_receipt');
178
224
  if (!failed) {
@@ -180,7 +226,7 @@ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unk
180
226
  const intent = applicationIntent(requestId);
181
227
  if (!intent || !boundApplicationTarget(d.networkId, d.daemonNodeId, intent.selection)
182
228
  || !validProviderApplicationReceipt(application, requestId, intent.expected_revision, intent.selection)
183
- || (application as any).runtime_provider !== `anet_${createHash('sha256').update(JSON.stringify([intent.selection.provider_id, intent.selection.auth_id])).digest('hex').slice(0, 20)}`
229
+ || (application as any).runtime_provider !== (intent.selection.account_id ? 'openai' : `anet_${createHash('sha256').update(JSON.stringify([intent.selection.provider_id, intent.selection.auth_id])).digest('hex').slice(0, 20)}`)
184
230
  || (application as any).applied_at < row.created_at - 30_000) return denied('invalid_application_receipt');
185
231
  } catch { return denied('invalid_application_receipt'); }
186
232
  }
@@ -201,6 +247,16 @@ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unk
201
247
  const targets = new Map(providerScanTargets(d.networkId, d.daemonNodeId).map(n => [n.node_id, n.alias]));
202
248
  const rows = (safe as any).codex_inventory.rows;
203
249
  if (rows.length !== targets.size || rows.some((r: any) => targets.get(r.node_id) !== r.alias)) return denied('invalid_provider_snapshot');
250
+ const local = (safe as any).codex_inventory.local_discovery;
251
+ if (local) {
252
+ 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]));
253
+ const ownership = buildControllableMap(
254
+ 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),
255
+ 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));
256
+ // Filesystem discovery is not control authority. Other-daemon and
257
+ // unregistered identities cannot enter this network's public receipt.
258
+ local.rows = local.rows.filter((r: any) => registered.get(r.node_id) === r.alias && !ownership.has(r.node_id));
259
+ }
204
260
  } catch { return denied('invalid_provider_snapshot'); }
205
261
  }
206
262
  try {
@@ -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 () => {
@@ -215,7 +218,9 @@ test("all production token issuers explicitly mark the rollout epoch", () => {
215
218
  expect(match[1]).toContain("node_identity_epoch"); count++;
216
219
  }
217
220
  }
218
- expect(count).toBe(9);
221
+ // Hub #938: clone_node mints a child ntok_ with node_identity_epoch=2,
222
+ // the same column create_node already writes. 9 issuers → 10.
223
+ expect(count).toBe(10);
219
224
  expect(readFileSync(`${root}/agent-network/bin/cli.ts`, "utf8")).toContain("'admin-reset', 'user', 1)");
220
225
  });
221
226
 
package/src/db.ts CHANGED
@@ -550,6 +550,11 @@ db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_applications (
550
550
  selection_json TEXT NOT NULL
551
551
  )`);
552
552
 
553
+ // Login commands contain only intent. OAuth tokens never enter Hub storage.
554
+ db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_logins (
555
+ request_id TEXT PRIMARY KEY, intent_json TEXT NOT NULL
556
+ )`);
557
+
553
558
  // At most one row per node may be in a non-terminal state at a time
554
559
  // (single-flight enforced at the tool layer; this table just stores).
555
560
  //
@@ -810,6 +815,15 @@ try {
810
815
  if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
811
816
  }
812
817
 
818
+ // Hub #938 — clone_node reuses this table (same token mint, sweeper, and
819
+ // register finalize) but must not be replayed as a blank create_node.
820
+ // NULL/empty = ordinary create. Non-empty JSON = clone directives.
821
+ try {
822
+ db.exec(`ALTER TABLE node_create_requests ADD COLUMN clone_json TEXT`);
823
+ } catch (e: any) {
824
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
825
+ }
826
+
813
827
  // #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
814
828
  // 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
815
829
  // getUserNetworkRole,tools.ts)。「不产生 network_id=NULL 孤儿」现是代码级保证 + #1492
@@ -26,7 +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
- const TOOLS_LIST_MAX_BYTES = 57_500;
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;
30
33
  /** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
31
34
  const TOOL_MAX_BYTES = 9_000;
32
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",
@@ -275,9 +276,10 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
275
276
  schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
276
277
  schedule_batch_interval: "schedule",
277
278
  // 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
278
- update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
279
+ update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write", clone_node: "node_write",
279
280
  stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
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
  };