@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2

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.
@@ -0,0 +1,131 @@
1
+ // Small in-memory view of live sessions built from `session/event`:
2
+ // titles, recent tool-call arguments (approval requests carry only callId), last assistant text.
3
+
4
+ const MAX_SESSIONS = 100
5
+ const MAX_CALLS_PER_SESSION = 64
6
+ const MAX_ARGUMENT_CHARS = 8000
7
+
8
+ /**
9
+ * @typedef {object} SessionInfo
10
+ * @property {string} title
11
+ * @property {Map<string, {name: string, arguments: string}>} calls
12
+ * @property {string} lastAssistantText
13
+ * @property {number} touchedAt
14
+ */
15
+
16
+ export class SessionTracker {
17
+ /**
18
+ * @param {{onTurnEnd?: (sessionId: string, reason: string) => void, now?: () => number}} hooks
19
+ */
20
+ constructor(hooks = {}) {
21
+ this.hooks = hooks
22
+ this.now = hooks.now ?? Date.now
23
+ /** @type {Map<string, SessionInfo>} */
24
+ this.sessions = new Map()
25
+ }
26
+
27
+ /** @param {string} sessionId */
28
+ info(sessionId) {
29
+ let info = this.sessions.get(sessionId)
30
+ if (info === undefined) {
31
+ info = { title: '', calls: new Map(), lastAssistantText: '', touchedAt: this.now() }
32
+ this.sessions.set(sessionId, info)
33
+ if (this.sessions.size > MAX_SESSIONS) {
34
+ const oldest = [...this.sessions.entries()].sort((a, b) => a[1].touchedAt - b[1].touchedAt)[0]
35
+ if (oldest !== undefined) this.sessions.delete(oldest[0])
36
+ }
37
+ }
38
+ info.touchedAt = this.now()
39
+ return info
40
+ }
41
+
42
+ /** @param {string} sessionId */
43
+ title(sessionId) { return this.sessions.get(sessionId)?.title ?? '' }
44
+
45
+ /**
46
+ * Fill titles from Session Controller summaries (`projections.values.title.title`), so sessions
47
+ * titled before this plugin started still get a name. Live `session/title` events stay authoritative.
48
+ * @param {Array<{sessionId: string, projections?: {values?: Record<string, any>}}>} summaries
49
+ */
50
+ learnTitles(summaries) {
51
+ for (const summary of summaries) {
52
+ const title = summaryTitle(summary)
53
+ if (title === '' || typeof summary.sessionId !== 'string') continue
54
+ const known = this.sessions.get(summary.sessionId)
55
+ if (known !== undefined && known.title !== '') continue
56
+ this.info(summary.sessionId).title = title
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {string} sessionId
62
+ * @param {string|undefined} callId
63
+ */
64
+ call(sessionId, callId) {
65
+ if (callId === undefined) return undefined
66
+ return this.sessions.get(sessionId)?.calls.get(callId)
67
+ }
68
+
69
+ /** @param {string} sessionId */
70
+ lastAssistantText(sessionId) { return this.sessions.get(sessionId)?.lastAssistantText ?? '' }
71
+
72
+ /**
73
+ * Consume one `session/event`.
74
+ * @param {{id: string}} session
75
+ * @param {{type: string, data?: any}} event
76
+ */
77
+ observe(session, event) {
78
+ const sessionId = session?.id
79
+ if (typeof sessionId !== 'string' || event === null || typeof event !== 'object') return
80
+ const data = event.data ?? {}
81
+ switch (event.type) {
82
+ case 'session/title':
83
+ if (typeof data.title === 'string') this.info(sessionId).title = data.title.slice(0, 200)
84
+ return
85
+ case 'tool/call': {
86
+ if (typeof data.callId !== 'string') return
87
+ const calls = this.info(sessionId).calls
88
+ calls.set(data.callId, {
89
+ name: typeof data.name === 'string' ? data.name : '',
90
+ arguments: typeof data.arguments === 'string' ? data.arguments.slice(0, MAX_ARGUMENT_CHARS) : '',
91
+ })
92
+ while (calls.size > MAX_CALLS_PER_SESSION) calls.delete(calls.keys().next().value)
93
+ return
94
+ }
95
+ case 'assistant/message': {
96
+ const text = textOf(data.message)
97
+ if (text !== '') this.info(sessionId).lastAssistantText = text.slice(-2000)
98
+ return
99
+ }
100
+ case 'turn/end': {
101
+ const reason = typeof data.reason?.kind === 'string' ? data.reason.kind : 'completed'
102
+ if (reason === 'forked') return
103
+ this.hooks.onTurnEnd?.(sessionId, reason)
104
+ return
105
+ }
106
+ default:
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Title carried by a Session Controller summary, or ''.
113
+ * @param {{projections?: {values?: Record<string, any>}}} summary
114
+ */
115
+ export function summaryTitle(summary) {
116
+ const value = summary?.projections?.values?.title
117
+ const title = typeof value === 'string' ? value : value?.title
118
+ return typeof title === 'string' ? title.slice(0, 200) : ''
119
+ }
120
+
121
+ /** Join the text parts of an assistant message. */
122
+ function textOf(message) {
123
+ const content = message?.content
124
+ if (typeof content === 'string') return content.trim()
125
+ if (!Array.isArray(content)) return ''
126
+ return content
127
+ .filter(part => part !== null && typeof part === 'object' && part.type === 'text' && typeof part.text === 'string')
128
+ .map(part => part.text)
129
+ .join('\n')
130
+ .trim()
131
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,187 @@
1
+ // Durable plugin state: host identity and registered devices (one JSON file, mode 0600).
2
+
3
+ import { randomBytes, randomUUID } from 'node:crypto'
4
+ import { mkdir, readFile, rename, writeFile, chmod } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+
7
+ export const MAX_DEVICES = 16
8
+ export const MAX_WATCHES_PER_DEVICE = 50
9
+
10
+ /**
11
+ * @typedef {object} DeviceRecord
12
+ * @property {string} publicKey - SPKI DER base64url
13
+ * @property {string} label
14
+ * @property {string|null} fcmToken
15
+ * @property {'redacted'|'full'} previewMode
16
+ * @property {boolean} pushEnabled
17
+ * @property {boolean} watchAll
18
+ * @property {Record<string, number>} watches - sessionId -> expiry (epoch ms)
19
+ * @property {number} createdAt
20
+ * @property {number} lastSeenAt
21
+ */
22
+
23
+ /** Owns the state file; writes are serialized and atomic (tmp + rename). */
24
+ export class StateStore {
25
+ /**
26
+ * @param {string} dir
27
+ * @param {{now?: () => number, log?: (...args: unknown[]) => void}} [options]
28
+ */
29
+ constructor(dir, options = {}) {
30
+ this.dir = dir
31
+ this.file = join(dir, 'state.json')
32
+ this.now = options.now ?? Date.now
33
+ this.log = options.log ?? (() => {})
34
+ /** @type {{version: 1, hostId: string, hostSecret: string, devices: Record<string, DeviceRecord>}} */
35
+ this.data = { version: 1, hostId: '', hostSecret: '', devices: {} }
36
+ this.writing = Promise.resolve()
37
+ this.dirty = false
38
+ this.timer = undefined
39
+ }
40
+
41
+ async load() {
42
+ await mkdir(this.dir, { recursive: true, mode: 0o700 })
43
+ try {
44
+ const parsed = JSON.parse(await readFile(this.file, 'utf8'))
45
+ if (parsed?.version !== 1 || typeof parsed.hostId !== 'string' || typeof parsed.hostSecret !== 'string') {
46
+ throw new Error('unsupported state file')
47
+ }
48
+ this.data = { version: 1, hostId: parsed.hostId, hostSecret: parsed.hostSecret, devices: sanitizeDevices(parsed.devices) }
49
+ } catch (error) {
50
+ if (error?.code !== 'ENOENT') {
51
+ // A corrupt file must not silently re-key the host: keep a copy and start fresh loudly.
52
+ this.log('state file unreadable; starting a new host identity:', error?.message ?? String(error))
53
+ try {
54
+ await rename(this.file, `${this.file}.corrupt-${this.now()}`)
55
+ } catch { /* best effort: the original may already be gone */ }
56
+ }
57
+ this.data = { version: 1, hostId: randomUUID(), hostSecret: randomBytes(32).toString('base64url'), devices: {} }
58
+ await this.flush()
59
+ }
60
+ return this
61
+ }
62
+
63
+ get hostId() { return this.data.hostId }
64
+ get hostSecret() { return this.data.hostSecret }
65
+
66
+ /** @returns {Array<[string, DeviceRecord]>} */
67
+ devices() { return Object.entries(this.data.devices) }
68
+
69
+ /** @param {string} id */
70
+ device(id) { return Object.hasOwn(this.data.devices, id) ? this.data.devices[id] : undefined }
71
+
72
+ /**
73
+ * Add a device; an older registration with the same device key (re-pairing) is replaced.
74
+ * @param {Omit<DeviceRecord, 'watches'|'createdAt'|'lastSeenAt'|'pushEnabled'|'watchAll'>} input
75
+ * @returns {string} new device id
76
+ */
77
+ addDevice(input) {
78
+ for (const [id, record] of this.devices()) if (record.publicKey === input.publicKey) delete this.data.devices[id]
79
+ const entries = this.devices().sort((a, b) => a[1].lastSeenAt - b[1].lastSeenAt)
80
+ while (entries.length >= MAX_DEVICES) delete this.data.devices[entries.shift()[0]]
81
+ const id = randomBytes(16).toString('hex')
82
+ const now = this.now()
83
+ this.data.devices[id] = { ...input, pushEnabled: true, watchAll: false, watches: {}, createdAt: now, lastSeenAt: now }
84
+ this.save(true)
85
+ return id
86
+ }
87
+
88
+ /** @param {string} id */
89
+ removeDevice(id) {
90
+ if (!Object.hasOwn(this.data.devices, id)) return false
91
+ delete this.data.devices[id]
92
+ this.save(true)
93
+ return true
94
+ }
95
+
96
+ /**
97
+ * @param {string} id
98
+ * @param {(record: DeviceRecord) => void} mutate
99
+ * @param {boolean} [urgent]
100
+ */
101
+ updateDevice(id, mutate, urgent = false) {
102
+ const record = this.device(id)
103
+ if (record === undefined) return false
104
+ mutate(record)
105
+ this.save(urgent)
106
+ return true
107
+ }
108
+
109
+ /**
110
+ * Devices that should hear about a session right now.
111
+ * @param {string} sessionId
112
+ * @returns {Array<[string, DeviceRecord]>}
113
+ */
114
+ watchers(sessionId) {
115
+ const now = this.now()
116
+ return this.devices().filter(([, record]) => record.pushEnabled && record.fcmToken
117
+ && (record.watchAll || (record.watches[sessionId] ?? 0) > now))
118
+ }
119
+
120
+ /** Schedule a write; urgent writes go out immediately, others are coalesced for 2 s. */
121
+ save(urgent = false) {
122
+ this.dirty = true
123
+ if (urgent) {
124
+ clearTimeout(this.timer)
125
+ this.timer = undefined
126
+ void this.flush()
127
+ return
128
+ }
129
+ if (this.timer === undefined) {
130
+ this.timer = setTimeout(() => {
131
+ this.timer = undefined
132
+ void this.flush()
133
+ }, 2000)
134
+ this.timer.unref?.()
135
+ }
136
+ }
137
+
138
+ flush() {
139
+ this.writing = this.writing.then(async () => {
140
+ this.dirty = false
141
+ const tmp = `${this.file}.tmp-${process.pid}`
142
+ await writeFile(tmp, `${JSON.stringify(this.data, null, 2)}\n`, { mode: 0o600 })
143
+ await chmod(tmp, 0o600)
144
+ await rename(tmp, this.file)
145
+ }).catch((error) => {
146
+ this.log('state write failed:', error?.message ?? String(error))
147
+ })
148
+ return this.writing
149
+ }
150
+
151
+ async close() {
152
+ clearTimeout(this.timer)
153
+ this.timer = undefined
154
+ if (this.dirty) await this.flush()
155
+ await this.writing
156
+ }
157
+ }
158
+
159
+ /** Keep only well-formed device records from disk. */
160
+ function sanitizeDevices(value) {
161
+ /** @type {Record<string, DeviceRecord>} */
162
+ const out = {}
163
+ if (value === null || typeof value !== 'object') return out
164
+ for (const [id, record] of Object.entries(value)) {
165
+ if (!/^[a-f0-9]{32}$/.test(id) || record === null || typeof record !== 'object') continue
166
+ // v1 records were bound to a dsh-mobile device (mobileDeviceId); they cannot be trusted any more.
167
+ if (typeof record.publicKey !== 'string' || record.mobileDeviceId !== undefined) continue
168
+ const watches = {}
169
+ if (record.watches !== null && typeof record.watches === 'object') {
170
+ for (const [sessionId, expiry] of Object.entries(record.watches)) {
171
+ if (typeof expiry === 'number' && sessionId.length <= 200) watches[sessionId] = expiry
172
+ }
173
+ }
174
+ out[id] = {
175
+ publicKey: record.publicKey,
176
+ label: typeof record.label === 'string' ? record.label.slice(0, 80) : '',
177
+ fcmToken: typeof record.fcmToken === 'string' ? record.fcmToken : null,
178
+ previewMode: record.previewMode === 'full' ? 'full' : 'redacted',
179
+ pushEnabled: record.pushEnabled !== false,
180
+ watchAll: record.watchAll === true,
181
+ watches,
182
+ createdAt: typeof record.createdAt === 'number' ? record.createdAt : 0,
183
+ lastSeenAt: typeof record.lastSeenAt === 'number' ? record.lastSeenAt : 0,
184
+ }
185
+ }
186
+ return out
187
+ }
@@ -0,0 +1,107 @@
1
+ // Gateway TLS material: one private CA (kept for years, pinned by phones) and a server
2
+ // certificate reissued whenever the set of listening addresses changes.
3
+
4
+ import { createPrivateKey, X509Certificate } from 'node:crypto'
5
+ import { hostname, networkInterfaces } from 'node:os'
6
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
7
+ import { join } from 'node:path'
8
+ import { createCa, fingerprintOf, issueServerCertificate } from './x509.mjs'
9
+
10
+ const RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000
11
+
12
+ /**
13
+ * Private IPv4 addresses a phone can reach: RFC 1918 LAN ranges and 100.64/10 (Tailscale CGNAT).
14
+ * LAN addresses come first so QR origins try the local network before Tailscale.
15
+ * @param {ReturnType<typeof networkInterfaces>} [table]
16
+ * @returns {string[]}
17
+ */
18
+ export function reachableAddresses(table = networkInterfaces()) {
19
+ const lan = []
20
+ const tailnet = []
21
+ for (const entries of Object.values(table)) {
22
+ for (const entry of entries ?? []) {
23
+ if (entry.family !== 'IPv4' && entry.family !== 4) continue
24
+ if (entry.internal) continue
25
+ const [a, b] = entry.address.split('.').map(Number)
26
+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) lan.push(entry.address)
27
+ else if (a === 100 && b >= 64 && b <= 127) tailnet.push(entry.address)
28
+ }
29
+ }
30
+ return [...new Set([...lan, ...tailnet])]
31
+ }
32
+
33
+ /** DNS names to put in the server certificate (helps `.local` and Tailscale MagicDNS users). */
34
+ export function localDnsNames(name = hostname()) {
35
+ const short = name.replace(/\.(local|lan|home)$/i, '')
36
+ return [...new Set([short, `${short}.local`].filter(n => /^[A-Za-z0-9-]{1,63}(\.local)?$/.test(n)))]
37
+ }
38
+
39
+ export class TlsStore {
40
+ /**
41
+ * @param {string} dir - stateDir/tls
42
+ * @param {{log?: (...a: unknown[]) => void, now?: () => number}} [options]
43
+ */
44
+ constructor(dir, options = {}) {
45
+ this.dir = dir
46
+ this.log = options.log ?? (() => {})
47
+ this.now = options.now ?? Date.now
48
+ this.ca = undefined
49
+ this.server = undefined
50
+ }
51
+
52
+ /** Load or create the CA. */
53
+ async loadCa() {
54
+ await mkdir(this.dir, { recursive: true, mode: 0o700 })
55
+ try {
56
+ const certPem = await readFile(join(this.dir, 'ca.pem'), 'utf8')
57
+ const keyPem = await readFile(join(this.dir, 'ca-key.pem'), 'utf8')
58
+ const cert = new X509Certificate(certPem)
59
+ if (!cert.ca || new Date(cert.validTo).getTime() <= this.now()) throw new Error('CA expired or invalid')
60
+ this.ca = { certPem, key: createPrivateKey(keyPem), der: cert.raw, fingerprint: fingerprintOf(cert.raw) }
61
+ } catch (error) {
62
+ if (error?.code !== 'ENOENT') this.log('gateway CA unusable; creating a new one (phones must pair again):', error?.message ?? String(error))
63
+ const ca = createCa({ now: new Date(this.now()) })
64
+ await this.write('ca-key.pem', ca.keyPem, 0o600)
65
+ await this.write('ca.pem', ca.certPem, 0o644)
66
+ this.ca = { certPem: ca.certPem, key: createPrivateKey(ca.keyPem), der: ca.certDer, fingerprint: ca.fingerprint }
67
+ }
68
+ return this.ca
69
+ }
70
+
71
+ /**
72
+ * Server certificate covering exactly these addresses (reused while it still matches).
73
+ * @param {string[]} ips
74
+ * @returns {Promise<{certPem: string, keyPem: string}>}
75
+ */
76
+ async serverFor(ips) {
77
+ if (this.ca === undefined) await this.loadCa()
78
+ const key = [...ips].sort().join(',')
79
+ if (this.server?.key === key && this.server.notAfter - this.now() > RENEW_BEFORE_MS) return this.server
80
+ try {
81
+ const certPem = await readFile(join(this.dir, 'server.pem'), 'utf8')
82
+ const keyPem = await readFile(join(this.dir, 'server-key.pem'), 'utf8')
83
+ const cert = new X509Certificate(certPem)
84
+ const notAfter = new Date(cert.validTo).getTime()
85
+ const covers = ips.every(ip => cert.checkIP(ip) !== undefined) && cert.verify(new X509Certificate(this.ca.certPem).publicKey)
86
+ const sameSet = (cert.subjectAltName ?? '').split(', ').filter(s => s.startsWith('IP Address:')).length === ips.length
87
+ if (covers && sameSet && notAfter - this.now() > RENEW_BEFORE_MS) {
88
+ this.server = { key, certPem, keyPem, notAfter }
89
+ return this.server
90
+ }
91
+ } catch { /* missing or stale: issue below */ }
92
+ const issued = issueServerCertificate({ caCertPem: this.ca.certPem, caKey: this.ca.key, ips, dnsNames: localDnsNames(), now: new Date(this.now()) })
93
+ await this.write('server-key.pem', issued.keyPem, 0o600)
94
+ await this.write('server.pem', issued.certPem, 0o644)
95
+ this.server = { key, ...issued, notAfter: new Date(new X509Certificate(issued.certPem).validTo).getTime() }
96
+ this.log('gateway certificate issued', JSON.stringify({ ips }))
97
+ return this.server
98
+ }
99
+
100
+ async write(name, content, mode) {
101
+ const path = join(this.dir, name)
102
+ const tmp = `${path}.tmp-${process.pid}`
103
+ await writeFile(tmp, content, { mode })
104
+ await chmod(tmp, mode)
105
+ await rename(tmp, path)
106
+ }
107
+ }
@@ -0,0 +1,61 @@
1
+ // Authenticated access to the local DSH Web server for the gateway.
2
+ // DSH's browser login is `/?token=<launch token>` → signed host-bound cookie. The gateway does
3
+ // that once (server side) and attaches the cookie to proxied requests; phones never see it.
4
+
5
+ import { request } from 'node:http'
6
+
7
+ export class Upstream {
8
+ /**
9
+ * @param {object} deps
10
+ * @param {() => number|undefined} deps.port - DSH webServer port (127.0.0.1)
11
+ * @param {(baseUrl: string) => string} deps.authenticatedUrl - ctx.connection.authenticatedUrl
12
+ * @param {(...a: unknown[]) => void} deps.log
13
+ */
14
+ constructor(deps) {
15
+ this.deps = deps
16
+ /** @type {{port: number, cookie: string} | undefined} */
17
+ this.cached = undefined
18
+ this.pending = undefined
19
+ }
20
+
21
+ invalidate() { this.cached = undefined }
22
+
23
+ /** @returns {Promise<{port: number, cookie: string} | undefined>} */
24
+ async target() {
25
+ const port = this.deps.port()
26
+ if (port === undefined || port === 0) return undefined
27
+ if (this.cached?.port === port) return this.cached
28
+ this.pending ??= this.login(port).finally(() => { this.pending = undefined })
29
+ return this.pending
30
+ }
31
+
32
+ /** @param {number} port */
33
+ async login(port) {
34
+ let url
35
+ try {
36
+ url = new URL(this.deps.authenticatedUrl(`http://127.0.0.1:${port}/`))
37
+ } catch (error) {
38
+ this.deps.log('gateway: cannot build DSH login URL:', error?.message ?? String(error))
39
+ return undefined
40
+ }
41
+ const cookie = await new Promise((resolve) => {
42
+ const req = request({ host: '127.0.0.1', port, method: 'GET', path: `${url.pathname}${url.search}`, headers: { host: `127.0.0.1:${port}`, accept: 'text/html' } }, (res) => {
43
+ res.resume()
44
+ const setCookie = res.headers['set-cookie'] ?? []
45
+ resolve(setCookie.map(line => line.split(';')[0].trim()).filter(Boolean).join('; '))
46
+ })
47
+ req.on('error', (error) => {
48
+ this.deps.log('gateway: DSH login failed:', error?.code ?? error?.message)
49
+ resolve('')
50
+ })
51
+ req.setTimeout(10_000, () => req.destroy(new Error('timeout')))
52
+ req.end()
53
+ })
54
+ if (cookie === '') {
55
+ this.deps.log('gateway: DSH login returned no cookie')
56
+ return undefined
57
+ }
58
+ this.cached = { port, cookie }
59
+ return this.cached
60
+ }
61
+ }