@goodandready/dsh-lanmode 0.1.0 → 0.3.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 CHANGED
@@ -1,77 +1,109 @@
1
1
  # dsh-lanmode
2
2
 
3
- **Settings over the LAN** for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh).
3
+ Open the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI from another device — a phone, a tablet, the laptop on the other side of the room — and have **all of it** work, including the parts the UI switches off when the page is not `localhost`.
4
4
 
5
- Open the Web UI at `http://192.168.1.50:3080` instead of `http://localhost:3080` and every settings card in the deployment goes blank your plugins' cards, and **Settings → Plugins → Plugin configuration** with them. No error appears, the plugins load fine, and reloading does not help. Saving silently does nothing.
5
+ Works whether you put a reverse proxy in front of the harness or let this plugin serve the network itself. Pick a mode, restart, done.
6
6
 
7
- This plugin fixes that. Install it, reload the page, and the settings work from any address.
7
+ ## What breaks without it
8
8
 
9
- ## Why it happens
9
+ Reach the UI at a LAN address and you get some mix of:
10
10
 
11
- The decision is made in the browser, from the page's hostname:
11
+ - **Settings Models**: `settings are unavailable in this browser`
12
+ - **Settings → Plugins → Plugin configuration**: empty, no cards at all
13
+ - every plugin's settings card: blank, and saving silently does nothing
14
+ - on plain HTTP, nothing loads at all: sessions and models never render
12
15
 
13
- ```js
14
- isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname)
15
- ```
16
+ None of that is the server refusing. The server serves settings over the network perfectly well.
16
17
 
17
- `isLoopbackHostname` accepts `localhost`, `[::1]` and `127.0.0.0/8` — nothing else. A page served at a LAN address is therefore "remote", and the settings service switches to a process-local mode where the shared mirror of the settings document is never read at all:
18
+ ## Why it happens
19
+
20
+ Two independent things.
21
+
22
+ **The settings service turns itself off.** The UI decides from the page's hostname:
18
23
 
19
24
  ```js
20
- ensure() { if (this.persistence === "memory") return Promise.resolve() }
21
- status: persistence === "host" ? "loading" : "unavailable"
22
- enqueue() { if (this.persistence === "memory") return Promise.resolve() }
25
+ isLoopback: pageLocation === undefined || isLoopbackHostname(hostname)
23
26
  ```
24
27
 
25
- Every bound namespace reports `status: "unavailable"` for the life of the page, and writes are dropped before they reach the wire.
28
+ `isLoopbackHostname` accepts `localhost`, `[::1]` and `127.0.0.0/8`, nothing else. Anything else puts the settings service into a process-local mode where the shared mirror of the settings document is never read, every namespace reports `status: "unavailable"` for the life of the page, and writes are dropped before they reach the wire.
26
29
 
27
- **The server does not share this restriction.** Both `settings.describe` and `settings.mutate` answer normally over the network as long as the request's `Origin` matches the page it came from which is exactly the case for requests the UI itself makes. Verified against a live harness behind a reverse proxy.
30
+ **Some Web APIs only exist on a secure context.** `crypto.randomUUID` is called on boot-critical paths, and `navigator.clipboard` behind the copy buttons. Over plain HTTP from a LAN address the browser withholds both.
28
31
 
29
32
  ## What the plugin does
30
33
 
31
- On a loopback page: nothing at all the core works there, and a second implementation would only be a second source of truth.
34
+ Everything happens through the web server's official index tap: one script is injected into `index.html` ahead of the boot manifest. No product source is modified, and removing the plugin restores the page exactly.
32
35
 
33
- On any other page it stands up its own copy of the same machinery over the same two calls, and publishes it:
36
+ | Piece | Setting | What it does |
37
+ |---|---|---|
38
+ | Settings | `settings` | Hands the two settings packages a connection whose `isLoopback` reads `true`. Everything downstream — the shared mirror, every namespace scope, the core's own pages — then behaves as it does on localhost. |
39
+ | `crypto.randomUUID` | `randomUuid` | Provides an RFC 4122 v4 implementation over `crypto.getRandomValues`, which insecure origins do have. A no-op where the real one exists. |
40
+ | `navigator.clipboard` | `clipboard` | Provides a `writeText` fallback so the copy buttons keep working. A no-op where the real one exists. |
34
41
 
35
- - as a service named `lanSettings`, for plugins that want to ask for it explicitly;
36
- - and, if the runtime allows a plugin to claim the name, in place of `settingsScope` — which repairs every settings surface at once, including the core's plugin configuration tab and plugins that know nothing about this one.
42
+ The settings substitution is deliberately narrow. Three packages read that flag, and the third is deliverables, where it decides whether a produced file may be opened locally — forcing it there would ask the Host to open paths on the server's desktop. Only the settings packages see the substitute.
37
43
 
38
- The snapshot it hands out has the same shape the core's has — `status`, `value`, `base`, `user`, `revision`, `writable` — so cards cannot tell the difference.
44
+ ## Two modes
39
45
 
40
- ## Install
46
+ ```yaml
47
+ - id: dsh-lanmode
48
+ config:
49
+ mode: proxy # proxy | direct
50
+ ```
41
51
 
42
- ```bash
43
- dsh plugin --profile web add @goodandready/dsh-lanmode
52
+ **`proxy`** (default) — something already listens on the network in front of the harness: nginx, Caddy, Tailscale serve, an SSH tunnel. The plugin only repairs the page and touches nothing else. This is the safe default: it cannot collide with whatever you already run.
53
+
54
+ **`direct`** — no proxy anywhere. The plugin opens a listener of its own and forwards to the harness, rewriting `Host` and `Origin` to the loopback authority so the harness's same-origin fence is satisfied:
55
+
56
+ ```yaml
57
+ - id: dsh-lanmode
58
+ config:
59
+ mode: direct
60
+ directHost: '0.0.0.0' # every interface
61
+ directPort: 3088
44
62
  ```
45
63
 
46
- Restart the Web UI afterwards, then reload the browser.
64
+ Then open `http://<the machine's IP>:3088` from any device on the network.
47
65
 
48
- ## Checking it on a machine where it is not needed
66
+ A listener rather than rebinding the harness itself, for two reasons: a bind host lives in the config tree and cannot be a switch inside the plugin, and rebinding to `0.0.0.0` collides with a reverse proxy already holding that port.
49
67
 
50
- The plugin stands aside on a loopback page, which makes it awkward to try out
51
- on the machine that runs the harness. A debug switch turns it on there anyway:
68
+ Changing the mode takes effect on restart.
52
69
 
53
- ```js
54
- localStorage.setItem('dsh-lanmode:force', '1'); location.reload()
55
- ```
70
+ ## Settings
71
+
72
+ All of it can be edited as the `dsh-lanmode` namespace — in `$DSH_HOME/settings.yaml`, or from the UI once the settings pages work.
73
+
74
+ | Setting | Default | Meaning |
75
+ |---|---|---|
76
+ | `mode` | `proxy` | `proxy` or `direct` |
77
+ | `directHost` | `0.0.0.0` | `direct`: which address to listen on |
78
+ | `directPort` | `3088` | `direct`: which port to listen on |
79
+ | `settings` | `true` | return the settings service |
80
+ | `randomUuid` | `true` | provide `crypto.randomUUID` on plain HTTP |
81
+ | `clipboard` | `true` | provide a clipboard fallback on plain HTTP |
82
+
83
+ ## Checking it
56
84
 
57
- Remove the key to go back to normal. This is only for looking at the plugin
58
- itself; nothing in day-to-day use needs it.
85
+ - `?lanmode=invert` — report the loopback flag as `false` even on localhost. The harness then fails exactly the way it does over the LAN; this is how the plugin is verified.
86
+ - `?lanmode=off` stand the plugin down entirely, to see the page as it would be without it.
87
+
88
+ On a loopback page the plugin substitutes `true` where the real value is already `true`, so it cannot change behaviour there.
59
89
 
60
90
  ## What it is not
61
91
 
62
- Not authentication. The plugin does not add a password and does not widen what the server accepts — the harness answers those same calls with or without it. If your harness is reachable by other people, put a real gate in front of it (HTTP auth in your reverse proxy, or a VPN); a plugin cannot do that job, because the web server service hands plugins their own routes and no way to intercept anyone else's.
92
+ **Not authentication.** It adds no password and does not widen what the server accepts — the harness answers those same calls with or without it. `direct` mode does make the harness reachable by anyone who can reach that port, exactly as a reverse proxy would. If other people share your network, put a real gate in front of the harness. A plugin cannot do that job: the web server hands plugins their own routes and no way to intercept anyone else's.
63
93
 
64
- ## Structure
94
+ **No microphone over plain HTTP.** Browsers withhold `navigator.mediaDevices` outside a secure context, so voice input plugins stop working on a plain-HTTP LAN address. Nothing can polyfill that. If you need the microphone, keep HTTPS in front and use `proxy` mode.
65
95
 
96
+ ## Install
97
+
98
+ ```bash
99
+ dsh plugin --profile web add @goodandready/dsh-lanmode
66
100
  ```
67
- dsh-lanmode/
68
- ├── package.json # dsh bundle/plugin metadata
69
- ├── cordis.patch.yml # bundle layer: inserts the plugin row
70
- ├── lib/index.js # host: nothing but a log line — the work is in the browser
71
- ├── lib/client.js # browser: the settings mirror and namespace scopes
72
- ├── README.md
73
- └── LICENSE # MIT
74
- ```
101
+
102
+ Restart the harness, then reload the browser.
103
+
104
+ ## Credit
105
+
106
+ The index-tap approach and the `crypto.randomUUID` polyfill come from [dsh-web-lan-access](https://github.com/AcidGr/dsh-web-lan-access) (MIT), which solves the secure-context half of this problem. Several other plugins in the same space — `dsh-lan-access`, `dsh-lan`, `dsh-LAN`, `dsh-lan-gate`, `dsh-Remote` — cover binding, mobile layout and device approval; none of them return the settings service, which is what this one is for.
75
107
 
76
108
  ## License
77
109
 
package/cordis.patch.yml CHANGED
@@ -1,10 +1,22 @@
1
1
  # dsh-lanmode bundle layer: applied automatically when the package is installed
2
2
  # as a profile bundle (package.json declares dsh.bundle.patch).
3
3
  #
4
- # `name` must stay the full npm package name: the client-modules registry
5
- # resolves the browser bundle by the loader entry name, so a shortened name
6
- # leaves the UI half silently out of window.__DSH_BOOT__ — and the UI half is
7
- # the whole plugin here.
4
+ # `name` must stay the full npm package name: the loader entry name is how the
5
+ # served bundle is resolved.
6
+ #
7
+ # Everything is configurable in the `dsh-lanmode` settings namespace, so the
8
+ # row below stays empty. To pin the mode in the tree instead of the settings
9
+ # document, override it in your own profile patch:
10
+ #
11
+ # - id: dsh-lanmode
12
+ # config:
13
+ # mode: direct
14
+ # directPort: 3088
15
+ #
16
+ # Note there is deliberately no webserver override here. Other plugins in this
17
+ # space rebind the harness to 0.0.0.0; that cannot be switched from inside a
18
+ # plugin and collides with a reverse proxy already holding the port. Direct
19
+ # mode opens a listener of its own instead.
8
20
  - insert:
9
21
  - id: dsh-lanmode
10
22
  name: '@goodandready/dsh-lanmode'
package/lib/bridge.js ADDED
@@ -0,0 +1,92 @@
1
+ // Прямой режим: слушатель на сетевом адресе, который передаёт всё харнессу.
2
+ //
3
+ // Зачем не привязка харнесса к 0.0.0.0, как делают соседние плагины. Привязка
4
+ // задаётся в дереве конфигурации и меняется только перезапуском, то есть
5
+ // «переключателем в плагине» быть не может. Хуже того, если перед харнессом
6
+ // уже стоит обратный прокси на том же порту, привязка столкнётся с ним лбами.
7
+ // Отдельный слушатель включается и гасится вместе со строкой плагина и живёт
8
+ // рядом с любым прокси.
9
+ //
10
+ // Заголовки Host и Origin переписываются на локальные: харнесс пропускает
11
+ // запрос, только когда Origin совпадает с адресом, по которому он слушает.
12
+ // Это ровно то, что делает любой обратный прокси, и это же означает, что
13
+ // прямой режим открывает харнесс всем, кто дотянется до этого порта, — как и
14
+ // nginx сегодня. Пароля здесь нет и быть не может: плагин чужие маршруты не
15
+ // перехватывает.
16
+
17
+ import http from 'node:http'
18
+
19
+ function rewritten(headers, authority) {
20
+ const out = { ...headers, host: authority }
21
+ if (out.origin) out.origin = 'http://' + authority
22
+ if (out.referer) out.referer = String(out.referer).replace(/^https?:\/\/[^/]+/, 'http://' + authority)
23
+ return out
24
+ }
25
+
26
+ /**
27
+ * @param ctx контекст плагина (нужен ctx.webServer.port)
28
+ * @param options {{host: string, port: number, log: (message: string) => void}}
29
+ * @returns функция остановки
30
+ */
31
+ export function startDirectBridge(ctx, options) {
32
+ const upstreamPort = ctx.webServer.port
33
+ if (!upstreamPort) {
34
+ options.log('прямой режим не поднят: веб-сервер ещё не сообщил порт')
35
+ return () => {}
36
+ }
37
+ const authority = '127.0.0.1:' + upstreamPort
38
+
39
+ const bridge = http.createServer((req, res) => {
40
+ const upstream = http.request({
41
+ host: '127.0.0.1',
42
+ port: upstreamPort,
43
+ method: req.method,
44
+ path: req.url,
45
+ headers: rewritten(req.headers, authority),
46
+ }, (answer) => {
47
+ res.writeHead(answer.statusCode || 502, answer.headers)
48
+ answer.pipe(res)
49
+ })
50
+ upstream.on('error', () => {
51
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
52
+ res.end('dsh-lanmode: харнесс не отвечает')
53
+ })
54
+ req.pipe(upstream)
55
+ })
56
+
57
+ // Веб-сокеты интерфейса идут через Upgrade: их надо передать сырыми.
58
+ bridge.on('upgrade', (req, socket, head) => {
59
+ const upstream = http.request({
60
+ host: '127.0.0.1',
61
+ port: upstreamPort,
62
+ method: req.method,
63
+ path: req.url,
64
+ headers: rewritten(req.headers, authority),
65
+ })
66
+ upstream.on('upgrade', (answer, upstreamSocket, upstreamHead) => {
67
+ const lines = ['HTTP/1.1 101 Switching Protocols']
68
+ for (const [key, value] of Object.entries(answer.headers)) lines.push(key + ': ' + value)
69
+ socket.write(lines.join('\r\n') + '\r\n\r\n')
70
+ if (upstreamHead && upstreamHead.length) socket.unshift(upstreamHead)
71
+ upstreamSocket.pipe(socket)
72
+ socket.pipe(upstreamSocket)
73
+ const drop = () => { try { upstreamSocket.destroy() } catch (already) { /* уже мертво */ } }
74
+ socket.on('error', drop)
75
+ socket.on('close', drop)
76
+ })
77
+ upstream.on('error', () => { try { socket.destroy() } catch (already) { /* уже мертво */ } })
78
+ if (head && head.length) upstream.write(head)
79
+ upstream.end()
80
+ })
81
+
82
+ bridge.on('error', (failure) => {
83
+ options.log('прямой режим не поднялся: ' + String(failure && failure.message || failure))
84
+ })
85
+
86
+ bridge.listen(options.port, options.host, () => {
87
+ options.log('прямой режим: слушаю ' + options.host + ':' + options.port
88
+ + ', передаю на ' + authority)
89
+ })
90
+
91
+ return () => { bridge.close() }
92
+ }
package/lib/index.js CHANGED
@@ -1,20 +1,142 @@
1
1
  // dsh-lanmode — хост-половина.
2
2
  //
3
- // Работа плагина целиком в браузере: ограничение, которое он снимает, живёт
4
- // в клиентском коде харнесса, а сервер и без него отдаёт настройки по сети.
5
- // Хост-половина существует потому, что запись в дереве плагинов — это модуль
6
- // на хосте; она ничего не регистрирует и ничего не слушает.
3
+ // Плагин возвращает веб-интерфейсу то, что он сам себе запрещает на странице,
4
+ // открытой не с localhost. Три независимых куска, каждый выключается отдельно.
7
5
  //
8
- // Единственное, что здесь есть, строчка в журнале при запуске, чтобы по
9
- // логу было видно, что плагин установлен.
6
+ // 1. Настройки. Интерфейс решает по имени хоста страницы:
7
+ //
8
+ // isLoopback: pageLocation === undefined || isLoopbackHostname(hostname)
9
+ //
10
+ // и своими считает только localhost, [::1] и 127.0.0.0/8. Дальше сервис
11
+ // настроек уходит в режим «памяти»: общий вид документа не читается никогда,
12
+ // каждый раздел получает статус "unavailable", запись выбрасывается до
13
+ // отправки. Наружу это выглядит как пустые карточки всех плагинов, пустая
14
+ // вкладка «Настройки плагинов» и страница «Модели» с надписью
15
+ // "settings are unavailable in this browser". Сервер это ограничение не
16
+ // разделяет: describe и mutate по сети отвечают штатно.
17
+ //
18
+ // 2. crypto.randomUUID. Существует только на защищённом соединении, а
19
+ // интерфейс зовёт его на пути загрузки. На чистом HTTP по сетевому адресу
20
+ // его нет, и каждый вызов к серверу падает. Приём и заготовка заплатки
21
+ // взяты у dsh-web-lan-access (AcidGr), MIT.
22
+ //
23
+ // 3. navigator.clipboard. Тоже только для защищённого соединения — без него
24
+ // кнопки «Копировать» молчат. Подставляем запасной путь.
25
+ //
26
+ // Всё это вставляется в index.html через официальную точку webServer.tapIndex.
27
+ //
28
+ // Чего здесь намеренно НЕТ — привязки к 0.0.0.0. Она нужна тем, кто ходит на
29
+ // харнесс напрямую, и ставится отдельным слоем в cordis.patch.yml профиля
30
+ // (образец — в нашем cordis.patch.yml). Включать её вслепую нельзя: если
31
+ // перед харнессом стоит обратный прокси на том же порту, они столкнутся.
32
+
33
+ import z from '@deepseek-ai/schemastery'
34
+ import { readFileSync } from 'node:fs'
35
+ import path from 'node:path'
36
+ import { fileURLToPath } from 'node:url'
37
+ import { startDirectBridge } from './bridge.js'
10
38
 
11
39
  export const name = 'dsh-lanmode'
12
- export const inject = []
40
+ export const inject = ['webServer']
41
+
42
+ export const Config = z.object({
43
+ mode: z
44
+ .string()
45
+ .description('How the browser reaches the harness. '
46
+ + '"proxy": something in front of it already listens on the network (nginx and friends) — '
47
+ + 'the plugin only repairs the page. '
48
+ + '"direct": the plugin also opens a listener of its own on the network and forwards to the '
49
+ + 'harness, so nothing else is needed.')
50
+ .default('proxy'),
51
+ directHost: z
52
+ .string()
53
+ .description('mode=direct: which address to listen on. 0.0.0.0 means every interface.')
54
+ .default('0.0.0.0'),
55
+ directPort: z
56
+ .number()
57
+ .description('mode=direct: which port to listen on. Keep it clear of whatever else is running.')
58
+ .default(3088),
59
+ settings: z
60
+ .boolean()
61
+ .description('Return the settings service on pages that are not localhost. '
62
+ + 'This is the part no other plugin does.')
63
+ .default(true),
64
+ randomUuid: z
65
+ .boolean()
66
+ .description('Provide crypto.randomUUID where the browser withholds it (plain HTTP). '
67
+ + 'A no-op on HTTPS and on localhost.')
68
+ .default(true),
69
+ clipboard: z
70
+ .boolean()
71
+ .description('Provide a fallback for navigator.clipboard.writeText on plain HTTP, '
72
+ + 'so the copy buttons keep working. A no-op where the real one exists.')
73
+ .default(true),
74
+ })
75
+
76
+ const here = path.dirname(fileURLToPath(import.meta.url))
13
77
 
14
- export function apply(ctx) {
78
+ /** Заплатка лежит рядом обычным файлом: так её видно и правится она как код. */
79
+ function shimSource() {
80
+ return readFileSync(path.join(here, 'shim.js'), 'utf8')
81
+ }
82
+
83
+ /** Namespace, который плагин объявляет: через него режим правится настройками. */
84
+ const NS = 'dsh-lanmode'
85
+
86
+ export function apply(ctx, config) {
87
+ // Значения берём из сервиса настроек, если он есть: тогда режим правится
88
+ // в настройках, а не только в дереве плагинов. Смена режима требует
89
+ // перезапуска — слушатель поднимается один раз при старте.
90
+ ctx.inject(['settings'], (sctx) => {
91
+ let effective = config
92
+ try {
93
+ const scope = sctx.settings.register(NS, Config, { base: config })
94
+ effective = scope.get() ?? config
95
+ } catch (alreadyRegistered) {
96
+ effective = config
97
+ }
98
+ start(sctx, effective)
99
+ })
100
+
101
+ // Без сервиса настроек тоже должно работать — тогда только дерево плагинов.
15
102
  ctx.effect(() => {
16
- // eslint-disable-next-line no-console
17
- console.info('[dsh-lanmode] установлен: настройки будут работать и на страницах, открытых не с localhost')
103
+ if (ctx.get && ctx.get('settings')) return () => {}
104
+ start(ctx, config)
18
105
  return () => {}
19
- }, 'dsh-lanmode: отметка о запуске')
106
+ }, 'dsh-lanmode: запуск без сервиса настроек')
107
+ }
108
+
109
+ function start(ctx, config) {
110
+ // Прямой режим: свой слушатель на сетевом адресе. В режиме прокси не
111
+ // поднимаем ничего — сеть уже обслуживает кто-то другой.
112
+ if (config.mode === 'direct') {
113
+ ctx.effect(() => startDirectBridge(ctx, {
114
+ host: config.directHost || '0.0.0.0',
115
+ port: config.directPort || 3088,
116
+ // eslint-disable-next-line no-console
117
+ log: (message) => console.info('[dsh-lanmode] ' + message),
118
+ }), 'dsh-lanmode: слушатель прямого режима')
119
+ }
120
+
121
+ const pieces = {
122
+ settings: config.settings !== false,
123
+ randomUuid: config.randomUuid !== false,
124
+ clipboard: config.clipboard !== false,
125
+ }
126
+ // Ничего не включено — и вставлять нечего.
127
+ if (!pieces.settings && !pieces.randomUuid && !pieces.clipboard) return
128
+
129
+ const script = '<script data-dsh-lanmode="1">'
130
+ + 'window.__DSH_LANMODE__=' + JSON.stringify(pieces) + ';'
131
+ + shimSource()
132
+ + '</script>'
133
+
134
+ ctx.effect(() => ctx.webServer.tapIndex((html) => {
135
+ if (html.includes('data-dsh-lanmode')) return html
136
+ // Сразу за <head>: заплатка должна отработать раньше всего остального,
137
+ // иначе заглушка приедет после первого же вызова, который её ждёт.
138
+ const at = html.indexOf('<head>')
139
+ if (at === -1) return script + html
140
+ return html.slice(0, at + '<head>'.length) + script + html.slice(at + '<head>'.length)
141
+ }), 'dsh-lanmode: вставка заплатки в index.html')
20
142
  }
package/lib/shim.js ADDED
@@ -0,0 +1,173 @@
1
+ (function () {
2
+ // Заплатка выполняется первой на странице, до загрузки чего бы то ни было.
3
+ // Что включено — решает хост-половина, значения приезжают в __DSH_LANMODE__.
4
+ var options = window.__DSH_LANMODE__ || {}
5
+
6
+ // Отладочные переключатели в адресной строке:
7
+ // ?lanmode=off — не вмешиваться совсем (посмотреть, как без плагина);
8
+ // ?lanmode=invert — притвориться чужой страницей на своей же
9
+ // (так проверяется, что заплатка правда управляет флагом).
10
+ var mode = ''
11
+ try { mode = new URLSearchParams(location.search).get('lanmode') || '' } catch (noSearch) { mode = '' }
12
+ if (mode === 'off') return
13
+ var loopbackAnswer = mode !== 'invert'
14
+
15
+ // ------------------------------------------------------- crypto.randomUUID
16
+ //
17
+ // Приём и заготовка — из dsh-web-lan-access (AcidGr), MIT. Существует только
18
+ // на защищённом соединении, а интерфейс зовёт его при загрузке: на чистом
19
+ // HTTP по сетевому адресу без этого не поднимается вообще ничего.
20
+ if (options.randomUuid) {
21
+ var crypto_ = globalThis.crypto
22
+ if (!crypto_) { try { crypto_ = globalThis.crypto = {} } catch (frozen) { crypto_ = null } }
23
+ if (crypto_ && typeof crypto_.randomUUID !== 'function' && typeof crypto_.getRandomValues === 'function') {
24
+ crypto_.randomUUID = function randomUUID() {
25
+ var bytes = new Uint8Array(16)
26
+ crypto_.getRandomValues(bytes)
27
+ bytes[6] = (bytes[6] & 15) | 64
28
+ bytes[8] = (bytes[8] & 63) | 128
29
+ var hex = ''
30
+ for (var i = 0; i < 16; i++) hex += bytes[i].toString(16).padStart(2, '0')
31
+ return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16)
32
+ + '-' + hex.slice(16, 20) + '-' + hex.slice(20)
33
+ }
34
+ }
35
+ }
36
+
37
+ // ------------------------------------------------------ navigator.clipboard
38
+ //
39
+ // Тоже только для защищённого соединения. Без него кнопки «Копировать»
40
+ // молча ничего не делают; подставляем старый способ через скрытое поле.
41
+ if (options.clipboard) {
42
+ var nav = window.navigator
43
+ if (nav && (!nav.clipboard || typeof nav.clipboard.writeText !== 'function')) {
44
+ var writeText = function (text) {
45
+ return new Promise(function (resolve, reject) {
46
+ try {
47
+ var area = document.createElement('textarea')
48
+ area.value = String(text)
49
+ area.setAttribute('readonly', '')
50
+ area.style.position = 'fixed'
51
+ area.style.opacity = '0'
52
+ document.body.appendChild(area)
53
+ area.select()
54
+ var copied = document.execCommand('copy')
55
+ document.body.removeChild(area)
56
+ copied ? resolve() : reject(new Error('copy rejected'))
57
+ } catch (failure) { reject(failure) }
58
+ })
59
+ }
60
+ try {
61
+ if (nav.clipboard) nav.clipboard.writeText = writeText
62
+ else Object.defineProperty(nav, 'clipboard', { configurable: true, value: { writeText: writeText } })
63
+ } catch (cannotDefine) { /* останется как было */ }
64
+ }
65
+ }
66
+
67
+ // ------------------------------------------------------------- настройки
68
+ if (!options.settings) return
69
+
70
+ // Флаг читают трое, и третьему подменять нельзя: в результатах работы он
71
+ // решает, можно ли открыть файл локально, и с подменой браузер просил бы
72
+ // открыть путь на машине сервера. Поэтому — только два настроечных пакета.
73
+ var TARGETS = [
74
+ '@deepseek-ai/dsh-client-ui-settings',
75
+ '@deepseek-ai/dsh-client-ui-settings-general',
76
+ ]
77
+
78
+ function connectionWithForcedFlag(connection) {
79
+ if (!connection || typeof connection !== 'object') return connection
80
+ return new Proxy(connection, {
81
+ get: function (target, prop) {
82
+ if (prop === 'isLoopback') return loopbackAnswer
83
+ var value = Reflect.get(target, prop, target)
84
+ return typeof value === 'function' ? value.bind(target) : value
85
+ },
86
+ })
87
+ }
88
+
89
+ function ctxWithForcedConnection(ctx) {
90
+ return new Proxy(ctx, {
91
+ get: function (target, prop) {
92
+ if (prop === 'get') {
93
+ return function (nameRequested) {
94
+ var value = target.get(nameRequested)
95
+ return nameRequested === 'connection' ? connectionWithForcedFlag(value) : value
96
+ }
97
+ }
98
+ if (prop === 'connection') return connectionWithForcedFlag(Reflect.get(target, prop, target))
99
+ var value = Reflect.get(target, prop, target)
100
+ return typeof value === 'function' ? value.bind(target) : value
101
+ },
102
+ })
103
+ }
104
+
105
+ function wrap(registration) {
106
+ if (!registration || TARGETS.indexOf(registration.id) === -1) return registration
107
+ if (typeof registration.factory !== 'function') return registration
108
+ var factory = registration.factory
109
+ var patched = {}
110
+ for (var key in registration) patched[key] = registration[key]
111
+ patched.factory = function (requireFn) {
112
+ var moduleExports = factory(requireFn)
113
+ if (!moduleExports || typeof moduleExports.apply !== 'function') return moduleExports
114
+ var originalApply = moduleExports.apply
115
+ moduleExports.apply = function (ctx) {
116
+ return originalApply.call(this, ctxWithForcedConnection(ctx))
117
+ }
118
+ return moduleExports
119
+ }
120
+ return patched
121
+ }
122
+
123
+ // Загрузчик подменяет собственный load, когда переходит из режима очереди в
124
+ // рабочий, — обёртка при этом слетала бы. Поэтому ставим её через
125
+ // свойство-аксессор: любая новая функция оборачивается снова.
126
+ function install(loader) {
127
+ if (!loader || typeof loader !== 'object') return loader
128
+ if (loader.load && loader.load.__dshLanmode) return loader
129
+ var wrapped
130
+
131
+ function rewrap(fn) {
132
+ if (typeof fn !== 'function' || fn.__dshLanmode) { wrapped = fn; return }
133
+ var inner = fn
134
+ wrapped = function (registration) { return inner.call(this, wrap(registration)) }
135
+ wrapped.__dshLanmode = true
136
+ }
137
+
138
+ rewrap(loader.load)
139
+ try {
140
+ Object.defineProperty(loader, 'load', {
141
+ configurable: true,
142
+ get: function () { return wrapped },
143
+ set: function (fn) { rewrap(fn) },
144
+ })
145
+ } catch (cannotDefine) {
146
+ loader.load = wrapped
147
+ }
148
+
149
+ // Уже поставленные в очередь до нас — тоже наши.
150
+ if (Array.isArray(loader.pendingQueue)) {
151
+ for (var i = 0; i < loader.pendingQueue.length; i++) {
152
+ loader.pendingQueue[i] = wrap(loader.pendingQueue[i])
153
+ }
154
+ }
155
+ return loader
156
+ }
157
+
158
+ if (window.__ModuleLoader__) {
159
+ install(window.__ModuleLoader__)
160
+ return
161
+ }
162
+ var held
163
+ try {
164
+ Object.defineProperty(window, '__ModuleLoader__', {
165
+ configurable: true,
166
+ get: function () { return held },
167
+ set: function (value) { held = install(value) },
168
+ })
169
+ } catch (cannotDefine) {
170
+ // Свойство неподатливо — молчим: страница останется такой же, какой была
171
+ // без плагина.
172
+ }
173
+ })()
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-lanmode",
3
- "version": "0.1.0",
4
- "description": "Settings over the LAN for DeepSeek Harness: the Web UI turns the settings service off on any page that is not localhost, even though the server serves settings over the network just fine. This plugin brings them back.",
3
+ "version": "0.3.0",
4
+ "description": "LAN and reverse-proxy access for the DeepSeek Harness Web UI: returns the settings service on pages that are not localhost, fills in the Web APIs the browser withholds on plain HTTP, and can open a listener of its own so nothing else is needed.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
8
8
  "exports": {
9
9
  ".": "./lib/index.js",
10
- "./client": "./lib/client.js",
11
10
  "./package.json": "./package.json",
12
11
  "./cordis.patch.yml": "./cordis.patch.yml"
13
12
  },
@@ -23,7 +22,9 @@
23
22
  "deepseek-harness",
24
23
  "settings",
25
24
  "lan",
26
- "remote"
25
+ "remote",
26
+ "proxy",
27
+ "nginx"
27
28
  ],
28
29
  "repository": {
29
30
  "type": "git",
@@ -39,15 +40,10 @@
39
40
  "dsh": {
40
41
  "bundle": {
41
42
  "patch": "./cordis.patch.yml"
42
- },
43
- "client": {
44
- "platform": "web",
45
- "inject": [
46
- "@deepseek-ai/dsh-client-runtime"
47
- ]
48
43
  }
49
44
  },
50
45
  "peerDependencies": {
51
- "@deepseek-ai/cordis": "^4.0.1"
46
+ "@deepseek-ai/cordis": "^4.0.1",
47
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6"
52
48
  }
53
49
  }
package/lib/client.js DELETED
@@ -1,253 +0,0 @@
1
- // dsh-lanmode — клиентская половина.
2
- //
3
- // Зачем это существует.
4
- //
5
- // Веб-интерфейс харнесса отключает настройки на любой странице, открытой не с
6
- // localhost. Решение принимается в браузере по имени хоста:
7
- //
8
- // isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname)
9
- //
10
- // и дальше сервис настроек уходит в режим «памяти»: общее зеркало документа
11
- // никогда не читается, каждый связанный раздел сразу получает статус
12
- // "unavailable", а запись молча выбрасывается. Внешне это выглядит как пустые
13
- // карточки настроек у всех плагинов сразу и пустая вкладка «Настройки
14
- // плагинов» — при полностью исправном сервере.
15
- //
16
- // Сервер этого ограничения не разделяет: и чтение (settings.describe), и
17
- // запись (settings.mutate) по сети работают штатно, если Origin совпадает с
18
- // адресом страницы — то есть ровно в том случае, когда запрос шлёт сам
19
- // интерфейс. Проверено на живом харнессе через обратный прокси.
20
- //
21
- // Поэтому плагин делает одно: на не-loopback странице поднимает собственный
22
- // экземпляр той же механики поверх тех же вызовов и отдаёт его как службу.
23
- // На loopback-странице он не делает ничего — там ядро справляется само.
24
-
25
- window.__ModuleLoader__.load({
26
- id: '@goodandready/dsh-lanmode',
27
- factory: (require) => {
28
- var module = { exports: {} }
29
- var exports = module.exports
30
-
31
- // ------------------------------------------------- зеркало документа
32
- //
33
- // Один читатель settings.describe на всю страницу: разделы выводятся из
34
- // него, поэтому они не могут разойтись во мнении о документе.
35
- function createMirror(api) {
36
- let snapshot = { status: 'idle', view: undefined, error: null }
37
- const listeners = new Set()
38
- let inFlight
39
- let rerun = false
40
-
41
- const notify = () => {
42
- for (const listener of [...listeners]) {
43
- try { listener() } catch (listenerFailure) { /* чужой слушатель нам не судья */ }
44
- }
45
- }
46
- const put = (next) => { snapshot = next; notify() }
47
-
48
- async function run() {
49
- do {
50
- rerun = false
51
- let outcome
52
- try {
53
- const response = await api.settings.describe({})
54
- outcome = response.result.ok
55
- ? { view: response.result.value }
56
- : { failure: response.result.error.message }
57
- } catch (wireFailure) {
58
- outcome = { failure: String(wireFailure && wireFailure.message || wireFailure) }
59
- }
60
- if (outcome.view !== undefined) {
61
- put({ status: 'ready', view: outcome.view, error: null })
62
- } else {
63
- // Держим то, что уже прочитали: неудачное обновление не должно
64
- // опустошать готовые разделы.
65
- put({ status: snapshot.view === undefined ? 'idle' : 'ready', view: snapshot.view, error: outcome.failure })
66
- }
67
- } while (rerun)
68
- }
69
-
70
- return {
71
- getSnapshot: () => snapshot,
72
- subscribe: (listener) => {
73
- listeners.add(listener)
74
- return () => { listeners.delete(listener) }
75
- },
76
- load() {
77
- if (inFlight !== undefined) { rerun = true; return inFlight }
78
- inFlight = run().finally(() => { inFlight = undefined })
79
- return inFlight
80
- },
81
- ensure() {
82
- if (inFlight !== undefined) return inFlight
83
- if (snapshot.status === 'idle') return this.load()
84
- return Promise.resolve()
85
- },
86
- // Ответ на запись возвращает свежий вид одного раздела — вкладываем его
87
- // на место, чтобы не перечитывать весь документ ради одного поля.
88
- acceptView(view) {
89
- const before = snapshot
90
- if (before.view === undefined) { this.load(); return }
91
- const known = before.view.namespaces.some((row) => row.ns === view.ns)
92
- const namespaces = known
93
- ? before.view.namespaces.map((row) => (row.ns === view.ns ? view : row))
94
- : before.view.namespaces.concat([view])
95
- put({ ...before, view: { ...before.view, namespaces } })
96
- },
97
- }
98
- }
99
-
100
- // ------------------------------------------------------- раздел настроек
101
- //
102
- // Снимок повторяет форму ядрового: status, value, base, user, revision,
103
- // writable. Карточки читают именно эти поля, поэтому подмена для них
104
- // незаметна.
105
- function createScope(api, mirror, namespace) {
106
- let snapshot = {
107
- status: 'loading',
108
- value: undefined,
109
- base: undefined,
110
- user: undefined,
111
- revision: undefined,
112
- writable: false,
113
- mode: 'host',
114
- }
115
- const listeners = new Set()
116
- const notify = () => {
117
- for (const listener of [...listeners]) {
118
- try { listener() } catch (listenerFailure) { /* см. выше */ }
119
- }
120
- }
121
-
122
- function derive() {
123
- const held = mirror.getSnapshot()
124
- if (held.view === undefined) return
125
- const row = held.view.namespaces.find((candidate) => candidate.ns === namespace)
126
- if (row === undefined) {
127
- // Хост про такой раздел не знает. Это законное состояние: плагин
128
- // может быть ещё не применён.
129
- snapshot = { ...snapshot, status: 'unavailable', writable: held.view.writable }
130
- notify()
131
- return
132
- }
133
- snapshot = {
134
- status: 'ready',
135
- value: row.value,
136
- base: row.base,
137
- user: row.user,
138
- revision: row.revision,
139
- writable: held.view.writable,
140
- mode: 'host',
141
- }
142
- notify()
143
- }
144
-
145
- const off = mirror.subscribe(derive)
146
- derive()
147
-
148
- // Записи выстраиваем в очередь: правка ревизии, пришедшая из ответа,
149
- // должна попасть в следующий запрос, иначе хост отвергнет его как
150
- // устаревший.
151
- let tail = Promise.resolve()
152
- function write(op) {
153
- const task = tail.then(async () => {
154
- const revision = snapshot.revision
155
- let response
156
- try {
157
- response = await api.settings.mutate({
158
- ns: namespace,
159
- ops: [op],
160
- ...(revision === undefined ? {} : { expectedRevision: revision }),
161
- })
162
- } catch (wireFailure) {
163
- await mirror.load()
164
- throw wireFailure
165
- }
166
- if (!response.result.ok) {
167
- // Чаще всего это разошедшаяся ревизия: перечитываем и отдаём
168
- // ошибку наверх, чтобы карточка показала неудачу сохранения.
169
- await mirror.load()
170
- throw new Error(response.result.error.message)
171
- }
172
- mirror.acceptView(response.result.value)
173
- })
174
- tail = task.catch(() => {})
175
- return task
176
- }
177
-
178
- return {
179
- getSnapshot: () => snapshot,
180
- subscribe: (listener) => {
181
- listeners.add(listener)
182
- return () => { listeners.delete(listener) }
183
- },
184
- set: (field, value) => write({ op: 'set', path: [field], value }),
185
- unset: (field) => write({ op: 'unset', path: [field] }),
186
- dispose: () => { off() },
187
- }
188
- }
189
-
190
- exports.inject = ['connection']
191
-
192
- // Отладочный переключатель: включает режим и на loopback-странице, где
193
- // ядро и без нас справляется. Нужен, чтобы проверить сам плагин там, где
194
- // это удобно сделать:
195
- //
196
- // localStorage.setItem('dsh-lanmode:force', '1'); location.reload()
197
- //
198
- // В обычной работе не нужен и по умолчанию выключен.
199
- function forcedOn() {
200
- try { return window.localStorage.getItem('dsh-lanmode:force') === '1' }
201
- catch (noStorage) { return false }
202
- }
203
-
204
- exports.apply = function apply(ctx) {
205
- const connection = ctx.get('connection')
206
-
207
- // На loopback-странице ядро работает штатно, и вмешиваться незачем.
208
- if (connection.isLoopback && !forcedOn()) return
209
-
210
- const mirror = createMirror(connection.api)
211
- const binder = {
212
- bind: (spec) => createScope(connection.api, mirror, spec && spec.namespace),
213
- describe: () => mirror,
214
- }
215
-
216
- // Документ меняют не только из этой вкладки; ядро слушает тот же сигнал.
217
- try {
218
- const remote = ctx.get('remote')
219
- if (remote && typeof remote.$on === 'function') {
220
- ctx.effect(() => remote.$on('settings/document-updated', () => { mirror.load() }),
221
- 'dsh-lanmode: перечитывать документ настроек по сигналу хоста')
222
- }
223
- } catch (noRemoteService) { /* без сигнала обойдёмся, останется ручное перечитывание */ }
224
-
225
- mirror.ensure()
226
-
227
- // Своя служба: ею может пользоваться любой плагин, которому нужны
228
- // настройки по сети.
229
- ctx.provide('lanSettings', binder)
230
-
231
- // Попытка встать на место ядровой службы. Если разрешено — оживает весь
232
- // интерфейс настроек разом, включая чужие плагины и вкладку ядра.
233
- // Если ядро не отдаёт имя, остаёмся со своей службой: наши плагины
234
- // умеют её спрашивать.
235
- let tookOver = false
236
- try {
237
- ctx.provide('settingsScope', binder)
238
- tookOver = true
239
- } catch (nameTaken) {
240
- tookOver = false
241
- }
242
-
243
- // Одна строка в консоли: без неё непонятно, работает режим или нет.
244
- try {
245
- console.info('[dsh-lanmode] режим включён (' + location.hostname + (connection.isLoopback ? ', принудительно' : '') + '); '
246
- + 'настройки подняты через сеть, ядровая служба '
247
- + (tookOver ? 'подменена' : 'оставлена как есть'))
248
- } catch (noConsole) { /* незачем */ }
249
- }
250
-
251
- return module.exports
252
- },
253
- })