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.
Files changed (60) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/client.js +167 -0
  5. package/cordis.patch.yml +26 -0
  6. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  7. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  8. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  9. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  10. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  11. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  12. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  13. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  14. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  15. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  16. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  17. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  18. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  19. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  20. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  21. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  22. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  23. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  24. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  25. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  26. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  27. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  28. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  29. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  30. package/docs/notes/approval-inbox-phase.md +31 -0
  31. package/docs/notes/design-decisions-summary.md +49 -0
  32. package/docs/notes/dev-release.md +67 -0
  33. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  34. package/docs/notes/network-v1-v2-implementation.md +71 -0
  35. package/docs/notes/pairing-session-2026-11-09.md +30 -0
  36. package/docs/notes/remote-tests-2026-11-09.md +33 -0
  37. package/docs/notes/room-logic-phase.md +31 -0
  38. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  39. package/package.json +51 -3
  40. package/src/host/approval-inbox.js +135 -0
  41. package/src/host/approval-web.js +95 -0
  42. package/src/host/delegation.js +127 -0
  43. package/src/host/direct-call.js +209 -0
  44. package/src/host/execution-slot.js +96 -0
  45. package/src/host/index.js +10 -0
  46. package/src/host/room-service.js +324 -0
  47. package/src/host/store.js +156 -0
  48. package/src/host/tool-policy.js +151 -0
  49. package/src/host/tools.js +590 -0
  50. package/src/index.js +3 -0
  51. package/src/net/direct.js +69 -0
  52. package/src/net/discovery.js +105 -0
  53. package/src/net/fleet-node.js +509 -0
  54. package/src/net/identity.js +167 -0
  55. package/src/net/index.js +9 -0
  56. package/src/net/protocol.js +73 -0
  57. package/src/net/relay-client.js +258 -0
  58. package/src/net/relay-server.js +366 -0
  59. package/src/net/secure-channel.js +538 -0
  60. 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
+ }