@sleep2agi/commhub-server 0.9.0-preview.120 → 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/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-acl-http.test.ts +2 -1
- package/src/api-host-supervisors-runtime-readiness.test.ts +13 -0
- package/src/auth.ts +8 -7
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/config-apply-validate.test.ts +33 -1
- package/src/config-apply-validate.ts +24 -0
- package/src/create-node-validate.test.ts +26 -1
- package/src/create-node-validate.ts +45 -1
- package/src/create-node-workdir.test.ts +13 -0
- package/src/daemon-provider-snapshots.test.ts +359 -0
- package/src/daemon-provider-snapshots.ts +269 -0
- package/src/daemon-token-owner-http.test.ts +3 -1
- package/src/db.ts +45 -0
- package/src/mcp-context-cost-http.test.ts +3 -1
- package/src/member-presence-http.test.ts +8 -4
- package/src/node-lifecycle-read-http.test.ts +13 -1
- package/src/node-lifecycle-read.ts +6 -0
- package/src/node-permissions.ts +3 -1
- package/src/report-status-host-schema.test.ts +24 -0
- package/src/runtime-readiness.ts +7 -0
- package/src/server.ts +40 -4
- package/src/shared/codex-inventory.ts +34 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/tool-audience-http.test.ts +15 -2
- package/src/tool-audience.ts +3 -1
- package/src/tools.ts +233 -19
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// #906: human-requested readback of ONE daemon's private Provider metadata.
|
|
2
|
+
// Hub registry is not a fallback. No credential values enter this protocol.
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
+
import { db } from "./db";
|
|
6
|
+
import { getUserNetworkRole } from "./auth";
|
|
7
|
+
import { validateDaemonProviderSnapshot } from "./shared/daemon-provider-snapshot";
|
|
8
|
+
import { encryptSecret, decryptSecret, VaultError } from './vault';
|
|
9
|
+
import { buildControllableMap } from './node-lifecycle-controllable';
|
|
10
|
+
import { validProviderApplicationSelection, validProviderApplicationReceipt, type ProviderApplicationSelection } from './shared/provider-application';
|
|
11
|
+
import { validLoginIntent, validLoginReceipt } from './shared/daemon-provider-login';
|
|
12
|
+
|
|
13
|
+
export function providerScanTargets(networkId: string, daemonId: string) {
|
|
14
|
+
const ownership = buildControllableMap(
|
|
15
|
+
db.all<{ request_id: string; daemon_node_id: string }>('SELECT request_id,daemon_node_id FROM node_create_requests WHERE network_id=?1', networkId),
|
|
16
|
+
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\'', networkId));
|
|
17
|
+
const targets = db.all<{ node_id: string; alias: string }>('SELECT node_id,alias FROM nodes WHERE network_id=?1 ORDER BY node_id', networkId)
|
|
18
|
+
.filter(n => ownership.get(n.node_id) === daemonId && typeof n.alias === 'string');
|
|
19
|
+
if (targets.length > 200) throw Error('inventory_limit'); // no silent incomplete denominator
|
|
20
|
+
return targets;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Human = { userId?: string | null; isNode: boolean; networkId: string | null };
|
|
24
|
+
type Daemon = { ok: false; error: string } | { ok: true; networkId: string; daemonNodeId: string; daemonAlias: string };
|
|
25
|
+
type Row = { request_id: string; network_id: string; daemon_node_id: string; status: string;
|
|
26
|
+
created_at: number; expires_at: number; observed_at: number | null; snapshot_json: string | null; error: string | null };
|
|
27
|
+
const denied = (error: string) => ({ ok: false as const, error });
|
|
28
|
+
function authorize(h: Human) {
|
|
29
|
+
if (h.isNode || !h.userId) return denied("user_token_required");
|
|
30
|
+
if (!h.networkId || !["owner", "admin"].includes(getUserNetworkRole(h.userId, h.networkId) || "")) return denied("provider_admin_required");
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function daemonExists(networkId: string, nodeId: string): boolean {
|
|
34
|
+
const node = db.get<{ config_snapshot: string }>(
|
|
35
|
+
`SELECT n.config_snapshot FROM nodes n WHERE n.node_id=?1 AND n.network_id=?2
|
|
36
|
+
AND EXISTS (SELECT 1 FROM api_tokens t WHERE t.network_id=n.network_id
|
|
37
|
+
AND t.name='node:' || n.alias AND t.revoked_at IS NULL
|
|
38
|
+
AND (t.expires_at IS NULL OR t.expires_at > datetime('now')))`, nodeId, networkId);
|
|
39
|
+
try { return JSON.parse(node?.config_snapshot || "{}").role === "host_supervisor"; } catch { return false; }
|
|
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()]);
|
|
45
|
+
db.run(`UPDATE daemon_provider_snapshots SET status='expired', error='request_expired'
|
|
46
|
+
WHERE network_id=?1 AND status='pending' AND expires_at <= ?2
|
|
47
|
+
AND (?3 IS NULL OR daemon_node_id=?3)`, [networkId, Date.now(), daemonId ?? null]);
|
|
48
|
+
db.run(`DELETE FROM daemon_provider_writes WHERE request_id IN
|
|
49
|
+
(SELECT request_id FROM daemon_provider_snapshots WHERE network_id=?1 AND status!='pending')`, [networkId]);
|
|
50
|
+
db.run(`DELETE FROM daemon_provider_applications WHERE request_id IN
|
|
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]);
|
|
54
|
+
db.run(`DELETE FROM daemon_provider_snapshots WHERE network_id=?1 AND expires_at < ?2`, [networkId, Date.now() - 7 * 86400_000]);
|
|
55
|
+
}
|
|
56
|
+
/** Validate the complete public projection before encrypting the private input. */
|
|
57
|
+
function validateWrite(provider: any, networkId: string, daemonId: string) {
|
|
58
|
+
if (Buffer.byteLength(JSON.stringify(provider) || '') > 64 * 1024) throw new Error('invalid_provider');
|
|
59
|
+
const publicProvider = { ...provider, runtimes: provider?.runtimes?.map((r: any) => ({ ...r,
|
|
60
|
+
auth: r.auth?.map((a: any) => {
|
|
61
|
+
const { key, ...rest } = a;
|
|
62
|
+
if (a.kind === 'chatgpt' && key !== undefined) throw new Error('invalid_provider');
|
|
63
|
+
if (key !== undefined && (typeof key !== 'string' || !key || key.length > 8192 || /[\s\x00-\x1f\x7f]/.test(key))) throw new Error('invalid_provider');
|
|
64
|
+
// Only CLI write fields may be supplied; public-state flags are not writable.
|
|
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' };
|
|
69
|
+
}),
|
|
70
|
+
})) };
|
|
71
|
+
validateDaemonProviderSnapshot({ network_id: networkId, daemon_node_id: daemonId, source: 'daemon', revision: 0, providers: [publicProvider] }, networkId, daemonId);
|
|
72
|
+
return publicProvider;
|
|
73
|
+
}
|
|
74
|
+
export function requestProviderWrite(h: Human, daemonId: string, expectedRevision: number, provider: unknown) {
|
|
75
|
+
const refusal = authorize(h); if (refusal) return refusal;
|
|
76
|
+
if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
|
|
77
|
+
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || expectedRevision >= Number.MAX_SAFE_INTEGER) return denied('invalid_revision');
|
|
78
|
+
try { validateWrite(provider, h.networkId!, daemonId); } catch { return denied('invalid_provider'); }
|
|
79
|
+
expire(h.networkId!, daemonId);
|
|
80
|
+
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');
|
|
81
|
+
try {
|
|
82
|
+
const enc = encryptSecret(JSON.stringify({ network_id: h.networkId, daemon_node_id: daemonId, provider }));
|
|
83
|
+
const requestId = `dps_${randomUUID()}`, now = Date.now();
|
|
84
|
+
db.transaction(() => {
|
|
85
|
+
db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
|
|
86
|
+
VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
|
|
87
|
+
db.run(`INSERT INTO daemon_provider_writes (request_id,expected_revision,ciphertext,iv,tag) VALUES (?1,?2,?3,?4,?5)`,
|
|
88
|
+
[requestId, expectedRevision, enc.ciphertext, enc.iv, enc.tag]);
|
|
89
|
+
});
|
|
90
|
+
return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 120_000 };
|
|
91
|
+
} catch (error) { return denied(error instanceof VaultError ? error.code : 'provider_write_unavailable'); }
|
|
92
|
+
}
|
|
93
|
+
function privateWrite(requestId: string, networkId: string, daemonId: string) {
|
|
94
|
+
const row = db.get<{ expected_revision: number; ciphertext: any; iv: any; tag: any }>('SELECT * FROM daemon_provider_writes WHERE request_id=?1', requestId);
|
|
95
|
+
if (!row) return null;
|
|
96
|
+
const payload = JSON.parse(decryptSecret({ ciphertext: Buffer.from(row.ciphertext), iv: Buffer.from(row.iv), tag: Buffer.from(row.tag) }));
|
|
97
|
+
if (payload.network_id !== networkId || payload.daemon_node_id !== daemonId) throw new Error('scope_mismatch');
|
|
98
|
+
validateWrite(payload.provider, networkId, daemonId);
|
|
99
|
+
return { expected_revision: row.expected_revision, provider: payload.provider };
|
|
100
|
+
}
|
|
101
|
+
function applicationIntent(requestId: string) {
|
|
102
|
+
const row = db.get<{ expected_revision: number; selection_json: string }>('SELECT * FROM daemon_provider_applications WHERE request_id=?1', requestId);
|
|
103
|
+
if (!row) return null;
|
|
104
|
+
const selection = JSON.parse(row.selection_json);
|
|
105
|
+
if (!validProviderApplicationSelection(selection)) throw Error('invalid_application');
|
|
106
|
+
return { expected_revision: row.expected_revision, selection };
|
|
107
|
+
}
|
|
108
|
+
function boundApplicationTarget(networkId: string, daemonId: string, selection: ProviderApplicationSelection) {
|
|
109
|
+
return selection.node_id !== daemonId && providerScanTargets(networkId, daemonId).find(n => n.node_id === selection.node_id);
|
|
110
|
+
}
|
|
111
|
+
export function requestProviderApplication(h: Human, daemonId: string, revision: number, selection: unknown) {
|
|
112
|
+
const refusal = authorize(h); if (refusal) return refusal;
|
|
113
|
+
if (!daemonExists(h.networkId!, daemonId)) return denied('daemon_not_found');
|
|
114
|
+
if (!Number.isSafeInteger(revision) || revision < 0) return denied('invalid_revision');
|
|
115
|
+
if (!validProviderApplicationSelection(selection)) return denied('invalid_application');
|
|
116
|
+
try {
|
|
117
|
+
if (!boundApplicationTarget(h.networkId!, daemonId, selection)) return denied('application_target_not_bound');
|
|
118
|
+
expire(h.networkId!, daemonId);
|
|
119
|
+
const requestId = `dpa_${randomUUID()}`, now = Date.now();
|
|
120
|
+
db.transaction(() => {
|
|
121
|
+
db.run(`INSERT INTO daemon_provider_snapshots (request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
|
|
122
|
+
VALUES (?1,?2,?3,?4,'pending',?5,?6)`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 600_000]);
|
|
123
|
+
db.run('INSERT INTO daemon_provider_applications (request_id,expected_revision,selection_json) VALUES (?1,?2,?3)',
|
|
124
|
+
[requestId, revision, JSON.stringify(selection)]);
|
|
125
|
+
});
|
|
126
|
+
return { ok: true as const, request_id: requestId, status: 'pending', expires_at: now + 600_000 };
|
|
127
|
+
} catch { return denied('provider_application_unavailable'); }
|
|
128
|
+
}
|
|
129
|
+
export function requestProviderSnapshot(h: Human, daemonId: string) {
|
|
130
|
+
const refusal = authorize(h); if (refusal) return refusal;
|
|
131
|
+
if (!daemonExists(h.networkId!, daemonId)) return denied("daemon_not_found");
|
|
132
|
+
expire(h.networkId!, daemonId);
|
|
133
|
+
// Repeated refresh clicks share the pending request; pending never means ready.
|
|
134
|
+
const pending = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at DESC LIMIT 1`, h.networkId!, daemonId);
|
|
135
|
+
if (pending) return { ok: true as const, request_id: pending.request_id, status: "pending", expires_at: pending.expires_at };
|
|
136
|
+
const requestId = `dps_${randomUUID()}`, now = Date.now();
|
|
137
|
+
db.run(`INSERT INTO daemon_provider_snapshots
|
|
138
|
+
(request_id,network_id,daemon_node_id,requested_by,status,created_at,expires_at)
|
|
139
|
+
VALUES (?1,?2,?3,?4,'pending',?5,?6) ON CONFLICT DO NOTHING`, [requestId, h.networkId!, daemonId, h.userId!, now, now + 120_000]);
|
|
140
|
+
const accepted = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending'`, h.networkId!, daemonId);
|
|
141
|
+
if (!accepted) return denied("provider_snapshot_busy");
|
|
142
|
+
return { ok: true as const, request_id: accepted.request_id, status: "pending", expires_at: accepted.expires_at };
|
|
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
|
+
}
|
|
159
|
+
export function getProviderSnapshot(h: Human, requestId: string) {
|
|
160
|
+
const refusal = authorize(h); if (refusal) return refusal;
|
|
161
|
+
expire(h.networkId!);
|
|
162
|
+
const row = db.get<Row>(`SELECT * FROM daemon_provider_snapshots WHERE request_id=?1 AND network_id=?2`, requestId, h.networkId!);
|
|
163
|
+
if (!row) return denied("request_not_found");
|
|
164
|
+
return { ok: true, request_id: row.request_id, daemon_node_id: row.daemon_node_id, status: row.status,
|
|
165
|
+
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,
|
|
167
|
+
...(row.request_id.startsWith('dpl_') ? { login: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}),
|
|
168
|
+
...(row.request_id.startsWith('dpa_') ? { application: row.snapshot_json ? JSON.parse(row.snapshot_json) : null } : {}) };
|
|
169
|
+
}
|
|
170
|
+
export function pullProviderSnapshots(d: Daemon, acceptWrites = false, acceptApplications = false, acceptLogins = false) {
|
|
171
|
+
if (!d.ok) return denied("caller_not_a_daemon");
|
|
172
|
+
if (!daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
|
|
173
|
+
expire(d.networkId, d.daemonNodeId);
|
|
174
|
+
const requests = db.all<{ request_id: string; expires_at: number }>(`SELECT request_id,expires_at FROM daemon_provider_snapshots
|
|
175
|
+
WHERE network_id=?1 AND daemon_node_id=?2 AND status='pending' ORDER BY created_at LIMIT 20`, d.networkId, d.daemonNodeId);
|
|
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
|
+
}
|
|
184
|
+
if (r.request_id.startsWith('dpa_')) {
|
|
185
|
+
if (!acceptApplications) return []; // old daemons must not consume applications as scans
|
|
186
|
+
const application = applicationIntent(r.request_id);
|
|
187
|
+
const target = application && boundApplicationTarget(d.networkId, d.daemonNodeId, application.selection);
|
|
188
|
+
if (!target) {
|
|
189
|
+
db.run("UPDATE daemon_provider_snapshots SET status='failed',error='application_target_not_bound',observed_at=?1 WHERE request_id=?2 AND status='pending'", [Date.now(), r.request_id]);
|
|
190
|
+
db.run('DELETE FROM daemon_provider_applications WHERE request_id=?1', [r.request_id]);
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
return [{ ...r, operation: 'apply', ...application!, alias: target.alias }];
|
|
194
|
+
}
|
|
195
|
+
if (!acceptWrites && db.get('SELECT request_id FROM daemon_provider_writes WHERE request_id=?1', r.request_id)) return [];
|
|
196
|
+
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) }];
|
|
198
|
+
}) }; } catch { return denied('provider_write_unavailable'); }
|
|
199
|
+
}
|
|
200
|
+
export function ackProviderSnapshot(d: Daemon, requestId: string, snapshot?: unknown, failed = false, appliedRevision?: number, application?: unknown, login?: unknown) {
|
|
201
|
+
if (!d.ok || !daemonExists(d.networkId, d.daemonNodeId)) return denied("caller_not_a_daemon");
|
|
202
|
+
expire(d.networkId, d.daemonNodeId);
|
|
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);
|
|
204
|
+
if (!row) return denied("request_not_found");
|
|
205
|
+
// First accepted receipt wins; reconnect replay cannot rewrite prior evidence.
|
|
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');
|
|
219
|
+
if (requestId.startsWith('dpa_')) {
|
|
220
|
+
if (snapshot !== undefined || appliedRevision !== undefined || (failed && application !== undefined)) return denied('invalid_application_receipt');
|
|
221
|
+
if (!failed) {
|
|
222
|
+
try {
|
|
223
|
+
const intent = applicationIntent(requestId);
|
|
224
|
+
if (!intent || !boundApplicationTarget(d.networkId, d.daemonNodeId, intent.selection)
|
|
225
|
+
|| !validProviderApplicationReceipt(application, requestId, intent.expected_revision, intent.selection)
|
|
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)}`)
|
|
227
|
+
|| (application as any).applied_at < row.created_at - 30_000) return denied('invalid_application_receipt');
|
|
228
|
+
} catch { return denied('invalid_application_receipt'); }
|
|
229
|
+
}
|
|
230
|
+
db.run(`UPDATE daemon_provider_snapshots SET status=?1,snapshot_json=?2,error=?3,observed_at=?4
|
|
231
|
+
WHERE request_id=?5 AND status='pending'`, [failed ? 'failed' : 'succeeded', failed ? null : JSON.stringify(application),
|
|
232
|
+
failed ? 'provider_application_unavailable' : null, Date.now(), requestId]);
|
|
233
|
+
db.run('DELETE FROM daemon_provider_applications WHERE request_id=?1', [requestId]);
|
|
234
|
+
return { ok: true, status: failed ? 'failed' : 'succeeded' };
|
|
235
|
+
}
|
|
236
|
+
if (application !== undefined) return denied('invalid_application_receipt');
|
|
237
|
+
let safe: unknown = null;
|
|
238
|
+
if (failed && snapshot !== undefined) return denied("invalid_provider_snapshot");
|
|
239
|
+
if (!failed) {
|
|
240
|
+
try { safe = validateDaemonProviderSnapshot(snapshot, d.networkId, d.daemonNodeId); }
|
|
241
|
+
catch { return denied("invalid_provider_snapshot"); }
|
|
242
|
+
if ((safe as any).codex_inventory) {
|
|
243
|
+
try {
|
|
244
|
+
const targets = new Map(providerScanTargets(d.networkId, d.daemonNodeId).map(n => [n.node_id, n.alias]));
|
|
245
|
+
const rows = (safe as any).codex_inventory.rows;
|
|
246
|
+
if (rows.length !== targets.size || rows.some((r: any) => targets.get(r.node_id) !== r.alias)) return denied('invalid_provider_snapshot');
|
|
247
|
+
} catch { return denied('invalid_provider_snapshot'); }
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const write = privateWrite(requestId, d.networkId, d.daemonNodeId);
|
|
251
|
+
if (write) {
|
|
252
|
+
const actual = safe as any;
|
|
253
|
+
if (appliedRevision !== write.expected_revision + 1 || actual.revision !== appliedRevision) return denied('provider_write_unconfirmed');
|
|
254
|
+
const expected = validateWrite(write.provider, d.networkId, d.daemonNodeId);
|
|
255
|
+
const saved = actual.providers.find((p: any) => p.id === expected.id);
|
|
256
|
+
// CLI canonicalizes label and endpoint. No credential is compared or returned here.
|
|
257
|
+
expected.label = expected.label.trim();
|
|
258
|
+
for (const r of expected.runtimes) for (const a of r.auth) if (a.baseUrl) a.baseUrl = new URL(a.baseUrl).toString().replace(/\/$/, '');
|
|
259
|
+
if (!isDeepStrictEqual(saved, expected)) return denied('provider_write_unconfirmed');
|
|
260
|
+
}
|
|
261
|
+
} catch { return denied('provider_write_unconfirmed'); }
|
|
262
|
+
}
|
|
263
|
+
db.run(`UPDATE daemon_provider_snapshots SET status=?1, snapshot_json=?2, error=?3, observed_at=?4
|
|
264
|
+
WHERE request_id=?5 AND status='pending'`, [failed ? "failed" : "succeeded", safe ? JSON.stringify(safe) : null,
|
|
265
|
+
failed ? "provider_snapshot_unavailable" : null, Date.now(), requestId]);
|
|
266
|
+
const accepted = db.get<Row>("SELECT * FROM daemon_provider_snapshots WHERE request_id=?1", requestId);
|
|
267
|
+
db.run('DELETE FROM daemon_provider_writes WHERE request_id=?1', [requestId]);
|
|
268
|
+
return { ok: true, status: accepted!.status };
|
|
269
|
+
}
|
|
@@ -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
|
@@ -320,6 +320,15 @@ db.exec(`
|
|
|
320
320
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
321
321
|
`);
|
|
322
322
|
|
|
323
|
+
// Human profile avatar. Additive for existing databases; old binaries ignore it.
|
|
324
|
+
try {
|
|
325
|
+
db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT");
|
|
326
|
+
} catch (e: any) {
|
|
327
|
+
// Both supported adapters report a duplicate column on the next startup.
|
|
328
|
+
// Do not send SQLite PRAGMA to PostgreSQL or swallow other migration errors.
|
|
329
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
330
|
+
}
|
|
331
|
+
|
|
323
332
|
// #261 P0-2 (2026-06-28): must_change_password flag. Added via ALTER
|
|
324
333
|
// (not in the CREATE block above) so it's a no-op on fresh DBs that
|
|
325
334
|
// already get the column via the CREATE path's full definition AND a
|
|
@@ -519,6 +528,33 @@ try {
|
|
|
519
528
|
}
|
|
520
529
|
|
|
521
530
|
// `node_config_updates` — pending + history. One row per dashboard write.
|
|
531
|
+
// #906: redacted daemon-owned inventory requests, never credential material.
|
|
532
|
+
db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_snapshots (
|
|
533
|
+
request_id TEXT PRIMARY KEY, network_id TEXT NOT NULL, daemon_node_id TEXT NOT NULL,
|
|
534
|
+
requested_by TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending',
|
|
535
|
+
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, observed_at INTEGER,
|
|
536
|
+
snapshot_json TEXT, error TEXT
|
|
537
|
+
)`);
|
|
538
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_daemon_provider_pending
|
|
539
|
+
ON daemon_provider_snapshots(network_id, daemon_node_id, status)`);
|
|
540
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_daemon_provider_inflight
|
|
541
|
+
ON daemon_provider_snapshots(network_id, daemon_node_id) WHERE status='pending'`);
|
|
542
|
+
// One encrypted mutation payload per request; public receipt remains secret-free.
|
|
543
|
+
db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_writes (
|
|
544
|
+
request_id TEXT PRIMARY KEY, expected_revision INTEGER NOT NULL,
|
|
545
|
+
ciphertext BLOB NOT NULL, iv BLOB NOT NULL, tag BLOB NOT NULL
|
|
546
|
+
)`);
|
|
547
|
+
// Application intent is credential-free; secrets stay in the daemon's store.
|
|
548
|
+
db.exec(`CREATE TABLE IF NOT EXISTS daemon_provider_applications (
|
|
549
|
+
request_id TEXT PRIMARY KEY, expected_revision INTEGER NOT NULL,
|
|
550
|
+
selection_json TEXT NOT NULL
|
|
551
|
+
)`);
|
|
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
|
+
|
|
522
558
|
// At most one row per node may be in a non-terminal state at a time
|
|
523
559
|
// (single-flight enforced at the tool layer; this table just stores).
|
|
524
560
|
//
|
|
@@ -779,6 +815,15 @@ try {
|
|
|
779
815
|
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
780
816
|
}
|
|
781
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
|
+
|
|
782
827
|
// #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
|
|
783
828
|
// 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
|
|
784
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,或者拆短)。 */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// GET /api/networks/:id/humans 给每个人加 online / last_seen_at(判据 = 此刻有没有活着的用户流
|
|
4
4
|
// /events/users/me);用户流上推 member_presence。两个方向都测:
|
|
5
5
|
// 看得到:普通成员、受限成员、viewer;连上 → online=true,断开 → online=false + last_seen_at。
|
|
6
|
-
// 看不到:非成员 403
|
|
6
|
+
// 看不到:非成员 403;节点令牌只拿身份字段(含头像),无在线状态;别的网络的人收不到推送;自己收不到自己的。
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
9
9
|
import { mkdtempSync, rmSync } from "fs";
|
|
@@ -125,7 +125,8 @@ describe("GET /api/networks/:id/humans — online / last_seen_at", () => {
|
|
|
125
125
|
test("never connected since hub start → online=false, last_seen_at=null (unknown, not 'long ago')", async () => {
|
|
126
126
|
const r = await get(users.pres_alpha.token, `/api/networks/${NET}/humans`);
|
|
127
127
|
expect(r.status).toBe(200);
|
|
128
|
-
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
128
|
+
expect(Object.keys(r.body.humans[0]).sort()).toEqual(["avatar_url", "display_name", "last_seen_at", "online", "user_id", "username"]);
|
|
129
|
+
expect(r.body.humans.every((h: any) => h.avatar_url === null)).toBe(true);
|
|
129
130
|
const beta = r.body.humans.find((h: any) => h.user_id === users.pres_beta.userId);
|
|
130
131
|
expect(beta.online).toBe(false);
|
|
131
132
|
expect(beta.last_seen_at).toBeNull();
|
|
@@ -181,11 +182,14 @@ describe("who must NOT see presence", () => {
|
|
|
181
182
|
expect(r.body.humans).toBeUndefined();
|
|
182
183
|
});
|
|
183
184
|
|
|
184
|
-
test("node token →
|
|
185
|
+
test("node token → identity fields including avatar, no online / last_seen_at", async () => {
|
|
185
186
|
const beta = await openUserStream(users.pres_beta.token, NET);
|
|
186
187
|
const r = await get(nodeToken, `/api/networks/${NET}/humans`);
|
|
187
188
|
expect(r.status).toBe(200);
|
|
188
|
-
for (const h of r.body.humans)
|
|
189
|
+
for (const h of r.body.humans) {
|
|
190
|
+
expect(Object.keys(h).sort()).toEqual(["avatar_url", "display_name", "user_id", "username"]);
|
|
191
|
+
expect(h.avatar_url).toBeNull();
|
|
192
|
+
}
|
|
189
193
|
// 节点令牌也不能订阅用户流(既有行为,这里钉住:不能借它收 member_presence)。
|
|
190
194
|
expect((await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: auth(nodeToken) })).status).toBe(403);
|
|
191
195
|
beta.close();
|
|
@@ -60,8 +60,20 @@ test("public errors redact free text for granted viewers on every projection", a
|
|
|
60
60
|
"bin: ENOENT: /private/fixture/bin/anet on fixture-host",
|
|
61
61
|
"adopt_unknown_secret", "adopt_start_timeout: /private/fixture", "", "adopt_start_timeout", null,
|
|
62
62
|
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
63
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
64
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
65
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
66
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
67
|
+
"adopt_codex_identity_unproven",
|
|
68
|
+
"TUI session mismatch", "opencode_launch_health:TUI session mismatch", "opencode_tui_session_mismatch",
|
|
63
69
|
]) {
|
|
64
|
-
const
|
|
70
|
+
const publicCodes = ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed", "opencode_tui_session_mismatch",
|
|
71
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
72
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
73
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
74
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
75
|
+
"adopt_codex_identity_unproven"];
|
|
76
|
+
const expected = error === null || publicCodes.includes(error) ? error : "lifecycle_error";
|
|
65
77
|
for (const [kind, table, request] of [
|
|
66
78
|
["adopt", "node_daemon_bindings", "adopt_read_active"],
|
|
67
79
|
["start", "node_start_requests", "start_read_failed"],
|
|
@@ -7,6 +7,11 @@ const publicLifecycleErrors = new Set([
|
|
|
7
7
|
"codex_fork_confirmation_invalid", "codex_fork_recovery_unsupported", "codex_fork_cli_unsupported",
|
|
8
8
|
"codex_fork_result_invalid", "codex_fork_outcome_unknown",
|
|
9
9
|
"adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed",
|
|
10
|
+
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
11
|
+
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
12
|
+
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
13
|
+
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
14
|
+
"adopt_codex_identity_unproven",
|
|
10
15
|
"adopt_active_binding_required", "adopt_binding_unavailable",
|
|
11
16
|
"adopt_binding_revoked_during_start", "adopt_explicit_private_socket_required",
|
|
12
17
|
"adopt_start_evidence_missing", "adopt_stop_evidence_missing",
|
|
@@ -27,6 +32,7 @@ const publicLifecycleErrors = new Set([
|
|
|
27
32
|
"adopt_tmux_socket_mismatch", "adopt_pane_invalid", "adopt_pane_unverified",
|
|
28
33
|
"adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
|
|
29
34
|
"adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
|
|
35
|
+
"opencode_tui_session_mismatch",
|
|
30
36
|
"adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
|
|
31
37
|
"adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
|
|
32
38
|
"adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
|
package/src/node-permissions.ts
CHANGED
|
@@ -246,6 +246,8 @@ export function streamVerdict(id: NodeIdentity, target: AgentRef | "network"): V
|
|
|
246
246
|
// ── MCP:每个工具归一类(test 钉住「每个注册的工具都归了类」,新工具忘了归类会红) ──
|
|
247
247
|
export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "schedule" | "node_write" | "human_only";
|
|
248
248
|
export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
249
|
+
daemon_provider_snapshot: "human_only",
|
|
250
|
+
pull_daemon_provider_snapshots: "always", ack_daemon_provider_snapshot: "always",
|
|
249
251
|
// 回复 / 上报 / 自己的收件箱 / 生命周期协议(节点自己的请求)—— 任何模式都能做
|
|
250
252
|
report_status: "always", report_completion: "always", get_inbox: "always", ack_inbox: "always",
|
|
251
253
|
mark_tasks_runtime_submitted: "always", mark_tasks_consumed: "always", send_reply: "always", send_peer_reply: "always", send_ack: "always",
|
|
@@ -273,7 +275,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
|
|
|
273
275
|
schedule_create: "schedule", schedule_update: "schedule", schedule_cancel: "schedule", schedule_run_now: "schedule",
|
|
274
276
|
schedule_batch_interval: "schedule",
|
|
275
277
|
// 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
|
|
276
|
-
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",
|
|
277
279
|
stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
|
|
278
280
|
// 只有人能做
|
|
279
281
|
upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
|
|
@@ -149,6 +149,30 @@ describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽"
|
|
|
149
149
|
* 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
|
|
150
150
|
* 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
|
|
151
151
|
* 而不是「必须是某个值」。 */
|
|
152
|
+
describe("adoption_candidates ingest does not reject the heartbeat or keep secrets", () => {
|
|
153
|
+
const caps = () => schemas.report_status.config_snapshot;
|
|
154
|
+
const base = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
|
|
155
|
+
|
|
156
|
+
test("a clean row is kept, a bad sibling is dropped, and token keys are stripped", () => {
|
|
157
|
+
const parsed = caps().parse({ ...base, daemon_capabilities: { adopt_capable: true, adoption_candidates: [
|
|
158
|
+
{ node_id: "n_ok", alias: "演示", workdir: "/tmp/proj", runtime: "claude-agent-sdk", launch_hint: "stopped", token: "ntok_supersecret" },
|
|
159
|
+
{ node_id: "n_bad", alias: "x", workdir: "relative", launch_hint: "stopped" },
|
|
160
|
+
{ node_id: "n_hint", alias: "y", workdir: "/tmp/y", launch_hint: "nope" },
|
|
161
|
+
] } });
|
|
162
|
+
expect(parsed.daemon_capabilities.adoption_candidates.filter(Boolean)).toEqual([
|
|
163
|
+
{ node_id: "n_ok", alias: "演示", workdir: "/tmp/proj", runtime: "claude-agent-sdk", launch_hint: "stopped" },
|
|
164
|
+
]);
|
|
165
|
+
expect(JSON.stringify(parsed)).not.toContain("ntok_supersecret");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a non-array does not reject report_status and does not keep the payload", () => {
|
|
169
|
+
const parsed = caps().parse({ ...base, daemon_capabilities: { adopt_capable: true, adoption_candidates: { token: "ntok_supersecret" } } });
|
|
170
|
+
expect(parsed.daemon_capabilities.adoption_candidates).toBeUndefined();
|
|
171
|
+
expect(JSON.stringify(parsed)).not.toContain("ntok_supersecret");
|
|
172
|
+
expect(parsed.daemon_capabilities.adopt_capable).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
152
176
|
describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
|
|
153
177
|
const rs = () => schemas.report_status;
|
|
154
178
|
const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
|
package/src/runtime-readiness.ts
CHANGED
|
@@ -40,6 +40,9 @@ export interface RuntimeReadinessOut {
|
|
|
40
40
|
auth?: string;
|
|
41
41
|
network?: string;
|
|
42
42
|
shared_login_count?: number;
|
|
43
|
+
/** Copied only for runtime key `opencode-cli`. */
|
|
44
|
+
generation?: "v1" | "v2";
|
|
45
|
+
accepted?: boolean;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
const ENUMS: Record<string, readonly string[]> = {
|
|
@@ -76,6 +79,10 @@ export function sanitizeRuntimeReadiness(raw: unknown): Record<string, RuntimeRe
|
|
|
76
79
|
&& e.shared_login_count >= 0 && e.shared_login_count <= 100_000) {
|
|
77
80
|
entry.shared_login_count = e.shared_login_count;
|
|
78
81
|
}
|
|
82
|
+
if (rt === "opencode-cli" && (e.generation === "v1" || e.generation === "v2")) {
|
|
83
|
+
entry.generation = e.generation;
|
|
84
|
+
if (typeof e.accepted === "boolean") entry.accepted = e.accepted;
|
|
85
|
+
}
|
|
79
86
|
out[rt] = entry;
|
|
80
87
|
n++;
|
|
81
88
|
}
|
package/src/server.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { expireStaleOpenTasks } from "./task-stale-open.js";
|
|
|
32
32
|
import { flagOrphanTasks } from "./task-orphan.js";
|
|
33
33
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, addAgentTimelineScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
34
34
|
import { lifecycleProjections, lifecycleRequestResponse } from "./node-lifecycle-read.js";
|
|
35
|
+
import { listAdoptionCandidates, snapshotReportsAdoptionDiscovery } from "./adoption-candidates.js";
|
|
35
36
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
36
37
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
37
38
|
import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
|
|
@@ -1455,6 +1456,30 @@ return Bun.serve({
|
|
|
1455
1456
|
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
|
|
1456
1457
|
}
|
|
1457
1458
|
|
|
1459
|
+
// Separate route: older Hubs must return unsupported, not silently ignore
|
|
1460
|
+
// avatar_url in their existing profile PUT and pretend that it was saved.
|
|
1461
|
+
if (url.pathname === "/api/auth/me/avatar" && req.method === "PUT") {
|
|
1462
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1463
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1464
|
+
const resolved = resolveToken(token);
|
|
1465
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1466
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1467
|
+
let body: unknown;
|
|
1468
|
+
try { body = await req.json(); } catch {
|
|
1469
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_json" }, { status: 400 }));
|
|
1470
|
+
}
|
|
1471
|
+
if (!body || typeof body !== "object" || Array.isArray(body)
|
|
1472
|
+
|| !Object.hasOwn(body, "avatar_url")
|
|
1473
|
+
|| Object.keys(body).some((key) => key !== "avatar_url")) {
|
|
1474
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_avatar_body", reason: "expected only avatar_url (string or null)" }, { status: 400 }));
|
|
1475
|
+
}
|
|
1476
|
+
const avatar = validateAvatarUrl((body as { avatar_url: unknown }).avatar_url);
|
|
1477
|
+
if (!avatar.ok) return withCors(req, Response.json({ ok: false, error: "invalid_avatar_url", reason: avatar.reason }, { status: 400 }));
|
|
1478
|
+
// No supplied target ID/alias; user identity comes exclusively from auth.
|
|
1479
|
+
db.run("UPDATE users SET avatar_url = ?1, updated_at = datetime('now') WHERE user_id = ?2", [avatar.value, resolved.user.user_id]);
|
|
1480
|
+
return withCors(req, Response.json({ ok: true, user_id: resolved.user.user_id, avatar_url: avatar.value }));
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1458
1483
|
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
1459
1484
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1460
1485
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
@@ -1473,7 +1498,7 @@ return Bun.serve({
|
|
|
1473
1498
|
db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
|
|
1474
1499
|
}
|
|
1475
1500
|
// Re-fetch
|
|
1476
|
-
const user = db.get<any>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
|
|
1501
|
+
const user = db.get<any>("SELECT user_id, username, display_name, avatar_url, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
|
|
1477
1502
|
return withCors(req, Response.json({ ok: true, user }));
|
|
1478
1503
|
} catch (e: any) {
|
|
1479
1504
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
@@ -2567,6 +2592,11 @@ return Bun.serve({
|
|
|
2567
2592
|
if (!lifecycleUser) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
2568
2593
|
return withCors(req, lifecycleRequestResponse(url, restScope));
|
|
2569
2594
|
}
|
|
2595
|
+
// Board #654: read-only hand-started adoption candidates. Not a binding.
|
|
2596
|
+
if (url.pathname === "/api/adoption-candidates" && req.method === "GET") {
|
|
2597
|
+
if (!lifecycleUser || !restAuth?.userId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
2598
|
+
return withCors(req, Response.json(listAdoptionCandidates(restScope, { userId: restAuth.userId, isAdmin })));
|
|
2599
|
+
}
|
|
2570
2600
|
|
|
2571
2601
|
const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
|
|
2572
2602
|
const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
|
|
@@ -4589,6 +4619,7 @@ return Bun.serve({
|
|
|
4589
4619
|
// upgraded past preview.55.
|
|
4590
4620
|
let snapCanCreate: boolean | undefined;
|
|
4591
4621
|
let snapAdoptCapable: boolean | undefined;
|
|
4622
|
+
let snapAdoptionDiscovery = false;
|
|
4592
4623
|
let snapBlockedReason: string | undefined;
|
|
4593
4624
|
// #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
|
|
4594
4625
|
// 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
|
|
@@ -4603,6 +4634,7 @@ return Bun.serve({
|
|
|
4603
4634
|
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
4604
4635
|
const caps = parsed?.daemon_capabilities;
|
|
4605
4636
|
if (typeof caps?.adopt_capable === "boolean") snapAdoptCapable = caps.adopt_capable;
|
|
4637
|
+
if (snapshotReportsAdoptionDiscovery(parsed)) snapAdoptionDiscovery = true;
|
|
4606
4638
|
if (caps && typeof caps.can_create_nodes === "boolean") {
|
|
4607
4639
|
snapCanCreate = caps.can_create_nodes;
|
|
4608
4640
|
}
|
|
@@ -4620,10 +4652,11 @@ return Bun.serve({
|
|
|
4620
4652
|
} catch { /* malformed */ }
|
|
4621
4653
|
}
|
|
4622
4654
|
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
|
|
4623
|
-
observedMsAgo: snapObservedMsAgo, readiness: snapReadiness, adoptCapable: snapAdoptCapable
|
|
4655
|
+
observedMsAgo: snapObservedMsAgo, readiness: snapReadiness, adoptCapable: snapAdoptCapable,
|
|
4656
|
+
adoptionDiscovery: snapAdoptionDiscovery };
|
|
4624
4657
|
})
|
|
4625
4658
|
.filter(({ role }) => role === "host_supervisor")
|
|
4626
|
-
.map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness, adoptCapable }) => {
|
|
4659
|
+
.map(({ row: r, canCreate, blockedReason, observedMsAgo, readiness, adoptCapable, adoptionDiscovery }) => {
|
|
4627
4660
|
let online = false;
|
|
4628
4661
|
let lastSeenAt: string | null = null;
|
|
4629
4662
|
if (r.session_last_seen) {
|
|
@@ -4667,7 +4700,10 @@ return Bun.serve({
|
|
|
4667
4700
|
// Do not reveal even the new capability for a node the user cannot read.
|
|
4668
4701
|
const visibleParams: any[] = [r.node_id];
|
|
4669
4702
|
const visibleSql = addAgentNetworkScope("SELECT node_id FROM nodes WHERE node_id=?1", visibleParams, restScope, { alias: "alias", nodeId: "node_id" });
|
|
4670
|
-
if (db.get(visibleSql, ...visibleParams))
|
|
4703
|
+
if (db.get(visibleSql, ...visibleParams)) {
|
|
4704
|
+
out.adopt_capable = adoptCapable;
|
|
4705
|
+
if (adoptionDiscovery) out.adoption_discovery = true;
|
|
4706
|
+
}
|
|
4671
4707
|
}
|
|
4672
4708
|
// #1353 Fix ② — only emit the two capability keys when the
|
|
4673
4709
|
// daemon actually reported them. Undefined vs. false is a
|