From 5c3029a418b27b9045fef210b7fd480fdcf29831 Mon Sep 17 00:00:00 2001 From: betterer <54624517+better-er@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:41:47 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E8=87=AA=E6=B3=A8=E5=86=8C=20RPC=20?= =?UTF-8?q?=E9=80=9A=E9=81=93=E5=B9=B6=E8=BF=81=E7=A7=BB=20sessionPersiste?= =?UTF-8?q?nce.stat=EF=BC=8C=E5=85=BC=E5=AE=B9=20dsh=200.1.5-rc.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smoke/verify.mjs | 63 ++++++++++++----- src/index.ts | 102 ++++++++++++---------------- src/rpc-channel.ts | 166 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 4 files changed, 256 insertions(+), 76 deletions(-) create mode 100644 src/rpc-channel.ts diff --git a/smoke/verify.mjs b/smoke/verify.mjs index f09ca6d..c31a3d7 100644 --- a/smoke/verify.mjs +++ b/smoke/verify.mjs @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import http from 'node:http' import vm from 'node:vm' let failed = 0 @@ -30,12 +31,15 @@ await writeFile(join(root, '.hidden'), 'x', 'utf8') await mkdir(join(root, 'sub')) await mkdir(join(root, 'node_modules')) -let handler = null -let handleOptions = null +let routeHandler = null +let rejection = undefined const scope = { - connection: { rpc: { handle(channel, h, options) { handler = h; handleOptions = { channel, options } } } }, + connection: { requestRejection: () => rejection }, + webServer: { register(route) { routeHandler = route.handler; return () => {} } }, + effect(cb) { return cb() }, + inject(deps, cb) { cb(scope) }, sessions: { get: (id) => (id === 'live-1' ? { header: { cwd: root } } : id === 'live-empty' ? { header: {} } : undefined) }, - sessionPersistence: { inspect: async (id) => (id === 'cold-1' ? { meta: { cwd: root } } : undefined) }, + sessionPersistence: { stat: async (id) => (id === 'cold-1' ? { header: { cwd: root } } : undefined) }, } const ctx = { fs: { @@ -48,43 +52,68 @@ const ctx = { hostApply(ctx) console.log('[host] RPC 通道') -check('注册通道', handleOptions !== null && handleOptions.channel === '/classic-coding', handleOptions) -check('authority', handleOptions?.options?.authority === 'trusted-host', handleOptions?.options) +check('注册 prefix 路由', routeHandler !== null) +if (routeHandler === null) { + console.log('\n失败 ' + ++failed + ' 项') + process.exit(1) +} + +const server = http.createServer((req, res) => routeHandler(req, res)) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) +const port = server.address().port -const describeLive = await handler('describe', { sessionId: 'live-1' }, undefined) +/** 打一次本通道 RPC,返回 HTTP 状态与信封。 */ +async function rpc(method, payload) { + const res = await fetch('http://127.0.0.1:' + port + '/classic-coding/' + method, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'smoke', method, payload }), + }) + return { status: res.status, body: await res.json() } +} + +const describeLive = (await rpc('describe', { sessionId: 'live-1' })).body.result check('describe live', describeLive.ok === true && describeLive.value.root === root, describeLive) -const describeCold = await handler('describe', { sessionId: 'cold-1' }, undefined) +const describeCold = (await rpc('describe', { sessionId: 'cold-1' })).body.result check('describe cold', describeCold.ok === true && describeCold.value.root === root, describeCold) -const describeMiss = await handler('describe', { sessionId: 'nope' }, undefined) +const describeMiss = (await rpc('describe', { sessionId: 'nope' })).body.result check('describe 未知会话报错', describeMiss.ok === false && /会话不存在/.test(describeMiss.error.message), describeMiss) -const describeEmpty = await handler('describe', { sessionId: 'live-empty' }, undefined) +const describeEmpty = (await rpc('describe', { sessionId: 'live-empty' })).body.result check('describe 空 cwd 报错', describeEmpty.ok === false && /工作目录缺失/.test(describeEmpty.error.message), describeEmpty) -const describeNoId = await handler('describe', {}, undefined) +const describeNoId = (await rpc('describe', {})).body.result check('describe 缺 sessionId 报错', describeNoId.ok === false && /缺少 sessionId/.test(describeNoId.error.message), describeNoId) -const list = await handler('listDir', { path: root }, undefined) +const list = (await rpc('listDir', { path: root })).body.result check('listDir 成功', list.ok === true, list) if (list.ok) { const names = list.value.entries.map((e) => e.name) check('listDir 过滤隐藏与 node_modules', names.join(',') === 'sub,a.txt,b.txt', names) check('listDir 目录优先', list.value.entries[0].type === 'directory', list.value.entries[0]) } -const listRel = await handler('listDir', { path: 'relative/x' }, undefined) +const listRel = (await rpc('listDir', { path: 'relative/x' })).body.result check('listDir 拒绝相对路径', listRel.ok === false && /绝对路径/.test(listRel.error.message), listRel) -const read = await handler('readFile', { path: join(root, 'a.txt') }, undefined) +const read = (await rpc('readFile', { path: join(root, 'a.txt') })).body.result check('readFile 内容', read.ok === true && read.value.content === '内容A', read) const target = join(root, 'out.txt') -const wrote = await handler('writeFile', { path: target, content: '写入成功' }, undefined) +const wrote = (await rpc('writeFile', { path: target, content: '写入成功' })).body.result check('writeFile 返回 ok', wrote.ok === true && wrote.value.ok === true, wrote) check('writeFile 落盘', (await readFile(target, 'utf8')) === '写入成功') -const wroteBad = await handler('writeFile', { path: target }, undefined) +const wroteBad = (await rpc('writeFile', { path: target })).body.result check('writeFile 缺 content 报错', wroteBad.ok === false && /缺少 content/.test(wroteBad.error.message), wroteBad) -const unknown = await handler('no-such', {}, undefined) +const unknown = (await rpc('no-such', {})).body.result check('未知端点 bad-request', unknown.ok === false && unknown.error.code === 'bad-request', unknown) +const notFound = await fetch('http://127.0.0.1:' + port + '/classic-coding/') +check('空端点 404', notFound.status === 404, notFound.status) +rejection = 401 +const unauth = await fetch('http://127.0.0.1:' + port + '/classic-coding/describe', { method: 'POST', body: '{}' }) +check('未授权 401', unauth.status === 401, unauth.status) +rejection = undefined +server.close() + // ─── client 端 ───────────────────────────────────────── console.log('[client] 模块注册') const code = readFileSync(fileURLToPath(new URL('../lib/client.js', import.meta.url)), 'utf8') diff --git a/src/index.ts b/src/index.ts index 0531b5a..b486c9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,8 @@ * 古法编程 - Host 端 * * 注册 RPC 端点,为 Client 端提供文件系统操作能力。 - * 经 ctx.connection.rpc.handle('/classic-coding', ...) 注册独立 RPC 通道: - * /api 共享通道的唯一拦截器槽位已被官方 gateway 占用,插件端点必须走 handle 独立通道。 + * 经 mountRpcChannel 直接向 webServer 自注册 /classic-coding 前缀路由,复用 connection 的信任判定, + * 实现官方 client-request/server-response 信封。共享通道 /api 的唯一拦截器槽位已被官方 gateway 占用。 * * 官方 @deepseek-ai 服务面在此以最小结构接口描述,插件运行时由 host 注入真实实现, * 不引入任何官方类型包依赖,避免版本漂移。 @@ -12,6 +12,7 @@ */ import { readdir, writeFile } from 'node:fs/promises' +import { mountRpcChannel } from './rpc-channel.ts' /** 绝对路径校验:Windows 盘符或 UNC 前缀 */ const ABS_RE = /^[A-Za-z]:[\\/]|^\\\\/ @@ -36,26 +37,15 @@ interface SessionsService { get(sessionId: string): SessionRecord | undefined } -/** 会话持久化:从磁盘冷读会话元数据。 */ +/** 会话持久化:按 id 冷读存储快照,只需要 header.cwd。 */ interface SessionPersistenceService { - inspect(sessionId: string, signal?: AbortSignal): Promise<{ meta?: { cwd?: string } } | undefined> + stat(sessionId: string, options?: { signal?: AbortSignal }): Promise<{ header?: { cwd?: string } } | undefined> } /** RPC 响应:四象限消息模型下的统一结果信封。 */ type RpcResult = | { ok: true; value: unknown } - | { ok: false; error: { code: string; message: string; details: unknown } } - -/** 连接服务:独立 RPC 通道注册。 */ -interface ConnectionService { - rpc: { - handle( - channel: string, - handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise, - options?: { authority?: string }, - ): void - } -} + | { ok: false; error: { code: string; message: string; details: object } } /** 插件上下文:apply 与 inject 回调共同可见的服务面。 */ interface PluginContext { @@ -63,7 +53,6 @@ interface PluginContext { inject( dependencies: string[], callback: (scope: PluginContext & { - connection: ConnectionService sessions: SessionsService sessionPersistence: SessionPersistenceService }) => void, @@ -73,7 +62,7 @@ interface PluginContext { /** 插件名,即 cordis.yml 配置条目 id */ const name = 'dsh-classic-coding' -/** 依赖的服务:fs 即文件系统;connection、sessions、sessionPersistence 在 apply 内显式等待 */ +/** 依赖的服务:fs 即文件系统;sessions、sessionPersistence 在 apply 内显式等待 */ const inject = ['fs'] /** 需要隐藏的目录 */ @@ -110,48 +99,43 @@ function readString(record: Record, key: string): string | unde /** * Host 端 apply:注册文件系统 RPC 端点。 * - * 用 ctx.inject(['connection', 'sessions', 'sessionPersistence'], ...) 延迟获取服务, - * 再经 ctx.connection.rpc.handle 注册独立的 /classic-coding 通道。 - * 共享通道 /api 的唯一拦截器槽位已被官方 gateway 占用,尝试 intercept 会抛出 - * 'already has an interceptor',因此插件端点必须走 handle 独立通道。 + * 用 ctx.inject(['sessions', 'sessionPersistence'], ...) 延迟获取服务, + * 再经 mountRpcChannel 自注册独立的 /classic-coding 通道。 + * dsh 0.1.5 的 connection.rpc.handle 在登记路由时解析 webServer 会抛 without inject,因此自行注册 prefix 路由。 * @param ctx - 插件上下文。 */ function apply(ctx: PluginContext): void { - ctx.inject(['connection', 'sessions', 'sessionPersistence'], function (scope) { - scope.connection.rpc.handle( - '/classic-coding', - async function (endpoint, payload, signal): Promise { - // handle 独立通道下 endpoint 即方法名,不含通道前缀 - const method = endpoint - try { - switch (method) { - case 'describe': - return { ok: true, value: await handleDescribe(scope.sessions, scope.sessionPersistence, payload, signal) } - case 'listDir': - return { ok: true, value: await handleListDir(ctx, payload, signal) } - case 'readFile': - return { ok: true, value: await handleReadFile(ctx, payload) } - case 'writeFile': - return { ok: true, value: await handleWriteFile(payload) } - default: - return { - ok: false, - error: { code: 'bad-request', message: `未知端点: ${method}`, details: { issues: [] } }, - } - } - } catch (e) { - return { - ok: false, - error: { - code: 'internal', - message: e instanceof Error ? e.message : String(e), - details: {}, - }, - } + ctx.inject(['sessions', 'sessionPersistence'], function (scope) { + mountRpcChannel(scope, '/classic-coding', async function (endpoint, payload, signal): Promise { + // 独立通道下 endpoint 即方法名,不含通道前缀 + const method = endpoint + try { + switch (method) { + case 'describe': + return { ok: true, value: await handleDescribe(scope.sessions, scope.sessionPersistence, payload, signal) } + case 'listDir': + return { ok: true, value: await handleListDir(ctx, payload, signal) } + case 'readFile': + return { ok: true, value: await handleReadFile(ctx, payload) } + case 'writeFile': + return { ok: true, value: await handleWriteFile(payload) } + default: + return { + ok: false, + error: { code: 'bad-request', message: `未知端点: ${method}`, details: { issues: [] } }, + } } - }, - { authority: 'trusted-host' }, - ) + } catch (e) { + return { + ok: false, + error: { + code: 'internal', + message: e instanceof Error ? e.message : String(e), + details: {}, + }, + } + } + }) }) } @@ -161,7 +145,7 @@ function apply(ctx: PluginContext): void { * * 会话已挂载为 live 即内存 SessionStore 命中时直接取 header.cwd; * 否则,如浏览器刷新、会话尚未被 host resume 进 live 表时回退到 - * sessionPersistence.inspect 从磁盘冷读 meta.cwd。两者都拿不到才抛错, + * sessionPersistence.stat 从磁盘冷读快照 header.cwd。两者都拿不到才抛错, * 绝不静默回退到进程目录。 * @param sessions - 会话服务。 * @param sessionPersistence - 会话持久化服务。 @@ -185,8 +169,8 @@ async function handleDescribe( } // 兜底:从持久化冷读会话 header,覆盖刷新后尚未 resume 的会话 - const inspection = await sessionPersistence.inspect(sessionId, signal) - const coldCwd = inspection?.meta?.cwd + const snapshot = await sessionPersistence.stat(sessionId, { signal }) + const coldCwd = snapshot?.header?.cwd if (coldCwd) return { root: coldCwd } if (live) throw new Error(`会话 ${sessionId} 工作目录缺失:header.cwd 为空`) throw new Error(`会话不存在: ${sessionId}`) diff --git a/src/rpc-channel.ts b/src/rpc-channel.ts new file mode 100644 index 0000000..e001fbe --- /dev/null +++ b/src/rpc-channel.ts @@ -0,0 +1,166 @@ +/** + * 在 webServer 上自注册一条 connection 风格的 RPC 通道。 + * + * dsh 0.1.5 的 connection.rpc.handle 会在登记路由时解析 webServer,实测任何插件上下文都抛 + * `cannot get property "webServer" without inject`,官方自身也从不走该路径。 + * 因此这里直接向 webServer 注册 prefix 路由,复用 connection.requestRejection 的 + * Host 校验与浏览器鉴权,并实现同样的 client-request/server-response 信封。 + * + * @module dsh-classic-coding/rpc-channel + */ + +import type { IncomingMessage, ServerResponse } from 'node:http' + +/** RPC 处理器的统一结果信封。 */ +export type RpcChannelResult = + | { ok: true; value: unknown } + | { ok: false; error: { code: string; message: string; details: object } } + +/** 通道端点处理器:endpoint 与请求体,返回结果信封。 */ +export type RpcChannelHandler = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise + +/** webServer 服务的最小面,仅注册 prefix 路由。 */ +interface WebServerFace { + register(route: { + kind: 'prefix' + path: string + handler: (req: IncomingMessage, res: ServerResponse) => void | Promise + }): () => void +} + +/** connection 服务的最小面,仅用请求信任判定。 */ +interface ConnectionFace { + requestRejection(request: { headers: IncomingMessage['headers'] }): 401 | 403 | undefined +} + +/** 可注入上下文的最小面。 */ +interface Injectable { + inject(deps: string[], callback: (scope: unknown) => void): unknown +} + +/** 注入作用域的最小面。 */ +interface Scope { + connection: ConnectionFace + webServer: WebServerFace + effect(callback: () => (() => void) | undefined, label: string): unknown +} + +/** 端点路径段的合法字符集,与官方 ENDPOINT_SEGMENT_PATTERN 一致。 */ +const SEGMENT = /^[A-Za-z0-9_$.-]+$/ +/** 请求体上限,RPC 信封远小于此值。 */ +const MAX_BODY_BYTES = 8 * 1024 * 1024 + +/** + * 挂载一条 RPC 通道。webServer 或 connection 缺席时不挂载,其余功能不受影响。 + * @param ctx - 插件上下文。 + * @param channel - 绝对通道前缀,例如 /dsh-pause。 + * @param handler - 端点处理器。 + */ +export function mountRpcChannel(ctx: Injectable, channel: string, handler: RpcChannelHandler): void { + ctx.inject(['connection', 'webServer'], (raw) => { + const scope = raw as Scope + scope.effect( + () => scope.webServer.register({ + kind: 'prefix', + path: channel, + handler: (req, res) => serve(scope.connection, channel, handler, req, res), + }), + `${channel}: rpc channel`, + ) + }) +} + +/** + * 处理一次 HTTP 请求:信任与鉴权、端点解析、信封校验、结果回写。 + * @param connection - connection 服务,用其信任判定。 + * @param channel - 本通道前缀。 + * @param handler - 端点处理器。 + * @param req - node 请求。 + * @param res - node 响应。 + */ +async function serve( + connection: ConnectionFace, + channel: string, + handler: RpcChannelHandler, + req: IncomingMessage, + res: ServerResponse, +): Promise { + const rejection = connection.requestRejection(req) + if (rejection !== undefined) { + res.writeHead(rejection) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') + return + } + if (req.method !== 'POST') return missing(res) + const pathname = new URL(req.url ?? '/', 'http://localhost').pathname + const prefix = `${channel}/` + if (!pathname.startsWith(prefix)) return missing(res) + const endpoint = pathname.slice(prefix.length) + if (!endpoint.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && SEGMENT.test(part))) return missing(res) + + let raw: string + try { + raw = await readBody(req) + } catch (error) { + res.writeHead(413) + res.end('payload too large') + return + } + let message: { type?: unknown; rpcId?: unknown; method?: unknown; payload?: unknown } + try { + message = JSON.parse(raw) as typeof message + } catch (error) { + res.writeHead(400) + res.end('body is not JSON') + return + } + const rpcId = typeof message.rpcId === 'string' ? message.rpcId : 'invalid-request' + if (message.type !== 'client-request' || typeof message.method !== 'string') { + return reply(res, rpcId, { ok: false, error: { code: 'gateway/bad-request', message: 'invalid client-request message', details: {} } }) + } + if (message.method !== endpoint) { + return reply(res, rpcId, { + ok: false, + error: { code: 'gateway/bad-request', message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, details: {} }, + }) + } + let result: RpcChannelResult + try { + result = await handler(endpoint, message.payload, new AbortController().signal) + } catch (error) { + result = { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } } + } + reply(res, rpcId, result) +} + +/** 路径不匹配或方法不符时统一 404,与官方未认领端点一致。 */ +function missing(res: ServerResponse): void { + res.writeHead(404) + res.end('not found') +} + +/** 回写一个 server-response 信封。 */ +function reply(res: ServerResponse, rpcId: string, result: RpcChannelResult): void { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ type: 'server-response', rpcId, result })) +} + +/** 读取并限长请求体。 */ +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = [] + let size = 0 + for await (const chunk of req) { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer + size += buffer.length + if (size > MAX_BODY_BYTES) { + req.destroy() + throw new Error('rpc channel: request body exceeds limit') + } + chunks.push(buffer) + } + return Buffer.concat(chunks).toString('utf8') +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index d64c3d9..a64549b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "noImplicitOverride": true, "skipLibCheck": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "noEmit": true From bca0f3432ec4cd29c8b0c8b067dede02773daac6 Mon Sep 17 00:00:00 2001 From: betterer <54624517+better-er@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:07:30 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E6=8C=89=20PR=20=E5=AE=A1=E9=98=85?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20RPC=20=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 超限先回 413 再断连接,并按 content-length 快速拒绝;P2 上限提到 300MiB 对齐官方 bridge;P3 取消信号绑定 res close;P4 非 application/json 返回 415;P5 README 同步自注册 prefix 路由;P6 smoke 补超限 413 与 content-type 415 用例、CI 加冒烟;P7 去掉重复的 RpcResult 结构。 --- .github/workflows/ci.yml | 4 +++ README.md | 2 +- smoke/verify.mjs | 26 +++++++++++++++++ src/index.ts | 9 ++---- src/rpc-channel.ts | 60 ++++++++++++++++++++++++++++++++-------- 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b413770..1d259e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,10 @@ jobs: - name: 构建 run: pnpm build + # 冒烟测试消费构建产物,验证 host RPC 与 client 注册的行为契约。 + - name: 冒烟测试 + run: pnpm smoke + # 出厂文件齐全性校验,缺失会在此失败,让 PR 作者一眼看到问题。 - name: 产物齐全性校验 run: | diff --git a/README.md b/README.md index e243a66..3893c45 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ dsh plugin --profile web remove dsh-classic-coding - **标准形态**:dsh 客户端插件,声明 `dsh.client`、导出 `./client`。 - **自挂载 bundle**:同时声明 `dsh.bundle`,用 `dsh plugin --profile web add` 从 GitHub 安装后自动识别为 profile layer 并挂载,无需手工写组合 entry。 -- **独立 RPC 通道**:Host 端经 `ctx.connection.rpc.handle('/classic-coding', ...)` 注册 `describe` / `listDir` / `readFile` / `writeFile` 四个端点,提供文件树与编辑器读写,底层走 DSH 内置 `ctx.fs` 文件系统服务。`/api` 共享通道的唯一拦截器槽位已被官方 gateway 占用,插件端点必须走 handle 独立通道。 +- **独立 RPC 通道**:Host 端经 `mountRpcChannel` 直接向 `webServer` 自注册 `/classic-coding` 前缀路由,复用 `connection.requestRejection` 做 Host 校验与浏览器鉴权,并实现官方 `client-request` / `server-response` 信封。dsh 0.1.5 的 `connection.rpc.handle` 在登记路由时解析 `webServer` 会抛 without inject,因此自行注册 prefix 路由。注册 `describe` / `listDir` / `readFile` / `writeFile` 四个端点,提供文件树与编辑器读写,底层走 DSH 内置 `ctx.fs` 文件系统服务。 - **构建产物**:源码在 `src/`,用 tsdown 构建出 `lib/index.js`、`lib/index.d.ts` 与 `lib/client.js`。`lib/` 不入库,从 GitHub 安装时由 `prepare` 自动构建;本地开发跑 `pnpm install` 后 `pnpm build`,类型检查用 `pnpm typecheck`。`package.json` 声明 `dsh.client.platform: "web"`、`exports["./client"] → ./lib/client.js`。 - **UI 挂载点**:`sidebar.footer.action` 触发按钮、`shell.overlay` 编辑器面板两个插槽注入。 - **样式**:DSH 主题 CSS 变量 `--dsw-alias-*`,明暗主题自适应,`data-ds-dark-theme` 属性变化时实时跟随切换 Monaco 主题。 diff --git a/smoke/verify.mjs b/smoke/verify.mjs index c31a3d7..e5ebbbe 100644 --- a/smoke/verify.mjs +++ b/smoke/verify.mjs @@ -11,6 +11,7 @@ import { join } from 'node:path' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import http from 'node:http' +import net from 'node:net' import vm from 'node:vm' let failed = 0 @@ -72,6 +73,23 @@ async function rpc(method, payload) { return { status: res.status, body: await res.json() } } +/** + * 用原始 socket 只发请求头:超限 content-length 触发的是快速拒绝,没必要真发满整份 body。 + */ +function rawRequest(port, head) { + return new Promise(function (resolve, reject) { + const socket = net.connect(port, '127.0.0.1') + let text = '' + socket.setEncoding('utf8') + socket.setTimeout(5000, function () { socket.destroy(); reject(new Error('raw request timeout')) }) + socket.on('data', function (chunk) { text += chunk }) + socket.on('end', function () { resolve(text) }) + // 服务端先回 413 再 destroy,连接可能以 RST 收尾;只要已收到响应就交给断言判断。 + socket.on('error', function (err) { if (text.length > 0) resolve(text); else reject(err) }) + socket.write(head) + }) +} + const describeLive = (await rpc('describe', { sessionId: 'live-1' })).body.result check('describe live', describeLive.ok === true && describeLive.value.root === root, describeLive) const describeCold = (await rpc('describe', { sessionId: 'cold-1' })).body.result @@ -112,6 +130,14 @@ rejection = 401 const unauth = await fetch('http://127.0.0.1:' + port + '/classic-coding/describe', { method: 'POST', body: '{}' }) check('未授权 401', unauth.status === 401, unauth.status) rejection = undefined +const plain = await fetch('http://127.0.0.1:' + port + '/classic-coding/describe', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: '{}', +}) +check('非 JSON content-type 返回 415', plain.status === 415, plain.status) +const oversized = await rawRequest(port, 'POST /classic-coding/describe HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: ' + (400 * 1024 * 1024) + '\r\n\r\n') +check('超限请求体返回 413', /^HTTP\/1\.1 413/.test(oversized), oversized.split('\r\n')[0]) server.close() // ─── client 端 ───────────────────────────────────────── diff --git a/src/index.ts b/src/index.ts index b486c9b..4ea1885 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ */ import { readdir, writeFile } from 'node:fs/promises' -import { mountRpcChannel } from './rpc-channel.ts' +import { mountRpcChannel, type RpcChannelResult } from './rpc-channel.ts' /** 绝对路径校验:Windows 盘符或 UNC 前缀 */ const ABS_RE = /^[A-Za-z]:[\\/]|^\\\\/ @@ -42,11 +42,6 @@ interface SessionPersistenceService { stat(sessionId: string, options?: { signal?: AbortSignal }): Promise<{ header?: { cwd?: string } } | undefined> } -/** RPC 响应:四象限消息模型下的统一结果信封。 */ -type RpcResult = - | { ok: true; value: unknown } - | { ok: false; error: { code: string; message: string; details: object } } - /** 插件上下文:apply 与 inject 回调共同可见的服务面。 */ interface PluginContext { fs: FsService @@ -106,7 +101,7 @@ function readString(record: Record, key: string): string | unde */ function apply(ctx: PluginContext): void { ctx.inject(['sessions', 'sessionPersistence'], function (scope) { - mountRpcChannel(scope, '/classic-coding', async function (endpoint, payload, signal): Promise { + mountRpcChannel(scope, '/classic-coding', async function (endpoint, payload, signal): Promise { // 独立通道下 endpoint 即方法名,不含通道前缀 const method = endpoint try { diff --git a/src/rpc-channel.ts b/src/rpc-channel.ts index e001fbe..5ffadc8 100644 --- a/src/rpc-channel.ts +++ b/src/rpc-channel.ts @@ -51,13 +51,22 @@ interface Scope { /** 端点路径段的合法字符集,与官方 ENDPOINT_SEGMENT_PATTERN 一致。 */ const SEGMENT = /^[A-Za-z0-9_$.-]+$/ -/** 请求体上限,RPC 信封远小于此值。 */ -const MAX_BODY_BYTES = 8 * 1024 * 1024 +/** + * 请求体上限,与官方 bridge 默认的 300MiB 对齐。 + * 请求体是整份文件内容加 JSON 封装,原来的 8MiB 会让约 8MiB 的文件保存失败, + * 而 readFile 响应不限长,会出现能打开却保存不了的不一致。 + */ +const MAX_BODY_BYTES = 300 * 1024 * 1024 +/** 唯一接受的 content-type,与官方信封契约一致。 */ +const JSON_MIME = 'application/json' + +/** 请求体超限的标记错误,由 serve 决定如何回写与断开。 */ +class PayloadTooLargeError extends Error {} /** * 挂载一条 RPC 通道。webServer 或 connection 缺席时不挂载,其余功能不受影响。 * @param ctx - 插件上下文。 - * @param channel - 绝对通道前缀,例如 /dsh-pause。 + * @param channel - 绝对通道前缀,例如 /classic-coding。 * @param handler - 端点处理器。 */ export function mountRpcChannel(ctx: Injectable, channel: string, handler: RpcChannelHandler): void { @@ -101,13 +110,22 @@ async function serve( if (!pathname.startsWith(prefix)) return missing(res) const endpoint = pathname.slice(prefix.length) if (!endpoint.split('/').every((part) => part !== '' && part !== '.' && part !== '..' && SEGMENT.test(part))) return missing(res) + if (!isJsonContentType(req)) { + res.writeHead(415) + res.end('unsupported media type') + return + } + // 声明了超限 content-length 时先快速拒绝,不必把整份超限请求读完。 + const declared = Number(req.headers['content-length']) + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return tooLarge(req, res) let raw: string try { raw = await readBody(req) } catch (error) { - res.writeHead(413) - res.end('payload too large') + if (error instanceof PayloadTooLargeError) return tooLarge(req, res) + res.writeHead(400) + res.end('body read failed') return } let message: { type?: unknown; rpcId?: unknown; method?: unknown; payload?: unknown } @@ -128,11 +146,17 @@ async function serve( error: { code: 'gateway/bad-request', message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`, details: {} }, }) } + // 客户端断开即取消,等价官方 bridge 传 request.signal 的语义。 + const controller = new AbortController() + const onClose = (): void => { controller.abort(new Error('client disconnected')) } + res.on('close', onClose) let result: RpcChannelResult try { - result = await handler(endpoint, message.payload, new AbortController().signal) + result = await handler(endpoint, message.payload, controller.signal) } catch (error) { result = { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} } } + } finally { + res.off('close', onClose) } reply(res, rpcId, result) } @@ -149,18 +173,30 @@ function reply(res: ServerResponse, rpcId: string, result: RpcChannelResult): vo res.end(JSON.stringify({ type: 'server-response', rpcId, result })) } -/** 读取并限长请求体。 */ +/** 先回 413 再销毁连接:顺序反了 socket 已断,413 永远发不出去。 */ +function tooLarge(req: IncomingMessage, res: ServerResponse): void { + res.writeHead(413, { connection: 'close' }) + res.end('payload too large') + req.destroy() +} + +/** content-type 必须声明为 application/json,charset 等参数可忽略。 */ +function isJsonContentType(req: IncomingMessage): boolean { + const header = req.headers['content-type'] + if (typeof header !== 'string') return false + const mime = header.split(';', 1)[0] + return mime !== undefined && mime.trim().toLowerCase() === JSON_MIME +} + +/** 读取并限长请求体,超限抛 PayloadTooLargeError,由调用方回写 413。 */ async function readBody(req: IncomingMessage): Promise { const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk as Buffer size += buffer.length - if (size > MAX_BODY_BYTES) { - req.destroy() - throw new Error('rpc channel: request body exceeds limit') - } + if (size > MAX_BODY_BYTES) throw new PayloadTooLargeError('rpc channel: request body exceeds limit') chunks.push(buffer) } return Buffer.concat(chunks).toString('utf8') -} \ No newline at end of file +} From 44fc74ae10ca5c47b224899fc962050ba4265062 Mon Sep 17 00:00:00 2001 From: betterer <54624517+better-er@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:15:35 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20requireAbsolute=20=E6=8E=A5=E5=8F=97?= =?UTF-8?q?=20POSIX=20=E7=BB=9D=E5=AF=B9=E8=B7=AF=E5=BE=84=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Linux=20CI=20=E4=B8=8B=20smoke=20=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smoke 加入 CI 后在 ubuntu 暴露既有问题:ABS_RE 只认 Windows 盘符与 UNC,/tmp 下的路径全被拒。补上 POSIX 根路径分支。 --- src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4ea1885..6ab9345 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,8 +14,8 @@ import { readdir, writeFile } from 'node:fs/promises' import { mountRpcChannel, type RpcChannelResult } from './rpc-channel.ts' -/** 绝对路径校验:Windows 盘符或 UNC 前缀 */ -const ABS_RE = /^[A-Za-z]:[\\/]|^\\\\/ +/** 绝对路径校验:Windows 盘符、UNC 前缀或 POSIX 根路径 */ +const ABS_RE = /^[A-Za-z]:[\\/]|^\\\\|^\// /** 文件系统服务:仅用到解析、路径转换与读文本三个方法。 */ interface FsService { From 49722272998ba0a53ac3d2bba3fc2f0ce6b995b0 Mon Sep 17 00:00:00 2001 From: betterer <54624517+better-er@users.noreply.github.com> Date: Fri, 11 Sep 2026 04:32:29 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E6=8C=89=E5=A4=8D=E5=AE=A1=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E6=94=B9=E7=94=A8=20path.isAbsolute=20=E5=B9=B6?= =?UTF-8?q?=E5=8A=A0=E5=9B=BA=20smoke=20=E8=90=BD=E7=9B=98=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 绝对路径校验改用 node:path 的 isAbsolute,按平台判定,删掉手写正则;R2 smoke 的 writeFile 落盘断言包进 try/catch,失败时记 FAIL 而不是让整个脚本崩溃。 --- smoke/verify.mjs | 4 +++- src/index.ts | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/smoke/verify.mjs b/smoke/verify.mjs index e5ebbbe..92fd01c 100644 --- a/smoke/verify.mjs +++ b/smoke/verify.mjs @@ -117,7 +117,9 @@ check('readFile 内容', read.ok === true && read.value.content === '内容A', r const target = join(root, 'out.txt') const wrote = (await rpc('writeFile', { path: target, content: '写入成功' })).body.result check('writeFile 返回 ok', wrote.ok === true && wrote.value.ok === true, wrote) -check('writeFile 落盘', (await readFile(target, 'utf8')) === '写入成功') +let landed = false +try { landed = (await readFile(target, 'utf8')) === '写入成功' } catch { landed = false } +check('writeFile 落盘', landed) const wroteBad = (await rpc('writeFile', { path: target })).body.result check('writeFile 缺 content 报错', wroteBad.ok === false && /缺少 content/.test(wroteBad.error.message), wroteBad) diff --git a/src/index.ts b/src/index.ts index 6ab9345..8586eb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,11 +12,9 @@ */ import { readdir, writeFile } from 'node:fs/promises' +import { isAbsolute } from 'node:path' import { mountRpcChannel, type RpcChannelResult } from './rpc-channel.ts' -/** 绝对路径校验:Windows 盘符、UNC 前缀或 POSIX 根路径 */ -const ABS_RE = /^[A-Za-z]:[\\/]|^\\\\|^\// - /** 文件系统服务:仅用到解析、路径转换与读文本三个方法。 */ interface FsService { /** 把路径解析成后端路径对象,受 signal 取消。 */ @@ -75,7 +73,7 @@ function requireAbsolute(path: unknown): asserts path is string { if (typeof path !== 'string' || path.length === 0) { throw new Error('path 必须是非空字符串') } - if (!ABS_RE.test(path)) { + if (!isAbsolute(path)) { throw new Error(`path 必须是绝对路径:收到 ${path}`) } }