dsh-mobilecode 0.2.1 → 0.4.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/README.md +39 -0
- package/lib/android-stream.js +378 -0
- package/lib/client.js +133 -0
- package/lib/frame-source.js +274 -0
- package/lib/index.js +234 -6
- package/lib/stream-access.js +220 -0
- package/lib/vision.js +130 -0
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — capability tokens and the transport fence for the live
|
|
3
|
+
* stream routes.
|
|
4
|
+
*
|
|
5
|
+
* Ported from ZSeven-W/dsh-android (stream-access.ts, MIT), same security
|
|
6
|
+
* posture:
|
|
7
|
+
* - HMAC-SHA256 capabilities `base64url(payload).base64url(mac)`, signed with a
|
|
8
|
+
* 32-byte per-install key (`~/.dsh/mobilecode/stream-access.key`, created
|
|
9
|
+
* atomically); tokens expire within 10 minutes.
|
|
10
|
+
* - Every route applies the loopback / trusted-browser transport fence (peer
|
|
11
|
+
* address, loopback Host, Sec-Fetch-Site / Origin) BEFORE any capability is
|
|
12
|
+
* consulted — Host/Origin are caller-controlled, so a LAN client cannot spoof
|
|
13
|
+
* localhost and a DNS-rebinding Host is rejected.
|
|
14
|
+
*
|
|
15
|
+
* The screenshot-cache containment walk of the reference is not ported: this
|
|
16
|
+
* plugin serves only the live stream over its routes (device_screen writes PNGs
|
|
17
|
+
* to disk directly), so there is no arbitrary-path-serving surface to fence.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
21
|
+
import { existsSync } from 'node:fs'
|
|
22
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { HOME } from './setup.js'
|
|
25
|
+
|
|
26
|
+
/** Hard capability lifetime (tokens expire within 10 minutes). */
|
|
27
|
+
export const TOKEN_TTL_MS = 10 * 60 * 1000
|
|
28
|
+
|
|
29
|
+
const KEY_BYTES = 32
|
|
30
|
+
const TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
|
|
31
|
+
const MAX_TOKEN_LENGTH = 16 * 1024
|
|
32
|
+
/** Signing may run ahead of verification by this much before the TTL cap trips. */
|
|
33
|
+
const CLOCK_SKEW_MS = 60 * 1000
|
|
34
|
+
|
|
35
|
+
/** adb device serials: `emulator-5554`, `RFCX123ABC`, or `host:port` for network adb. */
|
|
36
|
+
export const SERIAL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
|
|
37
|
+
|
|
38
|
+
function keyPath() {
|
|
39
|
+
return path.join(HOME, 'stream-access.key')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mac(key, payload) {
|
|
43
|
+
return createHmac('sha256', key).update(payload).digest()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function safeEqual(left, right) {
|
|
47
|
+
return left.length === right.length && timingSafeEqual(left, right)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Load or atomically create the per-install 32-byte signing key. */
|
|
51
|
+
export async function prepareStreamAccessKey() {
|
|
52
|
+
await mkdir(HOME, { recursive: true })
|
|
53
|
+
const file = keyPath()
|
|
54
|
+
if (existsSync(file)) {
|
|
55
|
+
const key = await readFile(file)
|
|
56
|
+
if (key.length === KEY_BYTES) return key
|
|
57
|
+
throw new Error('dsh-mobilecode: stream access key has an invalid length')
|
|
58
|
+
}
|
|
59
|
+
const candidate = randomBytes(KEY_BYTES)
|
|
60
|
+
try {
|
|
61
|
+
await writeFile(file, candidate, { flag: 'wx', mode: 0o600 })
|
|
62
|
+
return candidate
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error?.code !== 'EEXIST') throw error
|
|
65
|
+
const key = await readFile(file)
|
|
66
|
+
if (key.length !== KEY_BYTES) throw new Error('dsh-mobilecode: stream access key has an invalid length')
|
|
67
|
+
return key
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseStreamPayload(value) {
|
|
72
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
|
|
73
|
+
if (
|
|
74
|
+
value.v !== 1
|
|
75
|
+
|| value.kind !== 'mobilecode-stream'
|
|
76
|
+
|| typeof value.serial !== 'string'
|
|
77
|
+
|| !SERIAL_PATTERN.test(value.serial)
|
|
78
|
+
|| typeof value.exp !== 'number'
|
|
79
|
+
|| !Number.isSafeInteger(value.exp)
|
|
80
|
+
) return undefined
|
|
81
|
+
return { v: 1, kind: 'mobilecode-stream', serial: value.serial, exp: value.exp }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** HMAC capability encoder/verifier for the live stream URL. */
|
|
85
|
+
export class StreamAccessController {
|
|
86
|
+
#routeCount = 0
|
|
87
|
+
#keyPromise
|
|
88
|
+
|
|
89
|
+
constructor(resolveKey = prepareStreamAccessKey) {
|
|
90
|
+
this.resolveKey = resolveKey
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Whether at least one HTTP carrier currently owns the routes. */
|
|
94
|
+
get routeAvailable() {
|
|
95
|
+
return this.#routeCount > 0
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Mark one route attachment; the returned disposer removes it. */
|
|
99
|
+
attachRoute() {
|
|
100
|
+
this.#routeCount += 1
|
|
101
|
+
let active = true
|
|
102
|
+
return () => {
|
|
103
|
+
if (!active) return
|
|
104
|
+
active = false
|
|
105
|
+
this.#routeCount -= 1
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Mint a stream capability for one device serial. */
|
|
110
|
+
async signStreamToken(serial, options = {}) {
|
|
111
|
+
if (!SERIAL_PATTERN.test(serial)) throw new TypeError('dsh-mobilecode: signStreamToken requires a device serial')
|
|
112
|
+
const key = await this.#key()
|
|
113
|
+
const payload = { v: 1, kind: 'mobilecode-stream', serial, exp: Date.now() + this.#ttl(options.ttlMs) }
|
|
114
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
|
115
|
+
return { token: `${encoded}.${mac(key, encoded).toString('base64url')}`, expiresAt: payload.exp }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async verifyStreamToken(token) {
|
|
119
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) return undefined
|
|
120
|
+
const [encoded, signature] = token.split('.')
|
|
121
|
+
if (encoded === undefined || signature === undefined) return undefined
|
|
122
|
+
const key = await this.#key().catch(() => undefined)
|
|
123
|
+
if (key === undefined) return undefined
|
|
124
|
+
let supplied
|
|
125
|
+
try {
|
|
126
|
+
supplied = Buffer.from(signature, 'base64url')
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
if (!safeEqual(mac(key, encoded), supplied)) return undefined
|
|
131
|
+
try {
|
|
132
|
+
const payload = parseStreamPayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')))
|
|
133
|
+
if (payload === undefined) return undefined
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
if (payload.exp <= now) return undefined
|
|
136
|
+
if (payload.exp - now > TOKEN_TTL_MS + CLOCK_SKEW_MS) return undefined
|
|
137
|
+
return payload
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#ttl(ttlMs) {
|
|
144
|
+
if (ttlMs === undefined || !Number.isFinite(ttlMs)) return TOKEN_TTL_MS
|
|
145
|
+
return Math.min(TOKEN_TTL_MS, Math.max(1, Math.floor(ttlMs)))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#key() {
|
|
149
|
+
this.#keyPromise ??= this.resolveKey()
|
|
150
|
+
return this.#keyPromise
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── loopback / trusted-browser transport fence ───────────────────────────────
|
|
155
|
+
|
|
156
|
+
function isIpv4LoopbackAddress(address) {
|
|
157
|
+
const parts = address.split('.')
|
|
158
|
+
return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Trust the transport peer, never forwarded or caller-controlled host data.
|
|
163
|
+
* Node may expose an IPv4 peer directly or as an IPv4-mapped IPv6 address,
|
|
164
|
+
* including the compact hexadecimal form used by some platforms.
|
|
165
|
+
*/
|
|
166
|
+
export function isLoopbackRemoteAddress(address) {
|
|
167
|
+
if (address === undefined) return false
|
|
168
|
+
const normalized = address.toLowerCase().split('%', 1)[0]
|
|
169
|
+
if (normalized === '::1' || isIpv4LoopbackAddress(normalized)) return true
|
|
170
|
+
if (!normalized.startsWith('::ffff:')) return false
|
|
171
|
+
const mapped = normalized.slice('::ffff:'.length)
|
|
172
|
+
if (isIpv4LoopbackAddress(mapped)) return true
|
|
173
|
+
const hexadecimal = /^([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(mapped)
|
|
174
|
+
return hexadecimal !== null && (Number.parseInt(hexadecimal[1], 16) >>> 8) === 127
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isLoopbackHostname(hostname) {
|
|
178
|
+
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
|
179
|
+
return isIpv4LoopbackAddress(hostname)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function requestAuthority(req) {
|
|
183
|
+
const host = req.headers.host
|
|
184
|
+
if (typeof host !== 'string') return undefined
|
|
185
|
+
try {
|
|
186
|
+
const parsed = new URL(`http://${host}`)
|
|
187
|
+
if (parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' || parsed.username !== '' || parsed.password !== '') {
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
return parsed
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isLoopbackRequest(req) {
|
|
197
|
+
if (!isLoopbackRemoteAddress(req.socket?.remoteAddress)) return false
|
|
198
|
+
const authority = requestAuthority(req)
|
|
199
|
+
return authority !== undefined && isLoopbackHostname(authority.hostname)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isTrustedBrowserRequest(req, requireOrigin) {
|
|
203
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
204
|
+
const origin = req.headers.origin
|
|
205
|
+
if (origin === undefined) return !requireOrigin
|
|
206
|
+
if (typeof origin !== 'string') return false
|
|
207
|
+
const authority = requestAuthority(req)
|
|
208
|
+
if (authority === undefined) return false
|
|
209
|
+
try {
|
|
210
|
+
const parsed = new URL(origin)
|
|
211
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === authority.host
|
|
212
|
+
} catch {
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The transport fence applied to every stream route. */
|
|
218
|
+
export function isTrustedRequest(req, requireOrigin = false) {
|
|
219
|
+
return isLoopbackRequest(req) && isTrustedBrowserRequest(req, requireOrigin)
|
|
220
|
+
}
|
package/lib/vision.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — native multimodal delivery.
|
|
3
|
+
*
|
|
4
|
+
* When the routed model declares image input, the capture tools hand the model
|
|
5
|
+
* the screenshot ITSELF (a `{type:'image', attachment}` block) instead of only a
|
|
6
|
+
* file path it would have to open. DSH 0.1.1 carries images end to end: tool
|
|
7
|
+
* results may contain image blocks, bytes live in the durable attachment store
|
|
8
|
+
* (`ctx.get('attachments')`), and `llm.resolveModelInfo(...).inputModalities`
|
|
9
|
+
* says whether the routed model accepts images. This mirrors the in-tree
|
|
10
|
+
* `read_image` tool in dsh-tool-fs.
|
|
11
|
+
*
|
|
12
|
+
* The deliberate difference from `read_image`: where that tool REFUSES on a
|
|
13
|
+
* text-only route (the image is its whole point), the capture tools here
|
|
14
|
+
* DEGRADE. The primary output is always the JSON summary; the image block is an
|
|
15
|
+
* enhancement added only when (a) the attachment store is mounted, (b) the
|
|
16
|
+
* calling route's resolved model declares `image` input, and (c) admission
|
|
17
|
+
* succeeds. Any failure in that chain silently keeps the text-only behavior, so
|
|
18
|
+
* text-only routes, headless profiles, and older hosts never see a new error.
|
|
19
|
+
*
|
|
20
|
+
* Everything is typed structurally — the plugin is plain JS and must not depend
|
|
21
|
+
* on the host's attachment type exports.
|
|
22
|
+
* @module vision
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile } from 'node:fs/promises'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the optional vision services from the plugin context. Both come back
|
|
30
|
+
* absent on hosts that do not mount them; every consumer treats that as
|
|
31
|
+
* "stay text-only".
|
|
32
|
+
*/
|
|
33
|
+
export function resolveVisionServices(ctx) {
|
|
34
|
+
const get = typeof ctx?.get === 'function' ? ctx.get.bind(ctx) : undefined
|
|
35
|
+
if (get === undefined) return {}
|
|
36
|
+
const attachments = get('attachments')
|
|
37
|
+
const llm = get('llm')
|
|
38
|
+
return {
|
|
39
|
+
...(attachments !== undefined && typeof attachments.saveImage === 'function' ? { attachments } : {}),
|
|
40
|
+
...(llm !== undefined && typeof llm.resolveModelInfo === 'function' ? { llm } : {}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when the calling route's resolved model declares `image` input. Mirrors
|
|
46
|
+
* `read_image`'s gate (request-header config first, then agent options) but
|
|
47
|
+
* answers false instead of throwing: a tool result that enters durable history
|
|
48
|
+
* must not carry an image its route cannot replay.
|
|
49
|
+
*/
|
|
50
|
+
export async function imageInputActive(services, exec) {
|
|
51
|
+
if (services.llm === undefined || services.attachments === undefined) return false
|
|
52
|
+
try {
|
|
53
|
+
const routed = exec?.agent?.session?.requestHeader?.()?.config
|
|
54
|
+
const provider = routed?.provider ?? exec?.agent?.options?.provider
|
|
55
|
+
const model = routed?.model ?? exec?.agent?.options?.model
|
|
56
|
+
if (provider === undefined || model === undefined) return false
|
|
57
|
+
const info = await services.llm.resolveModelInfo(provider, model, exec?.signal)
|
|
58
|
+
return info?.inputModalities?.includes('image') === true
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Durably commit one screenshot PNG and return the plain reference for the
|
|
66
|
+
* result value, or undefined when the store is absent or admission fails
|
|
67
|
+
* (oversized, malformed) — never an error, per the degrade-not-refuse rule.
|
|
68
|
+
*/
|
|
69
|
+
export async function saveScreenshotAttachment(services, png, name) {
|
|
70
|
+
const attachments = services.attachments
|
|
71
|
+
if (attachments === undefined) return undefined
|
|
72
|
+
try {
|
|
73
|
+
const ref = await attachments.saveImage({ data: png, mediaType: 'image/png', name })
|
|
74
|
+
if (typeof ref?.attachmentId !== 'string' || ref.attachmentId === '') return undefined
|
|
75
|
+
return {
|
|
76
|
+
attachmentId: ref.attachmentId,
|
|
77
|
+
mediaType: ref.mediaType,
|
|
78
|
+
bytes: ref.bytes,
|
|
79
|
+
width: ref.width,
|
|
80
|
+
height: ref.height,
|
|
81
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convenience for the capture tools: gate on the route, read the file, and save
|
|
90
|
+
* the attachment — returning undefined (degrade) on any miss. Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export async function maybeAttachScreenshot(services, filePath, exec) {
|
|
93
|
+
if (services.attachments === undefined || typeof filePath !== 'string' || filePath === '') return undefined
|
|
94
|
+
if (!(await imageInputActive(services, exec))) return undefined
|
|
95
|
+
try {
|
|
96
|
+
const data = await readFile(filePath)
|
|
97
|
+
return await saveScreenshotAttachment(services, data, path.basename(filePath))
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Output-schema fragment for the optional `image` result field. */
|
|
104
|
+
export const IMAGE_REF_SCHEMA = {
|
|
105
|
+
type: 'object',
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
description: 'Durable attachment reference for the screenshot delivered to the model as an image block '
|
|
108
|
+
+ '(present only when the routed model declares image input).',
|
|
109
|
+
properties: {
|
|
110
|
+
attachmentId: { type: 'string', required: true },
|
|
111
|
+
mediaType: { type: 'string', required: true },
|
|
112
|
+
bytes: { type: 'number', required: true },
|
|
113
|
+
width: { type: 'number', required: true },
|
|
114
|
+
height: { type: 'number', required: true },
|
|
115
|
+
name: { type: 'string' },
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Append the image block to a render's content blocks when the value carries an
|
|
121
|
+
* `image` ref — so an image-capable model SEES the screen. Returns the same
|
|
122
|
+
* array for chaining.
|
|
123
|
+
*/
|
|
124
|
+
export function appendImageBlock(blocks, value) {
|
|
125
|
+
const image = value?.image
|
|
126
|
+
if (image !== undefined && typeof image.attachmentId === 'string') {
|
|
127
|
+
blocks.push({ type: 'image', attachment: image })
|
|
128
|
+
}
|
|
129
|
+
return blocks
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobilecode",
|
|
3
3
|
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run serve-sim / serve-avd preview servers, and build-install-launch the app on the simulator or emulator from the session — plus agent tools (device_run, device_detect). Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|