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.
@@ -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.7.7",
4
- "description": "Mobile gateway for dsh 0.1.5-rc.1/2 with per-session Agent preset selection before the first conversation, automatic preset locking after conversation starts, and live preset updates",
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",