dsh-device-fleet 0.0.0-stage → 0.1.0-dev.10
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/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +135 -2
- package/client.js +167 -0
- package/cordis.patch.yml +26 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +67 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -0
- package/docs/notes/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +51 -3
- package/src/host/approval-inbox.js +135 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +209 -0
- package/src/host/execution-slot.js +96 -0
- package/src/host/index.js +10 -0
- package/src/host/room-service.js +324 -0
- package/src/host/store.js +156 -0
- package/src/host/tool-policy.js +151 -0
- package/src/host/tools.js +590 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +509 -0
- package/src/net/identity.js +167 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- package/src/plugin/index.js +380 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// Self-hosted relay for fleet devices behind NAT (v2).
|
|
2
|
+
//
|
|
3
|
+
// Control plane: TCP with newline-delimited JSON.
|
|
4
|
+
// register -> register.ok
|
|
5
|
+
// connect(reqId,target) -> connect.pending; target gets incoming(reqId,from)
|
|
6
|
+
// accept/reject on the target side -> connect.ready (both sides, with data token)
|
|
7
|
+
// Data plane: a second TCP listener. Each side connects with a one-line header
|
|
8
|
+
// {fleetId, deviceId, reqId, token}\n
|
|
9
|
+
// and the relay splices the two raw sockets together. Payload bytes are the
|
|
10
|
+
// end-to-end encrypted frames from secure-channel.js, so the relay cannot read them.
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { EventEmitter } from 'node:events'
|
|
13
|
+
import net from 'node:net'
|
|
14
|
+
import { verifyRelayAuthToken } from './identity.js'
|
|
15
|
+
|
|
16
|
+
const MAX_CONTROL_LINE = 64 * 1024
|
|
17
|
+
const MAX_CLOCK_SKEW_MS = 120_000
|
|
18
|
+
const ACCEPT_TIMEOUT_MS = 20_000
|
|
19
|
+
const PAIR_TIMEOUT_MS = 20_000
|
|
20
|
+
|
|
21
|
+
function randomToken() {
|
|
22
|
+
return randomBytes(24).toString('base64url')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function normalizeFleets(fleets) {
|
|
26
|
+
const map = new Map()
|
|
27
|
+
if (fleets instanceof Map) {
|
|
28
|
+
for (const [fleetId, key] of fleets) map.set(fleetId, key)
|
|
29
|
+
return map
|
|
30
|
+
}
|
|
31
|
+
for (const [fleetId, key] of Object.entries(fleets ?? {})) map.set(fleetId, key)
|
|
32
|
+
return map
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function listen(server, port, host) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const onError = (error) => {
|
|
38
|
+
server.off('listening', onListening)
|
|
39
|
+
reject(error)
|
|
40
|
+
}
|
|
41
|
+
const onListening = () => {
|
|
42
|
+
server.off('error', onError)
|
|
43
|
+
const address = server.address()
|
|
44
|
+
resolve(address?.port ?? port)
|
|
45
|
+
}
|
|
46
|
+
server.once('error', onError)
|
|
47
|
+
server.once('listening', onListening)
|
|
48
|
+
server.listen(port, host)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{host?: string, port?: number, dataHost?: string, dataPort?: number, fleets?: Record<string,string>|Map<string,string>}} [options]
|
|
54
|
+
*/
|
|
55
|
+
export function createRelayServer({
|
|
56
|
+
host = '127.0.0.1',
|
|
57
|
+
dataHost = host,
|
|
58
|
+
port = 0,
|
|
59
|
+
dataPort = 0,
|
|
60
|
+
fleets = {},
|
|
61
|
+
} = {}) {
|
|
62
|
+
const fleetKeys = normalizeFleets(fleets)
|
|
63
|
+
if (fleetKeys.size === 0) throw new Error('relay needs at least one fleet key')
|
|
64
|
+
const relay = new EventEmitter()
|
|
65
|
+
/** @type {Map<string, {deviceId: string, fleetId: string, socket: net.Socket}>} */
|
|
66
|
+
const devices = new Map()
|
|
67
|
+
/** @type {Map<string, any>} */
|
|
68
|
+
const pending = new Map()
|
|
69
|
+
const state = { port: null, dataPort: null, closed: false }
|
|
70
|
+
const trackedSockets = new Set()
|
|
71
|
+
|
|
72
|
+
const send = (socket, message) => {
|
|
73
|
+
if (!socket || socket.destroyed || !socket.writable) return
|
|
74
|
+
try {
|
|
75
|
+
socket.write(JSON.stringify(message) + '\n')
|
|
76
|
+
} catch {
|
|
77
|
+
/* socket is going away */
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const cleanupPending = (reqId, reason, notify = true) => {
|
|
82
|
+
const entry = pending.get(reqId)
|
|
83
|
+
if (!entry) return
|
|
84
|
+
pending.delete(reqId)
|
|
85
|
+
clearTimeout(entry.acceptTimer)
|
|
86
|
+
clearTimeout(entry.pairTimer)
|
|
87
|
+
for (const socket of Object.values(entry.sockets ?? {})) {
|
|
88
|
+
try {
|
|
89
|
+
socket.destroy()
|
|
90
|
+
} catch {
|
|
91
|
+
/* ignore */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!notify) return
|
|
95
|
+
const fromDevice = devices.get(entry.fromDeviceId)
|
|
96
|
+
const targetDevice = devices.get(entry.targetDeviceId)
|
|
97
|
+
send(fromDevice?.socket, { t: 'connect.fail', reqId, reason })
|
|
98
|
+
send(targetDevice?.socket, { t: 'connect.fail', reqId, reason })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const removeDevice = (deviceId, socket) => {
|
|
102
|
+
const record = devices.get(deviceId)
|
|
103
|
+
if (!record || record.socket !== socket) return
|
|
104
|
+
devices.delete(deviceId)
|
|
105
|
+
for (const [reqId, entry] of pending) {
|
|
106
|
+
if (entry.fromDeviceId === deviceId || entry.targetDeviceId === deviceId) {
|
|
107
|
+
cleanupPending(reqId, 'peer-offline')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const handleControlLine = (conn, line) => {
|
|
113
|
+
let message
|
|
114
|
+
try {
|
|
115
|
+
message = JSON.parse(line)
|
|
116
|
+
} catch {
|
|
117
|
+
send(conn.socket, { t: 'error', code: 'bad-json' })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
switch (message?.t) {
|
|
121
|
+
case 'register': {
|
|
122
|
+
const { fleetId, deviceId, ts, token } = message
|
|
123
|
+
if (typeof fleetId !== 'string' || typeof deviceId !== 'string') {
|
|
124
|
+
send(conn.socket, { t: 'error', code: 'bad-register' })
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const fleetKey = fleetKeys.get(fleetId)
|
|
128
|
+
if (!fleetKey) {
|
|
129
|
+
send(conn.socket, { t: 'error', code: 'unknown-fleet' })
|
|
130
|
+
conn.socket.destroy()
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
if (typeof ts !== 'number' || Math.abs(Date.now() - ts) > MAX_CLOCK_SKEW_MS) {
|
|
134
|
+
send(conn.socket, { t: 'error', code: 'stale-token' })
|
|
135
|
+
conn.socket.destroy()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
if (!verifyRelayAuthToken(token, { fleetKey, fleetId, deviceId, ts })) {
|
|
139
|
+
send(conn.socket, { t: 'error', code: 'unauthorized' })
|
|
140
|
+
conn.socket.destroy()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
const previous = devices.get(deviceId)
|
|
144
|
+
if (previous && previous.socket !== conn.socket) {
|
|
145
|
+
send(previous.socket, { t: 'error', code: 'replaced' })
|
|
146
|
+
previous.socket.destroy()
|
|
147
|
+
}
|
|
148
|
+
devices.set(deviceId, { deviceId, fleetId, socket: conn.socket })
|
|
149
|
+
conn.registered = { deviceId, fleetId }
|
|
150
|
+
send(conn.socket, { t: 'register.ok', deviceId, server: 'dsh-device-fleet-relay', v: 1 })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
case 'ping': {
|
|
154
|
+
send(conn.socket, { t: 'pong' })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
case 'connect': {
|
|
158
|
+
if (!conn.registered) {
|
|
159
|
+
send(conn.socket, { t: 'connect.fail', reqId: message.reqId, reason: 'not-registered' })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const { reqId, targetDeviceId } = message
|
|
163
|
+
if (typeof reqId !== 'string' || typeof targetDeviceId !== 'string') {
|
|
164
|
+
send(conn.socket, { t: 'connect.fail', reqId: message.reqId, reason: 'bad-request' })
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const target = devices.get(targetDeviceId)
|
|
168
|
+
if (!target || target.fleetId !== conn.registered.fleetId || target.socket === conn.socket) {
|
|
169
|
+
send(conn.socket, { t: 'connect.fail', reqId, reason: 'offline' })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (pending.has(reqId)) {
|
|
173
|
+
send(conn.socket, { t: 'connect.fail', reqId, reason: 'duplicate-request' })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
const entry = {
|
|
177
|
+
reqId,
|
|
178
|
+
fleetId: conn.registered.fleetId,
|
|
179
|
+
fromDeviceId: conn.registered.deviceId,
|
|
180
|
+
targetDeviceId,
|
|
181
|
+
sockets: {},
|
|
182
|
+
tokens: null,
|
|
183
|
+
accepted: false,
|
|
184
|
+
acceptTimer: setTimeout(() => cleanupPending(reqId, 'accept-timeout'), ACCEPT_TIMEOUT_MS),
|
|
185
|
+
pairTimer: null,
|
|
186
|
+
}
|
|
187
|
+
entry.acceptTimer.unref?.()
|
|
188
|
+
pending.set(reqId, entry)
|
|
189
|
+
send(target.socket, { t: 'incoming', reqId, fromDeviceId: entry.fromDeviceId })
|
|
190
|
+
send(conn.socket, { t: 'connect.pending', reqId })
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
case 'accept': {
|
|
194
|
+
if (!conn.registered) return
|
|
195
|
+
const entry = pending.get(message.reqId)
|
|
196
|
+
if (!entry || entry.targetDeviceId !== conn.registered.deviceId) {
|
|
197
|
+
send(conn.socket, { t: 'error', code: 'unknown-request', reqId: message.reqId })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
if (entry.accepted) return
|
|
201
|
+
entry.accepted = true
|
|
202
|
+
clearTimeout(entry.acceptTimer)
|
|
203
|
+
entry.tokens = { [entry.fromDeviceId]: randomToken(), [entry.targetDeviceId]: randomToken() }
|
|
204
|
+
entry.pairTimer = setTimeout(() => cleanupPending(entry.reqId, 'data-timeout'), PAIR_TIMEOUT_MS)
|
|
205
|
+
entry.pairTimer.unref?.()
|
|
206
|
+
const from = devices.get(entry.fromDeviceId)
|
|
207
|
+
const target = devices.get(entry.targetDeviceId)
|
|
208
|
+
send(from?.socket, { t: 'connect.ready', reqId: entry.reqId, dataPort: state.dataPort, token: entry.tokens[entry.fromDeviceId] })
|
|
209
|
+
send(target?.socket, { t: 'connect.ready', reqId: entry.reqId, dataPort: state.dataPort, token: entry.tokens[entry.targetDeviceId] })
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
case 'reject': {
|
|
213
|
+
const entry = pending.get(message.reqId)
|
|
214
|
+
if (!entry || entry.targetDeviceId !== conn.registered?.deviceId) return
|
|
215
|
+
cleanupPending(entry.reqId, 'rejected')
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
default:
|
|
219
|
+
send(conn.socket, { t: 'error', code: 'unknown-message' })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const onControlSocket = (socket) => {
|
|
224
|
+
trackedSockets.add(socket)
|
|
225
|
+
socket.setNoDelay?.(true)
|
|
226
|
+
const conn = { socket, registered: null, buffer: '' }
|
|
227
|
+
socket.on('data', (chunk) => {
|
|
228
|
+
conn.buffer += chunk.toString('utf8')
|
|
229
|
+
if (conn.buffer.length > MAX_CONTROL_LINE * 8) {
|
|
230
|
+
socket.destroy()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
let newline = conn.buffer.indexOf('\n')
|
|
234
|
+
while (newline >= 0) {
|
|
235
|
+
const line = conn.buffer.slice(0, newline)
|
|
236
|
+
conn.buffer = conn.buffer.slice(newline + 1)
|
|
237
|
+
if (line.trim() !== '') handleControlLine(conn, line)
|
|
238
|
+
newline = conn.buffer.indexOf('\n')
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
socket.on('error', () => {})
|
|
242
|
+
socket.on('close', () => {
|
|
243
|
+
trackedSockets.delete(socket)
|
|
244
|
+
if (conn.registered) removeDevice(conn.registered.deviceId, socket)
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const pairDataSockets = (reqId) => {
|
|
249
|
+
const entry = pending.get(reqId)
|
|
250
|
+
if (!entry) return
|
|
251
|
+
const entries = Object.entries(entry.sockets)
|
|
252
|
+
if (entries.length !== 2) return
|
|
253
|
+
pending.delete(reqId)
|
|
254
|
+
clearTimeout(entry.pairTimer)
|
|
255
|
+
clearTimeout(entry.acceptTimer)
|
|
256
|
+
const [[sideA, socketA], [sideB, socketB]] = entries
|
|
257
|
+
for (const socket of [socketA, socketB]) trackedSockets.delete(socket)
|
|
258
|
+
const destroyBoth = () => {
|
|
259
|
+
for (const socket of [socketA, socketB]) {
|
|
260
|
+
try {
|
|
261
|
+
socket.destroy()
|
|
262
|
+
} catch {
|
|
263
|
+
/* ignore */
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
socketA.on('error', destroyBoth)
|
|
268
|
+
socketB.on('error', destroyBoth)
|
|
269
|
+
socketA.on('close', () => socketB.destroy())
|
|
270
|
+
socketB.on('close', () => socketA.destroy())
|
|
271
|
+
socketA.pipe(socketB)
|
|
272
|
+
socketB.pipe(socketA)
|
|
273
|
+
socketA.resume()
|
|
274
|
+
socketB.resume()
|
|
275
|
+
relay.emit('spliced', {
|
|
276
|
+
reqId,
|
|
277
|
+
fleetId: entry.fleetId,
|
|
278
|
+
fromDeviceId: entry.fromDeviceId,
|
|
279
|
+
targetDeviceId: entry.targetDeviceId,
|
|
280
|
+
sides: [sideA, sideB],
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const onDataSocket = (socket) => {
|
|
285
|
+
trackedSockets.add(socket)
|
|
286
|
+
socket.setNoDelay?.(true)
|
|
287
|
+
let headerBuffer = Buffer.alloc(0)
|
|
288
|
+
const onData = (chunk) => {
|
|
289
|
+
headerBuffer = Buffer.concat([headerBuffer, chunk])
|
|
290
|
+
const newline = headerBuffer.indexOf(0x0a)
|
|
291
|
+
if (newline < 0) {
|
|
292
|
+
if (headerBuffer.length > MAX_CONTROL_LINE) socket.destroy()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
socket.off('data', onData)
|
|
296
|
+
let header
|
|
297
|
+
try {
|
|
298
|
+
header = JSON.parse(headerBuffer.subarray(0, newline).toString('utf8'))
|
|
299
|
+
} catch {
|
|
300
|
+
socket.destroy()
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
const leftover = headerBuffer.subarray(newline + 1)
|
|
304
|
+
const entry = pending.get(header?.reqId)
|
|
305
|
+
if (!entry || !entry.tokens) {
|
|
306
|
+
socket.destroy()
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
const side =
|
|
310
|
+
header.deviceId === entry.fromDeviceId ? entry.fromDeviceId : header.deviceId === entry.targetDeviceId ? entry.targetDeviceId : null
|
|
311
|
+
if (!side || header.fleetId !== entry.fleetId || header.token !== entry.tokens[side]) {
|
|
312
|
+
socket.destroy()
|
|
313
|
+
return
|
|
314
|
+
}
|
|
315
|
+
if (entry.sockets[side]) {
|
|
316
|
+
socket.destroy()
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
socket.pause()
|
|
320
|
+
entry.sockets[side] = socket
|
|
321
|
+
if (leftover.length > 0) socket.unshift(leftover)
|
|
322
|
+
pairDataSockets(entry.reqId)
|
|
323
|
+
}
|
|
324
|
+
socket.on('data', onData)
|
|
325
|
+
socket.once('close', () => {
|
|
326
|
+
trackedSockets.delete(socket)
|
|
327
|
+
for (const [reqId, entry] of pending) {
|
|
328
|
+
if (entry.sockets && Object.values(entry.sockets).includes(socket)) cleanupPending(reqId, 'data-closed')
|
|
329
|
+
}
|
|
330
|
+
})
|
|
331
|
+
socket.on('error', () => {})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const controlServer = net.createServer(onControlSocket)
|
|
335
|
+
const dataServer = net.createServer(onDataSocket)
|
|
336
|
+
|
|
337
|
+
const ready = (async () => {
|
|
338
|
+
state.dataPort = await listen(dataServer, dataPort, dataHost)
|
|
339
|
+
state.port = await listen(controlServer, port, host)
|
|
340
|
+
return { port: state.port, dataPort: state.dataPort }
|
|
341
|
+
})()
|
|
342
|
+
ready.catch(() => {})
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
ready,
|
|
346
|
+
state,
|
|
347
|
+
on: relay.on.bind(relay),
|
|
348
|
+
once: relay.once.bind(relay),
|
|
349
|
+
close: async () => {
|
|
350
|
+
if (state.closed) return
|
|
351
|
+
state.closed = true
|
|
352
|
+
for (const [reqId] of pending) cleanupPending(reqId, 'server-closing', false)
|
|
353
|
+
for (const socket of trackedSockets) {
|
|
354
|
+
try {
|
|
355
|
+
socket.destroy()
|
|
356
|
+
} catch {
|
|
357
|
+
/* ignore */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
await Promise.all([
|
|
361
|
+
new Promise((resolve) => (controlServer.listening ? controlServer.close(() => resolve()) : resolve())),
|
|
362
|
+
new Promise((resolve) => (dataServer.listening ? dataServer.close(() => resolve()) : resolve())),
|
|
363
|
+
])
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
}
|