dsh-plugin-mobile-gateway 0.7.7 → 0.9.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/PROTOCOL.md +68 -12
- package/README.md +32 -22
- package/cordis.patch.yml +4 -0
- package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -0
- package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -0
- package/docs/multi-gateway-app-integration.md +2 -1
- package/lib/client.js +273 -18
- package/lib/cloudflare-tunnel.mjs +282 -0
- package/lib/cloudflared-binary.mjs +114 -0
- package/lib/dsh-host-adapter.mjs +47 -6
- package/lib/gateway-tools.mjs +36 -0
- package/lib/index.mjs +267 -47
- package/lib/plugin-management.mjs +194 -0
- package/lib/web-restart.mjs +47 -0
- package/package.json +3 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
const OFFICIAL_CONFIGURATION_ITEMS = Object.freeze([
|
|
2
|
+
{ id: 'shell', namespaces: ['bash-sandbox', 'pwsh-sandbox'] },
|
|
3
|
+
{ id: 'agent-loop', namespaces: ['agent-loop'] },
|
|
4
|
+
{ id: 'subagent', namespaces: ['subagent', 'subagent-model-selection-settings'] },
|
|
5
|
+
{ id: 'web-search', namespaces: ['web-search-deepseek'] },
|
|
6
|
+
])
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_REQUEST_TYPES = new Set([
|
|
9
|
+
'plugin-catalog',
|
|
10
|
+
'plugin-registries',
|
|
11
|
+
'plugin-inspect',
|
|
12
|
+
'plugin-install',
|
|
13
|
+
'plugin-install-status',
|
|
14
|
+
'plugin-install-cancel',
|
|
15
|
+
'plugin-bundle-set-enabled',
|
|
16
|
+
'plugin-entry-set-enabled',
|
|
17
|
+
'plugin-remove',
|
|
18
|
+
'plugin-settings',
|
|
19
|
+
'plugin-settings-mutate',
|
|
20
|
+
'plugin-version-exemptions',
|
|
21
|
+
'plugin-version-exemption-set',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
const FLAT_RESPONSE_TYPES = new Set([
|
|
25
|
+
'plugin-catalog', 'plugin-registries', 'plugin-settings',
|
|
26
|
+
'plugin-settings-mutate', 'plugin-version-exemptions',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
function record(value) {
|
|
30
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nonempty(value, maxLength = 2048) {
|
|
34
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function badRequest(type, requestId, message) {
|
|
38
|
+
return { kind: 'error', code: 'bad-request', message, requestType: type,
|
|
39
|
+
...(requestId === undefined ? {} : { requestId }) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function registryOption(msg, type, requestId) {
|
|
43
|
+
if (msg.registry === undefined) return { value: undefined }
|
|
44
|
+
if (msg.registry === null || (nonempty(msg.registry) && /^https?:\/\//i.test(msg.registry))) {
|
|
45
|
+
return { value: msg.registry }
|
|
46
|
+
}
|
|
47
|
+
return { error: badRequest(type, requestId, 'registry must be null or an HTTP(S) URL') }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireName(msg, field, type, requestId) {
|
|
51
|
+
if (!nonempty(msg[field], 512)) return { error: badRequest(type, requestId, `${field} must be a non-empty string`) }
|
|
52
|
+
return { value: msg[field].trim() }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function pluginCatalog(api) {
|
|
56
|
+
const inventory = await api.plugins.inventory()
|
|
57
|
+
if (!record(inventory) || !Array.isArray(inventory.entries)) throw new Error('pluginInventory/list returned an invalid inventory')
|
|
58
|
+
if (inventory.managementAvailable !== true) {
|
|
59
|
+
return { managementAvailable: false, bundles: [], plugins: [], officialItems: [], settingsWritable: false }
|
|
60
|
+
}
|
|
61
|
+
const [bundles, plugins, settings] = await Promise.all([
|
|
62
|
+
api.plugins.listBundles(),
|
|
63
|
+
api.plugins.listPlugins(),
|
|
64
|
+
api.settings.describe(),
|
|
65
|
+
])
|
|
66
|
+
if (!Array.isArray(bundles) || !Array.isArray(plugins) || !Array.isArray(settings?.namespaces)) {
|
|
67
|
+
throw new Error('plugin manager returned an invalid catalog')
|
|
68
|
+
}
|
|
69
|
+
const available = new Set(settings.namespaces.map(item => item.ns))
|
|
70
|
+
const officialItems = OFFICIAL_CONFIGURATION_ITEMS
|
|
71
|
+
.filter(item => item.namespaces.some(ns => available.has(ns)))
|
|
72
|
+
.map(item => ({ id: item.id, namespaces: item.namespaces.filter(ns => available.has(ns)) }))
|
|
73
|
+
return {
|
|
74
|
+
managementAvailable: true,
|
|
75
|
+
bundles: bundles.map(bundle => ({
|
|
76
|
+
...bundle,
|
|
77
|
+
experimental: bundle.name.startsWith('@deepseek-ai/dsh-experimental-'),
|
|
78
|
+
})),
|
|
79
|
+
plugins,
|
|
80
|
+
officialItems,
|
|
81
|
+
settingsWritable: settings.writable === true,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The mobile wire owns its request names and correlation ids. The Host adapter
|
|
87
|
+
* owns Typert endpoint names and argument descriptors.
|
|
88
|
+
*/
|
|
89
|
+
export async function handlePluginQuery(api, msg, { onInstallStart, onInstallEnd } = {}) {
|
|
90
|
+
const type = msg?.type
|
|
91
|
+
if (!PLUGIN_REQUEST_TYPES.has(type)) return null
|
|
92
|
+
const requestId = nonempty(msg.requestId, 128) ? msg.requestId.trim() : undefined
|
|
93
|
+
if (!requestId) return badRequest(type, undefined, `${type} requires a requestId`)
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
let value
|
|
97
|
+
let kind = type
|
|
98
|
+
if (type === 'plugin-catalog') {
|
|
99
|
+
value = await pluginCatalog(api)
|
|
100
|
+
} else if (type === 'plugin-registries') {
|
|
101
|
+
value = await api.plugins.registries()
|
|
102
|
+
} else if (type === 'plugin-inspect' || type === 'plugin-install') {
|
|
103
|
+
const spec = requireName(msg, 'spec', type, requestId)
|
|
104
|
+
if (spec.error) return spec.error
|
|
105
|
+
const registry = registryOption(msg, type, requestId)
|
|
106
|
+
if (registry.error) return registry.error
|
|
107
|
+
if (type === 'plugin-inspect') {
|
|
108
|
+
value = await api.plugins.inspect(spec.value, registry.value === undefined ? undefined : { registry: registry.value })
|
|
109
|
+
kind = 'plugin-inspection'
|
|
110
|
+
} else {
|
|
111
|
+
if (msg.enabled !== undefined && typeof msg.enabled !== 'boolean') {
|
|
112
|
+
return badRequest(type, requestId, 'enabled must be a boolean')
|
|
113
|
+
}
|
|
114
|
+
if (msg.approvedBuilds !== undefined && (!Array.isArray(msg.approvedBuilds)
|
|
115
|
+
|| msg.approvedBuilds.length > 100 || msg.approvedBuilds.some(name => !nonempty(name, 512)))) {
|
|
116
|
+
return badRequest(type, requestId, 'approvedBuilds must be an array of package names')
|
|
117
|
+
}
|
|
118
|
+
const options = {
|
|
119
|
+
requestId,
|
|
120
|
+
...(msg.enabled === undefined ? {} : { enabled: msg.enabled }),
|
|
121
|
+
...(registry.value === undefined ? {} : { registry: registry.value }),
|
|
122
|
+
...(msg.approvedBuilds === undefined ? {} : { approvedBuilds: msg.approvedBuilds }),
|
|
123
|
+
}
|
|
124
|
+
onInstallStart?.(requestId)
|
|
125
|
+
try {
|
|
126
|
+
value = await api.plugins.installBundle(spec.value, options)
|
|
127
|
+
} finally {
|
|
128
|
+
onInstallEnd?.(requestId)
|
|
129
|
+
}
|
|
130
|
+
kind = 'plugin-install-result'
|
|
131
|
+
}
|
|
132
|
+
} else if (type === 'plugin-install-status' || type === 'plugin-install-cancel') {
|
|
133
|
+
const installId = requireName(msg, 'installId', type, requestId)
|
|
134
|
+
if (installId.error) return installId.error
|
|
135
|
+
value = type === 'plugin-install-status'
|
|
136
|
+
? await api.plugins.waitForInstall(installId.value)
|
|
137
|
+
: await api.plugins.cancelInstall(installId.value)
|
|
138
|
+
} else if (type === 'plugin-bundle-set-enabled' || type === 'plugin-entry-set-enabled') {
|
|
139
|
+
const field = type === 'plugin-bundle-set-enabled' ? 'name' : 'entryId'
|
|
140
|
+
const target = requireName(msg, field, type, requestId)
|
|
141
|
+
if (target.error) return target.error
|
|
142
|
+
if (typeof msg.enabled !== 'boolean') return badRequest(type, requestId, 'enabled must be a boolean')
|
|
143
|
+
value = type === 'plugin-bundle-set-enabled'
|
|
144
|
+
? await api.plugins.setBundleEnabled(target.value, msg.enabled)
|
|
145
|
+
: await api.plugins.setPluginEnabled(target.value, msg.enabled)
|
|
146
|
+
} else if (type === 'plugin-remove') {
|
|
147
|
+
const name = requireName(msg, 'name', type, requestId)
|
|
148
|
+
if (name.error) return name.error
|
|
149
|
+
value = await api.plugins.removeBundle(name.value)
|
|
150
|
+
} else if (type === 'plugin-settings') {
|
|
151
|
+
const described = await api.settings.describe()
|
|
152
|
+
if (!record(described) || !Array.isArray(described.namespaces)) throw new Error('settings/describe returned an invalid response')
|
|
153
|
+
if (msg.ns !== undefined) {
|
|
154
|
+
const ns = requireName(msg, 'ns', type, requestId)
|
|
155
|
+
if (ns.error) return ns.error
|
|
156
|
+
const namespace = described.namespaces.find(item => item.ns === ns.value)
|
|
157
|
+
if (!namespace) return { kind: 'error', code: 'plugin-settings/not-found',
|
|
158
|
+
message: `settings namespace is unavailable: ${ns.value}`, requestType: type, requestId }
|
|
159
|
+
value = { writable: described.writable === true, namespace }
|
|
160
|
+
} else {
|
|
161
|
+
value = described
|
|
162
|
+
}
|
|
163
|
+
} else if (type === 'plugin-settings-mutate') {
|
|
164
|
+
const ns = requireName(msg, 'ns', type, requestId)
|
|
165
|
+
if (ns.error) return ns.error
|
|
166
|
+
if (!Number.isSafeInteger(msg.expectedRevision) || msg.expectedRevision < 0) {
|
|
167
|
+
return badRequest(type, requestId, 'expectedRevision must be a non-negative integer')
|
|
168
|
+
}
|
|
169
|
+
if (!Array.isArray(msg.ops) || msg.ops.length < 1 || msg.ops.length > 100
|
|
170
|
+
|| msg.ops.some(op => !record(op) || !['set', 'unset'].includes(op.op)
|
|
171
|
+
|| !Array.isArray(op.path) || op.path.some(part => !nonempty(part, 256))
|
|
172
|
+
|| (op.op === 'set' && !Object.hasOwn(op, 'value')))) {
|
|
173
|
+
return badRequest(type, requestId, 'ops must contain valid set or unset path edits')
|
|
174
|
+
}
|
|
175
|
+
value = await api.settings.mutate({ ns: ns.value, ops: msg.ops, expectedRevision: msg.expectedRevision })
|
|
176
|
+
} else if (type === 'plugin-version-exemptions') {
|
|
177
|
+
value = await api.plugins.listVersionExemptions()
|
|
178
|
+
} else if (type === 'plugin-version-exemption-set') {
|
|
179
|
+
const packageVersion = requireName(msg, 'packageVersion', type, requestId)
|
|
180
|
+
const runtimeVersion = requireName(msg, 'runtimeVersion', type, requestId)
|
|
181
|
+
if (packageVersion.error || runtimeVersion.error) return packageVersion.error || runtimeVersion.error
|
|
182
|
+
if (typeof msg.enabled !== 'boolean' || (msg.acceptRisk !== undefined && typeof msg.acceptRisk !== 'boolean')) {
|
|
183
|
+
return badRequest(type, requestId, 'enabled and acceptRisk must be booleans')
|
|
184
|
+
}
|
|
185
|
+
value = await api.plugins.setVersionExemption(packageVersion.value, runtimeVersion.value,
|
|
186
|
+
msg.enabled, msg.acceptRisk)
|
|
187
|
+
}
|
|
188
|
+
return { kind, requestId, ...(FLAT_RESPONSE_TYPES.has(type) && record(value)
|
|
189
|
+
? value : { result: value }) }
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { kind: 'error', code: error?.code || 'internal',
|
|
192
|
+
message: error?.message || String(error), requestType: type, requestId }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Detached handoff process: only launched by the loopback-only management API.
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
import { openSync, closeSync, appendFileSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
const logFile = '/tmp/mobile-gateway.log'
|
|
6
|
+
const note = (message) => {
|
|
7
|
+
try { appendFileSync(logFile, `[${new Date().toISOString()}] web restart: ${message}\n`) } catch {}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const spec = JSON.parse(Buffer.from(process.argv[2] || '', 'base64url').toString())
|
|
12
|
+
if (!Number.isSafeInteger(spec.pid) || spec.pid <= 0 || typeof spec.execPath !== 'string'
|
|
13
|
+
|| !Array.isArray(spec.execArgv) || !Array.isArray(spec.argv) || typeof spec.cwd !== 'string') {
|
|
14
|
+
throw new Error('invalid restart handoff')
|
|
15
|
+
}
|
|
16
|
+
const deadline = Date.now() + 30_000
|
|
17
|
+
while (Date.now() < deadline) {
|
|
18
|
+
try { process.kill(spec.pid, 0) } catch (error) {
|
|
19
|
+
if (error.code === 'ESRCH') break
|
|
20
|
+
throw error
|
|
21
|
+
}
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
process.kill(spec.pid, 0)
|
|
26
|
+
throw new Error('old DSH Web process did not exit within 30 seconds')
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (error.code !== 'ESRCH') throw error
|
|
29
|
+
}
|
|
30
|
+
const fd = openSync(logFile, 'a')
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(spec.execPath, [...spec.execArgv, ...spec.argv], {
|
|
33
|
+
cwd: spec.cwd, env: process.env, detached: true, stdio: ['ignore', fd, fd],
|
|
34
|
+
})
|
|
35
|
+
await new Promise((resolve, reject) => {
|
|
36
|
+
child.once('spawn', resolve)
|
|
37
|
+
child.once('error', reject)
|
|
38
|
+
})
|
|
39
|
+
child.unref()
|
|
40
|
+
note(`started DSH Web pid=${child.pid}`)
|
|
41
|
+
} finally {
|
|
42
|
+
closeSync(fd)
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
note(error?.message || String(error))
|
|
46
|
+
process.exitCode = 1
|
|
47
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-mobile-gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Authenticated mobile gateway for DeepSeek Harness with realtime session sync, device pairing, plugin management, and LAN or Cloudflare Tunnel access",
|
|
5
5
|
"main": "lib/index.mjs",
|
|
6
6
|
"files": [
|
|
7
7
|
"bin",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"bin": "bin/setup-ip.mjs",
|
|
28
28
|
"scripts": {
|
|
29
|
-
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
|
|
29
|
+
"test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/plugin-management.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/cloudflared-binary.test.mjs && node test/cloudflare-tunnel.test.mjs && node test/gateway-tools.test.mjs && node test/multi-gateway.test.mjs"
|
|
30
30
|
},
|
|
31
31
|
"exports": {
|
|
32
32
|
".": "./lib/index.mjs",
|