@sleep2agi/commhub-server 0.9.0-preview.121 → 0.9.0-preview.122
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/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/daemon-provider-snapshots.test.ts +103 -0
- package/src/daemon-provider-snapshots.ts +49 -6
- package/src/daemon-token-owner-http.test.ts +3 -1
- package/src/db.ts +14 -0
- package/src/mcp-context-cost-http.test.ts +3 -1
- package/src/node-permissions.ts +1 -1
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +5 -2
- package/src/shared/provider-application.ts +8 -3
- package/src/tool-audience-http.test.ts +10 -9
- package/src/tool-audience.ts +1 -1
- package/src/tools.ts +194 -13
|
@@ -4,6 +4,10 @@ import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "nod
|
|
|
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([]);
|
|
@@ -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
|
-
|
|
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,6 +141,21 @@ 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
|
}
|
|
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
|
+
}
|
|
136
159
|
export function getProviderSnapshot(h: Human, requestId: string) {
|
|
137
160
|
const refusal = authorize(h); if (refusal) return refusal;
|
|
138
161
|
expire(h.networkId!);
|
|
@@ -140,16 +163,24 @@ export function getProviderSnapshot(h: Human, requestId: string) {
|
|
|
140
163
|
if (!row) return denied("request_not_found");
|
|
141
164
|
return { ok: true, request_id: row.request_id, daemon_node_id: row.daemon_node_id, status: row.status,
|
|
142
165
|
expires_at: row.expires_at, observed_at: row.observed_at, error: row.error,
|
|
143
|
-
snapshot:
|
|
166
|
+
snapshot: !/^dp[al]_/.test(row.request_id) && row.snapshot_json ? JSON.parse(row.snapshot_json) : null,
|
|
167
|
+
...(row.request_id.startsWith('dpl_') ? { login: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}),
|
|
144
168
|
...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
|
|
145
169
|
}
|
|
146
|
-
export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false) {
|
|
170
|
+
export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false) {
|
|
147
171
|
if (!d.ok) return denied("caller_not_a_daemon");
|
|
148
172
|
if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
|
|
149
173
|
expire(d.networkId, d.daemonNodeId);
|
|
150
174
|
const requests = db.all<{ request_id: string; expires_at: number }>(`SELECT request_id,expires_at FROM daemon_provider_snapshots
|
|
151
175
|
WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at LIMIT 20`, d.networkId, d.daemonNodeId);
|
|
152
176
|
try { return { ok: true, requests: requests.flatMap<Record<string, unknown>>(r => {
|
|
177
|
+
if (r.request_id.startsWith('dpl_')) {
|
|
178
|
+
if (!acceptLogins) return [];
|
|
179
|
+
const row = db.get<{ intent_json: string }>('SELECT intent_json FROM daemon_provider_logins WHERE request_id=?1', r.request_id);
|
|
180
|
+
const intent = row && JSON.parse(row.intent_json);
|
|
181
|
+
if (!validLoginIntent(intent)) throw Error('invalid_login_intent');
|
|
182
|
+
return [{ ...r, operation: 'login', intent }];
|
|
183
|
+
}
|
|
153
184
|
if (r.request_id.startsWith('dpa_')) {
|
|
154
185
|
if (!acceptApplications) return []; // old daemons must not consume applications as scans
|
|
155
186
|
const application = applicationIntent(r.request_id);
|
|
@@ -166,13 +197,25 @@ export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApp
|
|
|
166
197
|
return [write ? { ...r, operation: 'put', ...write } : { ...r, scan_targets: providerScanTargets(d.networkId, d.daemonNodeId) }];
|
|
167
198
|
}) }; } catch { return denied('provider_write_unavailable'); }
|
|
168
199
|
}
|
|
169
|
-
export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown) {
|
|
200
|
+
export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown, login?: unknown) {
|
|
170
201
|
if (!d.ok || !daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
|
|
171
202
|
expire(d.networkId, d.daemonNodeId);
|
|
172
203
|
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
204
|
if (!row) return denied("request_not_found");
|
|
174
205
|
// First accepted receipt wins; reconnect replay cannot rewrite prior evidence.
|
|
175
206
|
if (row.status !== "pending") return { ok: row.status === "succeeded" || row.status === "failed", status: row.status };
|
|
207
|
+
if (requestId.startsWith('dpl_')) {
|
|
208
|
+
if (snapshot !== undefined || application !== undefined || appliedRevision !== undefined || (failed && login !== undefined)) return denied('invalid_login_receipt');
|
|
209
|
+
const intentRow = db.get<{ intent_json: string }>('SELECT intent_json FROM daemon_provider_logins WHERE request_id=?1', requestId);
|
|
210
|
+
const intent = intentRow && JSON.parse(intentRow.intent_json);
|
|
211
|
+
if (!failed && (!validLoginIntent(intent) || !validLoginReceipt(login, intent, d.networkId, d.daemonNodeId))) return denied('invalid_login_receipt');
|
|
212
|
+
db.run(`UPDATE daemon_provider_snapshots SET status=?1,snapshot_json=?2,error=?3,observed_at=?4
|
|
213
|
+
WHERE request_id=?5 AND status='pending'`, [failed ? 'failed' : 'succeeded', failed ? null : JSON.stringify(login),
|
|
214
|
+
failed ? 'provider_login_unavailable' : null, Date.now(), requestId]);
|
|
215
|
+
db.run('DELETE FROM daemon_provider_logins WHERE request_id=?1', [requestId]);
|
|
216
|
+
return { ok: true, status: failed ? 'failed' : 'succeeded' };
|
|
217
|
+
}
|
|
218
|
+
if (login !== undefined) return denied('invalid_login_receipt');
|
|
176
219
|
if (requestId.startsWith('dpa_')) {
|
|
177
220
|
if (snapshot !== undefined || appliedRevision !== undefined || (failed && application !== undefined)) return denied('invalid_application_receipt');
|
|
178
221
|
if (!failed) {
|
|
@@ -180,7 +223,7 @@ export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unk
|
|
|
180
223
|
const intent = applicationIntent(requestId);
|
|
181
224
|
if (!intent || !boundApplicationTarget(d.networkId, d.daemonNodeId, intent.selection)
|
|
182
225
|
|| !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)}`
|
|
226
|
+
|| (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
227
|
|| (application as any).applied_at < row.created_at - 30_000) return denied('invalid_application_receipt');
|
|
185
228
|
} catch { return denied('invalid_application_receipt'); }
|
|
186
229
|
}
|
|
@@ -215,7 +215,9 @@ test("all production token issuers explicitly mark the rollout epoch", () => {
|
|
|
215
215
|
expect(match[1]).toContain("node_identity_epoch"); count++;
|
|
216
216
|
}
|
|
217
217
|
}
|
|
218
|
-
|
|
218
|
+
// Hub #938: clone_node mints a child ntok_ with node_identity_epoch=2,
|
|
219
|
+
// the same column create_node already writes. 9 issuers → 10.
|
|
220
|
+
expect(count).toBe(10);
|
|
219
221
|
expect(readFileSync(`${root}/agent-network/bin/cli.ts`, "utf8")).toContain("'admin-reset', 'user', 1)");
|
|
220
222
|
});
|
|
221
223
|
|
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,9 @@ 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
|
-
|
|
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;
|
|
30
32
|
/** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
|
|
31
33
|
const TOOL_MAX_BYTES = 9_000;
|
|
32
34
|
/** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
|
package/src/node-permissions.ts
CHANGED
|
@@ -275,7 +275,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
|
275
275
|
schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
|
|
276
276
|
schedule_batch_interval: "schedule",
|
|
277
277
|
// 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
|
|
278
|
-
update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
|
|
278
|
+
update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write", clone_node: "node_write",
|
|
279
279
|
stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
|
|
280
280
|
// 只有人能做
|
|
281
281
|
upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// #906 managed login transport: only explicit actions and secret-free projections.
|
|
2
|
+
export type LoginIntent = { action: 'start' | 'read' | 'cancel' | 'save' | 'accounts'; session_id?: string; label?: string };
|
|
3
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
4
|
+
const object = (v: any) => v && typeof v === 'object' && !Array.isArray(v);
|
|
5
|
+
const keys = (v: any, names: string[]) => Object.keys(v).every(k => names.includes(k));
|
|
6
|
+
export function validLoginIntent(v: any): v is LoginIntent {
|
|
7
|
+
return object(v) && keys(v, ['action','session_id','label'])
|
|
8
|
+
&& ['start','read','cancel','save','accounts'].includes(v.action)
|
|
9
|
+
&& (v.action === 'accounts' ? v.session_id === undefined : typeof v.session_id === 'string' && uuid.test(v.session_id))
|
|
10
|
+
&& (v.action === 'save' ? typeof v.label === 'string' && !!v.label.trim() && v.label.length <= 100 && !/[\x00-\x1f\x7f]/.test(v.label) : v.label === undefined);
|
|
11
|
+
}
|
|
12
|
+
export function validLoginReceipt(v: any, intent: LoginIntent, network: string, daemon: string): boolean {
|
|
13
|
+
if (!object(v)) return false;
|
|
14
|
+
if (intent.action === 'accounts') return keys(v, ['accounts']) && Array.isArray(v.accounts) && v.accounts.length <= 100
|
|
15
|
+
&& v.accounts.every((a: any) => object(a) && keys(a, ['version','network_id','daemon_node_id','account_id','label','created_at','auth_state'])
|
|
16
|
+
&& a.version === 1 && a.network_id === network && a.daemon_node_id === daemon
|
|
17
|
+
&& typeof a.account_id === 'string' && uuid.test(a.account_id)
|
|
18
|
+
&& typeof a.label === 'string' && a.label.length <= 100
|
|
19
|
+
&& Number.isSafeInteger(a.created_at) && a.auth_state === 'not_checked');
|
|
20
|
+
if (!keys(v, ['request_id','status','error','challenge','account_id']) || v.request_id !== intent.session_id
|
|
21
|
+
|| !['idle','starting','awaiting_user','verifying','authenticated','cancelled','failed'].includes(v.status)) return false;
|
|
22
|
+
if (v.error !== undefined && !['login_start_failed','login_rejected','account_read_failed','login_process_exited','login_expired'].includes(v.error)) return false;
|
|
23
|
+
if (v.account_id !== undefined && (v.status !== 'authenticated' || typeof v.account_id !== 'string' || !uuid.test(v.account_id))) return false;
|
|
24
|
+
if (intent.action === 'cancel' && v.status !== 'cancelled') return false;
|
|
25
|
+
if (intent.action === 'save' && (v.status !== 'authenticated' || !v.account_id)) return false;
|
|
26
|
+
if (v.status === 'awaiting_user') return object(v.challenge) && keys(v.challenge, ['loginId','verificationUrl','userCode'])
|
|
27
|
+
&& typeof v.challenge.loginId === 'string' && uuid.test(v.challenge.loginId)
|
|
28
|
+
&& v.challenge.verificationUrl === 'https://auth.openai.com/codex/device'
|
|
29
|
+
&& typeof v.challenge.userCode === 'string' && /^[A-Z0-9-]{4,32}$/i.test(v.challenge.userCode);
|
|
30
|
+
return v.challenge === undefined;
|
|
31
|
+
}
|
|
@@ -28,13 +28,16 @@ export function validateDaemonProviderSnapshot(value: unknown, networkId: string
|
|
|
28
28
|
obj(r, ["runtime", "auth"]); if (r.runtime !== "codex-tui") fail();
|
|
29
29
|
const auths = arr(r.auth, 32); unique(auths, "id");
|
|
30
30
|
for (const a of auths) {
|
|
31
|
-
obj(a, ["id", "kind", "models", "baseUrl", "credential_present", "verification", "application"]);
|
|
31
|
+
obj(a, ["id", "kind", "models", "baseUrl", "account_id", "credential_present", "verification", "application"]);
|
|
32
32
|
if (!id(a.id) || a.verification !== "not_checked" || a.application !== "not_applied") fail();
|
|
33
33
|
const models = arr(a.models, 200);
|
|
34
34
|
if (new Set(models).size !== models.length || models.some(m => typeof m !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(m))) fail();
|
|
35
35
|
if (a.kind === "chatgpt") {
|
|
36
|
-
if (p.id !== "openai" || a.baseUrl !== undefined || a.credential_present !==
|
|
36
|
+
if (p.id !== "openai" || a.baseUrl !== undefined || typeof a.credential_present !== 'boolean') fail();
|
|
37
|
+
if (a.account_id !== undefined && (typeof a.account_id !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(a.account_id))) fail();
|
|
38
|
+
if (a.credential_present && !a.account_id) fail();
|
|
37
39
|
} else if (a.kind === "api_key") {
|
|
40
|
+
if (a.account_id !== undefined) fail();
|
|
38
41
|
if (a.credential_present !== true || typeof a.baseUrl !== "string" || a.baseUrl.length > 2048) fail();
|
|
39
42
|
let u: URL; try { u = new URL(a.baseUrl); } catch { return fail(); }
|
|
40
43
|
if (u.protocol !== "https:" || u.username || u.password || u.search || u.hash) fail();
|
|
@@ -2,23 +2,28 @@
|
|
|
2
2
|
export type ProviderApplicationSelection = {
|
|
3
3
|
node_id: string; node_revision: string; provider_id: string; auth_id: string;
|
|
4
4
|
model: string; confirm_restart: true;
|
|
5
|
+
account_id?: string;
|
|
5
6
|
};
|
|
6
7
|
const fields = (v: any, names: string[]) => v && typeof v === 'object' && !Array.isArray(v)
|
|
7
8
|
&& Object.keys(v).length === names.length && Object.keys(v).every(k => names.includes(k));
|
|
8
9
|
const id = (v: any) => typeof v === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(v);
|
|
9
10
|
const hash = (v: any) => typeof v === 'string' && /^[a-f0-9]{64}$/.test(v);
|
|
11
|
+
const account = (v: any) => typeof v === 'string' && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(v);
|
|
10
12
|
export function validProviderApplicationSelection(v: any): v is ProviderApplicationSelection {
|
|
11
|
-
return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart'
|
|
13
|
+
return fields(v, ['node_id', 'node_revision', 'provider_id', 'auth_id', 'model', 'confirm_restart',
|
|
14
|
+
...(v?.account_id !== undefined ? ['account_id'] : [])])
|
|
15
|
+
&& (v.account_id === undefined || (v.provider_id === 'openai' && account(v.account_id)))
|
|
12
16
|
&& id(v.node_id) && hash(v.node_revision) && id(v.provider_id) && id(v.auth_id) && id(v.model)
|
|
13
17
|
&& v.confirm_restart === true;
|
|
14
18
|
}
|
|
15
19
|
export function validProviderApplicationReceipt(v: any, requestId: string, revision: number,
|
|
16
20
|
selection: ProviderApplicationSelection): boolean {
|
|
17
21
|
return fields(v, ['status', 'request_id', 'node_id', 'provider_id', 'auth_id', 'model', 'runtime_provider',
|
|
18
|
-
'node_revision', 'provider_revision', 'verification', 'applied_at'])
|
|
22
|
+
'node_revision', 'provider_revision', 'verification', 'applied_at', ...(selection.account_id ? ['account_id'] : [])])
|
|
19
23
|
&& v.status === 'applied' && v.request_id === requestId && v.node_id === selection.node_id
|
|
20
24
|
&& v.provider_id === selection.provider_id && v.auth_id === selection.auth_id && v.model === selection.model
|
|
21
|
-
&&
|
|
25
|
+
&& (selection.account_id ? v.runtime_provider === 'openai' && v.account_id === selection.account_id
|
|
26
|
+
: typeof v.runtime_provider === 'string' && /^anet_[a-f0-9]{20}$/.test(v.runtime_provider))
|
|
22
27
|
&& hash(v.node_revision) && v.provider_revision === revision && v.verification === 'runtime_confirmed'
|
|
23
28
|
&& Number.isSafeInteger(v.applied_at) && v.applied_at > 0 && v.applied_at <= Date.now() + 30_000;
|
|
24
29
|
}
|
|
@@ -163,7 +163,7 @@ describe("what each caller sees", () => {
|
|
|
163
163
|
|
|
164
164
|
test("node modes: readonly / restricted drop broadcast + node writes + human-only; enforce drops human-only for normal nodes", async () => {
|
|
165
165
|
const ro = new Set((await listTools(T.node_readonly)).names);
|
|
166
|
-
for (const n of ["broadcast", "update_node_config", "create_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
|
|
166
|
+
for (const n of ["broadcast", "update_node_config", "create_node", "clone_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
|
|
167
167
|
for (const n of ["send_task", "requirements_update", "report_status", "get_all_status"]) expect([n, ro.has(n)]).toEqual([n, true]); // 看参数才拒的照列
|
|
168
168
|
expect(new Set((await listTools(T.node_normal)).names).has("upsert_provider")).toBe(true); // log 模式:调得通,照列
|
|
169
169
|
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
@@ -202,14 +202,15 @@ describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
|
202
202
|
// node 61 / 62,433 B → 62,946 B (+513); user 58 / 57,228 B unchanged
|
|
203
203
|
// (report_status is not on the user list). Budget only this measured addition
|
|
204
204
|
// (~154/509 B node/all margin).
|
|
205
|
-
// #906 registers daemon_provider_snapshot (user) plus pull/ack (protocol
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
|
|
205
|
+
// #906 registers daemon_provider_snapshot (user) plus pull/ack (protocol).
|
|
206
|
+
// Measured there: all 90 / 80,307 B; node 61 / 62,556 B; user 59 / 57,372 B,
|
|
207
|
+
// ceilings 80,800 / 63,100 / 57,500.
|
|
208
|
+
// Hub #938 adds clone_node (audience both, so it lands on the node list and
|
|
209
|
+
// the user list) plus optional expect_clone on get_create_request (protocol,
|
|
210
|
+
// so only the full list). Measured on this combined tree:
|
|
211
|
+
// all 91 / 81,228 B; node 62 / 63,281 B; user 60 / 58,169 B.
|
|
212
|
+
// Ceilings keep about 400 B of margin and nothing else.
|
|
213
|
+
const CEILINGS = { node: 63_700, user: 58_600, all: 81_700 } as const;
|
|
213
214
|
test("node / user / all", async () => {
|
|
214
215
|
const node = await listTools(T.node_normal);
|
|
215
216
|
const user = await listTools(T.owner);
|
package/src/tool-audience.ts
CHANGED
|
@@ -53,7 +53,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
|
53
53
|
send_desktop_message: "both", broadcast: "both", get_completions: "both",
|
|
54
54
|
update_node_config: "both", read_node_rules_file: "both", write_node_rules_file: "both",
|
|
55
55
|
list_node_skills: "both", read_node_skill: "both", get_rules_file_result: "both",
|
|
56
|
-
restart_node: "both", list_host_supervisors: "both", create_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
|
|
56
|
+
restart_node: "both", list_host_supervisors: "both", create_node: "both", clone_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
|
|
57
57
|
upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
|
|
58
58
|
list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
|
|
59
59
|
requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
|