Skip to content

Commit ee884d9

Browse files
feat: update deepseek harness
1 parent 723c354 commit ee884d9

4 files changed

Lines changed: 176 additions & 6 deletions

File tree

‎deepseek-harness/Caddyfile‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,17 @@ https://{$CADDY_ACCESS_HOST}:8443 {
1919
-Server
2020
}
2121

22-
@unexpected_origin `{http.request.header.Origin} != "" && {http.request.header.Origin} != "https://" + {http.request.hostport}`
23-
@settings_api path /api/settings.describe /api/settings.update /api/settings.replace /api/settings.mutate /api/credentials.describe /api/credentials.set /api/credentials.unset /api/llm.discoverModels
22+
@unsafe_api {
23+
path /api /api/*
24+
expression `({http.request.header.Origin} != "" && {http.request.header.Origin} != "https://" + {http.request.hostport}) || {http.request.header.Sec-Fetch-Site} == "cross-site"`
25+
}
26+
@settings_api path /api/settings/describe /api/settings/update /api/settings/replace /api/settings/mutate /api/credentials/describe /api/credentials/set /api/credentials/unset /api/llm/discoverModels
27+
28+
route @unsafe_api {
29+
respond "Forbidden" 403
30+
}
2431

2532
route @settings_api {
26-
respond @unexpected_origin "Forbidden" 403
2733
reverse_proxy 127.0.0.1:3080 {
2834
header_up Host 127.0.0.1:3080
2935
header_up -Origin

‎deepseek-harness/Dockerfile‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ RUN apt-get update && \
1313

1414
COPY deepseek-harness/patch-dsh-authenticated-settings.mjs /tmp/patch-dsh-authenticated-settings.mjs
1515

16-
RUN node /tmp/patch-dsh-authenticated-settings.mjs
16+
RUN npm install --prefix /tmp/dsh-browser-compat --no-audit --no-fund \
17+
core-js@3.51.0 esbuild@0.28.2 && \
18+
node /tmp/patch-dsh-authenticated-settings.mjs
1719

1820
FROM node:24-bookworm-slim
1921

@@ -57,10 +59,13 @@ RUN npm install --global "pnpm@${PNPM_VERSION}" && \
5759
COPY deepseek-harness/Caddyfile /etc/caddy/Caddyfile
5860
COPY deepseek-harness/dsh /usr/local/bin/dsh
5961
COPY deepseek-harness/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
62+
COPY deepseek-harness/test-runtime.mjs /tmp/test-dsh-runtime.mjs
6063

6164
RUN chmod 0755 /usr/local/bin/dsh /usr/local/bin/docker-entrypoint.sh && \
6265
dsh --help >/dev/null && \
63-
/usr/local/bin/docker-entrypoint.sh --self-test
66+
/usr/local/bin/docker-entrypoint.sh --self-test && \
67+
node --test /tmp/test-dsh-runtime.mjs && \
68+
rm /tmp/test-dsh-runtime.mjs
6469

6570
ENV DSH_HOME=/data/dsh \
6671
DSH_TELEMETRY_DISABLED=1

‎deepseek-harness/patch-dsh-authenticated-settings.mjs‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { readFile, writeFile } from 'node:fs/promises'
22
import { createRequire } from 'node:module'
33
import { dirname, join, resolve } from 'node:path'
4+
import { runInNewContext } from 'node:vm'
45

56
const dshRoot = resolve(process.argv[2] ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh')
67
const requireFromDsh = createRequire(join(dshRoot, 'package.json'))
@@ -18,7 +19,7 @@ function replaceOnce(source, original, replacement, label) {
1819
if (count !== 1 || source.includes(replacement)) {
1920
throw new Error(`unexpected ${label} source shape: original=${count}`)
2021
}
21-
return source.replace(original, replacement)
22+
return source.replace(original, () => replacement)
2223
}
2324

2425
// DSH 0.2.1 persists settings through remote.settings for authenticated clients.
@@ -39,3 +40,31 @@ await writeFile(connectionPath, connection, 'utf8')
3940
if ((connection.split(proxyAuthFlag).length - 1) !== 1) {
4041
throw new Error('browser proxy authentication patch verification failed')
4142
}
43+
44+
const compatRoot = resolve(process.argv[3] ?? '/tmp/dsh-browser-compat')
45+
const requireCompat = createRequire(join(compatRoot, 'package.json'))
46+
const { build, transform } = requireCompat('esbuild')
47+
const { outputFiles } = await build({
48+
entryPoints: [requireCompat.resolve('core-js/actual/iterator')],
49+
bundle: true,
50+
format: 'iife',
51+
target: 'es2022',
52+
minify: true,
53+
write: false,
54+
banner: { js: `/*!\n${await readFile(requireCompat.resolve('core-js/LICENSE'), 'utf8')}\n*/` },
55+
})
56+
const polyfill = outputFiles[0].text
57+
const pdfPath = join(await packageRoot('@deepseek-ai/dsh-client-ui-sidebar-documentpreview'), 'lib/client.pdf.js')
58+
let pdf = await readFile(pdfPath, 'utf8')
59+
// Preserve PDF.js license notices through esbuild's comment filtering.
60+
pdf = pdf.replaceAll('@licstart', '@license @licstart')
61+
const workerDeclaration = 'var _dsh_pdf_worker_default = '
62+
const workerLines = pdf.split('\n').filter(line => line.trimStart().startsWith(workerDeclaration))
63+
if (workerLines.length !== 1) throw new Error('unexpected PDF worker source shape')
64+
const workerSource = runInNewContext(workerLines[0].trim().slice(workerDeclaration.length, -1), {}, { timeout: 1000 })
65+
if (typeof workerSource !== 'string') throw new Error('unexpected PDF worker source value')
66+
// A Blob worker has its own globals; the page's polyfills cannot reach it.
67+
const worker = await transform(`${polyfill}\n${workerSource}`, { target: 'es2022', format: 'esm', minify: true })
68+
pdf = replaceOnce(pdf, workerLines[0], `${workerDeclaration}${JSON.stringify(worker.code)};`, 'PDF worker compatibility')
69+
const browser = await transform(`${polyfill}\n${pdf}`, { target: 'es2022' })
70+
await writeFile(pdfPath, browser.code, 'utf8')

‎deepseek-harness/test-runtime.mjs‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import assert from 'node:assert/strict'
2+
import { spawn, spawnSync } from 'node:child_process'
3+
import { once } from 'node:events'
4+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
5+
import http from 'node:http'
6+
import https from 'node:https'
7+
import { createRequire } from 'node:module'
8+
import { tmpdir } from 'node:os'
9+
import { join } from 'node:path'
10+
import test from 'node:test'
11+
import vm from 'node:vm'
12+
import { Worker } from 'node:worker_threads'
13+
14+
const dshRoot = process.env.DSH_TEST_ROOT ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh'
15+
const requireDsh = createRequire(join(dshRoot, 'package.json'))
16+
const pdfPath = join(requireDsh.resolve('@deepseek-ai/dsh-client-ui-sidebar-documentpreview/package.json'), '../lib/client.pdf.js')
17+
const pdf = await readFile(pdfPath, 'utf8')
18+
const removeIterator = `
19+
const prototype = Object.getPrototypeOf(Object.getPrototypeOf([][Symbol.iterator]()));
20+
for (const name of ['map', 'filter', 'take', 'drop', 'flatMap', 'reduce', 'toArray', 'forEach', 'some', 'every', 'find', 'join']) delete prototype[name];
21+
delete globalThis.Iterator;
22+
`
23+
const checkIterator = `
24+
JSON.stringify(Iterator.from([1, 2, 3]).filter(x => x > 1).map(x => x * 2).toArray()) === '[4,6]'
25+
&& new Map([[1, 'one']]).values().some(x => x === 'one')
26+
`
27+
28+
test('PDF page initializes Iterator helpers before registering its module', () => {
29+
let registered = false
30+
const context = vm.createContext({ window: { __ModuleLoader__: { load() { registered = true } } } })
31+
vm.runInContext(removeIterator, context)
32+
vm.runInContext(pdf, context, { timeout: 5000 })
33+
assert.ok(registered)
34+
assert.equal(vm.runInContext(checkIterator, context), true)
35+
})
36+
37+
test('embedded PDF worker initializes its own Iterator helpers', async t => {
38+
const declaration = pdf.split('\n').find(line => line.trimStart().startsWith('var _dsh_pdf_worker_default = '))
39+
assert.ok(declaration, 'PDF worker declaration must exist')
40+
const source = vm.runInNewContext(`${declaration}\n_dsh_pdf_worker_default`)
41+
const worker = new Worker(`
42+
const { parentPort, workerData } = require('node:worker_threads');
43+
(async () => {
44+
${removeIterator}
45+
const pdf = await import('data:text/javascript;base64,' + Buffer.from(workerData).toString('base64'));
46+
parentPort.postMessage({ ok: (${checkIterator}) && typeof pdf.WorkerMessageHandler === 'function' });
47+
})().catch(error => { throw error });
48+
`, { eval: true, workerData: source })
49+
t.after(() => worker.terminate())
50+
const [result] = await once(worker, 'message')
51+
assert.equal(result.ok, true)
52+
})
53+
54+
test('Caddy forwards valid settings requests and rejects cross-site API requests', { timeout: 60000 }, async t => {
55+
const caddy = process.env.CADDY_BINARY ?? 'caddy'
56+
const directory = await mkdtemp(join(tmpdir(), 'dsh-proxy-test-'))
57+
t.after(() => rm(directory, { recursive: true, force: true }))
58+
let forwarded = 0
59+
const backend = http.createServer(async (request, response) => {
60+
forwarded++
61+
let body = ''
62+
for await (const chunk of request) body += chunk
63+
response.setHeader('Content-Type', 'application/json')
64+
response.end(JSON.stringify({ ok: true, headers: request.headers, body: body ? JSON.parse(body) : null }))
65+
})
66+
backend.listen(0, '127.0.0.1')
67+
await once(backend, 'listening')
68+
t.after(() => new Promise(resolve => backend.close(resolve)))
69+
const backendPort = backend.address().port
70+
const reserve = http.createServer()
71+
reserve.listen(0, '127.0.0.1')
72+
await once(reserve, 'listening')
73+
const port = reserve.address().port
74+
await new Promise(resolve => reserve.close(resolve))
75+
const password = 'dsh-test-password'
76+
const hash = spawnSync(caddy, ['hash-password', '--algorithm', 'argon2id', '--plaintext', password], { encoding: 'utf8' })
77+
assert.equal(hash.status, 0, hash.error?.message ?? hash.stderr)
78+
const config = (await readFile(process.env.DSH_CADDYFILE ?? '/etc/caddy/Caddyfile', 'utf8'))
79+
.replaceAll(':8443', `:${port}`).replaceAll('127.0.0.1:3080', `127.0.0.1:${backendPort}`)
80+
const configPath = join(directory, 'Caddyfile')
81+
await writeFile(configPath, config)
82+
const processCaddy = spawn(caddy, ['run', '--config', configPath, '--adapter', 'caddyfile'], {
83+
env: { ...process.env, CADDY_ACCESS_HOST: '127.0.0.1', DSH_AUTH_USERNAME: 'test', DSH_AUTH_PASSWORD_HASH: hash.stdout.trim(), XDG_DATA_HOME: directory, XDG_CONFIG_HOME: directory },
84+
stdio: ['ignore', 'ignore', 'pipe'],
85+
})
86+
let logs = ''
87+
processCaddy.stderr.on('data', chunk => { logs += chunk })
88+
const exited = once(processCaddy, 'exit')
89+
t.after(async () => { processCaddy.kill('SIGTERM'); await exited })
90+
const origin = `https://127.0.0.1:${port}`
91+
const request = (pathname, headers = {}, authenticated = true) => new Promise((resolve, reject) => {
92+
const req = https.request(`${origin}${pathname}`, {
93+
method: 'POST', rejectUnauthorized: false, servername: '',
94+
headers: { 'Content-Type': 'application/json', Origin: origin, ...(authenticated ? { Authorization: `Basic ${Buffer.from(`test:${password}`).toString('base64')}` } : {}), ...headers },
95+
}, response => {
96+
let body = ''
97+
response.on('data', chunk => { body += chunk })
98+
response.on('end', () => resolve({ status: response.statusCode, body }))
99+
})
100+
req.setTimeout(3000, () => req.destroy(new Error('proxy request timed out')))
101+
req.on('error', reject)
102+
req.end(JSON.stringify({ value: 'test' }))
103+
})
104+
for (let attempt = 0; ; attempt++) {
105+
try { await request('/'); break } catch (error) {
106+
if (attempt === 100 || processCaddy.exitCode !== null) throw new Error(logs, { cause: error })
107+
await new Promise(resolve => setTimeout(resolve, 100))
108+
}
109+
}
110+
for (const endpoint of ['settings/describe', 'settings/update', 'settings/replace', 'settings/mutate', 'credentials/describe', 'credentials/set', 'credentials/unset', 'llm/discoverModels']) {
111+
const response = await request(`/api/${endpoint}`)
112+
assert.equal(response.status, 200, endpoint)
113+
const data = JSON.parse(response.body)
114+
assert.equal(data.ok, true)
115+
assert.equal(data.headers.host, `127.0.0.1:${backendPort}`)
116+
assert.equal(data.headers.origin, undefined)
117+
assert.equal(data.headers.authorization, undefined)
118+
assert.equal(data.body.value, 'test')
119+
}
120+
const beforeRejected = forwarded
121+
for (const pathname of ['/api', '/api/settings/update', '/api/credentials/set', '/api/remote.mux']) {
122+
for (const headers of [{ Origin: 'https://attacker.invalid' }, { Origin: 'null' }, { Origin: `http://127.0.0.1:${port}` }, { Origin: 'https://127.0.0.1:1' }, { 'Sec-Fetch-Site': 'cross-site' }, { Origin: '', 'Sec-Fetch-Site': 'cross-site' }]) {
123+
assert.equal((await request(pathname, headers)).status, 403)
124+
}
125+
}
126+
// Caddy can return an empty 200 for unmatched hosts; no request may reach DSH.
127+
assert.equal((await request('/api/settings/update', { Host: 'attacker.invalid', Origin: 'https://attacker.invalid' })).body, '')
128+
assert.equal((await request('/api/settings/describe', {}, false)).status, 401)
129+
assert.equal(forwarded, beforeRejected, 'rejected requests must never reach DSH')
130+
})

0 commit comments

Comments
 (0)