dsh-plugin-mobile-gateway 0.7.6 → 0.8.1
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/PROTOCOL.md +54 -14
- package/README.md +30 -22
- package/cordis.patch.yml +4 -0
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -0
- package/docs/multi-gateway-app-integration.md +2 -1
- package/lib/client.js +254 -18
- package/lib/cloudflare-tunnel.mjs +268 -0
- package/lib/cloudflared-binary.mjs +114 -0
- package/lib/dsh-host-adapter.mjs +20 -6
- package/lib/gateway-tools.mjs +36 -0
- package/lib/index.mjs +341 -61
- package/lib/web-restart.mjs +47 -0
- package/package.json +3 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { createReadStream } from 'node:fs'
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile)
|
|
9
|
+
const CLOUDFLARED_VERSION = '2026.9.3'
|
|
10
|
+
const MAX_RELEASE_BYTES = 80 * 1024 * 1024
|
|
11
|
+
// GitHub's asset digests for the pinned Cloudflare release. The release API may
|
|
12
|
+
// return 403 even when the public release assets remain available.
|
|
13
|
+
const RELEASE_DIGESTS = Object.freeze({
|
|
14
|
+
'cloudflared-darwin-amd64.tgz': 'd1155d0837487f261183b15c1eab6c4ebcad9dc49b94675f1524c3564cea3977',
|
|
15
|
+
'cloudflared-darwin-arm64.tgz': '587c2cfb1c230fe36c7fa7727da78be459dae028cabe8c001291999350f07095',
|
|
16
|
+
'cloudflared-windows-386.exe': '9b95ddc2eba67b86ed3dc4cc2a15881960563031b52ce564376af41fb91ad402',
|
|
17
|
+
'cloudflared-windows-amd64.exe': 'f096265ec2fcbe9bb6e2d64268db167ced3fcbb83d894bdb9e2fcdb26f2ea7e2',
|
|
18
|
+
})
|
|
19
|
+
const BINARY_DIGESTS = Object.freeze({
|
|
20
|
+
'cloudflared-darwin-amd64.tgz': 'ab588b3b4db9cdb4476c30a3db2a72635b1d8327d44741fee6799a0f37b0ec07',
|
|
21
|
+
'cloudflared-darwin-arm64.tgz': '5472c1a01c84bc31b3021056a73b4e5774ddddefc572124ea8fdf6c340639f32',
|
|
22
|
+
'cloudflared-windows-386.exe': RELEASE_DIGESTS['cloudflared-windows-386.exe'],
|
|
23
|
+
'cloudflared-windows-amd64.exe': RELEASE_DIGESTS['cloudflared-windows-amd64.exe'],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export function cloudflaredAsset(platform = process.platform, arch = process.arch) {
|
|
27
|
+
if (platform === 'darwin' && (arch === 'arm64' || arch === 'x64')) {
|
|
28
|
+
return `cloudflared-darwin-${arch === 'x64' ? 'amd64' : 'arm64'}.tgz`
|
|
29
|
+
}
|
|
30
|
+
if (platform === 'win32' && arch === 'x64') return 'cloudflared-windows-amd64.exe'
|
|
31
|
+
if (platform === 'win32' && arch === 'ia32') return 'cloudflared-windows-386.exe'
|
|
32
|
+
throw new Error(`unsupported Cloudflare PC architecture: ${platform}/${arch}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readBounded(response, limit) {
|
|
36
|
+
if (!response.ok) throw new Error(`Cloudflare binary download returned HTTP ${response.status} from GitHub Releases`)
|
|
37
|
+
const length = Number(response.headers.get('content-length'))
|
|
38
|
+
if (length > limit) throw new Error('Cloudflare binary download exceeds the size limit')
|
|
39
|
+
const chunks = []
|
|
40
|
+
let size = 0
|
|
41
|
+
for await (const chunk of response.body) {
|
|
42
|
+
size += chunk.length
|
|
43
|
+
if (size > limit) throw new Error('Cloudflare binary download exceeds the size limit')
|
|
44
|
+
chunks.push(Buffer.from(chunk))
|
|
45
|
+
}
|
|
46
|
+
return Buffer.concat(chunks, size)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function regularExecutable(file) {
|
|
50
|
+
try { return (await fs.lstat(file)).isFile() } catch { return false }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function sha256File(file) {
|
|
54
|
+
const hash = crypto.createHash('sha256')
|
|
55
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk)
|
|
56
|
+
return hash.digest('hex')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The default executable stays in DSH's private cache. External binaries are
|
|
60
|
+
// used only when an explicit path is configured.
|
|
61
|
+
export async function ensureCloudflared({ cacheDir, executable = 'cloudflared',
|
|
62
|
+
platform = process.platform, arch = process.arch, fetchImpl = fetch,
|
|
63
|
+
releaseDigests = RELEASE_DIGESTS, binaryDigests = BINARY_DIGESTS } = {}) {
|
|
64
|
+
if (executable !== 'cloudflared') return executable
|
|
65
|
+
const assetName = cloudflaredAsset(platform, arch)
|
|
66
|
+
const destination = path.join(cacheDir, `${CLOUDFLARED_VERSION}-${platform}-${arch}${platform === 'win32' ? '.exe' : ''}`)
|
|
67
|
+
await fs.mkdir(cacheDir, { recursive: true, mode: 0o700 })
|
|
68
|
+
await fs.chmod(cacheDir, 0o700)
|
|
69
|
+
const expectedDigest = releaseDigests[assetName]
|
|
70
|
+
const expectedBinaryDigest = binaryDigests[assetName]
|
|
71
|
+
if (!/^[0-9a-f]{64}$/.test(expectedDigest || '') ||
|
|
72
|
+
!/^[0-9a-f]{64}$/.test(expectedBinaryDigest || '')) {
|
|
73
|
+
throw new Error('Cloudflare release has no verifiable binary for this PC')
|
|
74
|
+
}
|
|
75
|
+
if (await regularExecutable(destination)) {
|
|
76
|
+
if (await sha256File(destination) === expectedBinaryDigest) {
|
|
77
|
+
await fs.chmod(destination, 0o700)
|
|
78
|
+
return destination
|
|
79
|
+
}
|
|
80
|
+
await fs.rm(destination)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const expectedUrl = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/${assetName}`
|
|
84
|
+
const response = await fetchImpl(expectedUrl, {
|
|
85
|
+
headers: { 'User-Agent': 'dsh-mobile-gateway' },
|
|
86
|
+
signal: AbortSignal.timeout(120_000),
|
|
87
|
+
})
|
|
88
|
+
const archive = await readBounded(response, MAX_RELEASE_BYTES)
|
|
89
|
+
const actual = crypto.createHash('sha256').update(archive).digest('hex')
|
|
90
|
+
if (actual !== expectedDigest) {
|
|
91
|
+
throw new Error('Cloudflare binary checksum mismatch')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stage = await fs.mkdtemp(path.join(cacheDir, '.cloudflared-'))
|
|
95
|
+
try {
|
|
96
|
+
let source
|
|
97
|
+
if (assetName.endsWith('.tgz')) {
|
|
98
|
+
const archiveFile = path.join(stage, assetName)
|
|
99
|
+
await fs.writeFile(archiveFile, archive, { mode: 0o600 })
|
|
100
|
+
await execFileAsync('tar', ['-xzf', archiveFile, '-C', stage], { timeout: 30_000 })
|
|
101
|
+
source = path.join(stage, 'cloudflared')
|
|
102
|
+
if (!await regularExecutable(source)) throw new Error('Cloudflare archive contains no cloudflared executable')
|
|
103
|
+
} else {
|
|
104
|
+
source = path.join(stage, 'cloudflared.exe')
|
|
105
|
+
await fs.writeFile(source, archive, { mode: 0o700 })
|
|
106
|
+
}
|
|
107
|
+
if (await sha256File(source) !== expectedBinaryDigest) throw new Error('Cloudflare executable checksum mismatch')
|
|
108
|
+
await fs.chmod(source, 0o700)
|
|
109
|
+
if (!await regularExecutable(destination)) await fs.rename(source, destination)
|
|
110
|
+
return destination
|
|
111
|
+
} finally {
|
|
112
|
+
await fs.rm(stage, { recursive: true, force: true })
|
|
113
|
+
}
|
|
114
|
+
}
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -13,13 +13,13 @@ function requireArray(value, endpoint) {
|
|
|
13
13
|
return value
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const DSH_VERSION = '0.1.
|
|
17
|
-
export const SESSION_FORMAT_VERSION =
|
|
16
|
+
export const DSH_VERSION = '0.1.7-rc.2'
|
|
17
|
+
export const SESSION_FORMAT_VERSION = 4
|
|
18
18
|
|
|
19
19
|
export function readHistoryRecords(records) {
|
|
20
20
|
return requireArray(records, 'session history').map(record => {
|
|
21
21
|
const value = requireRecord(record, 'session history record')
|
|
22
|
-
if (value.type !== 'event') throw new Error('DSH
|
|
22
|
+
if (value.type !== 'event') throw new Error('DSH Session V4 history requires event records')
|
|
23
23
|
const event = requireRecord(value.event, 'session history event')
|
|
24
24
|
if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
|
|
25
25
|
return event
|
|
@@ -31,7 +31,7 @@ export function readSessionSnapshot(frame, sessionId) {
|
|
|
31
31
|
if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
|
|
32
32
|
|| frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
|
|
33
33
|
if (frame.header.version !== SESSION_FORMAT_VERSION) {
|
|
34
|
-
throw Object.assign(new Error(
|
|
34
|
+
throw Object.assign(new Error(`mobile-gateway requires Session format ${SESSION_FORMAT_VERSION}; received ${frame.header.version}`), {
|
|
35
35
|
code: 'unsupported-session-format',
|
|
36
36
|
})
|
|
37
37
|
}
|
|
@@ -66,7 +66,7 @@ function requestArgs(request) {
|
|
|
66
66
|
return { request }
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// The
|
|
69
|
+
// The SessionController names its reserved list argument
|
|
70
70
|
// `_request`. Typert descriptors preserve that source parameter name exactly,
|
|
71
71
|
// so this endpoint cannot share the normal `{ request }` wrapper.
|
|
72
72
|
function sessionListArgs(request) {
|
|
@@ -160,7 +160,7 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
160
160
|
|
|
161
161
|
const commands = {
|
|
162
162
|
list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
|
|
163
|
-
// DSH
|
|
163
|
+
// DSH accepts `submittedAttachments`. The parsed
|
|
164
164
|
// wire images already carry the { type: 'image', ... } shape it expects, so
|
|
165
165
|
// only the field name changes.
|
|
166
166
|
execute: (sessionId, line, attachments, signal) => invoke(
|
|
@@ -253,6 +253,20 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
253
253
|
...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
|
|
254
254
|
}, signal),
|
|
255
255
|
},
|
|
256
|
+
permissionPresets: {
|
|
257
|
+
catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
|
|
258
|
+
},
|
|
259
|
+
schedule: {
|
|
260
|
+
async catalog(_payload = {}, signal) {
|
|
261
|
+
return { items: requireArray(await invoke('schedule', 'catalog', {}, signal), 'schedule/catalog') }
|
|
262
|
+
},
|
|
263
|
+
async list(payload, signal) {
|
|
264
|
+
return { items: requireArray(await invoke('schedule', 'list', requestArgs(payload), signal), 'schedule/list') }
|
|
265
|
+
},
|
|
266
|
+
history: (payload, signal) => invoke('schedule', 'history', requestArgs(payload), signal),
|
|
267
|
+
update: (payload, signal) => invoke('schedule', 'update', requestArgs(payload), signal),
|
|
268
|
+
delete: (payload, signal) => invoke('schedule', 'delete', requestArgs(payload), signal),
|
|
269
|
+
},
|
|
256
270
|
skills: {
|
|
257
271
|
list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
|
|
258
272
|
},
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { realpathSync } from 'node:fs'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
export function webRestartAvailable(profile, runtime = process) {
|
|
6
|
+
if (profile?.name !== 'web' || !runtime.argv?.[1]) return false
|
|
7
|
+
try {
|
|
8
|
+
return /[/\\]@deepseek-ai[/\\]dsh[/\\]lib[/\\]bin\.js$/.test(realpathSync(runtime.argv[1]))
|
|
9
|
+
} catch {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function prepareWebRestart(runtime = process, spawnChild = spawn) {
|
|
15
|
+
const argv = runtime.argv.slice(1)
|
|
16
|
+
// DSH Web opens a browser on every boot by default. This restart was
|
|
17
|
+
// initiated from an existing tab, so keep that tab and suppress the handoff.
|
|
18
|
+
const terminator = argv.indexOf('--')
|
|
19
|
+
const flagEnd = terminator < 0 ? argv.length : terminator
|
|
20
|
+
if (!argv.slice(0, flagEnd).includes('--no-open')) argv.splice(flagEnd, 0, '--no-open')
|
|
21
|
+
const payload = Buffer.from(JSON.stringify({
|
|
22
|
+
pid: runtime.pid,
|
|
23
|
+
execPath: runtime.execPath,
|
|
24
|
+
execArgv: runtime.execArgv,
|
|
25
|
+
argv,
|
|
26
|
+
cwd: runtime.cwd(),
|
|
27
|
+
})).toString('base64url')
|
|
28
|
+
const helper = spawnChild(runtime.execPath, [fileURLToPath(new URL('./web-restart.mjs', import.meta.url)), payload], {
|
|
29
|
+
cwd: runtime.cwd(), env: runtime.env, detached: true, stdio: 'ignore',
|
|
30
|
+
})
|
|
31
|
+
await new Promise((resolve, reject) => {
|
|
32
|
+
helper.once('spawn', resolve)
|
|
33
|
+
helper.once('error', reject)
|
|
34
|
+
})
|
|
35
|
+
helper.unref()
|
|
36
|
+
}
|