@goodandready/dsh-lanmode 0.6.2 → 0.6.4
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 +3 -0
- package/lib/assumptions.js +19 -3
- package/lib/bridge.js +46 -3
- package/lib/index.js +7 -3
- package/lib/loopback-source.js +49 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# dsh-lanmode
|
|
2
2
|
|
|
3
|
+
> Adapted for DeepSeek Harness **0.1.2-alpha.1**: the host now decides LAN trust from the request `Host` header, so the plugin no longer patches the page for it.
|
|
4
|
+
|
|
5
|
+
|
|
3
6
|
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
7
|
|
|
5
8
|
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.
|
package/lib/assumptions.js
CHANGED
|
@@ -42,14 +42,29 @@ export async function checkAssumptions(options) {
|
|
|
42
42
|
'webServer.port — без него не поднять прямой режим и не проверить, кто слушает сеть',
|
|
43
43
|
))
|
|
44
44
|
|
|
45
|
-
let
|
|
45
|
+
let page = { status: 0, html: '' }
|
|
46
46
|
try {
|
|
47
|
-
|
|
47
|
+
page = await options.fetchIndex()
|
|
48
48
|
} catch (unreachable) {
|
|
49
49
|
results.push(verdict('страница отдаётся', false, String(unreachable.message || unreachable)))
|
|
50
50
|
return results
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Харнесс может потребовать токен и ответить отказом. Тело отказа — не
|
|
54
|
+
// страница интерфейса, и искать в нём заплатку бессмысленно: получилось бы
|
|
55
|
+
// уверенное «её нет» там, где мы просто не смотрели.
|
|
56
|
+
if (page.status && page.status !== 200) {
|
|
57
|
+
results.push(verdict(
|
|
58
|
+
'страница отдаётся нам',
|
|
59
|
+
false,
|
|
60
|
+
'харнесс ответил ' + page.status + ' на запрос по петле без токена — проверить страницу отсюда нельзя. '
|
|
61
|
+
+ 'Это не поломка плагина: заплатка могла встать, но увидеть её мы не можем',
|
|
62
|
+
))
|
|
63
|
+
return results
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const html = page.html
|
|
67
|
+
|
|
53
68
|
results.push(verdict(
|
|
54
69
|
'заплатка попала на страницу',
|
|
55
70
|
html.includes('data-dsh-lanmode'),
|
|
@@ -72,5 +87,6 @@ export function summarize(results) {
|
|
|
72
87
|
if (bad.length === 0) return 'точки крепления на месте: ' + results.length + ' из ' + results.length
|
|
73
88
|
return 'ТОЧКИ КРЕПЛЕНИЯ УЕХАЛИ (' + bad.length + ' из ' + results.length + '): '
|
|
74
89
|
+ bad.map((item) => item.name).join('; ')
|
|
75
|
-
+ '. Плагин может работать не так, как
|
|
90
|
+
+ '. Плагин может работать не так, как задумано. Что именно не сошлось — в списке выше; '
|
|
91
|
+
+ 'ядро при этом может быть совершенно исправным'
|
|
76
92
|
}
|
package/lib/bridge.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import http from 'node:http'
|
|
19
19
|
import https from 'node:https'
|
|
20
|
+
import { forceLoopback, isConnectionBundle } from './loopback-source.js'
|
|
20
21
|
|
|
21
22
|
import { allowed } from './access.js'
|
|
22
23
|
import { REFUSED, isPrivileged } from './privileged.js'
|
|
@@ -57,6 +58,25 @@ function throttle(log, everyMs) {
|
|
|
57
58
|
* снаружи выглядит как поломка харнесса. Обратный прокси перед харнессом
|
|
58
59
|
* настраивают ровно так же, иначе он не годится.
|
|
59
60
|
*/
|
|
61
|
+
/**
|
|
62
|
+
* Пояснение про ядро, которое обходится без нашей правки страницы.
|
|
63
|
+
*
|
|
64
|
+
* До 0.1.2 признак «страница открыта с этой же машины» вычислялся в браузере,
|
|
65
|
+
* и разделы настроек по сети приезжали пустыми — поэтому плагин правил это
|
|
66
|
+
* вычисление в сборке на лету. С 0.1.2 ядро решает то же самое у себя, по
|
|
67
|
+
* заголовку `Host` запроса. Мост подменяет `Host` на петлю с самого начала,
|
|
68
|
+
* значит вопрос закрыт раньше, чем страница успевает его задать.
|
|
69
|
+
*
|
|
70
|
+
* Говорим об этом один раз: иначе строка повторяется на каждый запрос сборки.
|
|
71
|
+
*/
|
|
72
|
+
let coreNoted = false
|
|
73
|
+
|
|
74
|
+
function noteSelfSufficientCore(log) {
|
|
75
|
+
if (coreNoted) return
|
|
76
|
+
coreNoted = true
|
|
77
|
+
log('ядро само решает, доверять ли адресу, — по заголовку запроса; правка страницы не нужна')
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
function relaxTimeouts(server, streamTimeoutMs) {
|
|
61
81
|
server.timeout = streamTimeoutMs
|
|
62
82
|
server.requestTimeout = streamTimeoutMs
|
|
@@ -109,17 +129,40 @@ export function startDirectBridge(ctx, options) {
|
|
|
109
129
|
req.setTimeout(streamTimeoutMs)
|
|
110
130
|
res.setTimeout(streamTimeoutMs)
|
|
111
131
|
|
|
132
|
+
// Сборку с признаком «своя машина» правим на лету, всё остальное идёт
|
|
133
|
+
// кусками как шло. Для неё просим несжатый ответ: иначе пришлось бы
|
|
134
|
+
// распаковывать ради одной строки.
|
|
135
|
+
const patching = isConnectionBundle(req.url)
|
|
136
|
+
const headers = rewritten(req.headers, authority)
|
|
137
|
+
if (patching) headers['accept-encoding'] = 'identity'
|
|
138
|
+
|
|
112
139
|
const upstream = http.request({
|
|
113
140
|
host: '127.0.0.1',
|
|
114
141
|
port: upstreamPort,
|
|
115
142
|
method: req.method,
|
|
116
143
|
path: req.url,
|
|
117
|
-
headers
|
|
144
|
+
headers,
|
|
118
145
|
timeout: streamTimeoutMs || undefined,
|
|
119
146
|
}, (answer) => {
|
|
120
147
|
answer.setTimeout(streamTimeoutMs)
|
|
121
|
-
|
|
122
|
-
|
|
148
|
+
if (!patching) {
|
|
149
|
+
res.writeHead(answer.statusCode || 502, answer.headers)
|
|
150
|
+
answer.pipe(res)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const parts = []
|
|
154
|
+
answer.on('data', (chunk) => parts.push(chunk))
|
|
155
|
+
answer.on('end', () => {
|
|
156
|
+
const done = forceLoopback(Buffer.concat(parts).toString('utf8'))
|
|
157
|
+
if (!done.changed) noteSelfSufficientCore(options.log)
|
|
158
|
+
const body = Buffer.from(done.source, 'utf8')
|
|
159
|
+
const out = { ...answer.headers }
|
|
160
|
+
out['content-length'] = String(body.length)
|
|
161
|
+
delete out['content-encoding']
|
|
162
|
+
res.writeHead(answer.statusCode || 502, out)
|
|
163
|
+
res.end(body)
|
|
164
|
+
})
|
|
165
|
+
answer.on('error', () => { if (!res.writableEnded) res.end() })
|
|
123
166
|
})
|
|
124
167
|
upstream.on('error', () => {
|
|
125
168
|
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
package/lib/index.js
CHANGED
|
@@ -326,12 +326,16 @@ function start(ctx, config) {
|
|
|
326
326
|
// поломка обязана быть заметной.
|
|
327
327
|
ctx.effect(() => {
|
|
328
328
|
let alive = true
|
|
329
|
-
|
|
330
|
-
|
|
329
|
+
// Держим сам сервис, а не путь к нему: отложенное обращение через ctx
|
|
330
|
+
// в новых сборках возвращает пустоту, хотя сервис на месте.
|
|
331
|
+
const webServer = ctx.webServer
|
|
332
|
+
const port = webServer && webServer.port
|
|
333
|
+
const fetchIndex = () => fetch('http://127.0.0.1:' + port + '/')
|
|
334
|
+
.then((answer) => answer.text().then((html) => ({ status: answer.status, html })))
|
|
331
335
|
|
|
332
336
|
// Даём харнессу договорить о себе: страница отдаётся не в первый миг.
|
|
333
337
|
const timer = setTimeout(() => {
|
|
334
|
-
checkAssumptions({ webServer
|
|
338
|
+
checkAssumptions({ webServer, fetchIndex })
|
|
335
339
|
.then((results) => {
|
|
336
340
|
if (!alive) return
|
|
337
341
|
state.assumptions = results
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Признак «страница открыта с этой же машины» — в самом источнике.
|
|
2
|
+
//
|
|
3
|
+
// Ядро вычисляет его в браузере один раз, при сборке объекта соединения:
|
|
4
|
+
//
|
|
5
|
+
// isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname)
|
|
6
|
+
//
|
|
7
|
+
// Дальше это обычное свойство обычного объекта, и подменить его на странице
|
|
8
|
+
// можно — плагин так и делает. Но подмена успевает не везде: разделы настроек
|
|
9
|
+
// привязываются в момент своего apply, и тот, кто привязался раньше нашего
|
|
10
|
+
// клиентского кода, уже запомнил «нет». Вкладка «Модели» — как раз такой
|
|
11
|
+
// случай: она читает раздел с провайдерами до нас, получает пустое хранилище
|
|
12
|
+
// вместо файла, и девять заведённых провайдеров исчезают из списка вместе с
|
|
13
|
+
// возможностью их выбрать.
|
|
14
|
+
//
|
|
15
|
+
// Гонку не выиграть перестановкой: порядок загрузки задаёт не плагин. Поэтому
|
|
16
|
+
// значение правится там, где его больше никто не обгонит — в самом файле,
|
|
17
|
+
// пока он идёт через мост. Одна строка, одно вхождение, остальной файл
|
|
18
|
+
// нетронут.
|
|
19
|
+
//
|
|
20
|
+
// Настоящее значение при этом не теряется: клиентская часть плагина отдаёт его
|
|
21
|
+
// тем, кому оно действительно нужно — выдаче результатов работы, где флаг
|
|
22
|
+
// решает, открывать ли путь на своей машине или на серверной.
|
|
23
|
+
|
|
24
|
+
/** Путь, по которому ядро отдаёт сборку с этим вычислением. */
|
|
25
|
+
export const CONNECTION_BUNDLE = '/plugins/@deepseek-ai/dsh-client-connection/client.js'
|
|
26
|
+
|
|
27
|
+
/** Ровно то выражение, которое считает признак. */
|
|
28
|
+
const COMPUTED = 'isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname),'
|
|
29
|
+
|
|
30
|
+
/** Чем его заменяем. */
|
|
31
|
+
const ALWAYS = 'isLoopback: true,'
|
|
32
|
+
|
|
33
|
+
/** Тот ли это запрос: путь сверяем без строки запроса — там висит ?rev=... */
|
|
34
|
+
export function isConnectionBundle(url) {
|
|
35
|
+
const path = String(url ?? '').split('?')[0]
|
|
36
|
+
return path === CONNECTION_BUNDLE
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Заменить вычисление признака на «да».
|
|
41
|
+
*
|
|
42
|
+
* @param source текст сборки
|
|
43
|
+
* @returns `{ source, changed }` — исходник и признак того, что замена нашлась
|
|
44
|
+
*/
|
|
45
|
+
export function forceLoopback(source) {
|
|
46
|
+
const text = String(source)
|
|
47
|
+
if (!text.includes(COMPUTED)) return { source: text, changed: false }
|
|
48
|
+
return { source: text.replace(COMPUTED, ALWAYS), changed: true }
|
|
49
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-lanmode",
|
|
3
|
-
"version": "0.6.
|
|
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.",
|
|
3
|
+
"version": "0.6.4",
|
|
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. Adapted for DeepSeek Harness 0.1.2-alpha.1.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|