dsh-plugin-mobile-link 0.2.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/lib/state.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Agent identity persistence: `~/.dsh/mobile-link/agent.json`, mode 0600.
3
+ *
4
+ * The file holds the long-lived `agentId` / `agentSecret` for this computer.
5
+ * There are two ways it gets there, and they are deliberately indistinguishable
6
+ * afterwards: the relay operator provisions it by hand (relay/admin.py
7
+ * `agent-register --write-config`), or the person running the computer redeems
8
+ * an invite code themselves (`dsh-mobile-link enroll`, see enroll.js).
9
+ * Environment variables and the plugin config block override the file, which is
10
+ * how the integration test injects a throwaway identity.
11
+ */
12
+
13
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
14
+ import { homedir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+
17
+ import { isPlaceholderRelayUrl } from './dlp.js'
18
+
19
+ /**
20
+ * Raised when no identity exists yet — a *setup* state, not a failure.
21
+ *
22
+ * The live agent treats it differently from a transport error: retrying a
23
+ * missing credential forever would be noise, and the fix is a person running
24
+ * one command, not a reconnect.
25
+ */
26
+ export class MissingIdentity extends Error {
27
+ constructor(message, { stateFile } = {}) {
28
+ super(message)
29
+ this.name = 'MissingIdentity'
30
+ this.stateFile = stateFile
31
+ }
32
+ }
33
+
34
+ /** The environment variable a packaged install can pre-seed with an invite. */
35
+ export const INVITE_ENV = 'DSH_MOBILE_LINK_INVITE'
36
+
37
+ /** What a computer calls itself before its owner renames it. */
38
+ export const DEFAULT_AGENT_NAME = 'My computer'
39
+
40
+ export function dshHome() {
41
+ return process.env.DSH_HOME || join(homedir(), '.dsh')
42
+ }
43
+
44
+ export function defaultStatePath() {
45
+ return join(dshHome(), 'mobile-link', 'agent.json')
46
+ }
47
+
48
+ export function defaultEndpointFile() {
49
+ return join(dshHome(), 'desktop-shell', 'endpoint.json')
50
+ }
51
+
52
+ /**
53
+ * The desktop shell's log, which is the *second* place the live endpoint can be
54
+ * read from.
55
+ *
56
+ * The shell writes `endpoint.json` as a handoff, but it also removes it again
57
+ * (observed after the shell quit and re-attached to an already running `dsh
58
+ * web`), and a machine whose host was started as a bare `dsh web --port 0` only
59
+ * ever sees the URL printed into this log. Both spellings appear here, so the
60
+ * log is a usable last resort — without it the connector sits at the default
61
+ * port with no launch token and every phone request fails.
62
+ */
63
+ export function defaultShellLogFile() {
64
+ return join(dshHome(), 'desktop-shell', 'dsh-shell.log')
65
+ }
66
+
67
+ /** @returns {Promise<Record<string, unknown> | undefined>} */
68
+ export async function readState(path) {
69
+ try {
70
+ const raw = JSON.parse(await readFile(path, 'utf8'))
71
+ return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : undefined
72
+ } catch {
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ /** Write the identity atomically and with mode 0600. */
78
+ export async function writeState(path, state) {
79
+ await mkdir(dirname(path), { recursive: true })
80
+ const temporary = `${path}.${process.pid}.tmp`
81
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
82
+ await rename(temporary, path)
83
+ await chmod(path, 0o600).catch(() => {})
84
+ return path
85
+ }
86
+
87
+ /**
88
+ * Resolve the effective identity from (in order) explicit config, the
89
+ * environment, then the state file. Secrets are never logged.
90
+ *
91
+ * One exception to that order: the repository's placeholder relay address (see
92
+ * `PLACEHOLDER_RELAY_URL`) counts as *unset*, so an address already enrolled in
93
+ * the state file outranks it. Without this, the placeholder — which ships in the
94
+ * plugin config and is what an unconfigured environment falls back to — would
95
+ * beat the real address of a computer that is already enrolled, and the next
96
+ * restart would both dial the placeholder and overwrite the file with it.
97
+ *
98
+ * When the identity comes from config or the environment (rather than the file)
99
+ * and differs from what is on disk, it is persisted to `stateFile` with mode
100
+ * 0600 — that is how a fresh install turns a pasted config into the durable
101
+ * `~/.dsh/mobile-link/agent.json` the spec calls for. A read-only home
102
+ * directory is not fatal: the in-memory identity is still returned.
103
+ */
104
+ export async function resolveIdentity({ stateFile, agentId, agentSecret, relayUrl } = {}) {
105
+ const path = stateFile || defaultStatePath()
106
+ const file = await readState(path)
107
+ const configured = relayUrl || process.env.DSH_MOBILE_LINK_RELAY || ''
108
+ const resolved = {
109
+ stateFile: path,
110
+ relayUrl: isPlaceholderRelayUrl(configured) ? (file?.relayUrl || configured) : (configured || file?.relayUrl || ''),
111
+ agentId: agentId || process.env.DSH_MOBILE_LINK_AGENT_ID || file?.agentId || '',
112
+ agentSecret: agentSecret || process.env.DSH_MOBILE_LINK_AGENT_SECRET || file?.agentSecret || '',
113
+ agentName: file?.agentName || '',
114
+ exists: Boolean(file),
115
+ }
116
+ if (!resolved.agentId || !resolved.agentSecret) {
117
+ throw new MissingIdentity(
118
+ `mobile-link: this computer is not registered yet. Run "dsh-mobile-link enroll`
119
+ + ` --invite <code> --relay <relay-url>" with the invite code you were given, or set`
120
+ + ` ${INVITE_ENV} and restart DSH. (Provisioning by hand is still possible:`
121
+ + ` "python3 relay/admin.py agent-register --account <acc> --name <name>`
122
+ + ` --write-config ${path}".)`,
123
+ { stateFile: path },
124
+ )
125
+ }
126
+ if (!resolved.relayUrl) throw new Error('mobile-link: no relay URL configured')
127
+
128
+ const stale = !file
129
+ || file.agentId !== resolved.agentId
130
+ || file.agentSecret !== resolved.agentSecret
131
+ || file.relayUrl !== resolved.relayUrl
132
+ if (stale) {
133
+ try {
134
+ await writeState(path, {
135
+ relayUrl: resolved.relayUrl,
136
+ agentId: resolved.agentId,
137
+ agentSecret: resolved.agentSecret,
138
+ ...(resolved.agentName ? { agentName: resolved.agentName } : {}),
139
+ })
140
+ resolved.exists = true
141
+ resolved.persisted = true
142
+ } catch (error) {
143
+ resolved.persisted = false
144
+ resolved.persistError = error instanceof Error ? error.message : String(error)
145
+ }
146
+ }
147
+ return resolved
148
+ }
package/lib/ws.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * A tiny WebSocket adapter.
3
+ *
4
+ * DSH runs on modern Node, where `globalThis.WebSocket` exists and accepts a
5
+ * non-standard `{ headers }` option — that is what carries the DLP bearer token
6
+ * without putting it in the URL. When the global is missing we fall back to the
7
+ * `ws` package resolved from the DSH install. Everything above this file only
8
+ * sees `send` / `close` / `onMessage` / `onClose` / `onError`, so a future
9
+ * transport swap stays local.
10
+ */
11
+
12
+ import { createRequire } from 'node:module'
13
+ import { dirname, join } from 'node:path'
14
+ import { homedir } from 'node:os'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ export class SocketError extends Error {}
18
+
19
+ let cachedFactory
20
+
21
+ function candidateAnchors() {
22
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
23
+ const anchors = [import.meta.url]
24
+ anchors.push(join(home, 'profiles', 'web', 'package.json'))
25
+ anchors.push(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'))
26
+ if (process.argv[1]) anchors.push(process.argv[1])
27
+ return anchors
28
+ }
29
+
30
+ /** Resolve a WebSocket constructor, or throw with an actionable message. */
31
+ export function resolveWebSocket() {
32
+ if (cachedFactory) return cachedFactory
33
+ if (typeof globalThis.WebSocket === 'function') {
34
+ cachedFactory = globalThis.WebSocket
35
+ return cachedFactory
36
+ }
37
+ for (const anchor of candidateAnchors()) {
38
+ try {
39
+ const require = createRequire(anchor)
40
+ const loaded = require('ws')
41
+ const Ctor = loaded?.WebSocket ?? loaded
42
+ if (typeof Ctor === 'function') {
43
+ cachedFactory = Ctor
44
+ return cachedFactory
45
+ }
46
+ } catch {
47
+ /* try the next anchor */
48
+ }
49
+ }
50
+ throw new SocketError(
51
+ 'no WebSocket implementation available: DSH mobile-link needs Node >= 22 (global WebSocket) or the `ws` package',
52
+ )
53
+ }
54
+
55
+ function isEventTargetStyle(socket) {
56
+ return typeof socket.addEventListener === 'function' && typeof socket.removeEventListener === 'function'
57
+ }
58
+
59
+ export class WebSocketHandle {
60
+ constructor(socket) {
61
+ this.socket = socket
62
+ this.closed = false
63
+ this.closeCode = undefined
64
+ this.closeReason = undefined
65
+ this._messageHandlers = []
66
+ this._closeHandlers = []
67
+ this._errorHandlers = []
68
+ this._opened = new Promise((resolve, reject) => {
69
+ this._resolveOpen = resolve
70
+ this._rejectOpen = reject
71
+ })
72
+ this._opened.catch(() => {})
73
+ this._attach()
74
+ }
75
+
76
+ _attach() {
77
+ const socket = this.socket
78
+ if (isEventTargetStyle(socket)) {
79
+ socket.addEventListener('open', () => this._resolveOpen())
80
+ socket.addEventListener('message', (event) => {
81
+ const data = event?.data
82
+ if (typeof data === 'string') this._emitMessage(data)
83
+ else if (data instanceof ArrayBuffer) this._emitMessage(Buffer.from(data).toString('utf8'))
84
+ else if (ArrayBuffer.isView(data)) this._emitMessage(Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8'))
85
+ else this._emitMessage(undefined, true)
86
+ })
87
+ socket.addEventListener('close', (event) => this._finish(event?.code, event?.reason))
88
+ socket.addEventListener('error', (event) => this._emitError(event?.error ?? new SocketError('websocket error')))
89
+ return
90
+ }
91
+ socket.on('open', () => this._resolveOpen())
92
+ socket.on('message', (data, isBinary) => {
93
+ if (isBinary) this._emitMessage(undefined, true)
94
+ else if (typeof data === 'string') this._emitMessage(data)
95
+ else this._emitMessage(Buffer.from(data).toString('utf8'))
96
+ })
97
+ socket.on('close', (code, reason) => this._finish(code, reason === undefined ? undefined : String(reason)))
98
+ socket.on('error', (error) => this._emitError(error instanceof Error ? error : new SocketError(String(error))))
99
+ socket.on('unexpected-response', (_request, response) => {
100
+ this._emitError(new SocketError(`relay rejected the upgrade with HTTP ${response?.statusCode}`))
101
+ })
102
+ }
103
+
104
+ _emitMessage(text, binary = false) {
105
+ for (const handler of [...this._messageHandlers]) {
106
+ try {
107
+ handler(text, binary)
108
+ } catch {
109
+ /* a handler must never break the socket loop */
110
+ }
111
+ }
112
+ }
113
+
114
+ _emitError(error) {
115
+ for (const handler of [...this._errorHandlers]) handler(error)
116
+ this._rejectOpen(error)
117
+ }
118
+
119
+ _finish(code, reason) {
120
+ if (this.closed) return
121
+ this.closed = true
122
+ this.closeCode = code
123
+ this.closeReason = reason
124
+ this._rejectOpen(new SocketError(`websocket closed before opening (${code ?? 'unknown'})`))
125
+ for (const handler of [...this._closeHandlers]) handler(code, reason)
126
+ }
127
+
128
+ /** @returns {Promise<void>} resolves once the upgrade completed. */
129
+ opened(timeoutMs = 20000) {
130
+ if (timeoutMs <= 0) return this._opened
131
+ return Promise.race([
132
+ this._opened,
133
+ new Promise((_resolve, reject) => {
134
+ const timer = setTimeout(
135
+ () => reject(new SocketError(`websocket did not open within ${timeoutMs}ms`)),
136
+ timeoutMs,
137
+ )
138
+ timer.unref?.()
139
+ this._opened.then(() => clearTimeout(timer), () => clearTimeout(timer))
140
+ }),
141
+ ])
142
+ }
143
+
144
+ onMessage(handler) {
145
+ this._messageHandlers.push(handler)
146
+ }
147
+
148
+ onClose(handler) {
149
+ this._closeHandlers.push(handler)
150
+ }
151
+
152
+ onError(handler) {
153
+ this._errorHandlers.push(handler)
154
+ }
155
+
156
+ send(text) {
157
+ if (this.closed) throw new SocketError('websocket is closed')
158
+ this.socket.send(text)
159
+ }
160
+
161
+ close(code = 1000, reason = '') {
162
+ this.closed = true
163
+ try {
164
+ this.socket.close(code, reason)
165
+ } catch {
166
+ /* already gone */
167
+ }
168
+ }
169
+ }
170
+
171
+ /** Open a WebSocket and resolve once it is usable. */
172
+ export async function connect(url, { headers = {}, timeoutMs = 20000, impl } = {}) {
173
+ const factory = impl ?? resolveWebSocket()
174
+ let socket
175
+ try {
176
+ socket = new factory(url, { headers })
177
+ } catch (error) {
178
+ throw new SocketError(`cannot open ${url}: ${error instanceof Error ? error.message : error}`)
179
+ }
180
+ const handle = new WebSocketHandle(socket)
181
+ await handle.opened(timeoutMs)
182
+ return handle
183
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "dsh-plugin-mobile-link",
3
+ "version": "0.2.0",
4
+ "description": "Put your PC's DeepSeek Harness in your pocket: dials a relay over WSS so the native iOS client reaches this machine even without a public IP.",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "remote",
10
+ "relay",
11
+ "ios",
12
+ "mobile",
13
+ "websocket"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "jayantTang",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/jayantTang/DSH_Mobile.git",
20
+ "directory": "plugins/mobile-link"
21
+ },
22
+ "homepage": "https://github.com/jayantTang/DSH_Mobile#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/jayantTang/DSH_Mobile/issues"
25
+ },
26
+ "type": "module",
27
+ "main": "lib/index.js",
28
+ "bin": {
29
+ "dsh-mobile-link": "lib/cli.js"
30
+ },
31
+ "exports": {
32
+ ".": "./lib/index.js",
33
+ "./dlp": "./lib/dlp.js",
34
+ "./link": "./lib/link.js",
35
+ "./cordis.patch.yml": "./cordis.patch.yml",
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "lib",
40
+ "cordis.patch.yml",
41
+ "README.md"
42
+ ],
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test test/*.test.js"
48
+ },
49
+ "dsh": {
50
+ "bundle": {
51
+ "patch": "./cordis.patch.yml"
52
+ }
53
+ }
54
+ }