@hakimedes/dsh-easyremote 0.2.0
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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/android.js +50 -0
- package/dist/android.js.map +1 -0
- package/dist/autostart.js +104 -0
- package/dist/autostart.js.map +1 -0
- package/dist/cli-views.js +32 -0
- package/dist/cli-views.js.map +1 -0
- package/dist/cli.js +584 -0
- package/dist/cli.js.map +1 -0
- package/dist/cloudflared.js +54 -0
- package/dist/cloudflared.js.map +1 -0
- package/dist/command-router.js +12 -0
- package/dist/command-router.js.map +1 -0
- package/dist/connector-install.js +42 -0
- package/dist/connector-install.js.map +1 -0
- package/dist/controller.js +206 -0
- package/dist/controller.js.map +1 -0
- package/dist/doctor.js +46 -0
- package/dist/doctor.js.map +1 -0
- package/dist/domain.js +33 -0
- package/dist/domain.js.map +1 -0
- package/dist/download.js +59 -0
- package/dist/download.js.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/install-state.js +48 -0
- package/dist/install-state.js.map +1 -0
- package/dist/local-runtime.js +115 -0
- package/dist/local-runtime.js.map +1 -0
- package/dist/maintenance.js +58 -0
- package/dist/maintenance.js.map +1 -0
- package/dist/named-tunnel.js +49 -0
- package/dist/named-tunnel.js.map +1 -0
- package/dist/runtime.js +110 -0
- package/dist/runtime.js.map +1 -0
- package/dist/setup-progress.js +23 -0
- package/dist/setup-progress.js.map +1 -0
- package/dist/supervisor.js +81 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/wizard.js +251 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +56 -0
- package/runtime/connector/README.md +52 -0
- package/runtime/connector/cordis.patch.yml +3 -0
- package/runtime/connector/dsh.plugin.json +16 -0
- package/runtime/connector/lib/client.js +262 -0
- package/runtime/connector/lib/command-cache.d.ts +7 -0
- package/runtime/connector/lib/command-cache.js +37 -0
- package/runtime/connector/lib/command-cache.js.map +1 -0
- package/runtime/connector/lib/connector-config.d.ts +20 -0
- package/runtime/connector/lib/connector-config.js +74 -0
- package/runtime/connector/lib/connector-config.js.map +1 -0
- package/runtime/connector/lib/dsh-api.d.ts +38 -0
- package/runtime/connector/lib/dsh-api.js +76 -0
- package/runtime/connector/lib/dsh-api.js.map +1 -0
- package/runtime/connector/lib/index.d.ts +4 -0
- package/runtime/connector/lib/index.js +913 -0
- package/runtime/connector/lib/index.js.map +1 -0
- package/runtime/connector/lib/protocol.d.ts +15 -0
- package/runtime/connector/lib/protocol.js +96 -0
- package/runtime/connector/lib/protocol.js.map +1 -0
- package/runtime/connector/package.json +44 -0
- package/runtime/hub/database.js +239 -0
- package/runtime/hub/database.js.map +1 -0
- package/runtime/hub/index.js +1804 -0
- package/runtime/hub/index.js.map +1 -0
- package/runtime/hub/schema.js +111 -0
- package/runtime/hub/schema.js.map +1 -0
|
@@ -0,0 +1,1804 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import Fastify from 'fastify';
|
|
5
|
+
import websocket from '@fastify/websocket';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
8
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
9
|
+
import { getOrCreateHubId, openDatabase } from './database.js';
|
|
10
|
+
const PORT = Number(process.env.PORT ?? '8787');
|
|
11
|
+
const HOST = process.env.HOST ?? '127.0.0.1';
|
|
12
|
+
const HUB_ENTRY = process.env.HUB_ENTRY ?? 'https://dsh.infomind.cc';
|
|
13
|
+
const HUB_ENTRY_FILE = process.env.HUB_ENTRY_FILE;
|
|
14
|
+
const HUB_VERSION = process.env.DSH_EASYREMOTE_VERSION ?? '0.2.0';
|
|
15
|
+
const ACCESS_TTL_SECONDS = 15 * 60;
|
|
16
|
+
const REFRESH_TTL_MS = 60 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const COMMAND_TTL_MS = 30_000;
|
|
18
|
+
const HEARTBEAT_MS = 15_000;
|
|
19
|
+
const OFFLINE_MS = 45_000;
|
|
20
|
+
const HTTP_BODY_LIMIT = 1_000_000;
|
|
21
|
+
const WS_PAYLOAD_LIMIT = 512 * 1024;
|
|
22
|
+
const SESSION_EVENT_MAX = 2000;
|
|
23
|
+
const SESSION_EVENT_TTL_MS = 10 * 60 * 1000;
|
|
24
|
+
const COMMAND_RETENTION_MS = Number(process.env.COMMAND_RETENTION_MS ?? 7 * 24 * 60 * 60 * 1000);
|
|
25
|
+
const DB_PATH = process.env.DATABASE_PATH || './data/hub.sqlite';
|
|
26
|
+
const jwtSecretValue = process.env.JWT_SECRET || 'replace-me-secret-change-before-prod';
|
|
27
|
+
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || jwtSecretValue.length < 32)) {
|
|
28
|
+
throw new Error('JWT_SECRET must be explicitly set to at least 32 characters in production');
|
|
29
|
+
}
|
|
30
|
+
const JWT_SECRET = new TextEncoder().encode(jwtSecretValue);
|
|
31
|
+
const dbFile = resolve(DB_PATH);
|
|
32
|
+
mkdirSync(dirname(dbFile), { recursive: true });
|
|
33
|
+
const sqlite = openDatabase(dbFile);
|
|
34
|
+
const HUB_ID = getOrCreateHubId(sqlite);
|
|
35
|
+
function nowMs() {
|
|
36
|
+
return Date.now();
|
|
37
|
+
}
|
|
38
|
+
function objectValue(value) {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
40
|
+
? value
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
function normalizeModelSelection(value) {
|
|
44
|
+
const selection = objectValue(value);
|
|
45
|
+
if (!selection || typeof selection.provider !== 'string' || typeof selection.model !== 'string')
|
|
46
|
+
return null;
|
|
47
|
+
return {
|
|
48
|
+
provider: selection.provider,
|
|
49
|
+
model: selection.model,
|
|
50
|
+
...(typeof selection.reasoningEffort === 'string'
|
|
51
|
+
? { reasoningEffort: selection.reasoningEffort }
|
|
52
|
+
: {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function normalizeSessionModels(value) {
|
|
56
|
+
const result = objectValue(value);
|
|
57
|
+
const current = normalizeModelSelection(result?.current);
|
|
58
|
+
if (!result || !current)
|
|
59
|
+
throw { code: 'INTERNAL_ERROR', message: 'Node returned invalid model metadata' };
|
|
60
|
+
const groups = Array.isArray(result.groups) ? result.groups.flatMap((groupValue) => {
|
|
61
|
+
const group = objectValue(groupValue);
|
|
62
|
+
if (!group || typeof group.id !== 'string' || typeof group.name !== 'string' || !Array.isArray(group.models))
|
|
63
|
+
return [];
|
|
64
|
+
const models = group.models.flatMap((modelValue) => {
|
|
65
|
+
const model = objectValue(modelValue);
|
|
66
|
+
if (!model || typeof model.id !== 'string' || typeof model.name !== 'string')
|
|
67
|
+
return [];
|
|
68
|
+
const reasoningValue = objectValue(model.reasoning);
|
|
69
|
+
const efforts = Array.isArray(reasoningValue?.efforts)
|
|
70
|
+
? reasoningValue.efforts.flatMap((effortValue) => {
|
|
71
|
+
const effort = objectValue(effortValue);
|
|
72
|
+
if (!effort || typeof effort.id !== 'string' || typeof effort.name !== 'string')
|
|
73
|
+
return [];
|
|
74
|
+
return [{
|
|
75
|
+
id: effort.id,
|
|
76
|
+
name: effort.name,
|
|
77
|
+
...(typeof effort.description === 'string' ? { description: effort.description } : {}),
|
|
78
|
+
}];
|
|
79
|
+
})
|
|
80
|
+
: [];
|
|
81
|
+
return [{
|
|
82
|
+
id: model.id,
|
|
83
|
+
name: model.name,
|
|
84
|
+
...(typeof model.description === 'string' ? { description: model.description } : {}),
|
|
85
|
+
...(reasoningValue ? {
|
|
86
|
+
reasoning: {
|
|
87
|
+
efforts,
|
|
88
|
+
...(typeof reasoningValue.defaultEffort === 'string'
|
|
89
|
+
? { defaultEffort: reasoningValue.defaultEffort }
|
|
90
|
+
: {}),
|
|
91
|
+
},
|
|
92
|
+
} : {}),
|
|
93
|
+
}];
|
|
94
|
+
});
|
|
95
|
+
return [{ id: group.id, name: group.name, models }];
|
|
96
|
+
}) : [];
|
|
97
|
+
const failures = Array.isArray(result.failures) ? result.failures.flatMap((failureValue) => {
|
|
98
|
+
const failure = objectValue(failureValue);
|
|
99
|
+
if (!failure || typeof failure.id !== 'string' || typeof failure.name !== 'string' || typeof failure.message !== 'string')
|
|
100
|
+
return [];
|
|
101
|
+
return [{ id: failure.id, name: failure.name, message: failure.message }];
|
|
102
|
+
}) : [];
|
|
103
|
+
return { current, routable: result.routable === true, groups, failures };
|
|
104
|
+
}
|
|
105
|
+
function hashText(value) {
|
|
106
|
+
return createHash('sha256').update(value).digest('hex');
|
|
107
|
+
}
|
|
108
|
+
function randomHex(bytes) {
|
|
109
|
+
return randomBytes(bytes).toString('hex');
|
|
110
|
+
}
|
|
111
|
+
function sleep(ms) {
|
|
112
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
}
|
|
114
|
+
function isUuidv7(value) {
|
|
115
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
116
|
+
}
|
|
117
|
+
function parseBearer(auth) {
|
|
118
|
+
if (!auth)
|
|
119
|
+
return null;
|
|
120
|
+
if (!auth.startsWith('Bearer '))
|
|
121
|
+
return null;
|
|
122
|
+
return auth.slice(7);
|
|
123
|
+
}
|
|
124
|
+
function parsePairToken(auth) {
|
|
125
|
+
if (!auth)
|
|
126
|
+
return null;
|
|
127
|
+
if (!auth.startsWith('Pair '))
|
|
128
|
+
return null;
|
|
129
|
+
return auth.slice(5);
|
|
130
|
+
}
|
|
131
|
+
function parseNodeAuth(auth) {
|
|
132
|
+
if (!auth)
|
|
133
|
+
return null;
|
|
134
|
+
if (!auth.startsWith('Node '))
|
|
135
|
+
return null;
|
|
136
|
+
const raw = auth.slice(5);
|
|
137
|
+
const idx = raw.indexOf('.');
|
|
138
|
+
if (idx <= 0)
|
|
139
|
+
return null;
|
|
140
|
+
return {
|
|
141
|
+
nodeId: raw.slice(0, idx),
|
|
142
|
+
nodeSecret: raw.slice(idx + 1),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function normalizePublicOrigin(value) {
|
|
146
|
+
const url = new URL(value.trim());
|
|
147
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
148
|
+
throw new Error('Hub public origin must use HTTP or HTTPS');
|
|
149
|
+
}
|
|
150
|
+
return url.origin;
|
|
151
|
+
}
|
|
152
|
+
function currentPublicOrigin() {
|
|
153
|
+
if (HUB_ENTRY_FILE) {
|
|
154
|
+
try {
|
|
155
|
+
const raw = readFileSync(HUB_ENTRY_FILE, 'utf8').trim();
|
|
156
|
+
if (raw) {
|
|
157
|
+
const fileValue = raw.startsWith('{')
|
|
158
|
+
? JSON.parse(raw).publicOrigin
|
|
159
|
+
: raw;
|
|
160
|
+
if (typeof fileValue === 'string' && fileValue.trim()) {
|
|
161
|
+
return normalizePublicOrigin(fileValue);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
app.log.warn({ err: error, path: HUB_ENTRY_FILE }, 'Unable to read Hub entry file; using HUB_ENTRY');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return normalizePublicOrigin(HUB_ENTRY);
|
|
170
|
+
}
|
|
171
|
+
function pairingQrPayload(pairToken) {
|
|
172
|
+
const qr = new URL('dshremote://pair');
|
|
173
|
+
qr.searchParams.set('server', currentPublicOrigin());
|
|
174
|
+
qr.searchParams.set('token', pairToken);
|
|
175
|
+
qr.searchParams.set('hubId', HUB_ID);
|
|
176
|
+
return qr.toString();
|
|
177
|
+
}
|
|
178
|
+
const nodeConnections = new Map();
|
|
179
|
+
const mobileConnections = new Set();
|
|
180
|
+
const sessionSubscribers = new Map();
|
|
181
|
+
const sessionRings = new Map();
|
|
182
|
+
const sessionMeta = new Map();
|
|
183
|
+
function minimalPersistedCommandResult(action, frame) {
|
|
184
|
+
const base = {
|
|
185
|
+
v: 1,
|
|
186
|
+
kind: 'command.result',
|
|
187
|
+
commandId: frame.commandId,
|
|
188
|
+
...(frame.requestId ? { requestId: frame.requestId } : {}),
|
|
189
|
+
ok: frame.ok,
|
|
190
|
+
};
|
|
191
|
+
if (!frame.ok) {
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
error: { code: String(frame.error?.code || 'INTERNAL_ERROR') },
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const result = objectValue(frame.result);
|
|
198
|
+
if (action === 'session.create') {
|
|
199
|
+
const session = objectValue(result?.session);
|
|
200
|
+
const id = typeof session?.id === 'string'
|
|
201
|
+
? session.id
|
|
202
|
+
: typeof session?.sessionId === 'string'
|
|
203
|
+
? session.sessionId
|
|
204
|
+
: null;
|
|
205
|
+
if (!id)
|
|
206
|
+
return null;
|
|
207
|
+
return {
|
|
208
|
+
...base,
|
|
209
|
+
result: {
|
|
210
|
+
session: {
|
|
211
|
+
id,
|
|
212
|
+
...(typeof session?.title === 'string' ? { title: session.title } : {}),
|
|
213
|
+
...(typeof session?.agentPreset === 'string' ? { agentPreset: session.agentPreset } : {}),
|
|
214
|
+
...(typeof session?.lastSourceSeq === 'number' ? { lastSourceSeq: session.lastSourceSeq } : {}),
|
|
215
|
+
...(typeof session?.createdAt === 'number' ? { createdAt: session.createdAt } : {}),
|
|
216
|
+
...(typeof session?.updatedAt === 'number' ? { updatedAt: session.updatedAt } : {}),
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (action === 'session.selectModel') {
|
|
222
|
+
const selected = normalizeModelSelection(result?.selected);
|
|
223
|
+
return selected ? { ...base, result: { selected } } : null;
|
|
224
|
+
}
|
|
225
|
+
if (action === 'session.rename') {
|
|
226
|
+
const title = typeof result?.title === 'string' ? result.title : null;
|
|
227
|
+
if (!title)
|
|
228
|
+
return null;
|
|
229
|
+
return {
|
|
230
|
+
...base,
|
|
231
|
+
result: {
|
|
232
|
+
title,
|
|
233
|
+
...(typeof result?.seq === 'number' ? { seq: result.seq } : {}),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
const pendingCommandTimeout = new Map();
|
|
240
|
+
const pendingCommandResults = new Map();
|
|
241
|
+
const rateBuckets = new Map();
|
|
242
|
+
function keySession(nodeId, sessionId) {
|
|
243
|
+
return `${nodeId}:${sessionId}`;
|
|
244
|
+
}
|
|
245
|
+
function sendJson(ws, payload) {
|
|
246
|
+
if (ws.readyState === 1) {
|
|
247
|
+
ws.send(JSON.stringify(payload));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async function issueAccessToken(userId, orgId, deviceId) {
|
|
251
|
+
return new SignJWT({
|
|
252
|
+
orgId,
|
|
253
|
+
deviceId,
|
|
254
|
+
type: 'access',
|
|
255
|
+
})
|
|
256
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
257
|
+
.setSubject(userId)
|
|
258
|
+
.setIssuedAt()
|
|
259
|
+
.setExpirationTime(`${ACCESS_TTL_SECONDS}s`)
|
|
260
|
+
.sign(JWT_SECRET);
|
|
261
|
+
}
|
|
262
|
+
async function verifyAccessToken(token) {
|
|
263
|
+
try {
|
|
264
|
+
const { payload } = await jwtVerify(token, JWT_SECRET);
|
|
265
|
+
if (payload.type !== 'access' || typeof payload.sub !== 'string')
|
|
266
|
+
return null;
|
|
267
|
+
if (typeof payload.orgId !== 'string' || typeof payload.deviceId !== 'string')
|
|
268
|
+
return null;
|
|
269
|
+
return {
|
|
270
|
+
userId: payload.sub,
|
|
271
|
+
orgId: payload.orgId,
|
|
272
|
+
deviceId: payload.deviceId,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function countUsers() {
|
|
280
|
+
const row = sqlite.prepare('SELECT COUNT(1) AS c FROM users').get();
|
|
281
|
+
return row.c;
|
|
282
|
+
}
|
|
283
|
+
function getUser(userId) {
|
|
284
|
+
return sqlite.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
|
285
|
+
}
|
|
286
|
+
function getUserByNode(nodeId) {
|
|
287
|
+
return sqlite.prepare('SELECT owner_user_id FROM nodes WHERE id = ?').get(nodeId);
|
|
288
|
+
}
|
|
289
|
+
function createDefaultOrgAndUser(displayName) {
|
|
290
|
+
const orgId = uuidv7();
|
|
291
|
+
const userId = uuidv7();
|
|
292
|
+
const now = nowMs();
|
|
293
|
+
const orgName = 'Default Organization';
|
|
294
|
+
sqlite.prepare('INSERT INTO organizations (id, name, created_at) VALUES (?, ?, ?)').run(orgId, orgName, now);
|
|
295
|
+
sqlite.prepare('INSERT INTO users (id, org_id, display_name, created_at) VALUES (?, ?, ?, ?)').run(userId, orgId, displayName, now);
|
|
296
|
+
return { orgId, userId, displayName };
|
|
297
|
+
}
|
|
298
|
+
function createDevice(userId, kind, name) {
|
|
299
|
+
const deviceId = uuidv7();
|
|
300
|
+
sqlite.prepare('INSERT INTO devices (id, user_id, kind, display_name, created_at) VALUES (?, ?, ?, ?, ?)').run(deviceId, userId, kind, name, nowMs());
|
|
301
|
+
return deviceId;
|
|
302
|
+
}
|
|
303
|
+
function createRefreshToken(userId, deviceId) {
|
|
304
|
+
const refreshId = uuidv7();
|
|
305
|
+
const token = randomHex(32);
|
|
306
|
+
const tokenHash = hashText(token);
|
|
307
|
+
const expiresAt = nowMs() + REFRESH_TTL_MS;
|
|
308
|
+
const createdAt = nowMs();
|
|
309
|
+
sqlite.prepare('INSERT INTO refresh_tokens (id, user_id, device_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)').run(refreshId, userId, deviceId, tokenHash, expiresAt, createdAt);
|
|
310
|
+
return { refreshId, token, expiresAt, createdAt };
|
|
311
|
+
}
|
|
312
|
+
function writeAudit(event, actorUserId, nodeId, deviceId, details) {
|
|
313
|
+
sqlite.prepare('INSERT INTO audit_logs (id, event, actor_user_id, node_id, device_id, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
314
|
+
.run(uuidv7(), event, actorUserId, nodeId, deviceId, JSON.stringify(details), nowMs());
|
|
315
|
+
}
|
|
316
|
+
function ensureNodeMeta(nodeId) {
|
|
317
|
+
const row = sqlite.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
|
|
318
|
+
return row;
|
|
319
|
+
}
|
|
320
|
+
function getNodeFromAuthorization(authHeader) {
|
|
321
|
+
const auth = parseNodeAuth(authHeader);
|
|
322
|
+
if (!auth)
|
|
323
|
+
return null;
|
|
324
|
+
const node = ensureNodeMeta(auth.nodeId);
|
|
325
|
+
if (!node || node.revoked_at || node.credential_hash !== hashText(auth.nodeSecret))
|
|
326
|
+
return null;
|
|
327
|
+
return node;
|
|
328
|
+
}
|
|
329
|
+
function nodeOwnerId(nodeId) {
|
|
330
|
+
const row = sqlite.prepare('SELECT owner_user_id FROM nodes WHERE id = ?').get(nodeId);
|
|
331
|
+
return row?.owner_user_id;
|
|
332
|
+
}
|
|
333
|
+
function nodeIsOnline(nodeId) {
|
|
334
|
+
const socket = nodeConnections.get(nodeId);
|
|
335
|
+
return Boolean(socket && socket.ws.readyState === 1);
|
|
336
|
+
}
|
|
337
|
+
function publicNode(row) {
|
|
338
|
+
return {
|
|
339
|
+
id: row.id,
|
|
340
|
+
name: row.name,
|
|
341
|
+
platform: row.platform,
|
|
342
|
+
arch: row.arch,
|
|
343
|
+
pluginVersion: row.plugin_version,
|
|
344
|
+
dshVersion: row.dsh_version,
|
|
345
|
+
createdAt: row.created_at,
|
|
346
|
+
lastSeenAt: row.last_seen_at,
|
|
347
|
+
revokedAt: row.revoked_at,
|
|
348
|
+
online: nodeIsOnline(row.id),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function publicSession(row) {
|
|
352
|
+
return {
|
|
353
|
+
sessionId: row.session_id,
|
|
354
|
+
title: row.title,
|
|
355
|
+
lastEventSeq: row.last_event_seq,
|
|
356
|
+
updatedAt: row.updated_at,
|
|
357
|
+
createdAt: row.created_at,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function writeRingEvent(nodeId, sessionId, event) {
|
|
361
|
+
const k = keySession(nodeId, sessionId);
|
|
362
|
+
const bucket = sessionRings.get(k) ?? { events: [], lastSourceSeq: -1, updatedAt: nowMs() };
|
|
363
|
+
const next = {
|
|
364
|
+
...event,
|
|
365
|
+
createdAt: nowMs(),
|
|
366
|
+
};
|
|
367
|
+
const expectedNext = bucket.lastSourceSeq + 1;
|
|
368
|
+
if (bucket.events.length === 0 || event.sourceSeq >= expectedNext) {
|
|
369
|
+
bucket.lastSourceSeq = event.sourceSeq;
|
|
370
|
+
}
|
|
371
|
+
bucket.events.push(next);
|
|
372
|
+
bucket.events = bucket.events
|
|
373
|
+
.filter((item) => nowMs() - item.createdAt <= SESSION_EVENT_TTL_MS)
|
|
374
|
+
.sort((a, b) => a.sourceSeq - b.sourceSeq);
|
|
375
|
+
while (bucket.events.length > SESSION_EVENT_MAX) {
|
|
376
|
+
bucket.events.shift();
|
|
377
|
+
}
|
|
378
|
+
if (bucket.events.length > 0) {
|
|
379
|
+
bucket.lastSourceSeq = Math.max(...bucket.events.map((item) => item.sourceSeq));
|
|
380
|
+
}
|
|
381
|
+
bucket.updatedAt = nowMs();
|
|
382
|
+
sessionRings.set(k, bucket);
|
|
383
|
+
sqlite.prepare('INSERT OR REPLACE INTO session_index (id, node_id, session_id, title, last_event_seq, updated_at, created_at) VALUES (?, ?, ?, COALESCE((SELECT title FROM session_index WHERE id = ?), ?), ?, ?, COALESCE((SELECT created_at FROM session_index WHERE id = ?), ?))')
|
|
384
|
+
.run(k, nodeId, sessionId, k, `Session ${sessionId}`, bucket.lastSourceSeq, nowMs(), k, nowMs());
|
|
385
|
+
if (event.event.type === 'session.title') {
|
|
386
|
+
const title = typeof event.event.data?.title === 'string' ? event.event.data.title.trim() : '';
|
|
387
|
+
if (title) {
|
|
388
|
+
sqlite.prepare('UPDATE session_index SET title = ?, updated_at = ? WHERE id = ?')
|
|
389
|
+
.run(title, nowMs(), k);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function getSessionReplay(nodeId, sessionId, afterSourceSeq) {
|
|
394
|
+
const ring = sessionRings.get(keySession(nodeId, sessionId));
|
|
395
|
+
if (!ring) {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
if (ring.events.length === 0)
|
|
399
|
+
return [];
|
|
400
|
+
const minSeq = ring.events[0].sourceSeq;
|
|
401
|
+
const maxSeq = ring.events[ring.events.length - 1].sourceSeq;
|
|
402
|
+
if (afterSourceSeq < minSeq - 1 || afterSourceSeq > maxSeq) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return ring.events.filter((evt) => evt.sourceSeq > afterSourceSeq);
|
|
406
|
+
}
|
|
407
|
+
function broadcastToSession(nodeId, sessionId, payload) {
|
|
408
|
+
const key = keySession(nodeId, sessionId);
|
|
409
|
+
const targets = sessionSubscribers.get(key);
|
|
410
|
+
if (!targets)
|
|
411
|
+
return;
|
|
412
|
+
for (const ws of targets) {
|
|
413
|
+
sendJson(ws, payload);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
function approvalRequestFrame(nodeId, sessionId, approval) {
|
|
417
|
+
return {
|
|
418
|
+
v: 1,
|
|
419
|
+
kind: 'approval.request',
|
|
420
|
+
nodeId,
|
|
421
|
+
sessionId,
|
|
422
|
+
approval: {
|
|
423
|
+
approvalId: approval.approvalId,
|
|
424
|
+
title: approval.title || 'Approval',
|
|
425
|
+
summary: approval.summary || '',
|
|
426
|
+
toolCallId: approval.toolCallId,
|
|
427
|
+
nodeId,
|
|
428
|
+
sessionId,
|
|
429
|
+
...(typeof approval.cwd === 'string' ? { cwd: approval.cwd } : {}),
|
|
430
|
+
...(typeof approval.risk === 'string' ? { risk: approval.risk } : {}),
|
|
431
|
+
expiresAt: approval.expiresAt,
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function cleanupOfflineNodes() {
|
|
436
|
+
const now = nowMs();
|
|
437
|
+
for (const [nodeId, state] of nodeConnections.entries()) {
|
|
438
|
+
if (now - state.lastSeenAt > OFFLINE_MS) {
|
|
439
|
+
state.ws.close(4001, 'NODE_OFFLINE');
|
|
440
|
+
nodeConnections.delete(nodeId);
|
|
441
|
+
sqlite.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?').run(nowMs(), nodeId);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function cleanupStaleRates() {
|
|
446
|
+
const now = nowMs();
|
|
447
|
+
for (const [key, value] of rateBuckets.entries()) {
|
|
448
|
+
if (now - value.windowStart > 60_000) {
|
|
449
|
+
rateBuckets.delete(key);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function cleanupCommandMetadata() {
|
|
454
|
+
const cutoff = nowMs() - COMMAND_RETENTION_MS;
|
|
455
|
+
sqlite
|
|
456
|
+
.prepare("DELETE FROM commands WHERE created_at < ? AND status NOT IN ('pending', 'sent')")
|
|
457
|
+
.run(cutoff);
|
|
458
|
+
}
|
|
459
|
+
function getAuthUserFromRequest(authHeader) {
|
|
460
|
+
const token = parseBearer(authHeader);
|
|
461
|
+
if (!token)
|
|
462
|
+
return Promise.resolve(null);
|
|
463
|
+
return verifyAccessToken(token);
|
|
464
|
+
}
|
|
465
|
+
function getUserByRefreshToken(refreshToken) {
|
|
466
|
+
const tokenHash = hashText(refreshToken);
|
|
467
|
+
const row = sqlite
|
|
468
|
+
.prepare(`
|
|
469
|
+
SELECT rt.id, rt.user_id, rt.device_id, rt.expires_at, rt.revoked_at, rt.token_hash, u.org_id
|
|
470
|
+
FROM refresh_tokens rt
|
|
471
|
+
JOIN users u ON u.id = rt.user_id
|
|
472
|
+
WHERE rt.token_hash = ?
|
|
473
|
+
`)
|
|
474
|
+
.get(tokenHash);
|
|
475
|
+
return row;
|
|
476
|
+
}
|
|
477
|
+
function requireBody(request, schema) {
|
|
478
|
+
return schema.parse(request.body);
|
|
479
|
+
}
|
|
480
|
+
function sendError(reply, httpCode, errorCode, message, details) {
|
|
481
|
+
reply.code(httpCode).send({
|
|
482
|
+
code: errorCode,
|
|
483
|
+
message,
|
|
484
|
+
details,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
const app = Fastify({
|
|
488
|
+
logger: {
|
|
489
|
+
level: 'info',
|
|
490
|
+
redact: {
|
|
491
|
+
paths: [
|
|
492
|
+
'req.headers.authorization',
|
|
493
|
+
'req.body.pairToken',
|
|
494
|
+
'req.body.pollToken',
|
|
495
|
+
'req.body.refreshToken',
|
|
496
|
+
'req.body.nodeSecret',
|
|
497
|
+
'req.body.nodeSecretHash',
|
|
498
|
+
],
|
|
499
|
+
censor: '[REDACTED]',
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
bodyLimit: HTTP_BODY_LIMIT,
|
|
503
|
+
});
|
|
504
|
+
await app.register(websocket, {
|
|
505
|
+
options: {
|
|
506
|
+
maxPayload: WS_PAYLOAD_LIMIT,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
510
|
+
const key = request.ip || 'unknown';
|
|
511
|
+
const now = nowMs();
|
|
512
|
+
const bucket = rateBuckets.get(key) ?? { count: 0, windowStart: now };
|
|
513
|
+
if (now - bucket.windowStart > 60_000) {
|
|
514
|
+
bucket.count = 0;
|
|
515
|
+
bucket.windowStart = now;
|
|
516
|
+
}
|
|
517
|
+
bucket.count += 1;
|
|
518
|
+
rateBuckets.set(key, bucket);
|
|
519
|
+
if (bucket.count > 120) {
|
|
520
|
+
return sendError(reply, 429, 'RATE_LIMITED', 'Too many requests');
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
const httpError = {
|
|
524
|
+
UNAUTHORIZED: 401,
|
|
525
|
+
FORBIDDEN: 403,
|
|
526
|
+
INVALID_REQUEST: 400,
|
|
527
|
+
PAIR_TOKEN_INVALID: 400,
|
|
528
|
+
PAIR_TOKEN_EXPIRED: 410,
|
|
529
|
+
PAIR_TOKEN_ALREADY_USED: 409,
|
|
530
|
+
PAIRING_NOT_FOUND: 404,
|
|
531
|
+
NODE_NOT_FOUND: 404,
|
|
532
|
+
NODE_OFFLINE: 409,
|
|
533
|
+
NODE_REVOKED: 403,
|
|
534
|
+
SESSION_NOT_FOUND: 404,
|
|
535
|
+
APPROVAL_NOT_FOUND: 404,
|
|
536
|
+
APPROVAL_EXPIRED: 410,
|
|
537
|
+
APPROVAL_ALREADY_RESOLVED: 409,
|
|
538
|
+
CAPABILITY_UNAVAILABLE: 409,
|
|
539
|
+
AGENT_PRESET_NOT_FOUND: 400,
|
|
540
|
+
AGENT_PRESET_INVALID: 400,
|
|
541
|
+
AGENT_PRESET_LOCKED: 409,
|
|
542
|
+
MODEL_UNAVAILABLE: 409,
|
|
543
|
+
COMMAND_TIMEOUT: 408,
|
|
544
|
+
COMMAND_DUPLICATE: 200,
|
|
545
|
+
COMMAND_EXPIRED: 408,
|
|
546
|
+
PROTOCOL_UNSUPPORTED: 400,
|
|
547
|
+
INTERNAL_ERROR: 500,
|
|
548
|
+
};
|
|
549
|
+
const pairingBody = z.object({
|
|
550
|
+
nodeName: z.string().min(1),
|
|
551
|
+
platform: z.string().min(1),
|
|
552
|
+
arch: z.string().min(1),
|
|
553
|
+
pluginVersion: z.string().min(1),
|
|
554
|
+
dshVersion: z.string().min(1),
|
|
555
|
+
installId: z.string().min(1),
|
|
556
|
+
nodeSecretHash: z.string().length(64),
|
|
557
|
+
});
|
|
558
|
+
const requestIdSchema = z.string().refine(isUuidv7, { message: 'requestId must be UUIDv7' });
|
|
559
|
+
const claimBody = z.object({
|
|
560
|
+
pairToken: z.string().min(1),
|
|
561
|
+
ownerDisplayName: z.string().min(1).optional(),
|
|
562
|
+
deviceName: z.string().min(1).optional(),
|
|
563
|
+
});
|
|
564
|
+
const refreshBody = z.object({
|
|
565
|
+
refreshToken: z.string().min(1),
|
|
566
|
+
});
|
|
567
|
+
const createSessionBody = z.object({
|
|
568
|
+
requestId: requestIdSchema,
|
|
569
|
+
agentPreset: z.string().min(1).max(256).optional(),
|
|
570
|
+
});
|
|
571
|
+
const modelSelectionBody = z.object({
|
|
572
|
+
requestId: requestIdSchema,
|
|
573
|
+
provider: z.string().min(1).max(256),
|
|
574
|
+
model: z.string().min(1).max(512),
|
|
575
|
+
reasoningEffort: z.string().min(1).max(128).optional(),
|
|
576
|
+
});
|
|
577
|
+
const renameSessionBody = z.object({
|
|
578
|
+
requestId: requestIdSchema,
|
|
579
|
+
title: z.string().max(200).refine((title) => title.trim().length > 0, {
|
|
580
|
+
message: 'title must not be empty',
|
|
581
|
+
}),
|
|
582
|
+
});
|
|
583
|
+
const followupBody = z.object({
|
|
584
|
+
requestId: requestIdSchema,
|
|
585
|
+
content: z.string().min(1).max(131_072),
|
|
586
|
+
});
|
|
587
|
+
const steerBody = z.object({
|
|
588
|
+
requestId: requestIdSchema,
|
|
589
|
+
instruction: z.string().min(1).max(131_072),
|
|
590
|
+
});
|
|
591
|
+
const stopBody = z.object({
|
|
592
|
+
requestId: requestIdSchema,
|
|
593
|
+
reason: z.string().min(1).max(4096).optional(),
|
|
594
|
+
});
|
|
595
|
+
const approvalRespondBody = z.object({
|
|
596
|
+
requestId: requestIdSchema,
|
|
597
|
+
response: z.enum(['allow_once', 'deny']),
|
|
598
|
+
});
|
|
599
|
+
const wsSubscribeBody = z.object({
|
|
600
|
+
v: z.literal(1),
|
|
601
|
+
kind: z.literal('subscribe'),
|
|
602
|
+
requestId: requestIdSchema,
|
|
603
|
+
nodeId: z.string().min(1),
|
|
604
|
+
sessionId: z.string().min(1),
|
|
605
|
+
});
|
|
606
|
+
const wsSessionSyncBody = z.object({
|
|
607
|
+
v: z.literal(1),
|
|
608
|
+
kind: z.literal('session.sync'),
|
|
609
|
+
requestId: requestIdSchema,
|
|
610
|
+
nodeId: z.string().min(1),
|
|
611
|
+
sessionId: z.string().min(1),
|
|
612
|
+
afterSourceSeq: z.number().int().min(-1),
|
|
613
|
+
});
|
|
614
|
+
app.get('/healthz', async () => ({
|
|
615
|
+
ok: true,
|
|
616
|
+
server: 'dsh-hub',
|
|
617
|
+
version: 'v1',
|
|
618
|
+
}));
|
|
619
|
+
app.get('/readyz', async () => {
|
|
620
|
+
const row = sqlite.prepare('SELECT 1 as ok').get();
|
|
621
|
+
if (!row?.ok) {
|
|
622
|
+
throw new Error('DB not ready');
|
|
623
|
+
}
|
|
624
|
+
return { ok: true };
|
|
625
|
+
});
|
|
626
|
+
app.get('/v1/meta', async () => ({
|
|
627
|
+
hubId: HUB_ID,
|
|
628
|
+
version: HUB_VERSION,
|
|
629
|
+
publicOrigin: currentPublicOrigin(),
|
|
630
|
+
}));
|
|
631
|
+
app.post('/v1/node-pairings', async (request, reply) => {
|
|
632
|
+
const body = requireBody(request, pairingBody);
|
|
633
|
+
const id = uuidv7();
|
|
634
|
+
const pairToken = randomHex(32);
|
|
635
|
+
const pollToken = randomHex(32);
|
|
636
|
+
const now = nowMs();
|
|
637
|
+
const expiresAt = now + 5 * 60_000;
|
|
638
|
+
sqlite
|
|
639
|
+
.prepare(`INSERT INTO node_pairings (
|
|
640
|
+
id, pair_token_hash, poll_token_hash, install_id, node_name, platform, arch, plugin_version,
|
|
641
|
+
dsh_version, node_secret_hash, status, created_at, expires_at
|
|
642
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
|
|
643
|
+
.run(id, hashText(pairToken), hashText(pollToken), body.installId, body.nodeName, body.platform, body.arch, body.pluginVersion, body.dshVersion, body.nodeSecretHash, now, expiresAt);
|
|
644
|
+
writeAudit('pairing_created', null, null, null, { pairingId: id, nodeName: body.nodeName, platform: body.platform });
|
|
645
|
+
reply.code(200).send({
|
|
646
|
+
pairingId: id,
|
|
647
|
+
pairToken,
|
|
648
|
+
pollToken,
|
|
649
|
+
expiresAt,
|
|
650
|
+
qrPayload: pairingQrPayload(pairToken),
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
app.post('/v1/node-pairings/recover', async (request, reply) => {
|
|
654
|
+
const node = getNodeFromAuthorization(request.headers.authorization);
|
|
655
|
+
if (!node) {
|
|
656
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Valid Node authorization required');
|
|
657
|
+
}
|
|
658
|
+
const id = uuidv7();
|
|
659
|
+
const pairToken = randomHex(32);
|
|
660
|
+
const pollToken = randomHex(32);
|
|
661
|
+
const now = nowMs();
|
|
662
|
+
const expiresAt = now + 5 * 60_000;
|
|
663
|
+
sqlite
|
|
664
|
+
.prepare(`INSERT INTO node_pairings (
|
|
665
|
+
id, pair_token_hash, poll_token_hash, install_id, node_name, platform, arch, plugin_version,
|
|
666
|
+
dsh_version, node_secret_hash, status, created_at, expires_at, claimed_user_id, node_id
|
|
667
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`)
|
|
668
|
+
.run(id, hashText(pairToken), hashText(pollToken), node.install_id, node.name, node.platform, node.arch, node.plugin_version, node.dsh_version, node.credential_hash, now, expiresAt, node.owner_user_id, node.id);
|
|
669
|
+
writeAudit('mobile_recovery_created', node.owner_user_id, node.id, null, { pairingId: id });
|
|
670
|
+
reply.send({
|
|
671
|
+
pairingId: id,
|
|
672
|
+
pairToken,
|
|
673
|
+
pollToken,
|
|
674
|
+
expiresAt,
|
|
675
|
+
qrPayload: pairingQrPayload(pairToken),
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
app.get('/v1/node-pairings/:pairingId', async (request, reply) => {
|
|
679
|
+
const pairingId = request.params.pairingId;
|
|
680
|
+
const pollToken = parsePairToken(request.headers.authorization);
|
|
681
|
+
if (!pollToken) {
|
|
682
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing Pair authorization');
|
|
683
|
+
}
|
|
684
|
+
const pollHash = hashText(pollToken);
|
|
685
|
+
const row = sqlite
|
|
686
|
+
.prepare('SELECT * FROM node_pairings WHERE id = ? AND poll_token_hash = ?')
|
|
687
|
+
.get(pairingId, pollHash);
|
|
688
|
+
if (!row) {
|
|
689
|
+
return sendError(reply, httpError.PAIRING_NOT_FOUND, 'PAIRING_NOT_FOUND', 'Pairing not found');
|
|
690
|
+
}
|
|
691
|
+
if (row.status === 'claimed') {
|
|
692
|
+
const owner = sqlite
|
|
693
|
+
.prepare('SELECT display_name FROM users WHERE id = ?')
|
|
694
|
+
.get(row.claimed_user_id);
|
|
695
|
+
reply.send({
|
|
696
|
+
status: 'claimed',
|
|
697
|
+
nodeId: row.node_id,
|
|
698
|
+
ownerDisplayName: owner?.display_name ?? 'Owner',
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (row.status === 'pending') {
|
|
703
|
+
if (row.expires_at <= nowMs()) {
|
|
704
|
+
reply.send({
|
|
705
|
+
status: 'expired',
|
|
706
|
+
});
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
reply.send({
|
|
710
|
+
status: 'pending',
|
|
711
|
+
});
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
return sendError(reply, httpError.INVALID_REQUEST, 'INVALID_REQUEST', 'Unsupported pairing status');
|
|
715
|
+
});
|
|
716
|
+
app.post('/v1/node-pairings/claim', async (request, reply) => {
|
|
717
|
+
const body = requireBody(request, claimBody);
|
|
718
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
719
|
+
const currentUserCount = countUsers();
|
|
720
|
+
const row = sqlite
|
|
721
|
+
.prepare('SELECT * FROM node_pairings WHERE pair_token_hash = ?')
|
|
722
|
+
.get(hashText(body.pairToken));
|
|
723
|
+
if (!row) {
|
|
724
|
+
return sendError(reply, httpError.PAIRING_NOT_FOUND, 'PAIRING_NOT_FOUND', 'Pair token not found');
|
|
725
|
+
}
|
|
726
|
+
if (row.status !== 'pending') {
|
|
727
|
+
return sendError(reply, httpError.PAIR_TOKEN_ALREADY_USED, 'PAIR_TOKEN_ALREADY_USED', 'Pair token already used');
|
|
728
|
+
}
|
|
729
|
+
if (row.expires_at <= nowMs()) {
|
|
730
|
+
return sendError(reply, httpError.PAIR_TOKEN_EXPIRED, 'PAIR_TOKEN_EXPIRED', 'Pair token expired');
|
|
731
|
+
}
|
|
732
|
+
const isRecovery = Boolean(row.claimed_user_id && row.node_id);
|
|
733
|
+
if (!authUser && currentUserCount > 0 && !isRecovery) {
|
|
734
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Bearer required');
|
|
735
|
+
}
|
|
736
|
+
let userId;
|
|
737
|
+
let orgId;
|
|
738
|
+
if (isRecovery) {
|
|
739
|
+
const user = getUser(row.claimed_user_id);
|
|
740
|
+
const node = ensureNodeMeta(row.node_id);
|
|
741
|
+
if (!user || !node || node.revoked_at || node.owner_user_id !== row.claimed_user_id) {
|
|
742
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Recovery pairing is no longer valid');
|
|
743
|
+
}
|
|
744
|
+
userId = user.id;
|
|
745
|
+
orgId = user.org_id;
|
|
746
|
+
}
|
|
747
|
+
else if (authUser) {
|
|
748
|
+
const user = getUser(authUser.userId);
|
|
749
|
+
if (!user) {
|
|
750
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Invalid bearer user');
|
|
751
|
+
}
|
|
752
|
+
userId = authUser.userId;
|
|
753
|
+
orgId = user.org_id;
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
const ownerDisplayName = body.ownerDisplayName || 'Owner';
|
|
757
|
+
const created = createDefaultOrgAndUser(ownerDisplayName);
|
|
758
|
+
userId = created.userId;
|
|
759
|
+
orgId = created.orgId;
|
|
760
|
+
}
|
|
761
|
+
const deviceId = createDevice(userId, 'mobile', body.deviceName || 'Mobile Device');
|
|
762
|
+
const tokenInfo = createRefreshToken(userId, deviceId);
|
|
763
|
+
const accessToken = await issueAccessToken(userId, orgId, deviceId);
|
|
764
|
+
if (isRecovery) {
|
|
765
|
+
sqlite
|
|
766
|
+
.prepare('UPDATE node_pairings SET status = ?, claimed_at = ? WHERE id = ?')
|
|
767
|
+
.run('claimed', nowMs(), row.id);
|
|
768
|
+
writeAudit('mobile_recovery_claimed', userId, row.node_id, deviceId, { pairingId: row.id });
|
|
769
|
+
reply.send({
|
|
770
|
+
accessToken,
|
|
771
|
+
refreshToken: tokenInfo.token,
|
|
772
|
+
refreshExpiresAt: tokenInfo.expiresAt,
|
|
773
|
+
expiresIn: ACCESS_TTL_SECONDS,
|
|
774
|
+
nodeId: row.node_id,
|
|
775
|
+
tokenType: 'Bearer',
|
|
776
|
+
});
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const nodeId = uuidv7();
|
|
780
|
+
sqlite
|
|
781
|
+
.prepare(`INSERT INTO nodes (
|
|
782
|
+
id, org_id, owner_user_id, install_id, name, platform, arch, plugin_version,
|
|
783
|
+
dsh_version, credential_hash, created_at
|
|
784
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
785
|
+
.run(nodeId, orgId, userId, row.install_id, row.node_name, row.platform, row.arch, row.plugin_version, row.dsh_version, row.node_secret_hash, nowMs());
|
|
786
|
+
sqlite
|
|
787
|
+
.prepare('UPDATE node_pairings SET status = ?, claimed_user_id = ?, claimed_at = ?, node_id = ? WHERE id = ?')
|
|
788
|
+
.run('claimed', userId, nowMs(), nodeId, row.id);
|
|
789
|
+
sqlite
|
|
790
|
+
.prepare('INSERT INTO audit_logs (id, event, actor_user_id, node_id, device_id, details, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
791
|
+
.run(uuidv7(), 'pairing_claimed', userId, nodeId, deviceId, JSON.stringify({ pairingId: row.id }), nowMs());
|
|
792
|
+
writeAudit('pairing_claimed', userId, nodeId, deviceId, { pairingId: row.id });
|
|
793
|
+
reply.send({
|
|
794
|
+
accessToken,
|
|
795
|
+
refreshToken: tokenInfo.token,
|
|
796
|
+
refreshExpiresAt: tokenInfo.expiresAt,
|
|
797
|
+
expiresIn: ACCESS_TTL_SECONDS,
|
|
798
|
+
nodeId,
|
|
799
|
+
tokenType: 'Bearer',
|
|
800
|
+
});
|
|
801
|
+
});
|
|
802
|
+
app.post('/v1/auth/refresh', async (request, reply) => {
|
|
803
|
+
const body = requireBody(request, refreshBody);
|
|
804
|
+
const row = getUserByRefreshToken(body.refreshToken);
|
|
805
|
+
if (!row) {
|
|
806
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Invalid refresh token');
|
|
807
|
+
}
|
|
808
|
+
if (row.revoked_at) {
|
|
809
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Refresh token revoked');
|
|
810
|
+
}
|
|
811
|
+
if (row.expires_at <= nowMs()) {
|
|
812
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Refresh token expired');
|
|
813
|
+
}
|
|
814
|
+
sqlite
|
|
815
|
+
.prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?')
|
|
816
|
+
.run(nowMs(), row.id);
|
|
817
|
+
const newToken = createRefreshToken(row.user_id, row.device_id);
|
|
818
|
+
const accessToken = await issueAccessToken(row.user_id, row.org_id, row.device_id);
|
|
819
|
+
writeAudit('token_refreshed', row.user_id, null, row.device_id, { refreshId: row.id });
|
|
820
|
+
reply.send({
|
|
821
|
+
accessToken,
|
|
822
|
+
refreshToken: newToken.token,
|
|
823
|
+
refreshExpiresAt: newToken.expiresAt,
|
|
824
|
+
expiresIn: ACCESS_TTL_SECONDS,
|
|
825
|
+
tokenType: 'Bearer',
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
app.post('/v1/auth/logout', async (request, reply) => {
|
|
829
|
+
const body = requireBody(request, refreshBody);
|
|
830
|
+
const tokenHash = hashText(body.refreshToken);
|
|
831
|
+
const row = sqlite.prepare('SELECT id, user_id, device_id FROM refresh_tokens WHERE token_hash = ?').get(tokenHash);
|
|
832
|
+
if (!row) {
|
|
833
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Invalid refresh token');
|
|
834
|
+
}
|
|
835
|
+
sqlite.prepare('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?').run(nowMs(), row.id);
|
|
836
|
+
writeAudit('refresh_revoked', row.user_id, null, row.device_id, { refreshId: row.id });
|
|
837
|
+
reply.send({ ok: true });
|
|
838
|
+
});
|
|
839
|
+
app.get('/v1/me', async (request, reply) => {
|
|
840
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
841
|
+
if (!authUser) {
|
|
842
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
843
|
+
}
|
|
844
|
+
const user = getUser(authUser.userId);
|
|
845
|
+
if (!user) {
|
|
846
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Unknown user');
|
|
847
|
+
}
|
|
848
|
+
reply.send({
|
|
849
|
+
user: {
|
|
850
|
+
id: user.id,
|
|
851
|
+
displayName: user.display_name,
|
|
852
|
+
orgId: user.org_id,
|
|
853
|
+
},
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
app.get('/v1/nodes', async (request, reply) => {
|
|
857
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
858
|
+
if (!authUser) {
|
|
859
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
860
|
+
}
|
|
861
|
+
const rows = sqlite
|
|
862
|
+
.prepare('SELECT * FROM nodes WHERE owner_user_id = ? ORDER BY created_at DESC')
|
|
863
|
+
.all(authUser.userId);
|
|
864
|
+
const items = rows.map(publicNode);
|
|
865
|
+
reply.send({ items, count: items.length });
|
|
866
|
+
});
|
|
867
|
+
app.get('/v1/nodes/:nodeId', async (request, reply) => {
|
|
868
|
+
const nodeId = request.params.nodeId;
|
|
869
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
870
|
+
if (!authUser) {
|
|
871
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
872
|
+
}
|
|
873
|
+
const node = ensureNodeMeta(nodeId);
|
|
874
|
+
if (!node) {
|
|
875
|
+
return sendError(reply, httpError.NODE_NOT_FOUND, 'NODE_NOT_FOUND', 'Node not found');
|
|
876
|
+
}
|
|
877
|
+
if (node.owner_user_id !== authUser.userId) {
|
|
878
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
|
|
879
|
+
}
|
|
880
|
+
reply.send(publicNode(node));
|
|
881
|
+
});
|
|
882
|
+
app.post('/v1/nodes/:nodeId/revoke', async (request, reply) => {
|
|
883
|
+
const nodeId = request.params.nodeId;
|
|
884
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
885
|
+
if (!authUser) {
|
|
886
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
887
|
+
}
|
|
888
|
+
const node = ensureNodeMeta(nodeId);
|
|
889
|
+
if (!node) {
|
|
890
|
+
return sendError(reply, httpError.NODE_NOT_FOUND, 'NODE_NOT_FOUND', 'Node not found');
|
|
891
|
+
}
|
|
892
|
+
if (node.owner_user_id !== authUser.userId) {
|
|
893
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
|
|
894
|
+
}
|
|
895
|
+
sqlite.prepare('UPDATE nodes SET revoked_at = ? WHERE id = ?').run(nowMs(), nodeId);
|
|
896
|
+
const conn = nodeConnections.get(nodeId);
|
|
897
|
+
if (conn) {
|
|
898
|
+
conn.ws.close(4403, 'revoked');
|
|
899
|
+
nodeConnections.delete(nodeId);
|
|
900
|
+
}
|
|
901
|
+
writeAudit('node_revoked', authUser.userId, nodeId, authUser.deviceId, {});
|
|
902
|
+
reply.send({ ok: true, nodeId });
|
|
903
|
+
});
|
|
904
|
+
app.get('/v1/nodes/:nodeId/sessions', async (request, reply) => {
|
|
905
|
+
const nodeId = request.params.nodeId;
|
|
906
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
907
|
+
if (!authUser) {
|
|
908
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
909
|
+
}
|
|
910
|
+
if (nodeOwnerId(nodeId) !== authUser.userId) {
|
|
911
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const dispatched = await dispatchCommand(authUser, nodeId, null, 'session.list', uuidv7(), {}, true);
|
|
915
|
+
const resultFrame = await dispatched.resultPromise;
|
|
916
|
+
if (!resultFrame || !resultFrame.ok) {
|
|
917
|
+
throw {
|
|
918
|
+
code: resultFrame?.error?.code || 'INTERNAL_ERROR',
|
|
919
|
+
message: resultFrame?.error?.message || 'Session list command failed',
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
const result = resultFrame.result;
|
|
923
|
+
const remoteSessions = Array.isArray(result?.sessions) ? result.sessions : [];
|
|
924
|
+
const upsert = sqlite.prepare(`
|
|
925
|
+
INSERT INTO session_index (
|
|
926
|
+
id, node_id, session_id, title, last_event_seq, updated_at, created_at
|
|
927
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
928
|
+
ON CONFLICT(node_id, session_id) DO UPDATE SET
|
|
929
|
+
title = excluded.title,
|
|
930
|
+
last_event_seq = CASE
|
|
931
|
+
WHEN excluded.last_event_seq > session_index.last_event_seq THEN excluded.last_event_seq
|
|
932
|
+
ELSE session_index.last_event_seq
|
|
933
|
+
END,
|
|
934
|
+
updated_at = excluded.updated_at
|
|
935
|
+
`);
|
|
936
|
+
const indexedAt = nowMs();
|
|
937
|
+
const sessions = [];
|
|
938
|
+
for (const item of remoteSessions) {
|
|
939
|
+
if (!item || typeof item !== 'object')
|
|
940
|
+
continue;
|
|
941
|
+
const session = item;
|
|
942
|
+
const sessionId = typeof session.id === 'string'
|
|
943
|
+
? session.id
|
|
944
|
+
: typeof session.sessionId === 'string'
|
|
945
|
+
? session.sessionId
|
|
946
|
+
: null;
|
|
947
|
+
if (!sessionId)
|
|
948
|
+
continue;
|
|
949
|
+
const title = typeof session.title === 'string' && session.title.trim()
|
|
950
|
+
? session.title
|
|
951
|
+
: `Session ${sessionId}`;
|
|
952
|
+
const lastEventSeq = typeof session.lastSourceSeq === 'number'
|
|
953
|
+
? session.lastSourceSeq
|
|
954
|
+
: typeof session.lastEventSeq === 'number'
|
|
955
|
+
? session.lastEventSeq
|
|
956
|
+
: -1;
|
|
957
|
+
const updatedAt = typeof session.updatedAt === 'number' ? session.updatedAt : indexedAt;
|
|
958
|
+
const createdAt = typeof session.createdAt === 'number' ? session.createdAt : indexedAt;
|
|
959
|
+
const status = session.status === 'running' || session.status === 'idle' ? session.status : 'unknown';
|
|
960
|
+
const workspaceLabel = typeof session.cwdLabel === 'string'
|
|
961
|
+
? session.cwdLabel
|
|
962
|
+
: typeof session.workspaceLabel === 'string'
|
|
963
|
+
? session.workspaceLabel
|
|
964
|
+
: undefined;
|
|
965
|
+
const agentPreset = typeof session.agentPreset === 'string' && session.agentPreset
|
|
966
|
+
? session.agentPreset
|
|
967
|
+
: undefined;
|
|
968
|
+
upsert.run(keySession(nodeId, sessionId), nodeId, sessionId, title, lastEventSeq, updatedAt, createdAt);
|
|
969
|
+
sessions.push({
|
|
970
|
+
sessionId,
|
|
971
|
+
title,
|
|
972
|
+
status,
|
|
973
|
+
lastEventSeq,
|
|
974
|
+
updatedAt,
|
|
975
|
+
createdAt,
|
|
976
|
+
...(workspaceLabel ? { workspaceLabel } : {}),
|
|
977
|
+
...(agentPreset ? { agentPreset } : {}),
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
981
|
+
reply.send({ sessions, count: sessions.length });
|
|
982
|
+
}
|
|
983
|
+
catch (err) {
|
|
984
|
+
const statusCode = httpError[err?.code] ?? 500;
|
|
985
|
+
return sendError(reply, statusCode, err?.code || 'INTERNAL_ERROR', err?.message || 'Failed to list sessions');
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
async function dispatchCommand(authUser, nodeId, sessionId, action, requestId, payload, expectResult = false) {
|
|
989
|
+
const existing = sqlite
|
|
990
|
+
.prepare('SELECT * FROM commands WHERE request_id = ? AND user_id = ?')
|
|
991
|
+
.get(requestId, authUser.userId);
|
|
992
|
+
if (existing) {
|
|
993
|
+
const persistedResult = typeof existing.result_json === 'string'
|
|
994
|
+
? JSON.parse(existing.result_json)
|
|
995
|
+
: undefined;
|
|
996
|
+
const inFlightResult = pendingCommandResults.get(existing.id)?.promise;
|
|
997
|
+
return {
|
|
998
|
+
commandId: existing.id,
|
|
999
|
+
status: existing.status,
|
|
1000
|
+
requestId,
|
|
1001
|
+
duplicates: true,
|
|
1002
|
+
resultPromise: expectResult
|
|
1003
|
+
? persistedResult
|
|
1004
|
+
? Promise.resolve(persistedResult)
|
|
1005
|
+
: inFlightResult
|
|
1006
|
+
: undefined,
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const node = ensureNodeMeta(nodeId);
|
|
1010
|
+
if (!node) {
|
|
1011
|
+
throw { code: 'NODE_NOT_FOUND' };
|
|
1012
|
+
}
|
|
1013
|
+
if (node.owner_user_id !== authUser.userId) {
|
|
1014
|
+
throw { code: 'FORBIDDEN' };
|
|
1015
|
+
}
|
|
1016
|
+
if (node.revoked_at) {
|
|
1017
|
+
throw { code: 'NODE_REVOKED' };
|
|
1018
|
+
}
|
|
1019
|
+
const conn = nodeConnections.get(nodeId);
|
|
1020
|
+
if (!conn) {
|
|
1021
|
+
throw { code: 'NODE_OFFLINE' };
|
|
1022
|
+
}
|
|
1023
|
+
if (!conn.capabilities.has(action)) {
|
|
1024
|
+
throw { code: 'CAPABILITY_UNAVAILABLE' };
|
|
1025
|
+
}
|
|
1026
|
+
const commandId = uuidv7();
|
|
1027
|
+
const now = nowMs();
|
|
1028
|
+
const expiresAt = now + COMMAND_TTL_MS;
|
|
1029
|
+
sqlite
|
|
1030
|
+
.prepare(`INSERT INTO commands (id, request_id, user_id, device_id, node_id, session_id, action, status, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`)
|
|
1031
|
+
.run(commandId, requestId, authUser.userId, authUser.deviceId, nodeId, sessionId, action, now, expiresAt);
|
|
1032
|
+
const commandFrame = {
|
|
1033
|
+
v: 1,
|
|
1034
|
+
kind: 'command',
|
|
1035
|
+
commandId,
|
|
1036
|
+
requestId,
|
|
1037
|
+
nodeId,
|
|
1038
|
+
sessionId,
|
|
1039
|
+
action,
|
|
1040
|
+
payload,
|
|
1041
|
+
issuedAt: now,
|
|
1042
|
+
expiresAt,
|
|
1043
|
+
};
|
|
1044
|
+
let resultPromise;
|
|
1045
|
+
if (expectResult) {
|
|
1046
|
+
let resolveResult;
|
|
1047
|
+
let rejectResult;
|
|
1048
|
+
resultPromise = new Promise((resolve, reject) => {
|
|
1049
|
+
resolveResult = resolve;
|
|
1050
|
+
rejectResult = reject;
|
|
1051
|
+
});
|
|
1052
|
+
const timeout = setTimeout(() => {
|
|
1053
|
+
pendingCommandResults.delete(commandId);
|
|
1054
|
+
sqlite
|
|
1055
|
+
.prepare('UPDATE commands SET status = ?, error_code = ? WHERE id = ?')
|
|
1056
|
+
.run('failed', 'COMMAND_TIMEOUT', commandId);
|
|
1057
|
+
rejectResult({ code: 'COMMAND_TIMEOUT', message: 'Command result timed out' });
|
|
1058
|
+
}, COMMAND_TTL_MS);
|
|
1059
|
+
pendingCommandResults.set(commandId, {
|
|
1060
|
+
resolve: resolveResult,
|
|
1061
|
+
reject: rejectResult,
|
|
1062
|
+
timeout,
|
|
1063
|
+
promise: resultPromise,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
sendJson(conn.ws, commandFrame);
|
|
1067
|
+
sqlite
|
|
1068
|
+
.prepare('UPDATE commands SET status = ? WHERE id = ?')
|
|
1069
|
+
.run('sent', commandId);
|
|
1070
|
+
const t = setTimeout(() => {
|
|
1071
|
+
const c = sqlite.prepare('SELECT status FROM commands WHERE id = ?').get(commandId);
|
|
1072
|
+
if (c && c.status !== 'acked') {
|
|
1073
|
+
sqlite
|
|
1074
|
+
.prepare('UPDATE commands SET status = ?, error_code = ? WHERE id = ?')
|
|
1075
|
+
.run('failed', 'COMMAND_TIMEOUT', commandId);
|
|
1076
|
+
}
|
|
1077
|
+
pendingCommandTimeout.delete(commandId);
|
|
1078
|
+
}, COMMAND_TTL_MS);
|
|
1079
|
+
pendingCommandTimeout.set(commandId, t);
|
|
1080
|
+
return {
|
|
1081
|
+
commandId,
|
|
1082
|
+
status: 'sent',
|
|
1083
|
+
requestId,
|
|
1084
|
+
duplicates: false,
|
|
1085
|
+
resultPromise,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
app.get('/v1/nodes/:nodeId/agent-presets', async (request, reply) => {
|
|
1089
|
+
const nodeId = request.params.nodeId;
|
|
1090
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1091
|
+
if (!authUser) {
|
|
1092
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1093
|
+
}
|
|
1094
|
+
if (nodeOwnerId(nodeId) !== authUser.userId) {
|
|
1095
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
|
|
1096
|
+
}
|
|
1097
|
+
try {
|
|
1098
|
+
const dispatched = await dispatchCommand(authUser, nodeId, null, 'agentPreset.list', uuidv7(), {}, true);
|
|
1099
|
+
const resultFrame = await dispatched.resultPromise;
|
|
1100
|
+
if (!resultFrame?.ok) {
|
|
1101
|
+
throw {
|
|
1102
|
+
code: resultFrame?.error?.code || 'INTERNAL_ERROR',
|
|
1103
|
+
message: resultFrame?.error?.message || 'Agent preset command failed',
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
const result = resultFrame.result;
|
|
1107
|
+
const presets = Array.isArray(result?.presets)
|
|
1108
|
+
? result.presets.flatMap((value) => {
|
|
1109
|
+
if (!value || typeof value !== 'object')
|
|
1110
|
+
return [];
|
|
1111
|
+
const preset = value;
|
|
1112
|
+
if (typeof preset.id !== 'string' || !preset.id)
|
|
1113
|
+
return [];
|
|
1114
|
+
const trust = preset.trust === 'user' ? 'user' : 'system';
|
|
1115
|
+
return [{
|
|
1116
|
+
id: preset.id,
|
|
1117
|
+
trust,
|
|
1118
|
+
isDefault: preset.isDefault === true,
|
|
1119
|
+
...(typeof preset.name === 'string' ? { name: preset.name } : {}),
|
|
1120
|
+
...(typeof preset.description === 'string' ? { description: preset.description } : {}),
|
|
1121
|
+
...(typeof preset.broken === 'string' ? { broken: preset.broken } : {}),
|
|
1122
|
+
}];
|
|
1123
|
+
})
|
|
1124
|
+
: [];
|
|
1125
|
+
reply.send({ presets });
|
|
1126
|
+
}
|
|
1127
|
+
catch (err) {
|
|
1128
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1129
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
app.post('/v1/nodes/:nodeId/sessions', async (request, reply) => {
|
|
1133
|
+
const nodeId = request.params.nodeId;
|
|
1134
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1135
|
+
if (!authUser) {
|
|
1136
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1137
|
+
}
|
|
1138
|
+
const body = requireBody(request, createSessionBody);
|
|
1139
|
+
const requestId = body.requestId;
|
|
1140
|
+
try {
|
|
1141
|
+
const dispatched = await dispatchCommand(authUser, nodeId, null, 'session.create', requestId, {
|
|
1142
|
+
createdBy: 'mobile',
|
|
1143
|
+
...(body.agentPreset ? { agentPreset: body.agentPreset } : {}),
|
|
1144
|
+
}, true);
|
|
1145
|
+
const resultFrame = await dispatched.resultPromise;
|
|
1146
|
+
if (!resultFrame?.ok) {
|
|
1147
|
+
throw {
|
|
1148
|
+
code: resultFrame?.error?.code || 'INTERNAL_ERROR',
|
|
1149
|
+
message: resultFrame?.error?.message || 'Session create command failed',
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
const remoteSession = resultFrame.result?.session;
|
|
1153
|
+
const sessionId = typeof remoteSession?.id === 'string'
|
|
1154
|
+
? remoteSession.id
|
|
1155
|
+
: typeof remoteSession?.sessionId === 'string'
|
|
1156
|
+
? remoteSession.sessionId
|
|
1157
|
+
: null;
|
|
1158
|
+
if (!sessionId) {
|
|
1159
|
+
throw { code: 'INTERNAL_ERROR', message: 'Node did not return a session id' };
|
|
1160
|
+
}
|
|
1161
|
+
const title = typeof remoteSession?.title === 'string' && remoteSession.title.trim()
|
|
1162
|
+
? remoteSession.title
|
|
1163
|
+
: 'New Session';
|
|
1164
|
+
const lastEventSeq = typeof remoteSession?.lastSourceSeq === 'number'
|
|
1165
|
+
? remoteSession.lastSourceSeq
|
|
1166
|
+
: typeof remoteSession?.lastEventSeq === 'number'
|
|
1167
|
+
? remoteSession.lastEventSeq
|
|
1168
|
+
: -1;
|
|
1169
|
+
const updatedAt = typeof remoteSession?.updatedAt === 'number' ? remoteSession.updatedAt : nowMs();
|
|
1170
|
+
const createdAt = typeof remoteSession?.createdAt === 'number' ? remoteSession.createdAt : updatedAt;
|
|
1171
|
+
const agentPreset = typeof remoteSession?.agentPreset === 'string' && remoteSession.agentPreset
|
|
1172
|
+
? remoteSession.agentPreset
|
|
1173
|
+
: body.agentPreset;
|
|
1174
|
+
sqlite.prepare('INSERT OR IGNORE INTO session_index (id, node_id, session_id, title, last_event_seq, updated_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(`${nodeId}:${sessionId}`, nodeId, sessionId, title, lastEventSeq, updatedAt, createdAt);
|
|
1175
|
+
sqlite
|
|
1176
|
+
.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?')
|
|
1177
|
+
.run(nowMs(), nodeId);
|
|
1178
|
+
reply.code(202).send({
|
|
1179
|
+
commandId: dispatched.commandId,
|
|
1180
|
+
sessionId,
|
|
1181
|
+
requestId: dispatched.requestId,
|
|
1182
|
+
...(agentPreset ? { agentPreset } : {}),
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
catch (err) {
|
|
1186
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1187
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
app.get('/v1/nodes/:nodeId/sessions/:sessionId/snapshot', async (request, reply) => {
|
|
1191
|
+
const params = request.params;
|
|
1192
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1193
|
+
if (!authUser) {
|
|
1194
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1195
|
+
}
|
|
1196
|
+
const node = ensureNodeMeta(params.nodeId);
|
|
1197
|
+
if (!node || node.owner_user_id !== authUser.userId) {
|
|
1198
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
|
|
1199
|
+
}
|
|
1200
|
+
const conn = nodeConnections.get(params.nodeId);
|
|
1201
|
+
if (conn && conn.capabilities.has('session.snapshot')) {
|
|
1202
|
+
try {
|
|
1203
|
+
const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.snapshot', uuidv7(), {}, true);
|
|
1204
|
+
const resultFrame = await dispatched.resultPromise;
|
|
1205
|
+
if (resultFrame && resultFrame.ok) {
|
|
1206
|
+
const result = resultFrame.result;
|
|
1207
|
+
return reply.send({
|
|
1208
|
+
source: 'node',
|
|
1209
|
+
session: result?.session ?? {
|
|
1210
|
+
nodeId: params.nodeId,
|
|
1211
|
+
sessionId: params.sessionId,
|
|
1212
|
+
},
|
|
1213
|
+
events: result?.events ?? [],
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
// Fall through to ring-buffer fallback.
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
const row = sqlite
|
|
1222
|
+
.prepare('SELECT * FROM session_index WHERE node_id = ? AND session_id = ?')
|
|
1223
|
+
.get(params.nodeId, params.sessionId);
|
|
1224
|
+
if (!row) {
|
|
1225
|
+
return sendError(reply, httpError.SESSION_NOT_FOUND, 'SESSION_NOT_FOUND', 'Session not found');
|
|
1226
|
+
}
|
|
1227
|
+
const replay = getSessionReplay(params.nodeId, params.sessionId, -1) || [];
|
|
1228
|
+
reply.send({
|
|
1229
|
+
source: 'ring-buffer',
|
|
1230
|
+
session: {
|
|
1231
|
+
nodeId: params.nodeId,
|
|
1232
|
+
sessionId: params.sessionId,
|
|
1233
|
+
title: row.title,
|
|
1234
|
+
lastEventSeq: row.last_event_seq,
|
|
1235
|
+
},
|
|
1236
|
+
events: replay,
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
app.get('/v1/nodes/:nodeId/sessions/:sessionId/models', async (request, reply) => {
|
|
1240
|
+
const params = request.params;
|
|
1241
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1242
|
+
if (!authUser) {
|
|
1243
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1244
|
+
}
|
|
1245
|
+
if (nodeOwnerId(params.nodeId) !== authUser.userId) {
|
|
1246
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Node belongs to another user');
|
|
1247
|
+
}
|
|
1248
|
+
try {
|
|
1249
|
+
const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.models', uuidv7(), {}, true);
|
|
1250
|
+
const resultFrame = await dispatched.resultPromise;
|
|
1251
|
+
if (!resultFrame?.ok) {
|
|
1252
|
+
throw {
|
|
1253
|
+
code: resultFrame?.error?.code || 'INTERNAL_ERROR',
|
|
1254
|
+
message: resultFrame?.error?.message || 'Session model command failed',
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
reply.send(normalizeSessionModels(resultFrame.result));
|
|
1258
|
+
}
|
|
1259
|
+
catch (err) {
|
|
1260
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1261
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
app.post('/v1/nodes/:nodeId/sessions/:sessionId/model-selection', async (request, reply) => {
|
|
1265
|
+
const params = request.params;
|
|
1266
|
+
const body = requireBody(request, modelSelectionBody);
|
|
1267
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1268
|
+
if (!authUser) {
|
|
1269
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.selectModel', body.requestId, {
|
|
1273
|
+
provider: body.provider,
|
|
1274
|
+
model: body.model,
|
|
1275
|
+
...(body.reasoningEffort ? { reasoningEffort: body.reasoningEffort } : {}),
|
|
1276
|
+
}, true);
|
|
1277
|
+
const resultFrame = await dispatched.resultPromise;
|
|
1278
|
+
if (!resultFrame?.ok) {
|
|
1279
|
+
throw {
|
|
1280
|
+
code: resultFrame?.error?.code || 'INTERNAL_ERROR',
|
|
1281
|
+
message: resultFrame?.error?.message || 'Model selection command failed',
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
const result = objectValue(resultFrame.result);
|
|
1285
|
+
const selected = normalizeModelSelection(result?.selected);
|
|
1286
|
+
if (!selected)
|
|
1287
|
+
throw { code: 'INTERNAL_ERROR', message: 'Node returned an invalid model selection' };
|
|
1288
|
+
reply.send({ selected });
|
|
1289
|
+
}
|
|
1290
|
+
catch (err) {
|
|
1291
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1292
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
app.patch('/v1/nodes/:nodeId/sessions/:sessionId', async (request, reply) => {
|
|
1296
|
+
const params = request.params;
|
|
1297
|
+
const body = requireBody(request, renameSessionBody);
|
|
1298
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1299
|
+
if (!authUser) {
|
|
1300
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1301
|
+
}
|
|
1302
|
+
try {
|
|
1303
|
+
const dispatched = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.rename', body.requestId, { title: body.title }, true);
|
|
1304
|
+
const resultFrame = await dispatched.resultPromise;
|
|
1305
|
+
if (!resultFrame?.ok) {
|
|
1306
|
+
throw {
|
|
1307
|
+
code: resultFrame?.error?.code || 'INTERNAL_ERROR',
|
|
1308
|
+
message: resultFrame?.error?.message || 'Session rename command failed',
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
const result = objectValue(resultFrame.result);
|
|
1312
|
+
const title = typeof result?.title === 'string' ? result.title.trim() : '';
|
|
1313
|
+
const seq = typeof result?.seq === 'number' ? result.seq : undefined;
|
|
1314
|
+
if (!title)
|
|
1315
|
+
throw { code: 'INTERNAL_ERROR', message: 'Node returned an invalid session title' };
|
|
1316
|
+
sqlite.prepare('UPDATE session_index SET title = ?, updated_at = ? WHERE node_id = ? AND session_id = ?')
|
|
1317
|
+
.run(title, nowMs(), params.nodeId, params.sessionId);
|
|
1318
|
+
reply.send({ title, ...(seq === undefined ? {} : { seq }) });
|
|
1319
|
+
}
|
|
1320
|
+
catch (err) {
|
|
1321
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1322
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1323
|
+
}
|
|
1324
|
+
});
|
|
1325
|
+
app.post('/v1/nodes/:nodeId/sessions/:sessionId/followup', async (request, reply) => {
|
|
1326
|
+
const params = request.params;
|
|
1327
|
+
const body = requireBody(request, followupBody);
|
|
1328
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1329
|
+
if (!authUser) {
|
|
1330
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
const result = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.followup', body.requestId, {
|
|
1334
|
+
content: body.content,
|
|
1335
|
+
});
|
|
1336
|
+
reply.code(202).send(result);
|
|
1337
|
+
}
|
|
1338
|
+
catch (err) {
|
|
1339
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1340
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
app.post('/v1/nodes/:nodeId/sessions/:sessionId/steer', async (request, reply) => {
|
|
1344
|
+
const params = request.params;
|
|
1345
|
+
const body = requireBody(request, steerBody);
|
|
1346
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1347
|
+
if (!authUser) {
|
|
1348
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
const result = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.steer', body.requestId, {
|
|
1352
|
+
instruction: body.instruction,
|
|
1353
|
+
});
|
|
1354
|
+
reply.code(202).send(result);
|
|
1355
|
+
}
|
|
1356
|
+
catch (err) {
|
|
1357
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1358
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
app.post('/v1/nodes/:nodeId/sessions/:sessionId/stop', async (request, reply) => {
|
|
1362
|
+
const params = request.params;
|
|
1363
|
+
const body = requireBody(request, stopBody);
|
|
1364
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1365
|
+
if (!authUser) {
|
|
1366
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1367
|
+
}
|
|
1368
|
+
try {
|
|
1369
|
+
const result = await dispatchCommand(authUser, params.nodeId, params.sessionId, 'session.stop', body.requestId, {
|
|
1370
|
+
reason: body.reason || 'user_stop',
|
|
1371
|
+
});
|
|
1372
|
+
reply.code(202).send(result);
|
|
1373
|
+
}
|
|
1374
|
+
catch (err) {
|
|
1375
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1376
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
app.post('/v1/approvals/:approvalId/respond', async (request, reply) => {
|
|
1380
|
+
const approvalId = request.params.approvalId;
|
|
1381
|
+
const body = requireBody(request, approvalRespondBody);
|
|
1382
|
+
const authUser = await getAuthUserFromRequest(request.headers.authorization);
|
|
1383
|
+
if (!authUser) {
|
|
1384
|
+
return sendError(reply, httpError.UNAUTHORIZED, 'UNAUTHORIZED', 'Missing access token');
|
|
1385
|
+
}
|
|
1386
|
+
const row = sqlite.prepare('SELECT * FROM approvals WHERE id = ?').get(approvalId);
|
|
1387
|
+
if (!row) {
|
|
1388
|
+
return sendError(reply, httpError.APPROVAL_NOT_FOUND, 'APPROVAL_NOT_FOUND', 'Approval not found');
|
|
1389
|
+
}
|
|
1390
|
+
if (row.user_id !== authUser.userId) {
|
|
1391
|
+
return sendError(reply, httpError.FORBIDDEN, 'FORBIDDEN', 'Approval belongs to another user');
|
|
1392
|
+
}
|
|
1393
|
+
const existingCommand = sqlite
|
|
1394
|
+
.prepare('SELECT * FROM commands WHERE request_id = ? AND user_id = ?')
|
|
1395
|
+
.get(body.requestId, authUser.userId);
|
|
1396
|
+
if (existingCommand) {
|
|
1397
|
+
if (existingCommand.action !== 'approval.respond'
|
|
1398
|
+
|| existingCommand.node_id !== row.node_id
|
|
1399
|
+
|| existingCommand.session_id !== row.session_id
|
|
1400
|
+
|| row.response !== body.response) {
|
|
1401
|
+
return sendError(reply, httpError.INVALID_REQUEST, 'INVALID_REQUEST', 'requestId was already used for another write');
|
|
1402
|
+
}
|
|
1403
|
+
return reply.send({
|
|
1404
|
+
ok: true,
|
|
1405
|
+
approvalId,
|
|
1406
|
+
status: row.status,
|
|
1407
|
+
commandId: existingCommand.id,
|
|
1408
|
+
requestId: body.requestId,
|
|
1409
|
+
duplicate: true,
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
if (row.status !== 'pending') {
|
|
1413
|
+
return sendError(reply, httpError.APPROVAL_ALREADY_RESOLVED, 'APPROVAL_ALREADY_RESOLVED', 'Approval already resolved');
|
|
1414
|
+
}
|
|
1415
|
+
if (row.expires_at <= nowMs()) {
|
|
1416
|
+
return sendError(reply, httpError.APPROVAL_EXPIRED, 'APPROVAL_EXPIRED', 'Approval expired');
|
|
1417
|
+
}
|
|
1418
|
+
let dispatched;
|
|
1419
|
+
try {
|
|
1420
|
+
dispatched = await dispatchCommand(authUser, row.node_id, row.session_id, 'approval.respond', body.requestId, { approvalId, response: body.response });
|
|
1421
|
+
}
|
|
1422
|
+
catch (err) {
|
|
1423
|
+
const statusCode = httpError[err.code] ?? 500;
|
|
1424
|
+
return sendError(reply, statusCode, err.code || 'INTERNAL_ERROR', err.message || 'Command failed');
|
|
1425
|
+
}
|
|
1426
|
+
const status = body.response === 'allow_once' ? 'approved' : 'denied';
|
|
1427
|
+
sqlite
|
|
1428
|
+
.prepare('UPDATE approvals SET status = ?, resolved_at = ?, response = ? WHERE id = ?')
|
|
1429
|
+
.run(status, nowMs(), body.response, approvalId);
|
|
1430
|
+
broadcastToSession(row.node_id, row.session_id, {
|
|
1431
|
+
v: 1,
|
|
1432
|
+
kind: 'approval.resolved',
|
|
1433
|
+
approvalId,
|
|
1434
|
+
status,
|
|
1435
|
+
});
|
|
1436
|
+
reply.send({
|
|
1437
|
+
ok: true,
|
|
1438
|
+
approvalId,
|
|
1439
|
+
status,
|
|
1440
|
+
commandId: dispatched.commandId,
|
|
1441
|
+
requestId: body.requestId,
|
|
1442
|
+
duplicate: false,
|
|
1443
|
+
});
|
|
1444
|
+
});
|
|
1445
|
+
app.get('/v1/node/connect', { websocket: true }, (connection, request) => {
|
|
1446
|
+
const socket = connection;
|
|
1447
|
+
const auth = parseNodeAuth(request.headers.authorization);
|
|
1448
|
+
if (!auth) {
|
|
1449
|
+
socket.close(1008, 'UNAUTHORIZED');
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const nodeRow = ensureNodeMeta(auth.nodeId);
|
|
1453
|
+
if (!nodeRow) {
|
|
1454
|
+
socket.close(1008, 'NODE_NOT_FOUND');
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
const credentialHash = hashText(auth.nodeSecret);
|
|
1458
|
+
if (nodeRow.credential_hash !== credentialHash) {
|
|
1459
|
+
socket.close(1008, 'UNAUTHORIZED');
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (nodeRow.revoked_at) {
|
|
1463
|
+
socket.close(4403, 'NODE_REVOKED');
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
let acceptedNode = false;
|
|
1467
|
+
const now = nowMs();
|
|
1468
|
+
const state = {
|
|
1469
|
+
ws: socket,
|
|
1470
|
+
nodeId: auth.nodeId,
|
|
1471
|
+
userId: nodeRow.owner_user_id,
|
|
1472
|
+
capabilities: new Set(),
|
|
1473
|
+
protocolMin: 1,
|
|
1474
|
+
protocolMax: 1,
|
|
1475
|
+
lastSeenAt: now,
|
|
1476
|
+
};
|
|
1477
|
+
const closeExisting = nodeConnections.get(auth.nodeId);
|
|
1478
|
+
if (closeExisting) {
|
|
1479
|
+
closeExisting.ws.close(4009, 'NODE_REPLACED');
|
|
1480
|
+
}
|
|
1481
|
+
nodeConnections.set(auth.nodeId, state);
|
|
1482
|
+
socket.on('close', () => {
|
|
1483
|
+
const current = nodeConnections.get(auth.nodeId);
|
|
1484
|
+
if (current === state) {
|
|
1485
|
+
nodeConnections.delete(auth.nodeId);
|
|
1486
|
+
sqlite.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?').run(nowMs(), auth.nodeId);
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
socket.on('message', (raw) => {
|
|
1490
|
+
const text = raw.toString();
|
|
1491
|
+
if (!text)
|
|
1492
|
+
return;
|
|
1493
|
+
let payload;
|
|
1494
|
+
try {
|
|
1495
|
+
payload = JSON.parse(text);
|
|
1496
|
+
}
|
|
1497
|
+
catch {
|
|
1498
|
+
socket.close(1007, 'INVALID_JSON');
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
state.lastSeenAt = nowMs();
|
|
1502
|
+
if (payload?.v !== 1) {
|
|
1503
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'PROTOCOL_UNSUPPORTED', message: 'unsupported protocol version' });
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (!acceptedNode) {
|
|
1507
|
+
if (payload?.v !== 1 ||
|
|
1508
|
+
payload?.kind !== 'node.hello' ||
|
|
1509
|
+
payload?.protocolMin > 1 ||
|
|
1510
|
+
payload?.protocolMax < 1 ||
|
|
1511
|
+
!payload?.node ||
|
|
1512
|
+
payload.node.id !== auth.nodeId) {
|
|
1513
|
+
socket.close(4400, 'PROTOCOL_UNSUPPORTED');
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
state.protocolMin = payload.protocolMin;
|
|
1517
|
+
state.protocolMax = payload.protocolMax;
|
|
1518
|
+
state.capabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities : []);
|
|
1519
|
+
const persistedNode = ensureNodeMeta(auth.nodeId);
|
|
1520
|
+
const runtimeNode = payload.node;
|
|
1521
|
+
const runtimeValue = (key, fallback) => typeof runtimeNode[key] === 'string' && runtimeNode[key]
|
|
1522
|
+
? runtimeNode[key]
|
|
1523
|
+
: fallback;
|
|
1524
|
+
sqlite
|
|
1525
|
+
.prepare('UPDATE nodes SET name = ?, platform = ?, arch = ?, plugin_version = ?, dsh_version = ?, last_seen_at = ? WHERE id = ?')
|
|
1526
|
+
.run(runtimeValue('name', String(persistedNode?.name || 'DSH Node')), runtimeValue('platform', String(persistedNode?.platform || 'unknown')), runtimeValue('arch', String(persistedNode?.arch || 'unknown')), runtimeValue('pluginVersion', String(persistedNode?.plugin_version || 'unknown')), runtimeValue('dshVersion', String(persistedNode?.dsh_version || 'unknown')), nowMs(), auth.nodeId);
|
|
1527
|
+
acceptedNode = true;
|
|
1528
|
+
sendJson(socket, {
|
|
1529
|
+
v: 1,
|
|
1530
|
+
kind: 'node.hello.ack',
|
|
1531
|
+
nodeId: auth.nodeId,
|
|
1532
|
+
});
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (payload.kind === 'node.heartbeat') {
|
|
1536
|
+
sqlite.prepare('UPDATE nodes SET last_seen_at = ? WHERE id = ?').run(nowMs(), auth.nodeId);
|
|
1537
|
+
sendJson(socket, { v: 1, kind: 'node.heartbeat.ack' });
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
if (payload.kind === 'session.event') {
|
|
1541
|
+
if (typeof payload.sourceSeq !== 'number' || !payload.sessionId || typeof payload.nodeId !== 'string') {
|
|
1542
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'invalid session event' });
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
if (payload.nodeId !== auth.nodeId) {
|
|
1546
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'UNAUTHORIZED', message: 'nodeId mismatch' });
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const evt = payload;
|
|
1550
|
+
writeRingEvent(evt.nodeId, evt.sessionId, {
|
|
1551
|
+
v: 1,
|
|
1552
|
+
kind: 'session.event',
|
|
1553
|
+
nodeId: evt.nodeId,
|
|
1554
|
+
sessionId: evt.sessionId,
|
|
1555
|
+
sourceSeq: evt.sourceSeq,
|
|
1556
|
+
event: evt.event,
|
|
1557
|
+
});
|
|
1558
|
+
broadcastToSession(evt.nodeId, evt.sessionId, {
|
|
1559
|
+
v: 1,
|
|
1560
|
+
kind: 'session.event',
|
|
1561
|
+
nodeId: evt.nodeId,
|
|
1562
|
+
sessionId: evt.sessionId,
|
|
1563
|
+
sourceSeq: evt.sourceSeq,
|
|
1564
|
+
event: evt.event,
|
|
1565
|
+
createdAt: nowMs(),
|
|
1566
|
+
});
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
if (payload.kind === 'command.ack') {
|
|
1570
|
+
const commandId = payload.commandId;
|
|
1571
|
+
const status = payload.status || 'acked';
|
|
1572
|
+
if (commandId) {
|
|
1573
|
+
const command = sqlite
|
|
1574
|
+
.prepare('SELECT node_id FROM commands WHERE id = ?')
|
|
1575
|
+
.get(commandId);
|
|
1576
|
+
if (!command || command.node_id !== auth.nodeId) {
|
|
1577
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'UNAUTHORIZED', message: 'commandId does not belong to node' });
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
const errorCode = payload.errorCode ? String(payload.errorCode) : null;
|
|
1581
|
+
sqlite
|
|
1582
|
+
.prepare('UPDATE commands SET status = ?, acked_at = ?, error_code = ? WHERE id = ?')
|
|
1583
|
+
.run(status, nowMs(), errorCode, commandId);
|
|
1584
|
+
const timeout = pendingCommandTimeout.get(commandId);
|
|
1585
|
+
if (timeout) {
|
|
1586
|
+
clearTimeout(timeout);
|
|
1587
|
+
pendingCommandTimeout.delete(commandId);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (payload.kind === 'command.result') {
|
|
1593
|
+
const frame = payload;
|
|
1594
|
+
if (!frame.commandId || typeof frame.ok !== 'boolean') {
|
|
1595
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'invalid command result' });
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
const command = sqlite
|
|
1599
|
+
.prepare('SELECT node_id, action FROM commands WHERE id = ?')
|
|
1600
|
+
.get(frame.commandId);
|
|
1601
|
+
if (!command || command.node_id !== auth.nodeId) {
|
|
1602
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'UNAUTHORIZED', message: 'commandId does not belong to node' });
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const errorCode = frame.ok ? null : String(frame.error?.code || 'INTERNAL_ERROR');
|
|
1606
|
+
const persistedResult = minimalPersistedCommandResult(command.action, frame);
|
|
1607
|
+
sqlite
|
|
1608
|
+
.prepare('UPDATE commands SET status = ?, acked_at = COALESCE(acked_at, ?), error_code = ?, result_json = ? WHERE id = ?')
|
|
1609
|
+
.run(frame.ok ? 'completed' : 'failed', nowMs(), errorCode, persistedResult ? JSON.stringify(persistedResult) : null, frame.commandId);
|
|
1610
|
+
const ackTimeout = pendingCommandTimeout.get(frame.commandId);
|
|
1611
|
+
if (ackTimeout) {
|
|
1612
|
+
clearTimeout(ackTimeout);
|
|
1613
|
+
pendingCommandTimeout.delete(frame.commandId);
|
|
1614
|
+
}
|
|
1615
|
+
const pending = pendingCommandResults.get(frame.commandId);
|
|
1616
|
+
if (pending) {
|
|
1617
|
+
clearTimeout(pending.timeout);
|
|
1618
|
+
pendingCommandResults.delete(frame.commandId);
|
|
1619
|
+
pending.resolve(frame);
|
|
1620
|
+
}
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (payload.kind === 'approval.request') {
|
|
1624
|
+
const approval = payload.approval;
|
|
1625
|
+
if (!approval?.approvalId ||
|
|
1626
|
+
!approval?.sessionId ||
|
|
1627
|
+
!approval?.toolCallId ||
|
|
1628
|
+
typeof approval.expiresAt !== 'number') {
|
|
1629
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'invalid approval request' });
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
if (approval.nodeId && approval.nodeId !== auth.nodeId) {
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const status = sqlite
|
|
1636
|
+
.prepare('SELECT * FROM approvals WHERE id = ?')
|
|
1637
|
+
.get(approval.approvalId);
|
|
1638
|
+
if (!status) {
|
|
1639
|
+
sqlite
|
|
1640
|
+
.prepare(`INSERT INTO approvals (
|
|
1641
|
+
id, user_id, node_id, session_id, tool_call_id, status, expires_at, created_at, request_payload
|
|
1642
|
+
) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)`)
|
|
1643
|
+
.run(approval.approvalId, nodeRow.owner_user_id, auth.nodeId, approval.sessionId, approval.toolCallId, approval.expiresAt, nowMs(), JSON.stringify(approval));
|
|
1644
|
+
}
|
|
1645
|
+
if (!status || (status.status === 'pending' && status.expires_at > nowMs())) {
|
|
1646
|
+
broadcastToSession(auth.nodeId, approval.sessionId, approvalRequestFrame(auth.nodeId, approval.sessionId, approval));
|
|
1647
|
+
}
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
sendJson(socket, {
|
|
1651
|
+
v: 1,
|
|
1652
|
+
kind: 'error',
|
|
1653
|
+
code: 'INVALID_REQUEST',
|
|
1654
|
+
message: 'unknown frame',
|
|
1655
|
+
});
|
|
1656
|
+
});
|
|
1657
|
+
});
|
|
1658
|
+
app.get('/v1/realtime', { websocket: true }, async (connection, request) => {
|
|
1659
|
+
const socket = connection;
|
|
1660
|
+
const auth = await getAuthUserFromRequest(request.headers.authorization);
|
|
1661
|
+
if (!auth) {
|
|
1662
|
+
socket.close(1008, 'UNAUTHORIZED');
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const state = {
|
|
1666
|
+
ws: socket,
|
|
1667
|
+
userId: auth.userId,
|
|
1668
|
+
deviceId: auth.deviceId,
|
|
1669
|
+
subscriptions: new Set(),
|
|
1670
|
+
};
|
|
1671
|
+
mobileConnections.add(state);
|
|
1672
|
+
socket.on('close', () => {
|
|
1673
|
+
mobileConnections.delete(state);
|
|
1674
|
+
for (const sub of state.subscriptions) {
|
|
1675
|
+
const targets = sessionSubscribers.get(sub);
|
|
1676
|
+
if (!targets)
|
|
1677
|
+
continue;
|
|
1678
|
+
targets.delete(socket);
|
|
1679
|
+
if (targets.size === 0) {
|
|
1680
|
+
sessionSubscribers.delete(sub);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
const handleMobileMessage = (raw) => {
|
|
1685
|
+
const text = raw.toString();
|
|
1686
|
+
let payload;
|
|
1687
|
+
try {
|
|
1688
|
+
payload = JSON.parse(text);
|
|
1689
|
+
}
|
|
1690
|
+
catch {
|
|
1691
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Invalid JSON' });
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (payload?.v !== 1) {
|
|
1695
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'PROTOCOL_UNSUPPORTED', message: 'unsupported protocol version' });
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (payload.kind === 'subscribe') {
|
|
1699
|
+
const body = wsSubscribeBody.safeParse(payload);
|
|
1700
|
+
if (!body.success) {
|
|
1701
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Invalid subscribe payload' });
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const { nodeId, sessionId } = body.data;
|
|
1705
|
+
const node = ensureNodeMeta(nodeId);
|
|
1706
|
+
if (!node) {
|
|
1707
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'NODE_NOT_FOUND', message: 'Node not found' });
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
if (node.owner_user_id !== auth.userId) {
|
|
1711
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'FORBIDDEN', message: 'Node belongs to another user' });
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const subKey = keySession(nodeId, sessionId);
|
|
1715
|
+
state.subscriptions.add(subKey);
|
|
1716
|
+
const targets = sessionSubscribers.get(subKey) ?? new Set();
|
|
1717
|
+
targets.add(socket);
|
|
1718
|
+
sessionSubscribers.set(subKey, targets);
|
|
1719
|
+
const replay = getSessionReplay(nodeId, sessionId, -1);
|
|
1720
|
+
sendJson(socket, {
|
|
1721
|
+
v: 1,
|
|
1722
|
+
kind: 'subscribe.ok',
|
|
1723
|
+
requestId: body.data.requestId,
|
|
1724
|
+
nodeId,
|
|
1725
|
+
sessionId,
|
|
1726
|
+
});
|
|
1727
|
+
const pendingApprovals = sqlite
|
|
1728
|
+
.prepare(`SELECT request_payload FROM approvals
|
|
1729
|
+
WHERE user_id = ? AND node_id = ? AND session_id = ? AND status = 'pending' AND expires_at > ?
|
|
1730
|
+
ORDER BY created_at ASC`)
|
|
1731
|
+
.all(auth.userId, nodeId, sessionId, nowMs());
|
|
1732
|
+
for (const row of pendingApprovals) {
|
|
1733
|
+
try {
|
|
1734
|
+
const approval = JSON.parse(row.request_payload);
|
|
1735
|
+
sendJson(socket, approvalRequestFrame(nodeId, sessionId, approval));
|
|
1736
|
+
}
|
|
1737
|
+
catch {
|
|
1738
|
+
// A malformed legacy row is ignored instead of breaking subscription.
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
if (replay && replay.length > 0) {
|
|
1742
|
+
sendJson(socket, {
|
|
1743
|
+
v: 1,
|
|
1744
|
+
kind: 'session.sync',
|
|
1745
|
+
nodeId,
|
|
1746
|
+
sessionId,
|
|
1747
|
+
afterSourceSeq: replay[0].sourceSeq - 1,
|
|
1748
|
+
events: replay,
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
if (payload.kind === 'session.sync') {
|
|
1754
|
+
const body = wsSessionSyncBody.safeParse(payload);
|
|
1755
|
+
if (!body.success) {
|
|
1756
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Invalid sync payload' });
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const { nodeId, sessionId, afterSourceSeq } = body.data;
|
|
1760
|
+
const key = keySession(nodeId, sessionId);
|
|
1761
|
+
if (!state.subscriptions.has(key)) {
|
|
1762
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'FORBIDDEN', message: 'Not subscribed' });
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const replay = getSessionReplay(nodeId, sessionId, afterSourceSeq);
|
|
1766
|
+
if (replay === null) {
|
|
1767
|
+
sendJson(socket, {
|
|
1768
|
+
v: 1,
|
|
1769
|
+
kind: 'snapshot.required',
|
|
1770
|
+
nodeId,
|
|
1771
|
+
sessionId,
|
|
1772
|
+
message: 'Replay window missing',
|
|
1773
|
+
});
|
|
1774
|
+
}
|
|
1775
|
+
else {
|
|
1776
|
+
sendJson(socket, {
|
|
1777
|
+
v: 1,
|
|
1778
|
+
kind: 'session.sync',
|
|
1779
|
+
nodeId,
|
|
1780
|
+
sessionId,
|
|
1781
|
+
afterSourceSeq,
|
|
1782
|
+
events: replay,
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
sendJson(socket, { v: 1, kind: 'error', code: 'INVALID_REQUEST', message: 'Unsupported realtime frame' });
|
|
1788
|
+
};
|
|
1789
|
+
socket.on('message', handleMobileMessage);
|
|
1790
|
+
});
|
|
1791
|
+
app.setErrorHandler((error, request, reply) => {
|
|
1792
|
+
if (error instanceof z.ZodError) {
|
|
1793
|
+
return sendError(reply, 400, 'INVALID_REQUEST', 'Validation failed', { issues: error.issues });
|
|
1794
|
+
}
|
|
1795
|
+
app.log.error(error);
|
|
1796
|
+
reply.code(500).send({ code: 'INTERNAL_ERROR', message: 'Unexpected error' });
|
|
1797
|
+
});
|
|
1798
|
+
setInterval(cleanupOfflineNodes, 5_000);
|
|
1799
|
+
setInterval(cleanupStaleRates, 60_000);
|
|
1800
|
+
setInterval(cleanupCommandMetadata, 60 * 60_000);
|
|
1801
|
+
app.listen({ port: PORT, host: HOST }).then(() => {
|
|
1802
|
+
app.log.info(`dsh-hub listening on ${HOST}:${PORT}`);
|
|
1803
|
+
});
|
|
1804
|
+
//# sourceMappingURL=index.js.map
|