Files
apes-sidebar/tests/trusted-hosts.spec.ts
Menghuan1918 ba0114749f feat(dsh): adapt to DSH 0.1.5-alpha.1
Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
2026-09-09 11:15:30 +08:00

235 lines
8.0 KiB
TypeScript

/**
* Remote-access trust regression tests: the /sidebar fence must accept a
* reverse-proxy domain or LAN IP listed in the DSH web runtime's
* `webRuntime.trustedHosts` — the same trust list the /api gateway accepts
* (LAN IP literals sampled at bind plus `--trusted-host` authorities).
*
* Regression: the fence used to read the connection row's trustedHosts
* through the Loader (`entry.options.name === 'connection'`, which never
* matched the row's module-specifier name), so trustedHosts stayed empty and
* every remote /sidebar request was 403 "forbidden".
*/
import { describe, expect, it } from 'vitest'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { apply } from '../src/index.ts'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
interface FakeRes {
status: number
headers: Record<string, string>
body: string
writeHead(status: number, headers?: Record<string, string>): void
end(body?: string | Buffer): void
}
function fakeRes(): FakeRes {
return {
status: 0,
headers: {},
body: '',
writeHead(status, headers = {}) {
this.status = status
this.headers = headers
},
end(body) {
if (body !== undefined) this.body = body.toString()
},
} as FakeRes
}
function req(
method: string,
url: string,
headers: Record<string, string>,
body = '{}',
): IncomingMessage {
const chunks = [Buffer.from(body)]
return {
method,
url,
headers,
[Symbol.asyncIterator]: async function* () {
for (const chunk of chunks) yield chunk
},
} as unknown as IncomingMessage
}
/** Mount apply() against a fake context with a replaceable webRuntime trust list. */
function mount(initialTrustedHosts: readonly string[] = []): {
api: (r: IncomingMessage, s: ServerResponse) => Promise<void>
setTrustedHosts: (hosts: readonly string[]) => void
cleanup: () => void
} {
const runtime = { trustedHosts: [...initialTrustedHosts] }
const routes: SidebarWebRoute[] = []
const upgrades: SidebarWebUpgradeRoute[] = []
const effects: Array<() => void | (() => void)> = []
const ctx = {
webRuntime: runtime,
webServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { upgrades.push(route); return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
effect: (fn: () => void | (() => void)) => {
const cleanup = fn()
if (typeof cleanup === 'function') effects.push(cleanup)
},
inject: () => () => {},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
get: () => undefined,
}
apply(ctx as never)
const api = routes.find(route => route.path === '/sidebar/api')?.handler
if (api === undefined) throw new Error('test setup: /sidebar/api route not registered')
return {
api: api as (r: IncomingMessage, s: ServerResponse) => Promise<void>,
setTrustedHosts: (hosts) => { runtime.trustedHosts = [...hosts] },
cleanup: () => { for (const cleanup of effects) cleanup() },
}
}
/** A remote-domain request with the browser markers a same-origin fetch sends. */
function remoteDomainRequest(): IncomingMessage {
return req('POST', '/sidebar/api/session.cwd', {
host: 'example.com',
'sec-fetch-site': 'same-origin',
origin: 'https://example.com',
}, '{"sessionId":"test-session"}')
}
/** A LAN-IP request with the browser markers a same-origin fetch sends. */
function lanRequest(): IncomingMessage {
return req('POST', '/sidebar/api/session.cwd', {
host: '192.0.2.1:3080',
'sec-fetch-site': 'same-origin',
origin: 'http://192.0.2.1:3080',
}, '{"sessionId":"test-session"}')
}
describe('remote-access trust (webRuntime.trustedHosts)', () => {
it('accepts a reverse-proxy domain configured in webRuntime.trustedHosts', async () => {
const { api, cleanup } = mount(['example.com'])
try {
const res = fakeRes()
await api(remoteDomainRequest(), res as unknown as ServerResponse)
expect(res.status).toBe(200)
} finally {
cleanup()
}
})
it('accepts LAN IP literals from webRuntime.trustedHosts', async () => {
const { api, cleanup } = mount(['192.0.2.1'])
try {
const res = fakeRes()
await api(lanRequest(), res as unknown as ServerResponse)
expect(res.status).toBe(200)
} finally {
cleanup()
}
})
it('stays loopback-only when webRuntime.trustedHosts is empty', async () => {
const { api, cleanup } = mount([])
try {
const remote = fakeRes()
await api(remoteDomainRequest(), remote as unknown as ServerResponse)
expect(remote.status).toBe(403)
const loopback = fakeRes()
await api(req('POST', '/sidebar/api/session.cwd', {
host: '127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
}, '{"sessionId":"test-session"}'), loopback as unknown as ServerResponse)
expect(loopback.status).toBe(200)
} finally {
cleanup()
}
})
it('accepts an Origin that names the Host hostname without its port (Edge 151 serialization)', async () => {
// Some Chromium builds serialize the Origin of a non-default-port loopback
// page without the port; refusing those bricks every /sidebar route.
const { api, cleanup } = mount([])
try {
const res = fakeRes()
await api(req('POST', '/sidebar/api/session.cwd', {
host: '127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
origin: 'http://127.0.0.1',
}, '{"sessionId":"test-session"}'), res as unknown as ServerResponse)
expect(res.status).toBe(200)
} finally {
cleanup()
}
})
it('rejects an Origin whose hostname differs from the Host even on loopback', async () => {
// hostname, not port, re-decides trust: a same-127/8 address that is not
// the page's own hostname is still a different authority.
const { api, cleanup } = mount([])
try {
const res = fakeRes()
await api(req('POST', '/sidebar/api/session.cwd', {
host: '127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
origin: 'http://127.0.0.2',
}, '{"sessionId":"test-session"}'), res as unknown as ServerResponse)
expect(res.status).toBe(403)
} finally {
cleanup()
}
})
it('rejects the opaque "null" origin', async () => {
const { api, cleanup } = mount([])
try {
const res = fakeRes()
await api(req('POST', '/sidebar/api/session.cwd', {
host: '127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
origin: 'null',
}, '{"sessionId":"test-session"}'), res as unknown as ServerResponse)
expect(res.status).toBe(403)
} finally {
cleanup()
}
})
it('rejects cross-site browser markers even for a trusted host', async () => {
const { api, cleanup } = mount(['example.com'])
try {
const res = fakeRes()
// Same-origin origin: only the explicit cross-site marker can reject —
// this pins the marker branch (a mismatched origin would also 403).
await api(req('POST', '/sidebar/api/session.cwd', {
host: 'example.com',
'sec-fetch-site': 'cross-site',
origin: 'https://example.com',
}, '{"sessionId":"test-session"}'), res as unknown as ServerResponse)
expect(res.status).toBe(403)
} finally {
cleanup()
}
})
it('reads webRuntime.trustedHosts per request, not once at apply', async () => {
const { api, setTrustedHosts, cleanup } = mount([])
try {
const before = fakeRes()
await api(remoteDomainRequest(), before as unknown as ServerResponse)
expect(before.status).toBe(403)
// The trust list gains an authority after mount (e.g. config reload):
// the already-mounted fence must pick it up without a plugin restart.
setTrustedHosts(['example.com'])
const after = fakeRes()
await api(remoteDomainRequest(), after as unknown as ServerResponse)
expect(after.status).toBe(200)
} finally {
cleanup()
}
})
})