Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ jobs:
- name: 构建
run: pnpm build

# 冒烟测试消费构建产物,验证 host RPC 与 client 注册的行为契约。
- name: 冒烟测试
run: pnpm smoke

# 出厂文件齐全性校验,缺失会在此失败,让 PR 作者一眼看到问题。
- name: 产物齐全性校验
run: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 主题。
Expand Down
93 changes: 75 additions & 18 deletions smoke/verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ 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 net from 'node:net'
import vm from 'node:vm'

let failed = 0
Expand All @@ -30,12 +32,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: {
Expand All @@ -48,43 +53,95 @@ 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() }
}

/**
* 用原始 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 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)
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)

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
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 端 ─────────────────────────────────────────
console.log('[client] 模块注册')
const code = readFileSync(fileURLToPath(new URL('../lib/client.js', import.meta.url)), 'utf8')
Expand Down
111 changes: 44 additions & 67 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 注入真实实现,
* 不引入任何官方类型包依赖,避免版本漂移。
Expand All @@ -12,9 +12,8 @@
*/

import { readdir, writeFile } from 'node:fs/promises'

/** 绝对路径校验:Windows 盘符或 UNC 前缀 */
const ABS_RE = /^[A-Za-z]:[\\/]|^\\\\/
import { isAbsolute } from 'node:path'
import { mountRpcChannel, type RpcChannelResult } from './rpc-channel.ts'

/** 文件系统服务:仅用到解析、路径转换与读文本三个方法。 */
interface FsService {
Expand All @@ -36,25 +35,9 @@ interface SessionsService {
get(sessionId: string): SessionRecord | undefined
}

/** 会话持久化:从磁盘冷读会话元数据。 */
/** 会话持久化:按 id 冷读存储快照,只需要 header.cwd。 */
interface SessionPersistenceService {
inspect(sessionId: string, signal?: AbortSignal): Promise<{ meta?: { 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<RpcResult>,
options?: { authority?: string },
): void
}
stat(sessionId: string, options?: { signal?: AbortSignal }): Promise<{ header?: { cwd?: string } } | undefined>
}

/** 插件上下文:apply 与 inject 回调共同可见的服务面。 */
Expand All @@ -63,7 +46,6 @@ interface PluginContext {
inject(
dependencies: string[],
callback: (scope: PluginContext & {
connection: ConnectionService
sessions: SessionsService
sessionPersistence: SessionPersistenceService
}) => void,
Expand All @@ -73,7 +55,7 @@ interface PluginContext {
/** 插件名,即 cordis.yml 配置条目 id */
const name = 'dsh-classic-coding'

/** 依赖的服务:fs 即文件系统;connection、sessions、sessionPersistence 在 apply 内显式等待 */
/** 依赖的服务:fs 即文件系统;sessions、sessionPersistence 在 apply 内显式等待 */
const inject = ['fs']

/** 需要隐藏的目录 */
Expand All @@ -91,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}`)
}
}
Expand All @@ -110,48 +92,43 @@ function readString(record: Record<string, unknown>, 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<RpcResult> {
// 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<RpcChannelResult> {
// 独立通道下 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: {},
},
}
},
{ authority: 'trusted-host' },
)
}
})
})
}

Expand All @@ -161,7 +138,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 - 会话持久化服务。
Expand All @@ -185,8 +162,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}`)
Expand Down
Loading
Loading