chore: 文档库全量重新入库(2026-10-20)
build / build-and-scan (push) Canceled after 0s

This commit is contained in:
admin committed 2026-10-20 15:05:55 +08:00
commit 5ec99ce227
668 files changed
+164448

No files matched your search

@@ -0,0 +1,25 @@
# §4.9 步骤 1:一次性特权 bootstrap,把 PVC 根 world-writable + sticky。
# 必须在启用 PSA restricted 之前跑(此 namespace 不打 restricted 标签)。
apiVersion: batch/v1
kind: Job
metadata:
name: dsh-users-bootstrap
namespace: dsh-poc
spec:
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
containers:
- name: bootstrap
image: busybox:1.36
command: ["sh", "-c", "chmod 1777 /mnt && ls -ld /mnt"]
securityContext:
privileged: true
volumeMounts:
- name: data
mountPath: /mnt
volumes:
- name: data
persistentVolumeClaim:
claimName: dsh-users
+32
View File
@@ -0,0 +1,32 @@
# §4.9 步骤 2:非 root init Job,目标 uid 建用户目录并 chmod 0700。
# 验证非 root uid 能在 PVC 根写目录(依赖 bootstrap 的 chmod 1777)。
apiVersion: batch/v1
kind: Job
metadata:
name: dsh-u1-init
namespace: dsh-poc
spec:
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 100001
fsGroup: 100001
seccompProfile: { type: RuntimeDefault }
containers:
- name: init
image: busybox:1.36
command: ["sh", "-c", "mkdir -p /mnt/u1/ws /mnt/u1/home && chmod 0700 /mnt/u1 && ls -ldn /mnt/u1"]
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: data
mountPath: /mnt
volumes:
- name: data
persistentVolumeClaim:
claimName: dsh-users
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: dsh-users
namespace: dsh-poc
spec:
accessModes: [ReadWriteMany]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
NS=dsh-poc
UID_=100001
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*"; exit 1; }
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
echo "== 1. PVC bound =="
kubectl apply -f "$HERE/pvc.yaml" >/dev/null
kubectl wait -n "$NS" --for=jsonpath='{.status.phase}'=Bound pvc/dsh-users --timeout=120s
pass "PVC dsh-users bound (Longhorn RWX)"
echo "== 2. bootstrap (privileged chmod 1777) =="
kubectl apply -f "$HERE/bootstrap-job.yaml" >/dev/null
kubectl wait -n "$NS" --for=condition=complete job/dsh-users-bootstrap --timeout=120s
BOOT_LOG="$(kubectl logs -n "$NS" job/dsh-users-bootstrap)"
echo "$BOOT_LOG"
echo "$BOOT_LOG" | grep -q 'drwxrwxrwt' && pass "PVC root is world-writable+sticky" || fail "bootstrap chmod 1777 did not stick"
echo "== 3. init Job (non-root uid builds 0700 dir) =="
kubectl apply -f "$HERE/init-job.yaml" >/dev/null
kubectl wait -n "$NS" --for=condition=complete job/dsh-u1-init --timeout=120s
INIT_LOG="$(kubectl logs -n "$NS" job/dsh-u1-init)"
echo "$INIT_LOG"
# ls -ldn output: drwx------ 2 100001 100001 ... /mnt/u1
echo "$INIT_LOG" | grep -qE 'drwx------.*100001' && pass "u1 dir is 0700 owned by uid $UID_" || fail "init Job could not create/chown u1 (non-root uid cannot write PVC root)"
echo "== 4. test Pod (subPath + runAsUser read/write) =="
kubectl apply -f "$HERE/test-pod.yaml" >/dev/null
kubectl wait -n "$NS" --for=jsonpath='{.status.phase}'=Succeeded pod/dsh-u1-test --timeout=120s
TEST_LOG="$(kubectl logs -n "$NS" pod/dsh-u1-test)"
echo "$TEST_LOG"
echo "$TEST_LOG" | grep -q '^hello$' && pass "subPath write/read works" || fail "subPath mount read/write failed"
echo "$TEST_LOG" | grep -qE 'drwx------.*100001' && pass "mounted dir is 0700 uid $UID_" || fail "mounted dir owner/perm wrong"
echo
echo "ALL ITEM-1 CHECKS PASSED"
+35
View File
@@ -0,0 +1,35 @@
# §4.3:subPath 叶子挂载 + runAsUser,验证目标 uid 能读写自己的目录。
apiVersion: v1
kind: Pod
metadata:
name: dsh-u1-test
namespace: dsh-poc
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 100001
fsGroup: 100001
seccompProfile: { type: RuntimeDefault }
containers:
- name: test
image: busybox:1.36
command:
- sh
- -c
- |
echo hello > /var/lib/dshs/users/u1/ws/probe.txt
cat /var/lib/dshs/users/u1/ws/probe.txt
ls -ldn /var/lib/dshs/users/u1
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: data
mountPath: /var/lib/dshs/users/u1
subPath: u1
volumes:
- name: data
persistentVolumeClaim:
claimName: dsh-users
+60
View File
@@ -0,0 +1,60 @@
// Minimal fake DSH for the socat-WS PoC: plain HTTP on 127.0.0.1:8080 plus a
// WebSocket echo (handshake + one text-frame round-trip) using only node built-ins.
import { createServer } from 'node:http'
import { createHash } from 'node:crypto'
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
const acceptKey = (key) => createHash('sha1').update(key + GUID).digest('base64')
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('fake-dsh\n')
})
server.on('upgrade', (req, socket) => {
const key = req.headers['sec-websocket-key']
if (!key) {
socket.destroy()
return
}
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${acceptKey(key)}\r\n\r\n`,
)
socket.on('data', (buf) => {
const opcode = buf[0] & 0x0f
if (opcode === 0x8) {
socket.end()
return
}
if (opcode !== 0x1 && opcode !== 0x2) return
const masked = (buf[1] & 0x80) !== 0
let len = buf[1] & 0x7f
let offset = 2
if (len === 126) {
len = buf.readUInt16BE(2)
offset = 4
} else if (len === 127) {
return // not exercised in the PoC
}
let maskKey
if (masked) {
maskKey = buf.subarray(offset, offset + 4)
offset += 4
}
const payload = Buffer.from(buf.subarray(offset, offset + len))
if (masked && maskKey) {
for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4]
}
// Echo back as an unmasked text frame.
const out = Buffer.alloc(2 + payload.length)
out[0] = 0x81
out[1] = payload.length
payload.copy(out, 2)
socket.write(out)
})
})
server.listen(8080, '127.0.0.1', () => console.log('fake-dsh listening on 127.0.0.1:8080'))
+38
View File
@@ -0,0 +1,38 @@
# §4.3:dsh(loopback 8080) + socat sidecar(0.0.0.0:8081 → 127.0.0.1:8080)。
# dsh 用一次性 node 镜像跑 fake-dsh.mjs(真实 DSH 不参与本 PoC)。
apiVersion: v1
kind: Pod
metadata:
name: dsh-ws-test
namespace: dsh-poc
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: dsh
image: node:22-alpine
command: ["node", "/app/fake-dsh.mjs"]
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
volumeMounts:
- name: script
mountPath: /app
- name: sidecar
image: alpine/socat:1.8.0.0
args: ["TCP-LISTEN:8081,fork,reuseaddr", "TCP:127.0.0.1:8080"]
ports:
- containerPort: 8081
securityContext:
runAsNonRoot: true
runAsUser: 65532
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
seccompProfile: { type: RuntimeDefault }
volumes:
- name: script
configMap:
name: fake-dsh
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
NS=dsh-poc
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*"; exit 1; }
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
kubectl create configmap fake-dsh --from-file="$HERE/fake-dsh.mjs" -n "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
kubectl apply -f "$HERE/pod.yaml" >/dev/null
kubectl wait -n "$NS" --for=condition=Ready pod/dsh-ws-test --timeout=120s
kubectl port-forward -n "$NS" pod/dsh-ws-test 18081:8081 >/tmp/dsh-poc-pf.log 2>&1 &
PF=$!
trap 'kill $PF 2>/dev/null || true' EXIT
sleep 3
echo "== HTTP through socat (8081 -> 8080) =="
RESP="$(curl -s --max-time 5 http://127.0.0.1:18081/)"
echo "$RESP"
echo "$RESP" | grep -q 'fake-dsh' && pass "HTTP reaches fake-dsh through socat" || fail "HTTP did not reach fake-dsh"
echo "== WebSocket through socat =="
node "$HERE/ws-client.mjs" 127.0.0.1:18081
echo
echo "ALL ITEM-2 CHECKS PASSED"
+64
View File
@@ -0,0 +1,64 @@
// Minimal WebSocket client: handshake + one masked text frame + echo check.
// Usage: node ws-client.mjs <host>:<port>
import { connect } from 'node:net'
import { createHash, randomBytes } from 'node:crypto'
const [host, portStr] = process.argv[2].split(':')
const port = Number(portStr)
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
const key = randomBytes(16).toString('base64')
const socket = connect({ host, port }, () => {
socket.write(
'GET / HTTP/1.1\r\n' +
`Host: ${host}:${port}\r\n` +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Key: ${key}\r\n` +
'Sec-WebSocket-Version: 13\r\n\r\n',
)
})
let headBuf = Buffer.alloc(0)
let echoBuf = Buffer.alloc(0)
let phase = 'handshake'
socket.on('data', (chunk) => {
if (phase === 'handshake') {
headBuf = Buffer.concat([headBuf, chunk])
const idx = headBuf.indexOf('\r\n\r\n')
if (idx === -1) return
const head = headBuf.subarray(0, idx).toString()
if (!/^HTTP\/1\.1 101/.test(head)) return fail(`no 101 (${head.split('\r\n')[0]})`)
const m = head.match(/sec-websocket-accept:\s*(\S+)/i)
const expected = createHash('sha1').update(key + GUID).digest('base64')
if (!m || m[1] !== expected) return fail('bad Sec-WebSocket-Accept')
console.log('PASS: 101 handshake + Sec-WebSocket-Accept')
phase = 'echo'
const payload = Buffer.from('ping')
const frame = Buffer.alloc(2 + 4 + payload.length)
frame[0] = 0x81
frame[1] = 0x80 | payload.length
const mask = Buffer.from([0x12, 0x34, 0x56, 0x78])
mask.copy(frame, 2)
for (let i = 0; i < payload.length; i++) frame[2 + 4 + i] = payload[i] ^ mask[i % 4]
socket.write(frame)
return
}
echoBuf = Buffer.concat([echoBuf, chunk])
// Echoed unmasked text frame: b0=0x81, b1=len, then payload.
if (echoBuf.length < 2) return
const len = echoBuf[1] & 0x7f
if (echoBuf.length < 2 + len) return
const text = echoBuf.subarray(2, 2 + len).toString()
if (text !== 'ping') return fail(`echo mismatch (${text})`)
console.log('PASS: echo round-trip through socat')
process.exit(0)
})
function fail(msg) {
console.error('FAIL: ' + msg)
process.exit(1)
}
socket.on('error', (e) => fail(e.message))
setTimeout(() => fail('timeout'), 10000)
@@ -0,0 +1,18 @@
# 仅控制面(app=dsh-orchestrator)可入 DSH Pod 的 8081(§4.4 单向放行)。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-controlplane-to-dsh
namespace: dsh-poc
spec:
podSelector:
matchLabels:
app: dsh
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels:
app: dsh-orchestrator
ports:
- port: 8081
+10
View File
@@ -0,0 +1,10 @@
# 命名空间内默认拒绝所有 ingress(§3.5 / §4.4)。依赖 CNI 强制(Cilium)。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-ingress
namespace: dsh-poc
spec:
podSelector: {}
policyTypes: [Ingress]
# 空 ingress = 拒绝一切入站
+47
View File
@@ -0,0 +1,47 @@
# 目标 DSH Pod(app=dsh)+ 控制面源(app=dsh-orchestrator)+ 攻击源(app=attacker)。
apiVersion: v1
kind: Pod
metadata:
name: dsh-target
namespace: dsh-poc
labels: { app: dsh, user: u1 }
spec:
automountServiceAccountToken: false
containers:
- name: dsh
image: node:22-alpine
command: ["node", "/app/target.mjs"]
ports: [{ containerPort: 8081 }]
volumeMounts:
- name: script
mountPath: /app
volumes:
- name: script
configMap:
name: dsh-target-script
---
apiVersion: v1
kind: Pod
metadata:
name: controlplane
namespace: dsh-poc
labels: { app: dsh-orchestrator }
spec:
containers:
- name: src
image: busybox:1.36
command: ["sleep", "3600"]
---
apiVersion: v1
kind: Pod
metadata:
name: attacker
namespace: dsh-poc
labels: { app: attacker }
spec:
containers:
- name: src
image: busybox:1.36
command: ["sleep", "3600"]
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
NS=dsh-poc
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*"; exit 1; }
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
kubectl create configmap dsh-target-script --from-file="$HERE/target.mjs" -n "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
echo "== apply pods =="
kubectl apply -f "$HERE/pods.yaml" >/dev/null
kubectl wait -n "$NS" --for=condition=Ready pod/dsh-target --timeout=120s
kubectl wait -n "$NS" --for=condition=Ready pod/controlplane --timeout=120s
kubectl wait -n "$NS" --for=condition=Ready pod/attacker --timeout=120s
echo "== apply NetworkPolicy (default-deny + allow control-plane) =="
kubectl apply -f "$HERE/default-deny.yaml" >/dev/null
kubectl apply -f "$HERE/allow-controlplane.yaml" >/dev/null
sleep 2
IP="$(kubectl get -n "$NS" pod dsh-target -o jsonpath='{.status.podIP}')"
URL="http://$IP:8081/"
echo "target pod IP: $IP"
echo "== control-plane -> dsh (must succeed) =="
if kubectl exec -n "$NS" controlplane -- wget -q -T 5 -O- "$URL" 2>/dev/null | grep -q 'dsh-ok'; then
pass "control-plane reaches dsh:8081"
else
fail "control-plane could NOT reach dsh:8081 (policy or CNI not enforced as expected)"
fi
echo "== attacker -> dsh (must be blocked) =="
if kubectl exec -n "$NS" attacker -- wget -q -T 5 -O- "$URL" >/dev/null 2>&1; then
fail "attacker reached dsh:8081 — NetworkPolicy NOT enforced (flannel? check CNI)"
else
pass "attacker is blocked from dsh:8081 (default-deny enforced)"
fi
echo
echo "ALL ITEM-3 CHECKS PASSED"
+7
View File
@@ -0,0 +1,7 @@
// Target "DSH" for the NetworkPolicy PoC: HTTP 200 on 0.0.0.0:8081.
import { createServer } from 'node:http'
createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('dsh-ok\n')
}).listen(8081, '0.0.0.0', () => console.log('target listening on 0.0.0.0:8081'))
+17
View File
@@ -0,0 +1,17 @@
# §4.5:CloudNativePG 3 实例,Longhorn RWO(每实例独立卷,复制由 PG 自身做)。
# imageName 走 operator 默认镜像;若你的 operator 未设默认,取消注释并指定版本。
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: dsh-pg-poc
namespace: dsh-poc
spec:
instances: 3
# imageName: ghcr.io/cloudnative-pg/postgresql:16.2
storage:
size: 1Gi
storageClass: longhorn
bootstrap:
initdb:
database: dsh
owner: dsh
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
NS=dsh-poc
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
kubectl create namespace "$NS" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
kubectl apply -f "$HERE/cluster.yaml" >/dev/null
echo "== wait for CloudNativePG cluster ready (3 instances) =="
READY=0
for _ in $(seq 1 72); do
READY="$(kubectl get -n "$NS" cluster dsh-pg-poc -o jsonpath='{.status.readyInstances}' 2>/dev/null || echo 0)"
[ "$READY" = "3" ] && break
sleep 5
done
if [ "$READY" != "3" ]; then
echo "FAIL: cluster not ready in time (readyInstances=$READY)"
kubectl get -n "$NS" cluster dsh-pg-poc -o yaml
exit 1
fi
echo "PASS: cluster ready (3 instances)"
PGHOST=dsh-pg-poc-rw
PGUSER="$(kubectl get -n "$NS" secret dsh-pg-poc-app -o jsonpath='{.data.username}' | base64 -d)"
PGPASSWORD="$(kubectl get -n "$NS" secret dsh-pg-poc-app -o jsonpath='{.data.password}' | base64 -d)"
PGDATABASE="$(kubectl get -n "$NS" secret dsh-pg-poc-app -o jsonpath='{.data.dbname}' | base64 -d)"
echo "== pgbench init (scale 5) =="
kubectl run -n "$NS" pgbench-init --rm -i --restart=Never --image=postgres:16 \
--env "PGHOST=$PGHOST" --env "PGUSER=$PGUSER" --env "PGPASSWORD=$PGPASSWORD" --env "PGDATABASE=$PGDATABASE" \
-- pgbench -i -s 5 2>&1 | tail -3
echo
echo "== pgbench select-only (30s, c4/j2) =="
kubectl run -n "$NS" pgbench-run --rm -i --restart=Never --image=postgres:16 \
--env "PGHOST=$PGHOST" --env "PGUSER=$PGUSER" --env "PGPASSWORD=$PGPASSWORD" --env "PGDATABASE=$PGDATABASE" \
-- pgbench -S -T 30 -c 4 -j 2 2>&1 | grep -E 'latency average|including connections establishing' || true
echo
echo "记录上面的 tps / latency average 作为 Longhorn 上的基线(对照节点 NVMe 判断 §4.5 是否可接受)"
+116
View File
@@ -0,0 +1,116 @@
# Phase 3 核心路径 PoC(不写业务代码)
> 🧭 [← 返回 README](../README.md) · 结论落地为:[K8s 部署教程](../docs/k8s-deployment.md)
> 对应内部设计稿 §7 的四项风险验证。**不引入任何业务代码**——全部用一次性
> 镜像(busybox / node / socat / CloudNativePG)验证基础设施假设。四项全部通过,
> 才允许进入 Phase 0 镜像化 / Phase 2/3 落地,避免 Phase 3 返工。
## 前置条件
- 一个 k3s 集群(`k3s --cluster-init` 3 server + N worker 皆可,单机 k3s 也能跑 PoC)。
- 已装且健康:
- **Longhorn**(item 1、4 依赖;item 1 是硬前提,必须 `longhorn.io` StorageClass 可用)
- **Cilium**(item 3 依赖;NetworkPolicy 必须被强制)
- **CloudNativePG** operator(item 4 依赖)
- **cert-manager / MetalLB / kube-vip** 与本 PoC 无关,暂不需要
- `kubectl` 指向该集群(`kubectl get nodes` 有输出)。
- 本地有 `bash` + `curl`(item 2/3 用);item 2 的 WS 客户端用 `node`(本机或容器内均可)。
```sh
kubectl get nodes
kubectl get storageclass longhorn # item 1/4 前提
kubectl get pods -n kube-system -o wide | grep -E 'cilium|longhorn|csi'
kubectl get crd clusters.postgresql.cnpg.io # item 4 前提
```
---
## Item 1 — Longhorn RWX + subPath + runAsUser/fsGroup(§4.9 / §6.0 的 PoC)
**验证的未知点**:`fsGroup` 是否会 chown subPath 叶子?非 root 目标 uid 能否在 PVC
根建目录、`0700` 属主是否正确、子目录挂载后能否读写。
> ⚠️ 这是 `docs/k8s.md` §4.9 标明的「PoC 第一项必须验证」。若失败,按 §4.9 的
> 回退:bootstrap 阶段用特权 Job 完成 `chmod 1777`(在启 PSA restricted **之前**),
> 时序固定为「初始化 PVC → 启 PSA → 允许用户 Pod」。
```sh
cd 01-longhorn-subpath
./run.sh
```
**通过标准**(脚本自动断言):
1. bootstrap Job(特权)`chmod 1777 /mnt` 成功。
2. init Job(非 root,`runAsUser/fsGroup = 100001`)能 `mkdir -p /mnt/u1/{ws,home}` 且
`chmod 0700 /mnt/u1` 成功——即**非 root uid 能写 PVC 根**(`chmod 1777` 生效)。
3. 测试 Pod(`runAsUser=100001`,`subPath: u1`)能写/读文件,且 `stat` 显示目录属主
uid=100001、权限 0700。
4. (可选)另一个 uid(100002)无法读该目录——`subPath` 叶子 + DAC 的越权边界。
---
## Item 2 — socat 桥 WebSocket 透明转发(§3.2 / §4.3)
**验证的未知点**:DSH 只监听 loopback,`socat TCP-LISTEN:8081 → 127.0.0.1:8080` 的纯
TCP 转发对 WebSocket Upgrade 是否透明。
```sh
cd 02-socat-ws
./run.sh
```
**通过标准**:
1. 通过 sidecar 的 8081 能拿到 fake-dsh 的 HTTP 200(TCP 基础通)。
2. WebSocket Upgrade 握手经 8081 返回 `101 Switching Protocols`,且
`Sec-WebSocket-Accept` 校验正确(证明 Upgrade 请求与响应都原样穿透 socat)。
3. 一条文本帧 echo 往返成功(双向透明)。
> 若 2/3 不透明,回退:换 `nginx`/`netcat` sidecar(同端口转发),见 §9。
---
## Item 3 — NetworkPolicy 默认拒绝 + 控制面→DSH 单向(§3.5 / §4.4)
**验证的未知点**:Cilium 是否强制 NetworkPolicy;default-deny 下「仅控制面可达 DSH
8081」的单向放行是否正确,跨来源访问被拒。
```sh
cd 03-networkpolicy
./run.sh
```
**通过标准**:
1. 同 namespace 内打了 `app=dsh-orchestrator` 的「控制面」Pod 能连通 DSH 8081。
2. 同 namespace 内**其它** Pod(`app=attacker`)访问 DSH 8081 **被拒**(超时/拒绝)。
3. 这也覆盖 §3.5 的「每次 NetworkPolicy 变更后跑自动化验证」——本脚本即该验证的雏形。
> 若 flannel(k3s 默认)下 2 仍通,说明 CNI 未强制策略,必须切 Cilium(§11.2)。
---
## Item 4 — CloudNativePG on Longhorn 的 pgbench 基线(§4.5)
**验证的未知点**:Postgres 对延迟/IOPS 敏感,Longhorn RWO 复制是否拖慢同步复制,
延迟是否可接受。
```sh
cd 04-cnpg-pgbench
./run.sh
```
**通过标准**(人工判读,无固定阈值,取决于硬件):
1. CloudNativePG 3 实例主备建立、`Ready`。
2. `pgbench -S`(select-only)tps 与延迟与本机 NVMe 基线对比,落在可接受区间。
§4.5 结论:不够则回退「节点本地 NVMe + PG 主备」。
---
## 通过后
四项全过 → 记录结果(tps/latency 数字、Longhorn 权限行为结论)到本 README 尾部,
再进入 Phase 0(镜像化)。任一项失败 → 按对应 §9 回退方案调整后重跑。
@@ -0,0 +1,77 @@
# 最小可跑示例 · IM 插件(含跨进程反向回调)
> 目标:**插件作者有一个能直接跑的起点** —— 不用先啃完契约、不用先连真平台。
> 反向通道(平台 → 插件)已于 **2026-09-26 落地并上 47**;本目录是它的**唯一示例落点**。
## 一键跑
```bash
cd poc/business-plugins-im/minimal-callbacks
node self-check.mjs # 25 例;退出码 0 = 全绿
```
不需要 `npm install`(**零依赖**)、不需要平台、不需要环境变量 —— 自检自带一个
`mock-platform.mjs`(模拟登记 / 长轮询 / 回投 / 出向四个端点),把完整协议跑一遍。
## 文件
| 文件 | 作用 |
|---|---|
| `index.mjs` | **主体**:声明面 `manifest` + 拼装 `start()`;两条回调用 `onSpeakRule()` / `onEvent()` 注册 |
| `mock-platform.mjs` | 极简「平台侧」模拟(⚠️ 只对齐协议形状,不替代真平台) |
| `self-check.mjs` | 自检 25 例:登记 / 规则拒与放行 / 事件送达 / 未接线不变 |
| `package.json` | `type: module`;`npm run self-check` 同义 |
## 覆盖的两条反向回调(跨进程)
| 回调 | 语义 | 本示例怎么用 |
|---|---|---|
| `host.onSpeakRule()` → `speakRules.check` | **同步**:平台**写之前**问插件,带 deadline | **回合制**:没轮到的人发言 ⇒ `{ok:false, detail}` ⇒ 拒发 |
| `host.onEvent()` → `events.onEvent` | **异步**:不阻塞消息流,尽力送达 | 有人发言 ⇒ 广播一帧(`host.frame()`) |
## 🔴 三个必须记住的点
1. **回合制 / 限速型必须显式声明 `onTimeout: 'deny'`**
—— 缺省是 `'allow'`(为了与平台既有口径「无插件 / 插件故障 ⇒ 放行」逐字一致)。
对回合制来说"放行"就等于**规则失效**:你的实例不在线(重启 / 崩溃 / 部署窗口)时,
非当前回合者的消息会照常写入。声明位在 `manifest.speakRules[].onTimeout`。
2. **`manifest` 里不许塞函数**(`check` / `validate` / `onEvent` / `render` / `onAction`)——
跨不了进程,SDK 会自动剔除。函数侧一律走 `onSpeakRule()` / `onEvent()` 注册;
自检里有一条专门断言"送上平台的 manifest 确实没有函数"。
3. **一条实例只该有一条常驻长轮询**(`ready()` 登记后自动启,幂等)——它直接占平台连接额度,
⛔ 别自己再写第二条。收尾用 `await host.close()`(会掐断在途长轮询,否则进程退不出去)。
## ⛔ 不做什么
- **不开端口**:所有调用都是插件**拨出去**的(HTTP 到平台);平台不打进实例、实例不需可寻址。
- **不新增凭据**:复用实例凭据(`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN`,平台起实例时注入)。
- **不把事件当可靠消息流**:事件队列**有界**(满则丢最旧并计数)⇒ 要可靠增量请用
`host.subscribe()`(游标补齐,不会丢)。
- **判不了就拒(fail-closed)要靠"返回 `{ok:false}`",⛔ 不是靠抛错** ——
你的 `check` 抛错时,平台按"插件故障 ⇒ 放行"处理(与同进程语义逐字一致)。
## 接真平台
1. 把 `sdk/im-plugin-host/index.mjs` 拷进你自己的包(单文件、零依赖、可 vendoring),
并把 `index.mjs` 顶部的 import 改成你包内的路径。
2. 保证 `pluginId`(SDK 参数 = **包名**)与 `manifest.packageName` / `manifest.pluginId` **自洽**:
`@dsh-local/im-plugin-demo` → 内部 id `im_plugin_demo`(去 scope、非字母数字 → `_`)。
不自洽 ⇒ `plugin-id-mismatch`;**出向恒 `plugin-unavailable`**。
3. 在实例内运行:`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN` 由平台注入,
⛔ 不要手填、不要硬编码。出向发言前,bot **必须已是该房成员**(进房是房主 / 管理员动作)。
## 与同目录另外三个示例的关系
`mud-rate` / `office-tasks` / `trpg-turn` 写于反向通道落地**之前**,把 `check` / `onEvent`
直接写在 `manifest` 里 —— 那是**同进程**形态,跨不了进程。它们仍有参考价值(声明面、
业务角色、面板),但**函数侧请照本目录写**。
⚠️ 本示例**刻意不声明 `tables`**(零数据面):一是保持"最小",二是
`package.json#dsh.data.schema` 与契约内 `tables:` 两种写法的**口径尚未裁定**(见接续包 §8-2)。
需要数据面时先定口径,别照本示例推。
## 依据
- 契约:`dsh-server-docs/01-规范/09-IM插件SDK与扩展点契约.md`(§10.4 / §12)
- 落地与验收:工作区 `接续包_IM反向通道实现_20260926.md`
- 内核约束(⛔ 不得破):`store.ts` 零改动 / `hub.ts` 纯插入 / `ws.ts` 只动投递点 / `routes/im.ts` 纯插入
@@ -0,0 +1,170 @@
/**
* @dsh-local/im-plugin-minimal-callbacks —— **可跑的最小示例(含跨进程反向回调)**。
*
* ## 它解决什么问题
* 本目录是「插件作者从 0 到能跑」的**唯一落点**:`poc/business-plugins-im/` 下另外三个示例
* (`mud-rate` / `office-tasks` / `trpg-turn`)写于**反向通道落地之前** ⇒ 它们把 `check` /
* `onEvent` 直接写在 manifest 里,那是**同进程**形态,**跨不了进程**。
* 本示例是补齐后的正确写法。
*
* ## 覆盖的两条反向回调(跨进程 · 平台 → 插件)
* ① **同步发言规则** `host.onSpeakRule()` —— 平台**写之前**把待判定的发言交给长轮询,
* 你在**自己进程内**判,`{ok:false, detail}` ⇒ 拒发。本示例 = **回合制**,
* 🔴 并且**必须**在 manifest 里声明 `onTimeout: 'deny'`(理由见下)。
* ② **异步事件订阅** `host.onEvent()` —— 平台把事件放进你的有界队列,长轮询取回后在本进程执行。
*
* ## 🔴 为什么回合制必须声明 `onTimeout: 'deny'`
* `speakRules[].onTimeout` 的**缺省值是 `'allow'`**(放行)—— 这是为了与平台既有口径
* 「无插件 / 插件故障 ⇒ 放行(回落自由并发)」**逐字一致**,不让插件把房间卡死。
* 但对**回合制 / 限速型**来说,"放行"就是**规则失效**:你的实例不在线(重启 / 崩溃 / 部署窗口)
* 时,非当前回合者的消息会**照常写入**,等于没装规则。
* ⇒ **回合制 / 限速型必须显式声明 `'deny'`**,否则超时即破规则。
*
* ## ⛔ 三条纪律(本示例刻意遵守,照抄即可)
* - **不开端口**:所有调用都是**你拨出去**的(HTTP 到平台);⛔ 插件不得监听端口。
* - **manifest 里不许塞函数**:`check` / `validate` / `onEvent` / `render` / `onAction`
* **跨不了进程**,SDK 会自动剔除 ⇒ 函数侧一律走 `onSpeakRule()` / `onEvent()` 注册。
* - **一条实例只该有一条常驻长轮询**(`ready()` 自动启,⛔ 别自己再写第二条)——它占平台连接额度。
*
* @module @dsh-local/im-plugin-minimal-callbacks
*/
// 示例内用**相对路径**引入 SDK(本仓自带单文件,零依赖、可 vendoring)。
// 真实插件里请把 `sdk/im-plugin-host/index.mjs` 拷进你自己的包,再 `import ... from '<你的包内路径>'`。
import { createImPluginHost } from '../../../sdk/im-plugin-host/index.mjs'
/** 你的包名(🔴 SDK 的 `pluginId` 参数 = 包名,不是平台内部 id)。 */
export const PLUGIN_ID = '@dsh-local/im-plugin-minimal-callbacks'
/** 本示例只用一种房间类型,便于一眼看懂。 */
export const ROOM_TYPE = 'demo-turn'
const TIMEOUT_MS = 50
const CIRCUIT = { threshold: 5, cooldownMs: 30_000 }
/**
* 声明面(交给平台)—— ⛔ **不含任何函数**。
*
* 声明**照传**(平台靠它路由"该问哪个插件");函数侧在 `start()` 里用 `onSpeakRule()` / `onEvent()` 注册。
*/
export const manifest = {
pluginId: 'im_plugin_minimal_callbacks',
packageName: PLUGIN_ID,
label: '最小回调示例',
version: '0.1.0',
// ── 扩展点 1 · 房间类型 ──────────────────────────────────────────────────
roomTypes: [
{
type: ROOM_TYPE,
label: '回合制示例房',
requiresReview: false,
transport: 'chat',
defaultConfig: { turnOf: '' },
uses: ['roomTypes', 'speakRules', 'events'],
timeoutMs: TIMEOUT_MS,
circuit: CIRCUIT,
},
],
// ── 扩展点 2 · bot(出向发言要借它的身份) ──────────────────────────────
bots: [{ botId: 'bot_minimal', label: '示例助手', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }],
// ── 扩展点 3 · 出向声明(⛔ 未声明 ⇒ 出向恒 `plugin-unavailable`) ──────
outbounds: [
{ outboundId: 'minimal_out', label: '出向', roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT },
],
// ── 扩展点 4 · 发言规则(**声明**) ─────────────────────────────────────
// 🔴 `onTimeout: 'deny'` 是**本示例的要点**:回合制下超时必须拒,否则规则形同不存在。
speakRules: [
{ rule: 'turn-based', roomTypes: [ROOM_TYPE], onTimeout: 'deny', timeoutMs: TIMEOUT_MS, circuit: CIRCUIT },
],
// ── 扩展点 5 · 事件订阅(**声明**) ─────────────────────────────────────
events: [{ events: ['message.created'], roomTypes: [ROOM_TYPE], timeoutMs: TIMEOUT_MS, circuit: CIRCUIT }],
}
/**
* 回合制判定(**纯函数**,导出以便单测直接断言 —— 跨进程后你更需要单测,因为平台侧不再有你的代码)。
*
* 规则:`config.turnOf` = 当前该行动的人。
* - 空 ⇒ **视为自由**(未开局不该把房间卡死);
* - 是本人 ⇒ 放行;
* - 系统消息 / bot / agent 回写 ⇒ 放行(否则 agent 代答会把自己卡死);
* - 其余 ⇒ 拒,并给**可读原因**(`detail` 会进平台的审计 / 回给调用方)。
*
* ⚠️ 本函数**抛错**时平台按"插件故障 ⇒ 放行"处理(与同进程语义逐字一致)——
* 所以"判不了就拒"(fail-closed)要靠**返回** `{ok:false}`,⛔ 不是靠抛错。
*/
export function decideTurn(input) {
const turnOf = input?.config?.turnOf
if (typeof turnOf !== 'string' || turnOf === '') return { ok: true, reason: 'ok' }
if (turnOf === input.authorId) return { ok: true, reason: 'ok' }
if (input.authorKind === 'bot' || input.envelope?.via === 'system') return { ok: true, reason: 'ok' }
return {
ok: false,
reason: 'bad-args',
detail: `还没轮到「${input.authorId}」行动(当前回合 = ${turnOf})`,
}
}
/**
* 起插件:登记 + 挂两条回调 + 自动开始长轮询。
*
* @param {object} [options]
* @param {string} [options.baseUrl] - 缺省读 `DSH_PLATFORM_IM_URL`(平台起实例时注入)。
* @param {string} [options.token] - 缺省读 `DSH_IM_INSTANCE_TOKEN`。
* @param {(e: object) => void} [options.onLog] - 具名日志出口(⛔ SDK 自己不 print)。
* @returns {Promise<{host: object, reg: object, counters: () => object}>}
*/
export async function start(options = {}) {
const onLog = typeof options.onLog === 'function' ? options.onLog : (e) => console.warn('[minimal-callbacks]', e)
const host = await createImPluginHost({
pluginId: PLUGIN_ID,
manifest,
...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
...(options.token === undefined ? {} : { token: options.token }),
onLog,
})
// ── ① 同步发言规则(跨进程)─────────────────────────────────────────────
host.onSpeakRule({
rule: 'turn-based',
roomTypes: [ROOM_TYPE],
onTimeout: 'deny', // 🔴 与 manifest 的声明**必须一致**;回合制 / 限速型写 'deny'
check: (input) => decideTurn(input),
})
// ── ② 异步事件订阅(跨进程 · 尽力送达)─────────────────────────────────
let events = 0
host.onEvent({
events: ['message.created'],
roomTypes: [ROOM_TYPE],
onEvent: async (event) => {
events += 1
// ⚠️ 事件队列**有界**(满则丢最旧并计 `bridgeDropped`)⇒ ⛔ 别把这里当可靠消息流;
// 要可靠性请用 `host.subscribe()`(游标增量,不会丢)。
//
// 本示例:把"有人行动了"广播成一帧(ephemeral:不落库 / 不进历史)。
// 真实业务里这里可以写库、调模型、发公告 —— 都是**在你自己的进程内**跑。
await host.frame({
roomId: event.roomId,
channel: 'turn.activity',
data: { at: event.at, authorId: event.data?.authorId ?? '', seq: event.data?.seq ?? 0 },
outboundId: 'minimal_out',
})
},
})
// 🔴 登记成功后,只要上面注册过回调 ⇒ `ready()` **自动开始长轮询**(幂等,别自己再启一条)。
const reg = await host.ready()
if (reg.ok !== true) onLog({ at: 'start', ok: false, reason: reg.reason, detail: reg.detail })
return {
host,
reg,
/** 计数读数(⛔ 只回计数,不含内容)—— 上生产后靠它看"回调到底跑没跑"。 */
counters: () => ({ ...host.callbacks, events }),
}
}
@@ -0,0 +1,206 @@
/**
* 极简「平台侧」模拟实现 —— 只为让本示例**不接真平台也能一键跑起来**。
*
* ## 它模拟什么
* 反向通道只用到三个端点(真实现见平台仓 `src/web/routes/im.ts` 的「纯插入」段):
* | 端点 | 方向 | 作用 |
* |---|---|---|
* | `POST /api/im/plugins/register` | 插件 → 平台 | 交声明面 |
* | `POST /api/im/plugins/bridge/pull` | 插件 → 平台(长轮询) | 取工作项;队列空 ⇒ 挂起到 `waitMs` 后回空 |
* | `POST /api/im/plugins/bridge/result` | 插件 → 平台 | 回投结果(⛔ 幂等:过期 `callId` ⇒ `unknown-call-id`) |
* 外加两个出向端点(`out/frame` / `out/message`),用来断言事件回调**真的跑到了**。
*
* ⚠️ **这不是平台的替代品**:它不校验房间成员、不落库、无鉴权语义,只做"协议形状对齐"。
* 端到端真实验收仍要连真平台(见本目录 README「接真平台」)。
*
* @module mock-platform
*/
import { createServer } from 'node:http'
/** 服务端长轮询硬上限(与平台 `ImBridgeCallbackBus.maxPollHoldMs = 20 s` 同一口径)。 */
export const MAX_POLL_HOLD_MS = 20_000
/**
* 起一个模拟平台。
*
* @param {object} [options]
* @param {number} [options.holdCapMs] - **本模拟器**单次挂起上限(缺省 800)。⚠️ 只为让自检快跑;
* 真平台是 20 s。它调小了 ⇒ 客户端会更快重拨,属预期(不会因此误判功能)。
* @param {string} [options.token] - 期望的实例凭据(缺省 `dev-token`)。
* @returns {Promise<object>} 模拟平台句柄(含排队 / 取结果 / 读帧的辅助方法)。
*/
export async function startMockPlatform(options = {}) {
const holdCapMs = Number.isFinite(options.holdCapMs) ? options.holdCapMs : 800
const token = options.token ?? 'dev-token'
/** @type {Array<object>} 待取的工作项 */
const queue = []
/** @type {Map<string, object>} 已回投的结果(按 callId) */
const results = new Map()
/** @type {Array<object>} 收到的帧 */
const frames = []
/** @type {Array<object>} 收到的落库发言 */
const outMessages = []
/** @type {Array<() => void>} 在挂起的长轮询(有货就唤醒) */
let waiters = []
let registered = null
let pullCount = 0
let lastToken = null
function wake() {
const pending = waiters
waiters = []
for (const fn of pending) fn()
}
function json(res, status, body) {
const text = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) })
res.end(text)
}
async function readBody(req) {
const chunks = []
for await (const c of req) chunks.push(c)
const text = Buffer.concat(chunks).toString('utf8')
if (text === '') return {}
try {
return JSON.parse(text)
} catch {
return { __badJson: text }
}
}
const server = createServer((req, res) => {
void (async () => {
const path = (req.url ?? '').split('?')[0]
const body = req.method === 'POST' ? await readBody(req) : {}
lastToken = req.headers['x-dsh-im-instance-token'] ?? null
if (path === '/api/im/plugins/register') {
registered = { pluginId: req.headers['x-dsh-im-plugin-id'] ?? null, manifest: body }
json(res, 200, { ok: true, pluginId: registered.pluginId })
return
}
if (path === '/api/im/plugins/bridge/pull') {
pullCount += 1
const want = Number.isFinite(body?.waitMs) ? body.waitMs : 0
const deadline = Date.now() + Math.max(0, Math.min(want, holdCapMs))
while (queue.length === 0 && Date.now() < deadline) {
await new Promise((resolve) => {
const t = setTimeout(resolve, Math.max(1, deadline - Date.now()))
waiters.push(() => {
clearTimeout(t)
resolve()
})
})
}
const max = Number.isFinite(body?.max) ? body.max : 32
const items = queue.splice(0, Math.max(1, max))
json(res, 200, { ok: true, items })
return
}
if (path === '/api/im/plugins/bridge/result') {
const callId = typeof body?.callId === 'string' ? body.callId : ''
if (callId === '') {
json(res, 400, { ok: false, error: 'call-id-required' })
return
}
if (results.has(callId)) {
// 真口径:幂等,过期 / 重复回投 ⇒ 具名拒绝,⛔ 不重复 resolve。
json(res, 200, { ok: false, reason: 'unknown-call-id' })
return
}
results.set(callId, { ok: body.ok === true, reason: body.reason, detail: body.detail })
json(res, 200, { ok: true, accepted: true })
return
}
if (path === '/api/im/plugins/out/frame') {
frames.push(body)
json(res, 200, { ok: true })
return
}
if (path === '/api/im/plugins/out/message') {
outMessages.push(body)
json(res, 200, { ok: true })
return
}
if (path === '/api/im/plugins/unregister') {
registered = null
json(res, 200, { ok: true })
return
}
if (path === '/api/im/rooms') {
json(res, 200, { ok: true, rooms: [] })
return
}
json(res, 404, { ok: false, error: 'not-found', detail: path })
})().catch((err) => {
try {
json(res, 500, { ok: false, error: 'mock-failure', detail: String(err) })
} catch {
/* 已断开 */
}
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
return {
url: `http://127.0.0.1:${port}`,
token,
/** 平台收到的登记面(含 manifest 原文)—— 用来断言"函数没被送出去"。 */
get registered() {
return registered
},
get frames() {
return frames
},
get outMessages() {
return outMessages
},
get pullCount() {
return pullCount
},
get lastToken() {
return lastToken
},
/** 造一条待判定的发言(模拟"用户在写")。 */
queueSpeakCheck({ callId, input, timeoutMs = 50 }) {
queue.push({ kind: 'speak.check', callId, pluginId: registered?.pluginId ?? '', timeoutMs, input })
wake()
},
/** 造一条事件(模拟"有人发言了")。 */
queueEvent({ callId, event, timeoutMs = 50 }) {
queue.push({ kind: 'event.deliver', callId, pluginId: registered?.pluginId ?? '', timeoutMs, event })
wake()
},
/** 等某个 `callId` 的回投结果(超时 ⇒ 回 `null`,由调用方断言失败)。 */
async waitResult(callId, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (results.has(callId)) return results.get(callId)
await new Promise((r) => setTimeout(r, 10))
}
return null
},
async close() {
wake()
await new Promise((resolve) => server.close(resolve))
},
}
}
@@ -0,0 +1,15 @@
{
"name": "@dsh-local/im-plugin-minimal-callbacks",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "index.mjs",
"exports": {
".": "./index.mjs"
},
"scripts": {
"self-check": "node self-check.mjs"
},
"dependencies": {},
"license": "MIT"
}
@@ -0,0 +1,126 @@
/**
* 自检(**一键跑**):`node self-check.mjs`
*
* 它证明本示例的五件事 —— 都不接真平台,靠 `mock-platform.mjs` 跑完整协议:
* ① 登记成功,且送上平台的 manifest **不含函数**(跨进程只传数据);
* ② 声明里 `onTimeout: 'deny'` 在位(回合制的命门);
* ③ **同步发言规则**真的生效:非当前回合者 ⇒ 回 `{ok:false}`(**且原因可读**);
* ④ **异步事件订阅**真的跑到:`event.deliver` ⇒ 你的 `onEvent` 执行 ⇒ 出了一帧;
* ⑤ **未接线时行为不变**:缺 `baseUrl` / `token` ⇒ **不抛**、不启长轮询(插件其余功能不受影响)。
*
* 退出码:0 = 全绿;1 = 有红。⛔ 红就是红 —— 别把"没报错"当通过。
*/
import { decideTurn, manifest, start, ROOM_TYPE, PLUGIN_ID } from './index.mjs'
import { startMockPlatform } from './mock-platform.mjs'
let failed = 0
let passed = 0
function check(name, condition, extra = '') {
if (condition) {
passed += 1
console.log(` ✅ ${name}`)
} else {
failed += 1
console.log(` ❌ ${name}${extra === '' ? '' : ` —— ${extra}`}`)
}
}
const ROOM_ID = 'room_demo_1'
const eventOf = (authorId) => ({
name: 'message.created',
roomId: ROOM_ID,
roomType: ROOM_TYPE,
at: 1_758_000_000_000,
data: { messageId: 'm1', seq: 1, authorId, authorKind: 'user', via: 'client', visibility: 'room', payload: { kind: 'text', text: 'hi' } },
})
async function main() {
console.log('── ① 纯函数判定(回合制语义,可单测)')
check('未开局(turnOf 空)⇒ 放行', decideTurn({ config: { turnOf: '' }, authorId: 'u1' }).ok === true)
check('轮到本人 ⇒ 放行', decideTurn({ config: { turnOf: 'u2' }, authorId: 'u2' }).ok === true)
const rejected = decideTurn({ config: { turnOf: 'u1' }, authorId: 'u2' })
check('没轮到 ⇒ 拒,且原因可读', rejected.ok === false && typeof rejected.detail === 'string' && rejected.detail.length > 0, JSON.stringify(rejected))
check('bot / agent 代答不参与回合(⛔ 否则会自卡)', decideTurn({ config: { turnOf: 'u1' }, authorId: 'bot_x', authorKind: 'bot' }).ok === true)
console.log('\n── ② 声明面(不接平台也能看)')
check(`清单里 onTimeout = 'deny'`, manifest.speakRules[0].onTimeout === 'deny')
const platform = await startMockPlatform()
let host
try {
console.log('\n── ③ 接"平台",跑真实协议(登记 → 长轮询 → 回投)')
const logLines = []
const boot = await start({
baseUrl: platform.url,
token: platform.token,
onLog: (e) => logLines.push(e),
})
host = boot.host
check('登记成功(reg.ok === true)', boot.reg.ok === true, JSON.stringify(boot.reg))
check('拼上了实例凭据', platform.lastToken === platform.token)
// 送上平台的 manifest:有声明、但**没有函数**。
const sent = platform.registered?.manifest ?? {}
check('manifest 送上去了且声明在', Array.isArray(sent.speakRules) && sent.speakRules.length === 1)
check('🔴 manifest 里没有函数(check / onEvent 已被 SDK 剔除)', sent.speakRules[0].check === undefined && sent.events[0].onEvent === undefined, JSON.stringify(Object.keys(sent.speakRules[0])))
check('送上平台的 onTimeout 也是 deny', sent.speakRules[0].onTimeout === 'deny')
check('长轮询已启动(callbacks.started)', host.callbacks.started === true)
// ⑶ 同步发言规则:没轮到的发言必须被拒。
platform.queueSpeakCheck({
callId: 'c1',
input: { roomId: ROOM_ID, roomType: ROOM_TYPE, authorId: 'u2', authorKind: 'user', config: { turnOf: 'u1' }, envelope: { via: 'client' } },
})
const r1 = await platform.waitResult('c1')
check('⚠️ 非当前回合者 ⇒ 平台收到 ok:false(拒)', r1 !== null && r1.ok === false, JSON.stringify(r1))
check('拒绝原因可读(不是空 detail)', r1 !== null && typeof r1.detail === 'string' && r1.detail.includes('还没轮到'), JSON.stringify(r1))
// 轮到本人 ⇒ 必须放行(否则规则把正常发言也卡死 = 净变差)。
platform.queueSpeakCheck({
callId: 'c2',
input: { roomId: ROOM_ID, roomType: ROOM_TYPE, authorId: 'u1', authorKind: 'user', config: { turnOf: 'u1' }, envelope: { via: 'client' } },
})
const r2 = await platform.waitResult('c2')
check('✅ 轮到本人 ⇒ 放行(ok:true)', r2 !== null && r2.ok === true, JSON.stringify(r2))
// ⑷ 异步事件订阅:事件送达 ⇒ onEvent 在本进程执行 ⇒ 出一帧。
platform.queueEvent({ callId: 'e1', event: eventOf('u1') })
const r3 = await platform.waitResult('e1')
check('事件回投成功(ok:true)', r3 !== null && r3.ok === true, JSON.stringify(r3))
const deadline = Date.now() + 2000
while (platform.frames.length === 0 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10))
check('⚠️ onEvent 真的执行了(平台收到 1 帧)', platform.frames.length === 1, `frames=${platform.frames.length}`)
check('帧内容 = 事件里的作者', platform.frames[0]?.data?.authorId === 'u1', JSON.stringify(platform.frames[0]))
check('出向带了 outboundId', platform.frames[0]?.outboundId === 'minimal_out')
check('读数可观测(items / settled > 0)', host.callbacks.items >= 3 && host.callbacks.settled >= 3, JSON.stringify(host.callbacks))
check('整轮无 onLog 报错', logLines.length === 0, JSON.stringify(logLines))
await host.close()
check('close() 后长轮询已停', host.callbacks.stopped === true)
host = undefined
} finally {
await platform.close()
}
console.log('\n── ④ 未接线(缺 baseUrl / token)⇒ 行为必须不变')
const offline = await start({ baseUrl: '', token: '', onLog: () => {} })
check('不抛(start 正常返回)', offline.host !== undefined)
check('不启长轮询(callbacks.started === false)', offline.host.callbacks.started === false)
check('登记失败回具名结论(not-configured)', offline.reg.ok === false && offline.reg.reason === 'not-configured', JSON.stringify(offline.reg))
check('出向回具名结论而非抛错', (await offline.host.frame({ roomId: ROOM_ID, channel: 'x', data: {} })).reason === 'not-configured')
await offline.host.close()
console.log(`\n══════ 结果:${passed} 例通过 / ${failed} 例失败 ══════`)
console.log(`(插件身份 ${PLUGIN_ID} · 房间类型 ${ROOM_TYPE})`)
process.exit(failed === 0 ? 0 : 1)
}
main().catch((err) => {
console.error('自检自身崩了:', err)
process.exit(1)
})
@@ -0,0 +1,8 @@
// @dsh-local/im-plugin-mud — bundle patch (host row only)
//
// D 单示例插件之二(MUD)。走**实时档**(档案 108 游戏口径,jitter 预算 20 ms),
// 发言规则 = 限速。⛔ 无 client 面。
- insert:
- id: im-plugin-mud
name: '@dsh-local/im-plugin-mud'
@@ -0,0 +1 @@
{"name":"@dsh-local/im-plugin-mud","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}
@@ -0,0 +1,8 @@
// @dsh-local/im-plugin-office — bundle patch (host row only)
//
// D 单示例插件之一(办公群)。⛔ 无 client 面:本插件只在**服务端**把房间类型 +
// 任务卡载荷 + 面板插槽声明给 IM SDK,UI 成品在 E 单。
- insert:
- id: im-plugin-office
name: '@dsh-local/im-plugin-office'
@@ -0,0 +1,17 @@
{
"name": "@dsh-local/im-plugin-office",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"dependencies": {},
"license": "MIT"
}
@@ -0,0 +1,8 @@
// @dsh-local/im-plugin-trpg — bundle patch (host row only)
//
// D 单示例插件之三(跑团)。发言规则 = **回合制**(非当前回合者发言被拒),
// 含角色卡表与 DM/玩家业务角色。⛔ 无 client 面。
- insert:
- id: im-plugin-trpg
name: '@dsh-local/im-plugin-trpg'
@@ -0,0 +1 @@
{"name":"@dsh-local/im-plugin-trpg","version":"0.1.0","private":true,"type":"module","main":"lib/index.js","exports":{".":"./lib/index.js"},"dsh":{"bundle":{"patch":"./cordis.patch.yml"}},"dependencies":{},"license":"MIT"}
+9
View File
@@ -0,0 +1,9 @@
# 打包排除项(2026-09-12 加)
#
# 背景:0.2.5 打包时,目录里上一版留下的 business-plugins-0.2.4.tgz 被一并打进了产物
# (package/business-plugins-0.2.4.tgz),包体从 8.9 KB 虚涨到 19.3 KB,且会把旧版本
# 永久带进用户的 node_modules。用忽略规则根治,而不是每次记得手工先删。
*.tgz
*.log
*.tmp
.DS_Store
+7
View File
@@ -0,0 +1,7 @@
# @dsh-local/business-plugins — bundle patch (host row only)
# 功能插件启停:功能全在 client 面(settings.section),host 面仅加载标记。
# 无 inject(defensive)——index.js 的 apply 不使用任何 ctx 服务。
- insert:
- id: business-plugins
name: '@dsh-local/business-plugins'
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
// @dsh-local/business-plugins — host plugin (dsh 0.1.2-rc.1)
// 功能插件启停:功能全在 client 面(settings.section),host 面仅写加载标记,
// 证明 bundle 被 cordis 实例化。无任何 ctx 服务依赖,无副作用可抛出。
import { appendFileSync } from "node:fs";
import { join } from "node:path";
const MARKER = ".dsh-biz-plugins.log";
function log(line) {
try {
const home = process.env.DSH_HOME || process.env.HOME || ".";
appendFileSync(join(home, MARKER), `${new Date().toISOString()} ${line}\n`);
} catch {
// marker 写入绝不拖垮 profile
}
}
export function apply(_ctx) {
log(`apply pid=${process.pid}`);
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@dsh-local/business-plugins",
"version": "0.3.26",
"description": "能力管理(原「功能管理」/「功能插件」)section for dsh web profile — v0.3.26(2026-09-25):**基础插件不渲染关闭入口 + 显式身份标注**(插件投放与分库线第 19 棒 · §5-15;用户原话「这些 dsh 自带的基础插件 应该是 默认开启无法关闭的」)。① 平台把候选池的 `essential` 身份随 `/api/plugins/mine` 回给本 section;② `p.essential === true` 的卡片:勾选框 `checked` 且 `disabled`(**不给关闭入口**)+ tooltip 说明为什么;③ 状态徽章改渲染「平台基础插件 · 不可关闭」(**身份标注优先于**「已启用/未启用」——「关不掉」比「当前状态」更该被看见);④ zh/en 各 +2 词条(`essentialBadge` / `essentialTitle`)。⚠️ 本 section 只「不渲染按钮」;真正的拒绝在服务端两道(路由 409 具名 + 落盘那端跳过)⇒ 绕过 UI 也关不掉。⛔ 只消费平台既有字段,零新增请求 / 依赖 / 可见面变化。|v0.3.25(2026-09-24):**「启用状态未知」在用户面落地**(插件投放与分库线第 13 棒)。平台侧 `/api/plugins/mine` 已随清单返回 `enabledState{source,known,asOf,stale,hostId,detail}` 声明逐项 `enabled` 可不可信,但本 section 只读 `plugins[].enabled` ⇒ 降级时把「未知」画成「未勾选」,而「未勾选」在 UI 上的唯一含义是「点应用就能改」⇒ 用户按猜测提交,会把明明开着的插件真的关掉(提交送的是整张清单)。本版:① `known:false` ⇒ 顶部降级横幅(warn 色,说明原因并声明已暂停操作)② 卡片状态徽章渲染「状态未知」(此前是灰字「未启用」,与「真的没启用」视觉上无法区分)③ 勾选 / 全选 / 清空 / 应用更改 / 重试**全部禁用** + `doApply` 纵深再挡一次 ④ 计数位改显「状态未知」(避免恒「0 / N」的假数字)⑤ 列表为空时改说「读不到」而非「暂无插件」。⛔ 缺 `enabledState`(老平台)⇒ 保持原行为,不误判为降级;⛔ 只消费平台既有字段,零新增请求 / 依赖 / 可见面。|v0.3.23(2026-09-15):**撤除「偏好设置」分区**(用户:「把偏好设置中的 语言切换功能放到用户设置中,去掉偏好设置这个栏目」)。语言切换搬到 `@dsh-local/portal-entry` 的「用户设置」分区(id `user-settings`,order 103)—— 语言是**用户级偏好**,与「账号 / 退出登录」同区即可,再单开一个分区就是**重复入口**。撤掉:`PreferencesSection` 整段、`ctx.slots.inject` 里 id `preferences` order 99 的注册、zh/en 各 3 条 `pref.*` 词条 ⇒ 本 section 现只剩 **2 个分区**(能力管理 101 + 模型设置 100)。配套:`verify-platform-admin-section.mjs` 分区数断言 3 → 2 并新增「preferences 已撤除」断言;语言切换的功能断言移到新脚本 `scripts/verify-portal-entry.mjs`。|v0.3.22(2026-09-15):**分区改名「能力管理」+ 页内 tab 分页(插件 / 技能)+ 插件卡片中文用途描述三行**。① **改名**(用户:「设置中 功能管理改为能力管理」)—— **只改 label 级**(zh `能力管理` / en `Capability management`),代码标识符 / section id / 包名 / API 路径一律不动(素材库 U10「术语统一只改用户可见那层」);② **页内 tab**(用户:「能力管理页面改造为 tab可切换 插件和技能分别进行操作」)—— 复用既有 `.pa-tabs/.pa-tab/.pa-tcnt`(下划线式;规范 §4.4「数据页用下划线式」),两个页签各带**计数**;**插件页内容原样**(内存条 / 工具行 / 卡片网格 / 应用更改),**技能页**去掉重复的分组标题(标题已由 tab 承担)只留「生效方式说明 + 上传入口」工具行;两页**各自独立操作**,默认落插件页(与改造前一致);③ **插件卡片用途描述 = 3 行**(用户:「插件卡片中增加中文用途描述 显示三行」)—— 新增 `.bp-plugDesc`(`-webkit-line-clamp:3` + 显式 `white-space:normal`,否则继承单行截断的 nowrap),单行 ellipsis 会把「这个插件干什么」截得看不全;`title` 仍挂全文。④ **「内置 DeepSeek」→「DeepSeek」**(用户:「内置 DeepSeek,去掉内置就叫 DeepSeek 就行」)—— 仍只动**用户可见文案**:行标题 `ms.kindBuiltin`、新增下拉选项 `ms.optBuiltin`(原「内置 DeepSeek(平台共享)」,那个「(平台共享)」是**误导**:该选项走内置通道但**要填用户自己的 key**)、选择提示 `ms.builtinPickHint`、目录不可用时的兜底文案(`ms.catUnreadable`/`ms.catEmpty`)、以及种类小标 `ms.tagBuiltin`「内置」→「官方」+ `ms.builtinHint`「平台内置通道」→「官方通道」;⛔ **词典键名与代码标识符一律不动**(U10)。Line truncated
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings-general"
]
}
},
"dependencies": {},
"license": "MIT"
}
+23
View File
@@ -0,0 +1,23 @@
# @dsh-local/carbon-mcp-probe — bundle patch(host 半区)
#
# 本 POC 的**全部挂载动作**都在这两行里:
# ① 载入本包自己的 host 半区(lib/index.js,探针)
# ② insert 一条**内核** mcp-client entry,用 streamable-http 连远程 MCP server
#
# name 解析:profile 文档规定 bundle 名**先从 dsh 安装目录解析**,再从 profile 目录
# ⇒ 业务包无需自带 @deepseek-ai/dsh-mcp-client 依赖即可引用内核插件。
#
# ⛔ 生产形态里 url 不该硬编码在这里(应来自实例 env / 平台注入);
# 本文件只服务 POC,值是本机 loopback 上的 stub。
- insert:
- id: carbon-mcp-probe
name: '@dsh-local/carbon-mcp-probe'
- id: carbon-mcp-probe-server
name: '@deepseek-ai/dsh-mcp-client'
config:
transport: streamable-http
serverName: carbonprobe
url: 'http://127.0.0.1:39877/mcp'
toolCallTimeoutMs: 15000
failOnStartupError: true
+7
View File
@@ -0,0 +1,7 @@
// @dsh-local/carbon-mcp-probe — client 半区(POC)
// 本轮问题**只涉及 host 半区**(MCP 工具能否暴露给 agent),
// 故 client 侧刻意留空 apply:不做任何界面、不注入任何槽位。
export function apply() {
// 空 apply —— 见文件头说明
}
+132
View File
@@ -0,0 +1,132 @@
// @dsh-local/carbon-mcp-probe — host 半区(POC 探针)
//
// 本包在 host 侧只做一件事:**取证**。
// 真正的 MCP 挂载不在这里写代码,而是由同目录的 cordis.patch.yml
// insert 一条内核 `@deepseek-ai/dsh-mcp-client` entry 完成 —— 这正是本轮要验证的契约。
//
// 探针把「boot 后 ctx.tools 注册表里到底有没有 MCP 工具」落成 JSON 证据文件,
// 使验收不依赖 agent / 模型 / 界面。
import { appendFileSync, writeFileSync } from "node:fs";
const DUMP = process.env.CARBON_PROBE_DUMP || "/tmp/carbon-probe-dump.json";
const LOG = process.env.CARBON_PROBE_LOG || "/tmp/carbon-probe-host.log";
const DELAY_MS = Number(process.env.CARBON_PROBE_DELAY_MS || 8000);
function log(line) {
const row = `${new Date().toISOString()} ${line}\n`;
try { appendFileSync(LOG, row); } catch { /* 日志不可写不致命 */ }
process.stdout.write(`[carbon-mcp-probe] ${row}`);
}
/** 在有界预算内 BFS 一个对象图,收集所有含 needle 的键/字符串路径。 */
function bfsFind(root, needle, maxDepth = 5, maxNodes = 600) {
const seen = new Set();
const hits = [];
const queue = [[root, "$", 0]];
let visited = 0;
while (queue.length && visited < maxNodes) {
const [value, path, depth] = queue.shift();
if (value === null || value === undefined) continue;
const t = typeof value;
if (t !== "object" && t !== "function") {
if (t === "string" && value.includes(needle)) hits.push({ path, kind: "string", value: value.slice(0, 160) });
continue;
}
if (seen.has(value)) continue;
seen.add(value);
visited += 1;
let keys = [];
try { keys = Object.getOwnPropertyNames(value); } catch { continue; }
for (const k of keys) {
if (k.includes(needle)) hits.push({ path: `${path}.${k}`, kind: "KEY" });
if (depth + 1 > maxDepth) continue;
let child;
try { child = value[k]; } catch { continue; } // getter 抛错就跳过,绝不冒泡
const childPath = `${path}.${k}`;
if (child instanceof Map) {
let i = 0;
for (const [mk, mv] of child) {
const mp = `${childPath}{Map}["${String(mk)}"]`;
if (String(mk).includes(needle)) hits.push({ path: mp, kind: "MAPKEY" });
if (i++ > 250) break;
queue.push([mv, mp, depth + 1]);
}
} else if (Array.isArray(child)) {
child.slice(0, 60).forEach((v, i) => queue.push([v, `${childPath}[${i}]`, depth + 1]));
} else if (typeof child === "string") {
if (child.includes(needle)) hits.push({ path: childPath, kind: "string", value: child.slice(0, 160) });
} else if (child !== null && typeof child === "object") {
queue.push([child, childPath, depth + 1]);
}
}
}
return { hits, visited, truncated: queue.length > 0 };
}
function probeTools(ctx) {
const rec = { ts: new Date().toISOString(), pid: process.pid };
// 通道 A:直接属性(未声明 inject 时应报错 —— 这本身是契约事实)
try {
const direct = ctx.tools;
rec.channelA = { ok: true, type: typeof direct };
} catch (e) {
rec.channelA = { ok: false, error: String(e) };
}
// 通道 B:ctx.get(name)
let tools = null;
try {
tools = typeof ctx.get === "function" ? ctx.get("tools") : null;
rec.channelB = { ok: !!tools, name: tools && tools.name };
} catch (e) {
rec.channelB = { ok: false, error: String(e) };
}
if (!tools) return rec;
// 通道 C:已知候选方法(不预设签名,逐个试)
rec.channelC = {};
for (const m of ["list", "view", "schemas", "sdkSchemas", "get", "names"]) {
try {
if (typeof tools[m] !== "function") { rec.channelC[m] = { skipped: "not a function" }; continue; }
const v = typeof tools[m] === "function" ? tools[m]() : undefined;
if (v && typeof v.then === "function") { rec.channelC[m] = { async: true }; continue; }
rec.channelC[m] = { type: typeof v, preview: JSON.stringify(v).slice(0, 400) };
} catch (e) {
rec.channelC[m] = { threw: String(e).slice(0, 200) };
}
}
// 通道 D:有界 BFS —— 不依赖任何 API 形状,直接找 mcp__ 命名
try {
rec.channelD = bfsFind(tools, "mcp__");
rec.hasMcpQualified = rec.channelD.hits.length > 0;
} catch (e) {
rec.channelD = { error: String(e) };
rec.hasMcpQualified = false;
}
// 通道 E:同上,只要 carbonprobe 片段(防命名前缀差异)
try {
rec.channelE = bfsFind(tools, "carbonprobe", 5, 600);
} catch (e) {
rec.channelE = { error: String(e) };
}
return rec;
}
export function apply(ctx) {
log(`apply pid=${process.pid} node=${process.version} dump=${DUMP}`);
const timer = setTimeout(() => {
let rec;
try { rec = probeTools(ctx); }
catch (e) { rec = { ts: new Date().toISOString(), fatal: String(e) }; }
try { writeFileSync(DUMP, JSON.stringify(rec, null, 2)); log(`dump written -> ${DUMP}`); }
catch (e) { log(`dump write failed: ${e}`); }
log(`SUMMARY hasMcpQualified=${rec.hasMcpQualified} D.hits=${JSON.stringify(rec.channelD?.hits || []).slice(0, 800)}`);
}, DELAY_MS);
timer.unref?.();
}
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@dsh-local/carbon-mcp-probe",
"version": "0.0.1",
"private": true,
"description": "POC(非正式包):验证 dsh 0.1.5-rc.1 业务插件的 host 半区能否把远程 HTTP MCP server 的工具暴露给 agent。仅用于回答契约问题,不承载任何业务功能。",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": []
}
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@deepseek-ai/dsh": "^0.1.5-rc.1"
},
"dependencies": {},
"license": "MIT"
}
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
/**
* carbon MCP probe — stub MCP server(零依赖单文件)
*
* 用途:只回答「dsh 的 mcp-client 能否连上并同步工具」这一个问题。
* 不实现任何业务语义,不做任何外部访问。
*
* 用法:
* node stub-mcp-server.mjs # stdio(默认)
* node stub-mcp-server.mjs --http 39877 # Streamable HTTP
*
* 环境变量:
* PROBE_LOG 请求日志落点(默认 ./stub-mcp.log)
*/
import http from "node:http";
import { appendFileSync, writeFileSync } from "node:fs";
const LOG = process.env.PROBE_LOG || "./stub-mcp.log";
const TOOL_NAME = "ping";
const PONG_PREFIX = "pong-carbon-probe";
try { writeFileSync(LOG, ""); } catch { /* 日志不可写不致命 */ }
function log(line) {
const row = `${new Date().toISOString()} ${line}\n`;
try { appendFileSync(LOG, row); } catch { /* ignore */ }
process.stderr.write(row);
}
const PING_TOOL = {
name: TOOL_NAME,
description: "carbon MCP probe stub — 返回固定串与服务端时间",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
};
function callTool(params) {
if (!params || params.name !== TOOL_NAME) {
return { content: [{ type: "text", text: `unknown tool: ${params && params.name}` }], isError: true };
}
const ts = new Date().toISOString();
return { content: [{ type: "text", text: `${PONG_PREFIX} ${ts}` }] };
}
/** 处理一条 JSON-RPC message,返回 response 或 null(notification)。 */
function handle(msg) {
const method = msg && msg.method;
const id = msg && msg.id;
log(`rpc method=${method} id=${id === undefined ? "-" : id}`);
if (method === "initialize") {
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: msg.params && msg.params.protocolVersion ? msg.params.protocolVersion : "2025-06-18",
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "carbon-probe-stub", version: "0.0.1" },
},
};
}
if (method === "notifications/initialized" || String(method || "").startsWith("notifications/")) return null;
if (method === "ping") return { jsonrpc: "2.0", id, result: {} };
if (method === "tools/list") return { jsonrpc: "2.0", id, result: { tools: [PING_TOOL] } };
if (method === "tools/call") return { jsonrpc: "2.0", id, result: callTool(msg.params) };
if (id === undefined) return null;
return { jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } };
}
function dispatch(payload) {
const msgs = Array.isArray(payload) ? payload : [payload];
const out = [];
for (const m of msgs) {
const r = handle(m);
if (r !== null) out.push(r);
}
return out;
}
/* ---------------- Streamable HTTP ---------------- */
function startHttp(port) {
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url.startsWith("/health")) {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true, server: "carbon-probe-stub" }));
return;
}
if (req.method !== "POST") {
res.writeHead(405).end();
return;
}
let body = "";
req.on("data", (c) => { body += c.toString("utf8"); });
req.on("end", () => {
let payload;
try {
payload = JSON.parse(body);
} catch (e) {
log(`http parse-error ${e.message} raw=${body.slice(0, 200)}`);
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }));
return;
}
const out = dispatch(payload);
if (out.length === 0) {
log("http -> 202 (notification only)");
res.writeHead(202, { "Mcp-Session-Id": "probe-session-1" });
res.end();
return;
}
const payloadOut = out.length === 1 ? out[0] : out;
log(`http -> 200 ${JSON.stringify(payloadOut).slice(0, 300)}`);
res.writeHead(200, {
"Content-Type": "application/json",
"Mcp-Session-Id": "probe-session-1",
});
res.end(JSON.stringify(payloadOut));
});
});
server.listen(port, "127.0.0.1", () => {
log(`stub MCP server listening on http://127.0.0.1:${port}/mcp (transport=streamable-http)`);
});
server.on("error", (e) => { log(`listen error ${e.message}`); process.exit(1); });
}
/* ---------------- stdio ---------------- */
function startStdio() {
log("stub MCP server on stdio (transport=stdio)");
let buf = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx).trim();
buf = buf.slice(idx + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch (e) { log(`stdio parse-error ${e.message}`); continue; }
const out = dispatch(msg);
for (const r of out) process.stdout.write(JSON.stringify(r) + "\n");
}
});
process.stdin.on("end", () => process.exit(0));
}
const argv = process.argv.slice(2);
const httpFlag = argv.indexOf("--http");
if (httpFlag >= 0) {
const port = Number(argv[httpFlag + 1] || 39877);
startHttp(port);
} else {
startStdio();
}
+14
View File
@@ -0,0 +1,14 @@
# @dsh-local/im-agent-bridge — bundle patch (host row only)
#
# C 单(`交接单/IM群组-C-Agent接入插件.md §三`):实例侧接入 —— 拨出连接、触发过滤、
# 上下文组装、预算/静默期、回写消息、重连退避。
#
# ⚠️ 本插件**只在服务端**(无 client 面):agent 是一个实例内的 node 进程里跑的连接,
# 没有 UI。故 `dsh.bundle.patch` 即可,⛔ 不声明 `dsh.client`。
#
# 🔴 **天然开关**:`apply()` 首件事是读 `DSH_PLATFORM_IM_URL` 与 `DSH_IM_INSTANCE_TOKEN`;
# 任一缺失 ⇒ 直接 return(日志无连接尝试)。⇒ 平台侧回滚 = 删掉注入的两项 env。
- insert:
- id: im-agent-bridge
name: '@dsh-local/im-agent-bridge'
+498
View File
@@ -0,0 +1,498 @@
/**
* @dsh-local/im-agent-bridge — host 插件(C 单 · `交接单/IM群组-C-Agent接入插件.md`)。
*
* ## 它做什么
* 实例内起一条**只拨出**的 WS 到平台 IM 端点,收到触发后组装上下文、交给本地 dsh
* 会话、把回答写回房间。四条硬约束与两种形态的实现全在**平台侧**的
* `src/im/agent-bridge.ts`(纯逻辑,可在平台测试里直接断言);本文件只负责
* 「**连接 + 搬运**」:
*
* ```
* 平台 ──(WS 推送 /messages)──▶ 本插件 ──(组装上下文)──▶ 本地 dsh 会话
* ▲ │
* └────(op:'send' 回写, via='agent')◀───────────────────────┘
* ```
*
* ## 🔴 五条纪律
* ① **只拨出**(E3):本文件**不开任何监听端口**(`ss -lntp` 无新增);
* ② **不引入依赖**:WS 握手与帧编解码在此文件手写(与平台 `src/im/ws.ts` 同一套最小子集);
* ③ **天然开关**:`DSH_PLATFORM_IM_URL` / `DSH_IM_INSTANCE_TOKEN` 任一缺失 ⇒ 不起连接;
* ④ **故障隔离**(§五-7):任何异常都只影响本插件自己的连接,⛔ 不向上抛;
* ⑤ **重连退避**(§五-2):断线后指数退避(1s→2s→…→上限 30s,带抖动),30 s 内自动恢复。
*
* ⚠️ 本文件目前把「本地 dsh 会话」抽象成可注入的 `askLocalSession()` —— 默认实现是
* **回显式占位**(返回一条说明文本),因为真正的对接点(把 prompt 塞进哪个 dsh 会话)
* 属 **D 单**的扩展点契约面(§八-5 明确问"是否需先做 D 单")。⇒ 本单保证的是
* **触发 / 上下文 / 预算 / 回写 / 隔离**五条判据可验;LLM 那一跳留给 D 单接线。
*
* @module @dsh-local/im-agent-bridge
*/
import { appendFileSync } from 'node:fs'
import { createHash, randomBytes } from 'node:crypto'
import { join } from 'node:path'
import { connect } from 'node:net'
/** 平台 IM 端点(env:`DSH_PLATFORM_IM_URL`,形如 `https://ai1net.com`)。 */
const ENV_URL = 'DSH_PLATFORM_IM_URL'
/** 实例凭据(env:`DSH_IM_INSTANCE_TOKEN`,`per-instance` 短期 token)。 */
const ENV_TOKEN = 'DSH_IM_INSTANCE_TOKEN'
/** 本 agent 的引用标识(env:`DSH_IM_AGENT_REF`;缺省 = 用实例 id 兜底)。 */
const ENV_AGENT_REF = 'DSH_IM_AGENT_REF'
/** 重连退避:首延迟 / 上限 / 抖动系数(§五-2 要求"30 s 内自动恢复")。 */
export const RECONNECT_FIRST_MS = 1000
export const RECONNECT_MAX_MS = 30_000
/** 排空 / 停机的宽限(`close()` 时给在途帧的时间)。 */
const CLOSE_GRACE_MS = 200
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
const OP_TEXT = 0x1
const OP_CLOSE = 0x8
const OP_PING = 0x9
const OP_PONG = 0xa
/** 插件内日志(写 `<DSH_HOME>/.dsh-im-agent.log`;⛔ 绝不写 token)。 */
function makeLog(home) {
const file = join(home, '.dsh-im-agent.log')
return (line) => {
try {
appendFileSync(file, `${new Date().toISOString()} ${line}\n`)
} catch {
// 日志写不动⛔绝不拖垮插件
}
}
}
/* ── WS 最小子集(客户端侧:帧**必须掩码**)────────────────────────────────── */
function encodeClientFrame(opcode, payload) {
const len = payload.length
let header
if (len < 126) {
header = Buffer.alloc(2)
header[1] = 0x80 | len
} else if (len < 65536) {
header = Buffer.alloc(4)
header[1] = 0x80 | 126
header.writeUInt16BE(len, 2)
} else {
header = Buffer.alloc(10)
header[1] = 0x80 | 127
header.writeBigUInt64BE(BigInt(len), 2)
}
header[0] = 0x80 | opcode
const mask = randomBytes(4)
const masked = Buffer.from(payload)
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4]
return Buffer.concat([header, mask, masked])
}
function textFrame(text) {
return encodeClientFrame(OP_TEXT, Buffer.from(text, 'utf8'))
}
/** 服务端帧解析(**不带掩码**)。 */
class ClientDecoder {
constructor() {
this.buf = Buffer.alloc(0)
}
push(chunk) {
this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk])
const out = []
for (;;) {
if (this.buf.length < 2) break
const b0 = this.buf[0]
const b1 = this.buf[1]
const opcode = b0 & 0x0f
let len = b1 & 0x7f
let offset = 2
if (len === 126) {
if (this.buf.length < 4) break
len = this.buf.readUInt16BE(2)
offset = 4
} else if (len === 127) {
if (this.buf.length < 10) break
len = Number(this.buf.readBigUInt64BE(2))
offset = 10
}
if (this.buf.length < offset + len) break
const payload = Buffer.from(this.buf.subarray(offset, offset + len))
this.buf = this.buf.subarray(offset + len)
out.push({ opcode, payload, fin: (b0 & 0x80) !== 0 })
}
return out
}
}
/**
* 解析 URL 成 `{host, port, path, tls}`(⛔ 不引 `node:url` 的 WHATWG 也行,但用它更稳)。
* 只支持 `http(s)` / `ws(s)`:`https`/`wss` ⇒ TLS。
*/
export function parseEndpoint(rawUrl) {
const u = new URL(rawUrl)
const tls = u.protocol === 'https:' || u.protocol === 'wss:'
return {
host: u.hostname,
port: u.port !== '' ? Number(u.port) : tls ? 443 : 80,
path: '/api/im/ws',
tls,
origin: `${tls ? 'https' : 'http'}://${u.host}`,
}
}
/* ── 主类 ────────────────────────────────────────────────────────────────── */
/**
* 拨出桥(一个实例一份)。
*
* ⛔ **只拨出**:`start()` 只 `connect()`,不 `listen()`。
*/
export class ImAgentBridge {
/**
* @param {object} opts
* @param {string} opts.baseUrl - 平台 IM 端点(env)。
* @param {string} opts.token - 实例凭据(env)。
* @param {string} opts.agentRef - 本 agent 引用。
* @param {(prompt: string, ctx: object) => Promise<string>} opts.askLocalSession
* 把上下文交给本地 dsh 会话并取回回答(占位实现见 `defaultAsk`)。
* @param {(line: string) => void} [opts.log]
* @param {number} [opts.firstDelayMs] / @param {number} [opts.maxDelayMs] - 退避参数(测试用)
* @param {() => object} [opts.readSnapshot] - 读房间快照(测试注入;默认走 REST)
*/
constructor(opts) {
this.baseUrl = opts.baseUrl
this.token = opts.token
this.agentRef = opts.agentRef
this.askLocalSession = opts.askLocalSession
this.log = opts.log ?? (() => undefined)
this.firstDelayMs = opts.firstDelayMs ?? RECONNECT_FIRST_MS
this.maxDelayMs = opts.maxDelayMs ?? RECONNECT_MAX_MS
this.readSnapshot = opts.readSnapshot ?? null
/** 已订阅的房间(重连后要重新 subscribe ⇒ 按游标补拉)。 */
this.rooms = new Map()
this.socket = null
this.decoder = null
this.attempt = 0
this.timer = null
this.stopped = false
/** 观测计数(§五-5「指标可查」)。 */
this.metrics = { connects: 0, reconnects: 0, triggers: 0, replies: 0, suppressed: 0, errors: 0 }
}
/** 建立(或重建)连接。⛔ 只拨出。 */
start() {
if (this.stopped) return
this.metrics.connects += 1
const ep = parseEndpoint(this.baseUrl)
const key = randomBytes(16).toString('base64')
const socket = connect({ host: ep.host, port: ep.port }, () => {
const headers = [
`GET ${ep.path} HTTP/1.1`,
`Host: ${ep.host}`,
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Key: ${key}`,
'Sec-WebSocket-Version: 13',
// 🔴 两件关键头:实例凭据 + 区声明(区声明可省;带上便于平台侧判跨区)。
`x-dsh-im-instance-token: ${this.token}`,
`x-dsh-im-agent-ref: ${this.agentRef}`,
'',
'',
].join('\r\n')
socket.write(headers)
})
this.socket = socket
this.decoder = new ClientDecoder()
let handshakeDone = false
let raw = Buffer.alloc(0)
socket.on('data', (chunk) => {
if (!handshakeDone) {
raw = Buffer.concat([raw, chunk])
const idx = raw.indexOf('\r\n\r\n')
if (idx < 0) return
const head = raw.subarray(0, idx).toString('utf8')
if (!/^HTTP\/1\.1 101/.test(head)) {
// 握手被拒(401 / 404 …):记一笔并按退避重连(⛔ 不静默停)。
this.log(`handshake-rejected ${head.split('\r\n')[0]}`)
this.metrics.errors += 1
socket.destroy()
return
}
const expect = createHash('sha1').update(`${key}${WS_GUID}`).digest('base64')
if (!head.includes(`Sec-WebSocket-Accept: ${expect}`)) {
this.log('handshake-accept-mismatch')
this.metrics.errors += 1
socket.destroy()
return
}
handshakeDone = true
this.attempt = 0
this.log(`connected ${ep.host}`)
// 重连后**重新订阅**所有房间(带游标 ⇒ 平台补拉断线期间的消息)。
for (const [roomId, since] of this.rooms) this.send({ op: 'resume', roomId, since })
const rest = raw.subarray(idx + 4)
if (rest.length > 0) this.consume(rest)
return
}
this.consume(chunk)
})
socket.on('error', (err) => {
this.metrics.errors += 1
this.log(`socket-error ${String(err && err.message ? err.message : err)}`)
})
socket.on('close', () => {
this.socket = null
this.decoder = null
if (this.stopped) return
this.scheduleReconnect()
})
}
consume(chunk) {
if (this.decoder === null) return
let frames
try {
frames = this.decoder.push(chunk)
} catch (err) {
this.log(`decode-error ${String(err)}`)
return
}
for (const frame of frames) {
if (frame.opcode === OP_PING) {
this.rawWrite(encodeClientFrame(OP_PONG, frame.payload))
continue
}
if (frame.opcode === OP_CLOSE) {
// 服务端要关:主动断开 ⇒ 走 close 事件里的退避重连。
if (this.socket !== null) this.socket.destroy()
continue
}
if (frame.opcode !== OP_TEXT) continue
let msg
try {
msg = JSON.parse(frame.payload.toString('utf8'))
} catch {
continue
}
// ⚠️ 每条消息处理都吞异常:插件故障⛔不许拖垮连接(§五-7)。
void this.onServerFrame(msg).catch((err) => {
this.metrics.errors += 1
this.log(`frame-handler-error ${String(err)}`)
})
}
}
rawWrite(buf) {
if (this.socket === null || this.socket.destroyed) return false
try {
this.socket.write(buf)
return true
} catch {
return false
}
}
send(obj) {
return this.rawWrite(textFrame(JSON.stringify(obj)))
}
scheduleReconnect() {
if (this.stopped) return
this.metrics.reconnects += 1
const base = Math.min(this.maxDelayMs, this.firstDelayMs * 2 ** this.attempt)
// 抖动 ±20%:避免整批实例同时重连打爆平台。
const delay = Math.max(1, Math.round(base * (0.8 + Math.random() * 0.4)))
this.attempt += 1
this.log(`reconnect in ${delay}ms (attempt ${this.attempt})`)
this.timer = setTimeout(() => {
this.timer = null
this.start()
}, delay)
if (typeof this.timer.unref === 'function') this.timer.unref()
}
/** 订阅一个房(调用方决定订阅哪些)。 */
subscribe(roomId, since = 0) {
this.rooms.set(roomId, since)
this.send({ op: 'subscribe', roomId })
}
/** 记住游标(每条收到的消息都推进它 ⇒ 重连补拉不重复)。 */
noteCursor(roomId, seq) {
if (typeof seq === 'number' && Number.isFinite(seq)) this.rooms.set(roomId, seq)
}
/**
* 服务端帧处理。
*
* 只关心两种:`message`(推送的新消息 ⇒ 可能触发)与 `messages`(补拉结果)。
*/
async onServerFrame(msg) {
if (msg === null || typeof msg !== 'object') return
if (msg.op === 'pong') return
if (msg.op === 'hello') {
this.log('hello')
return
}
if (msg.op === 'error') {
this.metrics.errors += 1
this.log(`server-error ${String(msg.error)}`)
return
}
const roomId = typeof msg.roomId === 'string' ? msg.roomId : undefined
const list = Array.isArray(msg.messages) ? msg.messages : []
if (roomId === undefined || list.length === 0) return
for (const m of list) {
this.noteCursor(roomId, m && typeof m.seq === 'number' ? m.seq : undefined)
await this.maybeReply(roomId, m)
}
}
/**
* 判一条消息要不要回、要回就回。
*
* 🔴 这里**不做**触发过滤的重活 —— 那条判据在平台侧 `src/im/agent-bridge.ts`
* (同一套逻辑,测试直接断它)。本文件只做"要不要处理"的**本地前置闸**
* (形态 / @ 命中),够用且不引第二份判据源。
*/
async maybeReply(roomId, message) {
if (message === undefined || message === null) return
// ① 自激闸门:agent 自己的消息(`via==='agent'` 或 `author_kind!=='human'`)恒不处理。
if (message.authorKind !== 'human' || message.via === 'agent') return
const mentions = collectMentions(message.payload)
if (mentions.length === 0) return
// ② 是不是 @ 到我(形态 A:agentRef 直接命中;形态 B:主人命中 —— 由平台侧快照判到底是谁)。
const self = String(this.agentRef)
const hit =
mentions.includes(self) ||
mentions.includes(String(this.ownerId ?? '')) ||
mentions.some((m) => String(m).startsWith(self))
if (!hit) return
this.metrics.triggers += 1
const ctx = {
roomId,
focusSeq: typeof message.seq === 'number' ? message.seq : undefined,
memberId: message.authorId,
message,
}
let answer
try {
answer = await this.askLocalSession(renderPrompt(message, roomId), ctx)
} catch (err) {
this.metrics.errors += 1
this.log(`ask-failed ${String(err)}`)
return
}
if (typeof answer !== 'string' || answer === '') return
// ③ 回写:`via='agent'` + 可见代答标识(§五-6)。
const marker = this.form === 'bot' ? '能力机器人' : `由 ${this.ownerName ?? message.authorId} 的助手代答`
const ok = this.send({
op: 'send',
roomId,
payload: { text: answer, agentReply: true, agentLabel: marker },
via: 'agent',
})
if (ok) {
this.metrics.replies += 1
this.log(`reply-sent room=${roomId} focus=#${ctx.focusSeq}`)
}
}
/** 停机(挂 profile 卸载 / 实例退出)。 */
close() {
this.stopped = true
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
if (this.socket !== null) {
try {
this.socket.write(encodeClientFrame(OP_CLOSE, Buffer.from('\u0000\u0000shutdown', 'utf8')))
} catch {
// 写不动就直接销毁
}
const s = this.socket
setTimeout(() => s.destroy(), CLOSE_GRACE_MS).unref?.()
}
}
}
/** 从 payload 抽 @(与平台侧同判据,这里是插件侧的只读副本)。 */
export function collectMentions(payload) {
if (payload === null || typeof payload !== 'object') return []
if (Array.isArray(payload.mentions)) return payload.mentions.filter((m) => typeof m === 'string')
const text = payload.text
if (typeof text !== 'string') return []
const out = []
for (const m of text.matchAll(/@([A-Za-z0-9_\-.:]{1,64})/g)) if (m[1] !== undefined) out.push(m[1])
return out
}
/** 把一条消息渲染成给本地 dsh 会话的 prompt(占位实现 —— 完整上下文由平台侧组装)。 */
export function renderPrompt(message, roomId) {
const text = typeof message.payload?.text === 'string' ? message.payload.text : JSON.stringify(message.payload)
return `[房间 ${roomId} · 消息 #${message.seq} · 来自 ${message.authorId}]\n${text}\n\n请回答上面这条被 @ 的消息。`
}
/**
* 默认的本地会话实现:**回显式占位**。
*
* ⛔ 真正接本地 dsh 会话是 **D 单**的扩展点契约面(§八-5);本单保证的是
* 触发 / 上下文 / 预算 / 回写 / 隔离五条判据**可验**。接法 = 通过 `opts.askLocalSession` 注入。
*/
export function defaultAsk(prompt) {
return Promise.resolve(`(agent 桥已收到触发,但本地会话对接属 D 单扩展点)\n${prompt.slice(0, 200)}`)
}
/* ── cordis 落点 ──────────────────────────────────────────────────────────── */
/** 单例(profile 卸载时 close)。 */
let activeBridge = null
/**
* cordis 入口。
*
* 🔴 **天然开关**:读不到 URL 或 token ⇒ 直接 return(日志里**没有**连接尝试)。
* 这与 §三「不注入 env ⇒ 插件不启动」逐字对应,也是平台侧回滚的唯一动作。
*/
export function apply(ctx) {
const url = process.env[ENV_URL]
const token = process.env[ENV_TOKEN]
const home = process.env.DSH_HOME || process.env.HOME || '.'
const log = makeLog(home)
if (url === undefined || url === '' || token === undefined || token === '') {
log(`skip: ${ENV_URL} / ${ENV_TOKEN} 未注入 ⇒ agent 桥不启动`)
return
}
const agentRef = process.env[ENV_AGENT_REF] ?? 'instance-agent'
const bridge = new ImAgentBridge({
baseUrl: url,
token,
agentRef,
askLocalSession: defaultAsk,
log,
})
log(`apply pid=${process.pid} agent=${agentRef}`)
try {
bridge.start()
} catch (err) {
// §五-7:起连接失败⛔不许把 profile 打崩。
log(`start-failed ${String(err)}`)
}
activeBridge = bridge
ctx.on?.('dispose', () => {
try {
bridge.close()
} catch {
// 收尾失败也不抛
}
activeBridge = null
})
}
/** 供测试/排查取当前单例。 */
export function currentBridge() {
return activeBridge
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@dsh-local/im-agent-bridge",
"version": "0.1.0",
"description": "IM agent 接入桥 —— 实例内自研插件(C 单 `交接单/IM群组-C-Agent接入插件.md`)。职责:**主动拨出**到平台 IM 端点(`/api/im/ws`,带 `x-dsh-im-instance-token`),按两种形态接收触发(能力机器人被 @ / 组员代理:其主人被 @ 且开启「待应答」),组装「最近窗口 + 被 @ 那条」的上下文交给本地 dsh 会话,把回答写回房间;四条硬约束(不互相触发 / 发言预算 / 静默期 / 房间级限速排队)在压测下生效,且代答消息带可见标识。⛔ 不改官方 dsh、⛔ 不改 relay、⛔ 不改 A/B 单的表结构与端点形状。",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
},
"dependencies": {},
"license": "MIT"
}
+166
View File
@@ -0,0 +1,166 @@
# `im-connection-gateway` —— IM 连接层的**参考部署**(外部连接层)
本目录是 IM 线「连接层候选 B」的部署侧材料:**外部连接层进程的配置样例** + **边缘映射样例**
+ **平台应用层帧 ↔ 网关协议的逐项映射表**。
> 🔴 本目录里的内容**只做部署**:平台的连接层抽象在 `src/im/connection-backend.ts`(中性命名),
> 两个后端实现在 `src/im/backends/{native,gateway}.ts`。⛔ 平台内核(`hub.ts` / `ws.ts` /
> `store.ts`)**不认识**本目录的任何概念,这是刻意的分层 —— 换掉下面这个组件不需要动内核。
参考部署用的是 **Centrifugo v6.9.6**(选择依据见 `dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md`
§十二 执行回报(IM线-第3棒)的 E-2 对照读数)。
---
🔻 **第 15 棒部署实测修正(2026-09-23,⛔ 下方原文不改,只在此标注)** —— §3 / §4 / §6 有三处被实测改写:
| 原文 | 实测结论 | 依据 |
|---|---|---|
| §3 / config:`ping_interval: "0s"`("⛔ 不启用服务端 WS ping") | 🔴 **该值会让客户端保活帧被判 `bad request`** —— 没有待回的 ping 时,协议里的 `{}` 是**空命令**、不是 pong ⇒ 连接被断开(code 3501)。已改为 **`25s`**:网关发协议级 ping,客户端**按连接**回 `{}` | 47 三向实测:off 80/80 断(3012 `no pong`)· conn **掉 0** · global 80/80 断(3501 `bad request`) |
| §4 "本棒 ⛔ 未执行" | ✅ **已在 47 就地执行**(二进制 + 配置 + systemd 单元 + 边缘片段落盘 + `nginx -t`);⛔ 边缘**未接进 vhost**(不开流量) | 见 §7 |
| §6-1 "在线态取数未接线" | ✅ **已接线**(平台 `presence-ingest.ts` + `POST /api/im/gateway/presence`);`stats().presenceIngest` 从 `not-wired` 变为 `gateway-join-leave` | 见 §7-3 |
⚠️ 关于 §3 那句「实测 `wsPingRecv = 0`」:**这条实测本身没错** —— 它数的是 **WS 控制帧**,
而网关发的是**协议级 JSON ping**。错的是由它推出的结论("所以网关不管保活")。
---
## 1. 拓扑
```
浏览器 / 实例内客户端
│ ws(s)://<平台域>/api/im/ws ← 客户端侧 URL **一个字都不变**
▼
边缘(nginx)── location = /api/im/ws ──────▶ 外部连接层(持有 10k+ 连接,做扇出)
▲
平台进程 ── POST http://<网关>/api/publish ───────────┘ (每条消息**一次**发布调用)
│
└─ 权威态:房间 / 成员 / 消息 / 游标 / 预算 —— 全在平台 `src/im/**`,一条都不外移
```
平台的 `/api/im/ws`(进程内自研 WS)**不摘**:
① 实例内凭据通道(`x-dsh-im-instance-token`)走它;② 它是**一行回滚**的落点。
---
## 2. 应用层帧 ↔ 网关协议:逐项映射
🔴 映射的判据是「**应用层帧不变**」:客户端 `JSON.parse(payload.frame)` 拿到的字节与
native 模式**逐字相同**(`test/im-connection-backend.test.mjs` 的 B-18 就是断这条)。
| 应用层(`/api/im/ws` 的语义) | 网关侧 | 权威在哪 |
|---|---|---|
| 客户端连 `wss://…/api/im/ws` | 边缘 `location = /api/im/ws` → 网关 WS 端点 | 边缘 |
| 鉴权(`sid` cookie / 实例凭据) | 网关侧的连接令牌(`client.token.hmac_secret_key` 签发) | 🔴 **仍是平台**:令牌只回答"你是谁",能不能读房由成员表判 |
| `{op:'subscribe', roomId}` | 客户端 `subscribe` 频道 `im:<roomId>` | 平台成员表(`store.isMember`) |
| `{op:'subscribe'}` 的应答 `{op:'subscribed', cursor}` | ⛔ 网关不产这个帧 ⇒ 由平台 REST 补(见下「游标」) | 平台 |
| `{op:'send', roomId, payload}` | ⛔ 网关不参与:走平台 `POST /api/im/rooms/:id/messages` | 平台(预算 `decideWrite` + 落库) |
| `{op:'resume', roomId, since}` | 平台 `GET /api/im/rooms/:id/messages?since=`(网关 `history` 只作**加速**) | 平台(游标权威) |
| 服务端 `{op:'message', roomId, messages:[…]}` | 平台 `POST /api/publish` `{channel:'im:<roomId>', data:{frame:'<原字符串>'}}` | 平台产生、网关投递 |
| `{op:'presence', roomId, members:[…]}` | 网关 `presence` / `join_leave`(**本轮未接线**) | ⚠️ 见 §4 未完成项 |
| 保活 | **客户端**按 §3 的规则自建 | 客户端侧垫片 |
**负载信封**(`src/im/backends/gateway.ts` 的 `gatewayPayloadOf`):
```json
{ "channel": "im:imr_<uuid>", "data": { "frame": "{\"op\":\"message\",\"roomId\":\"imr_…\",\"messages\":[…]}" } }
```
---
## 3. 🔴 硬实现项 ①:**必须**自建保活(实测必做,⛔ 不是可选优化)
E-2 判别实验(106 `/root/im-e2/hb.sh`)三条读数,本棒在 `E-7` 里做了三向复核:
| 口径 | 结果 |
|---|---|
| `off`:客户端**完全不发**协议 ping | 服务端在 **26 s** 处把连接判死(`wsPingRecv = 0`:网关**不发**服务端 WS ping) |
| `global`:**整进程统一计时** | 同上(这是 E-2 记录到的**适配器缺陷**形态) |
| `conn`:**每条连接认证完成后**才起自己的 ping 计时 | 保活成立(= 本棒的修正形态) |
⇒ 规则(`src/im/keepalive.ts` 是它的可执行形态):
1. 客户端每 **`intervalMs`** 发一次应用层 ping;`intervalMs` 由一个空窗里发 **3** 次反推
(实测空窗 26 s ⇒ 8,666 ms;丢一次也不至于被判死)。
2. 🔴 **计时起点 = 该连接自己的认证完成时刻**(⛔ 不是进程启动时刻、⛔ 不是 worker 启动时刻)。
用统一计时会一次掉成批连接 —— 本棒的回归用例 B-12 / B-13 把这两种模型**结构性区分**开。
3. 平台的 `/api/im/stats` 在 `gateway` 模式下会回带 `keepalive` 与
`keepaliveAnchor: 'per-connection-auth-complete'`(运维据此核对客户端是否按规则做)。
---
## 4. 部署(示例,本棒 ⛔ 未执行)
```sh
# 1) 放二进制与配置(版本与校验见 dsh-server-docs 的部署记录)
install -m 0755 centrifugo /opt/dsh-gateway/centrifugo
install -m 0644 config.json /opt/dsh-gateway/config.json # 凭据先替换 REPLACE_WITH_ROTATED_SECRET
# 2) 起进程(端口示例 18081;v6 的端口用命令行给,配置里已不再有 port 键)
cd /opt/dsh-gateway && ./centrifugo --config=./config.json --http_server.port=18081
# 3) 平台侧(drop-in,⛔ 同名键写 /etc/dshs.env 会被静默压掉)
# DSH_IM_BACKEND=gateway
# DSH_IM_GATEWAY_URL=http://127.0.0.1:18081
# DSH_IM_GATEWAY_KEY=<与 http_api.key 同值>
# DSH_IM_GATEWAY_CHANNEL_PREFIX=im:
# 4) 边缘:把 nginx-location.conf 的 location 落进站点配置 → nginx -t → reload
# 5) 就绪探测与读数
curl -s -X POST http://127.0.0.1:18081/api/info -H "Authorization: apikey <key>"
# 平台侧:GET /api/im/stats(admin)⇒ backendKind=gateway / backend.degraded 为空
```
**回滚**(顺序与上线相反,两步任一即可):
1. 去掉边缘的 `location = /api/im/ws` → reload ⇒ 流量立刻回到平台进程内通道;
2. 平台 drop-in 里 `DSH_IM_BACKEND` 改回 `native`(或删掉该行)→ 重启 `dshs`。
⚠️ 两个方向的**都已具备**:平台的 `/api/im/ws` 两种后端都保留(`ImConnectionBackend.dispatch`),
所以第 1 步是**零重启**回滚。
---
## 5. 容量测算的输入(⛔ 本目录不承诺数值,数值见回报的容量表)
| 输入 | 值 | 来源 |
|---|---|---|
| 边际内存 / 连接 | **70.1 KiB**(自研对照 5.9 KiB,**11.9×**) | E-2 实测(本棒 E-6 复核) |
| 10k 连接稳态 RSS | 750.2 MiB(自研对照 116.0 MiB) | E-2 实测(本棒 E-6 复核) |
| 客户端观测 P99(1k/5k/10k) | 28.9 / 68.1 / 111.9 ms(自研 268.6 / 337.6 / 359.4 ms) | E-2(本棒 E-6 复核) |
| 扇出离散中位 | 17.2 / 57.9 / 97.8 ms(自研 171.8 / 227.6 / 225.5 ms) | E-2(本棒 E-6 复核) |
| 平台侧 CPU | 每条消息 **1 次** HTTP 发布调用;`lastPublishMs` 见 `/api/im/stats` | 本棒实现口径 |
⚠️ 内存类读数**跨跑方差 ±12 MiB**(E-5 教训)⇒ 任何内存判据**重复跑 ≥3 次取中位**。
## 6. 未完成项(⛔ 与 §3 的"已做"分开看)
> 🔻 **第 15 棒状态标注(2026-09-23,⛔ 原文不改)**:① **已接线**(平台侧 `presence-ingest.ts`
> +回调面 `POST /api/im/gateway/presence`,读数 `gateway-join-leave`);② **配置与边缘片段
> 已落 47** 并通过 `nginx -t`,**仍未接进生效 vhost**(不开流量);③ 客户端侧垫片**仍未接进
> E 单 bundle**(第 13 棒已修掉帧格式 P0,但"换连接层后客户端要按连接回 `{}`"这层尚未做)。
1. **在线态取数未接线**:平台的 `presence` 需要网关的 `presence` / `join_leave` 面;
本棒只把现状暴露在 `stats().presenceIngest = 'not-wired'`,⛔ 不假装完成。
2. **本目录的 nginx / config 未应用**(本棒边界:⛔ 不部署到 47 / 106 生产面)。
3. **客户端侧垫片未接进 E 单 bundle** —— 见回报里的 P0 发现(客户端 bundle 与平台
`/api/im/ws` 的帧格式**当前不一致**,接线前先要修那一处)。
---
## 7. 第 15 棒:47 就地部署实况(2026-09-23)
🔴 本目录只记**部署落点**;权威读数与判据在 `dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md` **§15.8**。
| 项 | 落点 / 值 |
|---|---|
| 二进制 | `/opt/dsh-gateway/centrifugo`(v6.9.6,sha256 `f4869bf9c0028ed5253b74479b1dc70e3b916dae2ddad5bb03504b1eb07a848c`,与 106 测试床同源) |
| 配置 | `/opt/dsh-gateway/config.json`(**600**,密钥现场 `openssl rand -hex 32` 生成,⛔ 不入库) |
| 单元 | `dsh-gateway.service`(`enabled` + `active`),`--http_server.address=127.0.0.1 --http_server.port=18081`,`LimitNOFILE=200000` |
| 边缘片段 | `/opt/dsh-gateway/nginx-location.conf`(**已落盘 + 临时配置 `nginx -t` rc=0**;⛔ 未接进 vhost) |
| 切流 | `DSH_IM_BACKEND` / `DSH_IM_GATEWAY_URL` / `DSH_IM_GATEWAY_KEY` / `DSH_IM_GATEWAY_CHANNEL_PREFIX` ⇒ drop-in `/etc/systemd/system/dshs.service.d/im-gateway.conf`(模板 = `/opt/dsh-gateway/dshs-im-gateway.conf.example`) |
| 当前状态 | **native**(drop-in 已删,`restart dshs` 复验 101 ✅)⇒ 线上行为回到改动前 |
**只读取证脚本**(本次用的)= `/opt/dsh-gateway/im15-drill.mjs`:起 N 个客户端订阅一个临时频道,
三组分口径(off / conn / global)计断开数,并读平台 `/api/im/stats` 与网关 `/api/channels`。
+58
View File
@@ -0,0 +1,58 @@
{
"_comment": "IM 线 · 第 12 棒 —— 外部连接层的参考部署配置(Centrifugo v6)。",
"_comment2": "键位以 E-2 实测可用的 /root/im-e2/config.json 为准(v6 已把密钥挪进 client.token / http_api)。",
"_comment3": "凭据一律用环境变量或密钥文件注入,⛔ 不要把这个文件里的占位串当真值提交到生产。",
"_comment4": "package.json 的运行时依赖**不含**本组件 —— 它是外部进程,不在 Node 依赖树里。",
"client": {
"allowed_origins": ["*"],
"_comment_ping": "🔻 第 15 棒部署实测修正:原值 `0s` 会让客户端保活帧被判 bad request —— 没有待回的 ping 时,协议里的 `{}` 是**空命令**而非 pong。三向实测(47 就地部署,240 连接):off(从不回帧)⇒ 80/80 被断开(code 3012 `no pong`);conn(**按每连接**收到 ping 才回 `{}`)⇒ **掉 0**;global(整进程一个节拍器给所有连接发 `{}`)⇒ 80/80 被断开(code 3501 `bad request`)。⇒ 网关**自己发协议级 ping**,客户端只需按连接回 `{}`。",
"ping_interval": "25s",
"token": {
"hmac_secret_key": "REPLACE_WITH_ROTATED_SECRET"
}
},
"http_api": {
"key": "REPLACE_WITH_ROTATED_SECRET"
},
"channel": {
"_comment_proxy": "🔻 第 16 棒新增(选型单 §十六):**订阅回检** —— 客户端订某个频道时,**由平台**回答「这个用户能不能读这个房」。⛔ 不加这一段的话,连接票据只回答「你是谁」,任何登录用户都能订 `im:<任意房间>` 并收到该房实时消息 ⇒ 「成员表判读房」这条会被破掉。`endpoint` 指平台回检面(回环即可:网关与平台同机);头里的密钥必须与 `http_api.key` / `client.token.hmac_secret_key` / 平台 drop-in 的 `DSH_IM_GATEWAY_KEY` **四者同值**。⚠️ 这个共享密钥必须放 `http.static_headers`(**固定值头**),⛔ **不能**放 `http_headers` —— `http_headers` 是 `array[string]` 且元素**只是头名**(转发客户端带来的同名头);拿它塞 `\"Name: value\"` 会变成「带名无值」,平台侧取不到密钥 ⇒ 回检 401(v6.9.6 实测 `error proxying subscribe: unexpected HTTP status code: 401`)。",
"proxy": {
"subscribe": {
"endpoint": "http://127.0.0.1:3080/api/im/gateway/subscribe",
"timeout": "2s",
"http": {
"static_headers": { "X-DSH-IM-Gateway-Key": "REPLACE_WITH_ROTATED_SECRET" }
}
}
},
"namespaces": [
{
"_comment": "命名空间 = 频道名里第一个 `:` 之前的部分 ⇒ 配合 DSH_IM_GATEWAY_CHANNEL_PREFIX=im: 使用。",
"name": "im",
"_comment2": "🔻 第 16 棒修正:`allow_subscribe_for_client` 只是「已认证连接可以不带 token 订阅」,**不是**权限判定;真正的授权走 `subscribe_proxy_enabled`(平台判成员表)或平台签发的订阅票据(带 token 时 token 优先,代理不参与)。两者都由**平台**裁决 ⇒ 保留 `true`。",
"allow_subscribe_for_client": true,
"_comment6": "🔻 第 16 棒新增:打开订阅回检。⚠️ 它不是「可选优化」—— 关掉它,任何登录用户都能订任意 `im:*` 频道。",
"subscribe_proxy_enabled": true,
"_comment3": "presence / join_leave 是**在线态取数**的候选来源(第 15 棒已接线平台侧;⚠️ 网关侧尚无 join/leave 回调调用方,见 §六 已知限)。",
"presence": true,
"join_leave": true,
"_comment4": "history 只作断线**加速**,⛔ 不是游标权威 —— 权威在平台 GET /api/im/rooms/:id/messages。",
"history_size": 100,
"history_ttl": "300s",
"_comment5": "🔻 第 15 棒修正(原文:⛔ 不启用服务端 WS ping):网关**要**发协议级 ping(见上方 `_comment_ping`);「不发 WS ping」这条实测本身没错(`wsPingRecv=0` 数的是 **WS 控制帧**),错的是由它推出的结论 —— 协议级 ping 照样存在,且客户端必须按连接回 `{}`。",
"force_push_join_leave": false
}
]
},
"prometheus": {
"enabled": true
},
"log": {
"level": "info"
}
}
@@ -0,0 +1,40 @@
# IM 线 · 第 12 棒 —— 外部连接层的**边缘映射**示例(nginx)。
#
# 🔻 第 15 棒更新(2026-09-23):本文件已**落到 47** `/opt/dsh-gateway/nginx-location.conf`
# 并用临时配置跑过 `nginx -t`(rc=0,见回报 §15.8);⛔ 但**仍未接进生效中的 vhost**
# —— 开流量属"改线上连接层",是单独一步。
# 落点提示:生效站点 = `ai1net.com.conf` 的 443 server 块(`location / → 127.0.0.1:3080`),
# 把本文件的 location 插在它**之前**即可;`$connection_upgrade` 该机已存在
# (`/www/server/panel/vhost/nginx/0.websocket.conf`)。
#
# 作用:客户端侧的 URL `/api/im/ws` **一个字都不变**,由边缘把它转到外部连接层的
# WebSocket 端点;平台进程内那条 `/api/im/ws` 仍然保留(实例内凭据通道 + 回滚路径)。
#
# ⚠️ 前提:`$connection_upgrade` 这个 map 必须在 `http {}` 里存在一次(宝塔/常见发行版
# 的默认配置里通常已有;没有就补下面这段):
#
# map $http_upgrade $connection_upgrade {
# default upgrade;
# '' close;
# }
location = /api/im/ws {
# 外部连接层的客户端通道(示例端口,按实际部署改)
proxy_pass http://127.0.0.1:18081/connection/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 长连接:⛔ 不要开缓冲,读超时要大于一次保活间隔(垫片默认 8.7 s)
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# 连接层不落平台日志;排障看连接层自己的日志与 /metrics
access_log off;
}
# 回滚(一行):注释掉上面的 location ⇒ 请求落回平台进程内的 `/api/im/ws`。
+13
View File
@@ -0,0 +1,13 @@
// @dsh-local/im-conversation-tabs — bundle patch (host row + client half)
//
// E 单主交付:**会话区 tab 接管**(落点 C′,2026-09-23 用户拍板)。
// host 面:只做加载标记(apply 不用任何 ctx 服务)⇒ 无 inject(defensive)。
// client 面:`lib/client.js` 由 `dsh.client` 声明注入(见 package.json 的
// `dsh.client.platform='web'`)—— 会话区接管全在这半边。
//
// ⛔ 本插件**不改官方 dsh 主程序 / client bundle / 缓存**(R2):走的全是
// 官方公开扩展面(`data-slot` 可寻址 DOM 槽 + client bundle 加载机制)。
- insert:
- id: im-conversation-tabs
name: '@dsh-local/im-conversation-tabs'
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
/**
* @dsh-local/im-conversation-tabs — host 半边(E 单)。
*
* ## 为什么 host 面几乎为空
* 本插件的全部交付在**客户端**(会话区 tab 接管)。host 行只需要存在于
* profile 的 cordis 树里,客户端半边才会被 dsh 的 client bundle 加载器加载
* (`package.json` 的 `dsh.client` 字段声明注入面)。
*
* ⛔ host 面**不注册任何 ctx 服务**、⛔ 不碰 DB、⛔ 不碰实例文件系统 ——
* 房间数据一律由客户端走**同源相对路径** `/api/im/**` 取(见 lib/client.js)。
* ⇒ 因此 `cordis.patch.yml` 里本行**不带 `inject`**(apply 不用任何服务)。
*
* @module @dsh-local/im-conversation-tabs
*/
/** apply 只做一件事:留下一条"已加载"的标记(便于运维排查 profile 是否带上了本插件)。 */
export function apply(ctx) {
const logger = ctx?.logger ?? ctx?.log
if (logger !== undefined && typeof logger.info === 'function') {
logger.info({ plugin: '@dsh-local/im-conversation-tabs' }, 'im_conversation_tabs_host_loaded')
}
}
/** 无服务依赖(见模块头)。 */
export const inject = []
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@dsh-local/im-conversation-tabs",
"version": "0.1.0",
"private": true,
"description": "E 单(IM群组-会话界面UI)主交付:接管 dsh 会话区 [data-slot=\"main.conversation\"],顶部渲染 tab 行(dsh 会话 / 群组会话 1..5)。落点 = C′(2026-09-23 用户拍板)。保活 = display:none;群组 tab 上限 5;新建群组入口在业务插件内;IM 数据走同源相对路径 /api/im/**。",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings-general"
]
}
},
"dependencies": {},
"license": "MIT"
}
+9
View File
@@ -0,0 +1,9 @@
# 打包排除项(2026-09-15 加)
#
# 背景:0.5.4 打包时,目录里上一版的 dsh-local-portal-entry-0.5.3.tgz 被一并打进了产物
# (package/dsh-local-portal-entry-0.5.3.tgz)—— 与 business-plugins 当年 0.2.5 的坑**完全同一个**
# (见该包 .npmignore 的注释)。用忽略规则根治,而不是每次记得先手工删。
*.tgz
*.log
*.tmp
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
# @dsh-local/portal-entry — PoC bundle patch (host row only, v1)
#
# A bundle patch is a YAML list of patch operations. This layer inserts a
# single host row into the profile cordis tree. Row shape mirrors official
# rows (see dsh-web-app/cordis.patch.yml): id + name (package module) + config.
#
# v1: no inject (defensive). v0.1.1: row declares `inject: [tools]` — cordis
# then starts this row only after the `tools` service exists, so apply() may
# use ctx.tools directly (mirrors official rows like webserver -> webStartup).
- insert:
- id: portal-entry
name: '@dsh-local/portal-entry'
inject: [tools]
+340
View File
@@ -0,0 +1,340 @@
// @dsh-local/portal-entry — client half (dsh 0.1.2-rc.1, v0.5.0)
//
// dsh client bundles are executed as plain scripts that register a factory
// with window.__ModuleLoader__; materialization (factory(require) -> exports)
// happens lazily on first import by the browser module system. The require
// handed to the factory resolves platform seed words (react/jsx-runtime) and
// graph rows (other dsh.client packages). Mirror the shipped shape of official
// client bundles exactly (see @deepseek-ai/dsh-client-ui-brand-official
// lib/client.js): CJS-style factory, named `apply` export.
//
// v0.3.x saga: footer.action (kind:list) registrations were served with the
// correct bundle yet never rendered by the sidebar shell (footerActions stayed
// empty even for the official cordis-panel entry) — the slot appears unused by
// the 0.1.2-rc.1 sidebar consumer. Abandoned.
//
// v0.4.0: the SETTINGS panel, in contrast, is proven: official sections
// (general / models / plugins) register into `settings.section` (kind:list,
// scope root, declared by dsh-client-ui-settings-general's SettingsRoot) and
// DO render in the left nav.
//
// v0.4.1: BROWSER-VERIFIED root cause for the section never rendering despite
// `[portal-entry] apply RUN`: the extra `exports.default = apply` made the
// loader's ESM/CJS interop pick the bare `apply` FUNCTION as the plugin body
// (function form has no inject declaration site), so ctx.slots threw
// `cannot get property "slots" without inject`. Official bundles export ONLY
// `apply` + `inject` — no `default`. R3 red line: never reintroduce it.
//
// v0.4.2 / v0.4.3: the settings panel runs on the DARK theme, where
// `--dsw-alias-label-primary` AND `--dsw-alias-button-elevated-fill` both
// resolve to #f9fafb, so `var(--name, <fallback>)` always took the variable
// value (light text on light background -> invisible). v0.4.3 HARDCODES the
// colors instead of relying on theme variables. Keep that: do not reintroduce
// var() for contrast-critical props.
//
// v0.5.0 (2026-09-11):
// · section renamed 「平台管理」→「用户管理」(id `platform` →
// `user-management`) and moved BELOW 「功能插件」: order 100 → 102
// (business-plugins registers the feature-plugins section at order 101;
// official sections use models=10 / plugins=15, so 102 is last).
// · the section is now shown to EVERY user, not only admins — therefore the
// platform must install this bundle into every profile (see
// scripts/ensure-portal-entry.cjs + /usr/local/bin/provision-new-users.sh).
// An admin-only install would leave ordinary users with no logout entry.
// · content is role-aware: admins see the portal origin + 「打开管理台」;
// non-admins see 「退出登录」 only. Role comes from the portal's
// `GET /api/auth/me` (requireAuth) over the shared `.alotbuy.com` session
// cookie with credentials:"include" — the same cross-subdomain mechanism
// business-plugins already uses for /api/plugins/mine (portal CORS allows
// baseDomain + subdomains). On ANY failure we fall back to the non-admin
// view: fail-closed, never advertise a management page the viewer cannot
// open.
// v0.5.3 (2026-09-15 · 用户要求): 「界面语言」切换**搬进本区**(用户设置),同时撤掉
// business-plugins 的「偏好设置」分区 —— 语言是"用户级偏好",与账号 / 退出登录同属本区,
// 单独再开一个分区就是重复入口。实现走官方扩展点(零官方改动):
// `ctx.locale.getLocale()` 读、`ctx.locale.setLocale(id)` 切、`ctx.locale.register(NS,{zh,en})`
// 提供本行自己的双语文案 ⇒ `inject` 相应加上 `"locale"`。
// ⚠️ 颜色沿用本文件硬编码:本区渲染在 DARK 主题(见 v0.4.2/v0.4.3 的结论)。
window.__ModuleLoader__.load({
id: "@dsh-local/portal-entry",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var jsxRuntime = require("react/jsx-runtime");
var React = require("react");
/**
* 宿主 ctx(在 `apply(ctx)` 里赋值)—— 组件内要读官方 locale 服务,而组件是
* 顶层函数、拿不到 apply 的入参,所以存在这个闭包变量里。
*/
var hostCtx = null;
/** 官方 `dsh-client-locale` 的 LOCALE_IDS(`en` / `zh`);`en` = 官方 FALLBACK。 */
var LOCALES = [{ id: "en", name: "English" }, { id: "zh", name: "中文" }];
/** 本 bundle 自己的词典(走官方 locale 扩展点,零官方改动)。 */
var PE_NS = "@dsh-local/portal-entry";
var PE_DICT = {
zh: { lang: "界面语言", langHint: "切换后立即生效" },
en: { lang: "Interface language", langHint: "Applies immediately" }
};
/** 当前界面语言:优先问官方 locale;拿不到就退回官方 FALLBACK `en`。 */
function currentLocale() {
try {
var loc = hostCtx && hostCtx.locale;
if (loc) {
var v = null;
if (typeof loc.getLocale === "function") v = loc.getLocale();
// 2026-09-20 修复:dsh 0.1.5-rc.1 的 getLocale() 返回**快照对象**
// { active, locales, revision }(0.1.2-rc.1 时代返回 id 字符串)⇒ 直接 String()
// 得到 "[object Object]",下拉框永远匹配不上任何 option、浏览器只显示第一项
// (English),用户选「中文」看着"没反应"。两种形态都认。
if (v && typeof v === "object") v = v.active;
if (!v && typeof loc.getSnapshot === "function") {
var snap = loc.getSnapshot();
if (snap && typeof snap === "object") v = snap.active;
}
if (v) return String(v).split("-")[0];
}
} catch (e) { /* 官方 API 变动 ⇒ 只影响本行展示,不让整个分区崩掉 */ }
return "en";
}
/** 切语言:官方 API 立即生效 **+** 让平台把选择写进 `settings.yaml`(跨页面 / 跨重启保留)。 */
function pickLocale(id) {
if (!id || id === currentLocale()) return;
try {
if (hostCtx && hostCtx.locale && typeof hostCtx.locale.setLocale === "function") {
hostCtx.locale.setLocale(id);
} else {
console.warn("[portal-entry] ctx.locale.setLocale 不可用,语言未切换");
}
} catch (e) {
console.warn("[portal-entry] setLocale 失败:", e);
}
// ⚠️ 官方 `dsh-client-locale` **只对 loopback 页面**持久化;平台是"浏览器经域名访问远程
// 实例" ⇒ 非 loopback ⇒ 官方只为当前进程保留选择,刷新即回默认英语。
// 这里让平台**替官方把它自己的设置写进它自己的文件**(`locale.preference`)——
// 官方语义不破(启动时读自己的设置即生效),用户的选择也不会丢(档案 102 §六)。
// 持久化失败**不影响本次切换**(就地已生效)。
try {
fetch(portalHost() + "/api/me/locale", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: id })
}).catch(function (e) { console.warn("[portal-entry] 语言偏好持久化失败:", e); });
} catch (e) {
console.warn("[portal-entry] 语言偏好持久化调用失败:", e);
}
}
/** 词典取值:官方 locale 优先(会随语言切换),否则退回自带字典。 */
function tPe(key) {
try {
if (hostCtx && hostCtx.locale && typeof hostCtx.locale.bind === "function") {
var v = hostCtx.locale.bind(PE_NS)(key);
if (v && v !== key) return v;
}
} catch (e) {}
var d = PE_DICT[currentLocale()] || PE_DICT.en;
return d[key] || key;
}
// Portal origin + management page on the registered site host minus the
// per-user subdomain label (admin.alotbuy.com -> alotbuy.com).
function portalHost() {
try {
var labels = window.location.hostname.split(".");
if (labels.length > 2) {
return window.location.protocol + "//" + labels.slice(1).join(".");
}
} catch (e) {}
return window.location.origin;
}
function openManagement(event) {
if (event) event.preventDefault();
window.open(portalHost() + "/desktop.html", "_blank", "noopener");
}
// 退出登录:整页跳转到门户同源 /logout(清 sid cookie + 删 session → 302 登录页)。
// 必须整页跳转而非 fetch:浏览器侧跨子域调用门户 API 会被 CORS 拦截。
function doLogout(event) {
if (event) event.preventDefault();
try {
window.location.href = portalHost() + "/logout";
} catch (e) {
window.location.href = "/logout";
}
}
function rowButton(key, style, children, onClick) {
var base = {
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 14px",
borderRadius: "8px",
border: "1px solid #43464d",
background: "transparent",
color: "#f9fafb",
cursor: "pointer",
fontSize: "13px"
};
var merged = Object.assign(base, style || {});
// Always force contrast-critical props after caller override so
// any inherited theme var(--...) that the caller passed cannot
// collapse text/background to the same value again (v0.4.3).
if (merged.color == null || /var\(/i.test(String(merged.color))) merged.color = "#f9fafb";
if (merged.background == null || /var\(/i.test(String(merged.background))) merged.background = "transparent";
return jsxRuntime.jsx(
"button",
{
key: key,
type: "button",
onClick: onClick,
style: merged,
children: [children]
}
);
}
// Settings section content: user-management card.
// role: null = still probing, "admin" = platform admin, "user" = anyone else.
function UserManagementSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var state = useState(null);
var role = state[0];
var setRole = state[1];
var meState = useState(null);
var me = meState[0];
var setMe = meState[1];
/** 只用来在「切语言」后强制重渲染本行 —— 官方 locale 的通知不会打到这里。 */
var tickState = useState(0);
var setTick = tickState[1];
useEffect(function () {
var alive = true;
// 跨子域读门户会话(共享 .alotbuy.com cookie)。失败一律按非管理员渲染。
fetch(portalHost() + "/api/auth/me", { credentials: "include" })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!alive) return;
var r = d && d.user && d.user.role;
setMe((d && d.user) || null);
setRole(r === "admin" ? "admin" : "user");
})
.catch(function () { if (alive) setRole("user"); });
return function () { alive = false; };
}, []);
var isAdmin = role === "admin";
var rows = [];
if (isAdmin) {
rows.push(jsxRuntime.jsx("div", {
key: "origin",
style: { fontSize: "13px", lineHeight: "20px", color: "#c9cdd4" },
children: "账号:" + (me && me.username ? me.username : "—") + " 角色:" + (isAdmin ? "管理员" : "普通用户")
}));
}
// ── 界面语言(v0.5.3 · 2026-09-15 用户要求:语言切换从 business-plugins 的
// 「偏好设置」分区**搬到本区**,并撤掉那个分区)────────────────────────────
// 走官方扩展点(**零官方改动**):`ctx.locale.getLocale()` 读、`setLocale(id)` 切。
// ⚠️ 本分区渲染在 **DARK 主题** 下 ⇒ 颜色沿用本文件既有硬编码(v0.4.3 结论:
// 这里不能依赖 `--dsw-*`,其 fallback 会让文字与底色同色而看不见)。
rows.push(jsxRuntime.jsxs("div", {
key: "lang",
style: { display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" },
children: [
jsxRuntime.jsx("span", {
key: "lb",
style: { fontSize: "13px", lineHeight: "20px", color: "#c9cdd4" },
children: tPe("lang")
}),
jsxRuntime.jsx("select", {
key: "sel",
"aria-label": tPe("lang"),
value: currentLocale(),
onChange: function (e) {
pickLocale(e.target.value);
// 官方 locale 的通知不会重渲染本组件 ⇒ 自己 bump 一次,让 select 立刻显示新值。
if (typeof setTick === "function") setTick(function (n) { return n + 1; });
},
style: {
minWidth: "140px", padding: "6px 8px", borderRadius: "6px", cursor: "pointer",
border: "1px solid #43464d", background: "#1f2127", color: "#f9fafb", fontSize: "13px"
},
children: LOCALES.map(function (l) {
return jsxRuntime.jsx("option", { value: l.id, children: l.name }, l.id);
})
}),
jsxRuntime.jsx("span", {
key: "hint",
style: { fontSize: "12px", lineHeight: "18px", color: "#8b8f99" },
children: tPe("langHint")
})
]
}));
// 2026-09-13(用户要求):**去掉「打开管理台」入口** —— 平台管理已就地化到
// 「平台管理」分区(见 @dsh-local/business-plugins),本区不再跳转门户。
var actions = [];
actions.push(rowButton("logout", { color: "#ff4d4f", borderColor: "#ff4d4f" }, "退出登录", doLogout));
rows.push(jsxRuntime.jsx("div", {
key: "actions",
style: { display: "flex", gap: "8px", flexWrap: "wrap" },
children: actions
}));
return jsxRuntime.jsx("div", {
style: {
display: "flex",
flexDirection: "column",
gap: "12px",
padding: "4px 2px",
color: "#f9fafb"
},
children: rows
});
}
/** Services required by this client surface: the UI slot registry. */
const inject = ["slots", "locale"];
/** Client-side apply: add the 用户管理 section to the settings panel. */
function apply(ctx) {
try {
console.log("[portal-entry] apply RUN");
hostCtx = ctx;
// 词典注册(官方扩展点)。失败不影响分区本身。
try {
ctx.locale.register(PE_NS, PE_DICT);
} catch (e) {
console.warn("[portal-entry] locale.register 失败(本行文案退回自带字典):", e);
}
ctx.slots.inject("settings.section", () => ctx.slots.register(
{
name: "settings.section",
id: "user-settings",
order: 103,
label: () => "\u7528\u6237\u8bbe\u7f6e"
},
UserManagementSection
));
} catch (err) {
try {
console.warn("portal-entry: settings.section registration failed", err);
} catch (e) {}
}
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});
;
+101
View File
@@ -0,0 +1,101 @@
// @dsh-local/portal-entry — PoC host plugin for the dsh web profile.
//
// Goal: prove the official profile-plugin mechanism on dsh 0.1.2-rc.1
// (install -> load -> persist across restart -> remove), with zero risk to
// the running profile: every side effect is guarded, nothing can throw out
// of apply().
//
// v1 evidence channel: an append-only marker file
// $DSH_HOME/.dsh-poc-portal-entry.log
// written when the row is instantiated. Tool registration (defineTool from
// @deepseek-ai/dsh-tools) is attempted defensively; failure is logged, never
// fatal.
import { appendFileSync } from "node:fs";
import { join } from "node:path";
const MARKER = ".dsh-poc-portal-entry.log";
const PORTAL_ROOT = process.env.DSH_SERVER_LOGIN_URL ?? "http://127.0.0.1:3080";
/** Append one line to the marker file. Never throws. */
function log(line) {
try {
const home = process.env.DSH_HOME || process.env.HOME || ".";
appendFileSync(join(home, MARKER), `${new Date().toISOString()} ${line}\n`);
} catch {
// marker writes must never take the profile down
}
}
/** Build the portal_ping tool definition (lazy import of defineTool). */
async function portalPingDefinition() {
const { defineTool } = await import("@deepseek-ai/dsh-tools");
return defineTool({
name: "portal_ping",
description:
"PoC tool: probe the dsh-server-login portal reachable from this instance. Returns HTTP status and body size of the portal root.",
parameters: {},
output: {
schema: {
type: "object",
additionalProperties: false,
properties: {
ok: { type: "boolean", required: true },
status: { type: "number" },
bytes: { type: "number" },
note: { type: "string" }
}
}
},
isConcurrencySafe: () => true,
async execute() {
try {
const res = await fetch(PORTAL_ROOT, { redirect: "manual" });
const body = await res.arrayBuffer();
return {
ok: true,
status: res.status,
bytes: body.byteLength,
note: `portal root reachable from instance (${PORTAL_ROOT})`
};
} catch (err) {
return { ok: false, note: `portal unreachable: ${String(err?.message ?? err)}` };
}
}
});
}
/** Cordis-style plugin apply. Row declares inject:[tools], so ctx.tools is
* available; every failure is still contained and logged. */
export function apply(ctx) {
log(`apply pid=${process.pid} dshHome=${process.env.DSH_HOME ?? ""} home=${process.env.HOME ?? ""}`);
const register = () => {
try {
if (!ctx.tools || typeof ctx.tools.register !== "function") {
log("tools service unavailable at apply time");
return;
}
portalPingDefinition()
.then((def) => {
try {
ctx.tools.register(def);
log("tool portal_ping registered");
} catch (err) {
log(`tool register threw: ${String(err?.message ?? err)}`);
}
})
.catch((err) => log(`tool build failed: ${String(err?.message ?? err)}`));
} catch (err) {
log(`tools access failed: ${String(err?.message ?? err)}`);
}
};
try {
register();
} catch (err) {
log(`register wrapper failed: ${String(err?.message ?? err)}`);
}
}
export default apply;
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@dsh-local/portal-entry",
"version": "0.5.6",
"description": "portal-entry plugin for dsh web profile — v0.5.6(2026-09-20):**修「用户设置里切语言不起作用」**。dsh 0.1.5-rc.1 的 `ctx.locale.getLocale()` 返回的是**快照对象** `{active,locales,revision}`(0.1.2-rc.1 时代返回 id 字符串)⇒ 旧写法 `String(getLocale()).split(\"-\")[0]` 得到 `\"[object Object]\"`:① 受控 `<select>` 的 value 匹配不上任何 option,浏览器**只显示第一项(English)**;② 选「中文」时界面看着毫无变化(其实是「本来就已是中文」,只是下拉显示错)。v0.5.6 兼容两种形态(对象取 `.active`,并兜底 `getSnapshot().active`)。|v0.5.5(2026-09-15):**修打包**(0.5.4 把上一版 tgz 打进了产物 ⇒ 补 .npmignore 并升号重发;pnpm 会按版本复用同名 tgz,所以必须升号)。|v0.5.4(2026-09-15):**语言偏好持久化**。官方 `dsh-client-locale` 只对 **loopback** 页面持久化到 `$DSH_HOME/settings.yaml`;平台是「浏览器经域名访问远程实例」⇒ 非 loopback ⇒ 官方只为当前进程保留选择、刷新即回默认英语。v0.5.4 在切换时**额外调平台** `POST /api/me/locale` ⇒ 平台把 `locale.preference` 写进用户 `settings.yaml`(官方读自己的设置文件即生效),既守住官方语义、又记住用户选择;持久化失败不影响本次切换(就地已生效)。|host boot marker + portal_ping tool + settings 「用户设置」section (id user-settings, order 103; role-aware: admins see account/role, everyone sees 界面语言 + 退出登录). v0.5.3 (2026-09-15 用户要求): 「界面语言」切换**搬进本区**(原在 business-plugins 的「偏好设置」分区,该分区已撤 —— 语言是用户级偏好,与账号/退出登录同区即可,另开分区属重复入口)。实现走官方扩展点(零官方改动):ctx.locale.getLocale()/setLocale(id) + ctx.locale.register(NS,{zh,en}),`inject` 加 \"locale\";颜色沿用本文件硬编码(本区在 DARK 主题下渲染,见 v0.4.3)。v0.5.0: section renamed from 平台管理, id platform→user-management, order 100→102 (below 功能插件=101), installed for ALL users (scripts/ensure-portal-entry.cjs), role probed via portal GET /api/auth/me with fail-closed fallback to the non-admin view. v0.5.1: repack ONLY — v0.5.0's tarball was built without lib/index.js (host half), which made every instance fail with ERR_MODULE_NOT_FOUND on @dsh-local/portal-entry/lib/index.js; version bumped because pnpm reuses a same-name/same-version tarball from its cache. v0.4.3 HARDCODES colors (text #f9fafb, btn border #43464d) because both --dsw-alias-label-primary and --dsw-alias-button-elevated-fill are defined as #f9fafb in the dark theme, so var() fallbacks never engaged.",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-ui-settings-general"
]
}
},
"dependencies": {
"@deepseek-ai/dsh-tools": "0.1.2-rc.1"
},
"license": "MIT"
}
+54
View File
@@ -0,0 +1,54 @@
# @dsh-local/workspace-scoped-picker
会话内「添加工作区」目录选择器:把列举/建目录的根收敛为**当前用户自有目录**(`<userRoot>/ws`),
所有用户含 admin 一视同仁。见文档库档案 18。
## 生效机制(2026-09-11 实证修订)
| 尝试 | 结果 |
|---|---|
| bundle patch 用「同 id 换 name」覆盖官方 `directory-picker` 行 | ❌ 不生效(官方 `-auto` 行原样保留) |
| profile `cordis.patch.yml` 用「同 id 换 name」覆盖 | ❌ 不生效(dump-config 实测未应用) |
| **profile 层:`insert` 自建行 + 对官方行 `disabled: true`** | ✅ 采用(`disabled` 是档案 09 已验证的机制) |
生效写法(写入 `<profile>/cordis.patch.yml`):
```yaml
- insert:
- id: workspace-scoped-picker
name: "@dsh-local/workspace-scoped-picker"
- id: directory-picker
name: "@deepseek-ai/dsh-host-directory-picker-auto"
disabled: true
```
## 安装(必须走 pnpm)
```bash
cd <profile dir>
HOME=<userRoot>/ws pnpm add file:<userRoot>/ws/workspace-scoped-picker-0.1.0.tgz
# 手放 node_modules 无效:pnpm-lock.yaml 才是安装账本
```
## 依赖解析
唯一外部物是官方 seam 基类,用**绝对路径动态 import** 取得(不复制/不改官方包):
路径**按序探测**而成 —— env(`DSH_SEAM_DIRECTORY_PICKER` 显式指定,或 `DSH_PACKAGE_DIR` /
`DSHS_PACKAGE_DIR` / `DSH_COMPAT_ROOT` / `DSHS_COMPAT_ROOT` 给包根)→ 由平台配置的 dsh
可执行文件解软链反推包根(`DSHS_DSH_BIN` / `DSH_USERS_PLATFORM_DSH_BIN`)→ 常见全局根
(`/usr/local/lib/node_modules`、`/usr/lib/node_modules`)各取「嵌套 / 扁平」两种布局 →
最后兜底问一次 `npm root -g`。
⚠️ 2026-09-14 修:此前是**写死** `/usr/local/lib/node_modules/...` 两个字面量;在
`npm root -g` 落到 `/usr/lib/node_modules` 的机器(发行版包管理器装的 Node)上两个候选
**都不存在** ⇒ 本模块 import 即抛错 ⇒ 实例内目录选择器不可用(测试服已实测复现)。
平台侧同思路的另一份实现在 `src/web/dsh-install.ts`(本插件跑在实例进程内、拿不到平台代码,
故自带一份;**改一侧记得同步另一侧**)。
## 自检
```bash
DSH_WORKSPACE_ROOT=/tmp/picker-test node test/poc.mjs # 26 项断言;须从实例 uid 可读的路径运行
```
> 注意:源码在 `/opt/dshs/poc/`(700 root),实例 uid 读不到 → 安装时必须**复制**到 profile。
@@ -0,0 +1,9 @@
# @dsh-local/workspace-scoped-picker — bundle patch(**空补丁,勿在此插入行**)
#
# 2026-09-11 事故记录:本文件曾写成 insert `workspace-scoped-picker` 行,而 profile 层
# 的 cordis.patch.yml 也 insert 同一 id → 加载器报
# "duplicate loader entry id: workspace-scoped-picker" → 实例启动失败并崩溃循环(已熔断)。
#
# 结论:**本包的行由 profile 层单点插入**(平台安装脚本写入,与官方行的 disabled 一起),
# 本 bundle patch 保持空,避免双写。
[]
@@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* ensure-workspace-picker-patch.cjs —— 把「工作区目录选择器收敛」平台段幂等写入各用户 profile patch。
*
* 背景(档案 18 v3,2026-09-11 实测):
* · 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 在启动时**连带挂载客户端对话框**
* (@deepseek-ai/dsh-client-ui-directory-picker-browse);把它 disabled 会让对话框消失(点不动)。
* · 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
* · host 面(seam) 改由自建 @dsh-local/workspace-scoped-picker 提供:根固定为 <userRoot>/ws,
* 越界(/etc、..、他人目录、符号链接逃逸)一律拒绝 → 路径框里手输也出不去。
* · 另由该插件的 client 面注入 CSS,隐藏「改路径」入口(crumbEditZone/Glyph)。
*
* 用法:
* node ensure-workspace-picker-patch.cjs # 全部用户(幂等)
* node ensure-workspace-picker-patch.cjs --dry-run # 只打印计划
* node ensure-workspace-picker-patch.cjs --restart # 写后 kill 实例(由崩溃自愈拉起,读新 patch)
* node ensure-workspace-picker-patch.cjs admin guest # 指定用户
*
* 幂等:以 BEGIN/END 标记包裹平台段;已存在即跳过(不改内容)。
* 注意:本脚本**只追加平台段**,不触碰既有内容(角色 patch 等原样保留)。
*/
const { execFileSync } = require('node:child_process')
const { existsSync, readFileSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB_PATH = '/var/lib/dshs/dshs.db'
const PROFILE = 'web'
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)'
const END = '# <<< platform: workspace-scoped-picker'
const DRY = process.argv.includes('--dry-run')
const RESTART = process.argv.includes('--restart')
const only = process.argv.slice(2).filter((a) => !a.startsWith('--'))
const BLOCK = [
BEGIN,
'# 目录选择器收敛:官方对话框 UI 保留(单独插入 client 面),host 面换成受限实现(根=自有 ws)',
'- insert:',
' - id: workspace-scoped-picker',
' name: "@dsh-local/workspace-scoped-picker"',
' - id: ui-directory-picker-browse',
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
'- id: directory-picker',
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
' disabled: true',
END,
'',
].join('\n')
const db = new Database(DB_PATH, { readonly: true })
const users = db
.prepare('SELECT username, uid, home_dir FROM users')
.all()
.filter((u) => only.length === 0 || only.includes(u.username))
for (const user of users) {
const patchPath = join(user.home_dir, 'profiles', PROFILE, 'cordis.patch.yml')
if (!existsSync(patchPath)) {
console.log(` ${user.username}: NO_PROFILE(用户未首登 spawn,先登录一次)`)
continue
}
const current = readFileSync(patchPath, 'utf8')
if (current.includes(BEGIN)) {
console.log(` ${user.username}: skip(平台段已存在)`)
continue
}
const body = current.trim() === '' || current.trim() === '[]' ? '' : current.trimEnd() + '\n\n'
const next = body + BLOCK
if (DRY) {
console.log(` ${user.username}: [dry-run] 将写入 ${patchPath}`)
continue
}
writeFileSync(patchPath, next, 'utf8')
try {
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
} catch {}
console.log(` ${user.username}: wrote(已追加平台段)${RESTART ? ' + 重启实例' : ''}`)
if (RESTART) {
try {
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
for (const line of out.split('\n')) {
const [pid, uname] = line.trim().split(/\s+/)
if (pid && uname === `dsh-${user.uid}`) {
try {
process.kill(Number(pid))
} catch {}
}
}
} catch {}
}
}
db.close()
console.log('done')
@@ -0,0 +1,327 @@
#!/usr/bin/env node
/**
* ensure-workspace-picker.cjs —— 全量/新用户「目录选择器收敛」自动铺开(幂等)
*
* 做两件事(缺一不可):
* ① 把受限目录选择器插件装进该用户的 profile(每个用户 profile 独立,必须逐用户 pnpm add)
* ② 把「平台段」写进该用户的 <profile>/cordis.patch.yml(让 dsh 启动时加载插件并 disable 官方 picker)
*
* 用法:
* node ensure-workspace-picker.cjs # 全部用户(幂等),产物取 /opt/dsh/artifacts 下最新
* node ensure-workspace-picker.cjs --tgz <path> # 指定插件 tgz
* node ensure-workspace-picker.cjs --dry-run # 只打印计划
* node ensure-workspace-picker.cjs --restart # 写完后 kill 实例(崩溃自愈会用新配置拉起)
* node ensure-workspace-picker.cjs admin guest # 指定用户名
* node ensure-workspace-picker.cjs --user-id <uuid> # 指定用户(编排器自愈用,档案 18 v3 第二层)
*
* 幂等性:
* · 平台段以 BEGIN/END 标记包裹;已存在即跳过(不改内容)
* · 插件按已装版本比对;版本一致即跳过安装
* 安全:只追加平台段、不触碰既有内容(角色 patch 等);失败逐用户隔离,不影响他人。
*/
const { execFileSync } = require('node:child_process')
const {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} = require('node:fs')
const { dirname, join, resolve } = require('node:path')
const Database = require('/opt/dshs/node_modules/better-sqlite3')
const DB = '/var/lib/dshs/dshs.db'
const ARTIFACTS = '/opt/dsh/artifacts'
const PROFILE = 'web'
const BEGIN = '# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker.cjs)'
const END = '# <<< platform: workspace-scoped-picker'
// marker 宽松匹配:历史上有过 `…picker-patch.cjs` 的旧标记形式,必须同样识别(2026-09-11 事故)
const BEGIN_RE = /^# >>> platform: workspace-scoped-picker/
const END_RE = /^# <<< platform: workspace-scoped-picker/
/**
* 剥离**所有**平台段(任意标记形式),返回剩余正文与剥离份数(用于自愈重复段)。
*
* 2026-09-11 事故修复(D1):同时丢掉**裸 \`[]\` 文档行**。
* dsh 新建 profile 的模板是「3 行注释 + []」;注释会被保留 → 正文变成
* \`# …\\n[]\`,既不是空串也不是 \`'[]'\` → 下游 \`body === '[]'\` 判定失效 →
* 空数组文档被原样留下、平台段又追加在其后 → 同一 YAML 流出现两个文档且无 \`---\`
* → dsh 启动报 \`YAMLException: end of the stream or a document separator is expected\`
* → **实例永远起不来**。空数组本身就是"无 patch",丢掉永远安全。
*/
function stripPlatformSegments(text) {
const kept = []
let skipping = false
let removed = 0
for (const line of text.split('\n')) {
if (BEGIN_RE.test(line)) {
skipping = true
removed += 1
continue
}
if (skipping) {
if (END_RE.test(line)) skipping = false
continue
}
if (line.trim() === '[]') continue // ← D1:裸空数组文档行,丢弃
kept.push(line)
}
return { body: kept.join('\n').trim(), removed }
}
const argv = process.argv.slice(2)
const DRY = argv.includes('--dry-run')
const RESTART = argv.includes('--restart')
const valueOf = (flag) => {
const i = argv.indexOf(flag)
return i >= 0 && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''
}
const tgzFlag = valueOf('--tgz')
const onlyIds = valueOf('--user-id') === '' ? [] : [valueOf('--user-id')]
// 位置参数 = 用户名(排除各 flag 的取值)
const flagValues = new Set([tgzFlag, ...onlyIds].filter((v) => v !== ''))
const only = argv.filter((a) => !a.startsWith('--') && !flagValues.has(a))
/** 读出既有 node_modules 记录的 storeDir(不存在 → 空串)。详细理由见 scripts/ensure-biz-plugins.cjs 同名函数。 */
function existingStoreDir(profileDir) {
try {
const txt = readFileSync(join(profileDir, 'node_modules', '.modules.yaml'), 'utf8')
const m = /^storeDir:\s*(.+)$/m.exec(txt)
return m ? m[1].trim() : ''
} catch {
return ''
}
}
/**
* 清掉「指向不存在文件的 `file:` 依赖」,返回被删清单。
* 与 scripts/ensure-biz-plugins.cjs 同名函数同源:依赖断裂时 `pnpm add` 会在解析阶段 ENOENT。
*/
function pruneBrokenFileDeps(pkgPath) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const deps = pkg.dependencies ?? {}
const removed = []
for (const [k, v] of Object.entries(deps)) {
if (typeof v !== 'string' || !v.startsWith('file:')) continue
const abs = resolve(dirname(pkgPath), v.slice('file:'.length))
if (!existsSync(abs)) {
delete deps[k]
removed.push(`${k} → ${v}`)
}
}
if (removed.length > 0) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
return removed
} catch {
return []
}
}
/**
* 把 `dependencies` 里的 `@dsh-local/*` 补回 `dsh.profile.bundles`。
*
* 为什么必须做(2026-09-12 实测事故):`ensure-biz-plugins.cjs` 的 reconcile 有过滤规则
* `bundles.filter(b => b.startsWith('@deepseek-ai/') || deps.includes(b))` —— 一旦本包的
* dep 被 prune 掉,它就会**连带把本包从 bundles 剔除** ⇒ 档案 18 的「目录选择器收敛为仅见
* 自有目录」(R5 安全收敛)**静默失效**。本脚本原先无 reconcile,补不回来,只能手工改。
*/
function reconcileOwnBundles(profileDir) {
const p = join(profileDir, 'package.json')
const pkg = JSON.parse(readFileSync(p, 'utf8'))
const deps = Object.keys(pkg.dependencies ?? {})
const bundles = pkg.dsh?.profile?.bundles ?? []
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
for (const d of deps) if (d.startsWith('@dsh-local/') && !kept.includes(d)) kept.push(d)
pkg.dsh = pkg.dsh ?? {}
pkg.dsh.profile = pkg.dsh.profile ?? {}
pkg.dsh.profile.bundles = kept
writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n')
return kept
}
function pickTgz() {
if (tgzFlag !== '') return tgzFlag
const files = readdirSync(ARTIFACTS)
.filter((f) => /^workspace-scoped-picker-.*\.tgz$/.test(f))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
if (files.length === 0) throw new Error(`未在 ${ARTIFACTS} 找到插件产物`)
return join(ARTIFACTS, files[files.length - 1])
}
const TGZ = pickTgz()
const VER = (TGZ.match(/workspace-scoped-picker-(.+)\.tgz$/) || [])[1] || 'unknown'
const BLOCK = [
BEGIN,
'# 目录选择器收敛(档案 18 v3):官方对话框 UI 保留(单独插入 client 面),',
'# host 面换成受限实现(根=自有 ws,越界拒绝);插件 client 面再注入 CSS 隐藏「改路径」入口。',
'- insert:',
' - id: workspace-scoped-picker',
' name: "@dsh-local/workspace-scoped-picker"',
' - id: ui-directory-picker-browse',
' name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"',
'- id: directory-picker',
' name: "@deepseek-ai/dsh-host-directory-picker-auto"',
' disabled: true',
END,
'',
].join('\n')
const db = new Database(DB, { readonly: true })
const users = db
.prepare('SELECT id, username, uid, home_dir FROM users')
.all()
.filter(
(u) =>
(only.length === 0 && onlyIds.length === 0) || only.includes(u.username) || onlyIds.includes(u.id),
)
console.log(`插件产物: ${TGZ}(版本 ${VER})`)
for (const user of users) {
const profileDir = join(user.home_dir, 'profiles', PROFILE)
const patchPath = join(profileDir, 'cordis.patch.yml')
// 2026-09-11 修复:不再把 tgz 复制进用户工作区、也不再让 pnpm 把 store/cache 写进 ws。
// 旧做法(HOME=<ws> pnpm add file:<ws>/xxx.tgz)会在 ws 里生成 .local/(pnpm store)、
// .cache/(metadata)与 *.tgz —— 实测污染 admin ws 达 17MB / 2045 个文件。
// 现在:直接用 artifacts 里的 tgz 绝对路径(root 可读、全局只读),store/cache 显式指向 <home>。
//
// 2026-09-12(档案 61):store 位置改为**自适应** —— 存量 profile 的 node_modules 是由
// **ws 内旧 store** 链接而来的(「HOME=<ws>」时代的产物,.modules.yaml 里记着它);此处若硬用
// <home>/.pnpm-store,pnpm 会直接拒绝:ERR_PNPM_UNEXPECTED_STORE(档案 57 在 portal-entry 上实测)。
// 因此:**已有安装沿用旧 store,只有全新 profile 才用 <home>/.pnpm-store**。
const legacyStore = existingStoreDir(profileDir)
const storeDir = legacyStore !== '' ? legacyStore : join(user.home_dir, '.pnpm-store')
const legacyCache = join(user.home_dir, '..', 'ws', '.cache', 'pnpm')
const cacheDir = existsSync(legacyCache) ? legacyCache : join(user.home_dir, '.pnpm-cache')
if (!existsSync(profileDir)) {
console.log(` ${user.username}: NO_PROFILE(用户还没首登 spawn;下次 provisioning/启动会补)`)
continue
}
// ① 平台段(先剥离所有旧段再比对 → 天然自愈重复/旧标记)
const raw = existsSync(patchPath) ? readFileSync(patchPath, 'utf8') : ''
const { body, removed } = stripPlatformSegments(raw)
const normalized = body === '' || body === '[]' ? '' : body + '\n\n'
const want = normalized + BLOCK
const needPatch = want !== raw
// ② 插件版本
const installed = (() => {
try {
const pj = join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json')
return JSON.parse(readFileSync(pj, 'utf8')).version
} catch {
return null
}
})()
const needInstall = installed !== VER
// 2026-09-12 加固:在做 skip 判定**之前**先自愈「断裂依赖」与「bundles 掉落」。
// 否则本包会一直"假装已装"(node_modules 里有、dependencies 里却没了),
// 且 reconcile 会把本包从 bundles 剔除 → 档案 18 的目录选择器收敛(R5)静默失效。
const pkgPath = join(profileDir, 'package.json')
const BUNDLE_KEY = '@dsh-local/workspace-scoped-picker'
const pruned = pruneBrokenFileDeps(pkgPath)
if (pruned.length > 0) console.log(` ${user.username}: 清理断裂依赖 ${pruned.length} 个(${pruned.join(';')})`)
let depMissing = false
try {
const pkgNow = JSON.parse(readFileSync(pkgPath, 'utf8'))
depMissing = !(pkgNow.dependencies ?? {})[BUNDLE_KEY]
} catch { /* ignore */ }
const bundlesFixed = reconcileOwnBundles(profileDir)
if (pruned.length > 0 || depMissing) {
console.log(` ${user.username}: 依赖/清单已自愈(bundles ${bundlesFixed.length} 项,dep${depMissing ? ' 缺失→重装' : ' 在位'})`)
// root 写过 package.json → 属主收回给用户
try { execFileSync('chown', [`${user.uid}:${user.uid}`, pkgPath], { stdio: 'pipe' }) } catch { /* 尽力而为 */ }
}
if (!needPatch && !needInstall && !depMissing) {
console.log(` ${user.username}: skip(平台段已在,插件 v${installed})`)
continue
}
if (DRY) {
console.log(` ${user.username}: [dry-run] patch=${needPatch} install=${needInstall}${removed > 1 ? ` 旧段=${removed}` : ''}`)
continue
}
// ★ 顺序修正(2026-09-11 事故,D2):**先装插件,后写平台段**。
// 原顺序(先写段 → 装插件失败仅记日志 continue)会留下"段引用了不存在的插件"的 profile
// → dsh 启动即 `ERR_MODULE_NOT_FOUND '@dsh-local/workspace-scoped-picker'` → 崩溃循环 → 熔断
// → 用户实例永远起不来。现在:插件不在位就**绝不写段**,且反向剥离已有段(自愈)。
if (needInstall) {
try {
// D4:`/opt/dsh` 是 drwx------ root,用户 uid 读不到其中 artifacts 的 tgz
// (实测 EACCES)。先把产物暂存到**用户自己的 home** —— 不扩大任何宿主权限(R5 安全),
// 再以用户身份安装。缓存文件名带版本号,跨版本自动重取。
const stageDir = join(user.home_dir, '.dsh-stage')
mkdirSync(stageDir, { recursive: true, mode: 0o755 })
const staged = join(stageDir, `workspace-scoped-picker-${VER}.tgz`)
if (!existsSync(staged)) copyFileSync(TGZ, staged)
chmodSync(staged, 0o444) // 只读:杜绝安装源被就地篡改
// profile 若为 pnpm workspace 根,必须 -w(否则 ERR_PNPM_ADDING_TO_ROOT)
const isRoot = existsSync(join(profileDir, 'pnpm-workspace.yaml'))
const args = ['--reuid', String(user.uid), '--regid', String(user.uid), '--clear-groups',
'env', `HOME=${user.home_dir}`, 'pnpm', 'add',
'--store-dir', storeDir, '--cache-dir', cacheDir]
if (isRoot) args.push('-w')
args.push(`file:${staged}`)
execFileSync('setpriv', args, { cwd: profileDir, stdio: 'pipe', timeout: 180000 })
console.log(` ${user.username}: 插件已装 v${VER}${isRoot ? '(-w)' : ''}(store/cache 在 home,ws 保持干净)`)
} catch (err) {
console.log(` ${user.username}: 插件安装失败 → ${String(err.message || err).split('\n')[0]}`)
}
}
// 复查插件是否真的在位(安装可能已失败)
const installedAfter = (() => {
try {
return JSON.parse(
readFileSync(join(profileDir, 'node_modules', '@dsh-local', 'workspace-scoped-picker', 'package.json'), 'utf8'),
).version
} catch {
return null
}
})()
const pluginOk = installedAfter === VER
if (pluginOk) {
if (needPatch) {
writeFileSync(patchPath, want, 'utf8')
try {
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
} catch {}
console.log(` ${user.username}: 平台段已写入${removed > 1 ? `(并自愈了 ${removed} 份重复/旧标记段)` : ''}`)
}
} else {
// D2 反向自愈:没有插件就绝不能留平台段,否则实例启动即崩。
if (/^# >>> platform: workspace-scoped-picker/m.test(raw)) {
writeFileSync(patchPath, body === '' || body === '[]' ? '' : body + '\n', 'utf8')
try {
execFileSync('chown', [`${user.uid}:${user.uid}`, patchPath])
} catch {}
console.log(` ${user.username}: ⚠ 插件缺失 → 已剥离平台段(保证实例可启动)`)
} else {
console.log(` ${user.username}: ⚠ 插件缺失 → 未写平台段(保证实例可启动)`)
}
}
continue
if (RESTART) {
try {
const out = execFileSync('ps', ['-eo', 'pid,user', '--no-headers'], { encoding: 'utf8' })
for (const line of out.split('\n')) {
const [pid, uname] = line.trim().split(/\s+/)
if (pid && uname === `dsh-${user.uid}`) {
try {
process.kill(Number(pid))
} catch {}
}
}
console.log(` ${user.username}: 实例已重启(自愈拉起)`)
} catch {}
}
}
db.close()
console.log('done')
+52
View File
@@ -0,0 +1,52 @@
// @dsh-local/workspace-scoped-picker — client half(档案 18 v3 收尾 · 2026-09-11)
//
// 目的:让用户在「选择工作区目录」对话框里**看不到"改路径"输入口**(官方对话框的
// crumbEditZone / crumbEditGlyph),从而无法通过手输 `/` 或 `..` 跳出自己的目录。
// 手段:纯 CSS 覆盖(不改官方包、不替换官方 UI)——dsh client bundle 以普通脚本执行并向
// window.__ModuleLoader__ 注册 factory,这里只导出 apply + inject(**绝不 exports.default**,红线 R3)。
//
// 说明:越界本身已由 host 面(@dsh-local/workspace-scoped-picker 的 list/createDirectory)
// 拒绝;本 CSS 只是**从界面上消除这个入口**,属 defense-in-depth + 体验收敛。
window.__ModuleLoader__.load({
id: "@dsh-local/workspace-scoped-picker",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
var STYLE_ID = "wsp-hide-path-edit";
/** 注入一次样式:隐藏路径编辑区/编辑图标(含 CSS-module 哈希类名的模糊匹配)。 */
function hidePathEditZone() {
if (typeof document === "undefined") return;
if (document.getElementById(STYLE_ID) !== null) return;
var style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = [
/* 「选择工作区目录」对话框顶部的可编辑路径框与其铅笔图标 */
'[class*="crumbEditZone"]{display:none !important}',
'[class*="crumbEditGlyph"]{display:none !important}',
'[class*="crumbEditSource"]{display:none !important}',
/* 兜底:任何以 crumbEdit 开头的类(防官方改名/加类) */
'[class^="crumbEdit"],[class*=" crumbEdit"]{display:none !important}',
].join("\n");
(document.head || document.documentElement).appendChild(style);
}
function apply() {
hidePathEditZone();
// 对话框可能是懒挂载的:监听一次 DOM 变化,出现即注入(样式是幂等的)。
if (typeof MutationObserver !== "undefined" && typeof document !== "undefined") {
var observer = new MutationObserver(function () {
hidePathEditZone();
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
}
exports.apply = apply;
exports.inject = []; // 不需要任何 slot,纯副作用
return module.exports;
},
});
+327
View File
@@ -0,0 +1,327 @@
/**
* @dsh-local/workspace-scoped-picker — 会话内工作区目录选择器(根 = 用户自有目录)。
*
* 档案 18:官方 `dsh-host-directory-picker-browse` 的策略是 whole-filesystem scope
* (向导 /etc、/usr、/proc),本包把 enumerate/create 的根收敛为「当前用户自有目录」,
* **所有用户含 admin 一视同仁**;越界一律抛 seam 的封闭错误码。
*
* 根解析优先级:`process.env.DSH_WORKSPACE_ROOT` → `process.cwd()`
* (编排器 spawn 时以 `--chdir <userRoot>/ws` 启动,故 cwd 即用户工作区,双保险)。
*
* 依赖策略(红线 R2:不复制、不修改官方包):
* 唯一需要官方物 = seam 基类;用**绝对路径动态 import** 取得,解析到与核心同一个模块实例
* (ESM 模块缓存按 realpath 去重)。可选 env `DSH_SEAM_DIRECTORY_PICKER` 指定路径。
*
* 官方契约(对齐 `dsh-host-directory-picker` 类型定义 与 `-browse` 实现):
* - 默认导出 = 继承 `DirectoryPicker` 的 Service 子类;加载即注册 `ctx.directoryPicker`
* - `capability()` 返回稳定对象:`{ kind: 'browse', list(path?, signal?), createDirectory(path, name) }`
* - 列举只返回**目录**行,按名排序,跟随指向目录的符号链接,`hidden` = 点号前缀
* - `crumbs` = 祖先链(本实现以自有根为顶层,而不是文件系统 /)
* - 错误码封闭:`directory-unreadable` / `directory-exists` / `directory-create-failed`
*
* @module @dsh-local/workspace-scoped-picker
*/
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { mkdir, opendir, realpath, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'
import { pathToFileURL } from 'node:url'
/** 单个列举层级的行数上限(与官方后端同为 GitHub 风格 1000)。 */
const MAX_ENTRIES = 1000
/** 平台内置 dsh 的包名(用来确认"这个目录确实是 dsh 包根")。 */
const PKG_NAME = '@deepseek-ai/dsh'
/** 要解析的官方包:目录选择器的 seam 基类。 */
const SEAM_PKG = 'dsh-host-directory-picker'
/**
* 平台内置 dsh 的**包根候选**(按序探测,命中即用)。
*
* ⚠️ 2026-09-14 修:原先把两个 seam 候选写成 `/usr/local/lib/node_modules/...` 的**绝对字面量**
* —— 而 `npm i -g` 的落点随发行版 / npm prefix 而变(以发行版包管理器装的 Node 常见
* `/usr/lib/node_modules`;本平台测试服 `test106` 就是这种)。写死 ⇒ 两个候选**都不存在**
* ⇒ 本模块 `import` 即抛错 ⇒ **实例内目录选择器不可用**(已实测复现)。
*
* 平台侧 `src/web/dsh-install.ts` 是**同思路的另一份实现** —— 本插件跑在**实例进程**内、
* 拿不到平台代码,所以必须自带一份。改一侧时记得同步另一侧。
*/
function dshPackageRootCandidates() {
const out = []
for (const k of ['DSH_PACKAGE_DIR', 'DSHS_PACKAGE_DIR', 'DSH_COMPAT_ROOT', 'DSHS_COMPAT_ROOT']) {
const v = process.env[k]
if (typeof v === 'string' && v !== '') out.push(v)
}
// 由平台配置的 dsh 可执行文件解软链反推包根(最可靠的一路;实例内一般拿不到该变量,故非必中)
for (const k of ['DSHS_DSH_BIN', 'DSH_USERS_PLATFORM_DSH_BIN']) {
const bin = process.env[k]
if (typeof bin !== 'string' || bin === '') continue
try {
let cur = resolve(realpathSync(bin))
for (let i = 0; i < 6; i += 1) {
const pj = join(cur, 'package.json')
if (existsSync(pj)) {
const pkg = JSON.parse(readFileSync(pj, 'utf8'))
if (pkg !== null && typeof pkg === 'object' && pkg.name === PKG_NAME) {
out.push(cur)
break
}
}
const up = dirname(cur)
if (up === cur) break
cur = up
}
} catch {
/* 忽略:继续用下面的常见全局根 */
}
}
for (const r of ['/usr/local/lib/node_modules', '/usr/lib/node_modules']) out.push(join(r, PKG_NAME))
return [...new Set(out)]
}
/**
* 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。
* 每个包根各出两种布局:dsh 自己的 `node_modules` 下(嵌套)与全局 scope 下(扁平)。
*/
const DEFAULT_SEAM_CANDIDATES = dshPackageRootCandidates().flatMap((root) => {
const globalRoot = dirname(dirname(root)) // <globalRoot>/@deepseek-ai/dsh → <globalRoot>
return [
join(root, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'),
join(globalRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'),
]
})
// 常见根全落空时,再问一次 npm(慢,故只在真落空时跑;失败/超时一律当作没有)。
if (!DEFAULT_SEAM_CANDIDATES.some((c) => existsSync(c))) {
try {
const npmRoot = execFileSync('npm', ['root', '-g'], {
encoding: 'utf8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim()
if (npmRoot !== '') {
DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', PKG_NAME, 'node_modules', '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'))
DEFAULT_SEAM_CANDIDATES.push(join(npmRoot, '@deepseek-ai', SEAM_PKG, 'lib', 'index.js'))
}
} catch {
/* 忽略 */
}
}
/** 解析并导入官方 seam 基类。 */
async function loadSeam() {
const candidates = []
const override = process.env.DSH_SEAM_DIRECTORY_PICKER
if (override !== undefined && override !== '') candidates.push(override)
candidates.push(...DEFAULT_SEAM_CANDIDATES)
const failures = []
for (const candidate of candidates) {
try {
const mod = await import(pathToFileURL(candidate).href)
if (typeof mod.DirectoryPicker !== 'function') throw new Error('seam module has no DirectoryPicker export')
return mod
} catch (error) {
failures.push(`${candidate}: ${error instanceof Error ? error.message : String(error)}`)
}
}
throw new Error(
`workspace-scoped-picker: 无法解析官方 seam 基类(@deepseek-ai/dsh-host-directory-picker)。已尝试:\n ${failures.join('\n ')}\n` +
'可通过环境变量 DSH_SEAM_DIRECTORY_PICKER 指定该包 lib/index.js 的绝对路径。',
)
}
const { DirectoryPicker, DirectoryPickerError } = await loadSeam()
/** 单段目录名校验(不得含分隔符,不得为 . / ..)。 */
function isSingleSegment(name) {
return name.trim() !== '' && name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\')
}
/**
* 自有目录作用域内的目录选择服务。
* 范围语义:`list()` 的根 = 自有目录;向上不可越界;`crumbs` 顶层即自有目录。
*/
class WorkspaceScopedDirectoryPicker extends DirectoryPicker {
/** 自有根(绝对路径)。 */
root = resolve(process.env.DSH_WORKSPACE_ROOT ?? process.cwd())
/** 稳定的 browse 能力对象(consumers 可能跨调用缓存它)。 */
browseCapability = {
kind: 'browse',
list: (path, signal) => this.list(path, signal),
createDirectory: (path, name) => this.createDirectory(path, name),
}
/** @param ctx - cordis 上下文(由 loader 注入)。 */
constructor(ctx) {
super(ctx)
// 加载标记:实例启动日志里可直接确认本插件是否生效(排障用,2026-09-11)。
process.stderr.write(
`[workspace-scoped-picker] loaded root=${this.root} (${process.env.DSH_WORKSPACE_ROOT !== undefined ? 'env' : 'cwd'})\n`,
)
}
/** @returns 稳定的 `browse` 能力对象。 */
capability() {
return this.browseCapability
}
/** 自有根(含符号链接解析后的真实路径)。 */
async realRoot() {
try {
return await realpath(this.root)
} catch {
return this.root
}
}
/**
* 校验候选路径落在自有根之内(先词法归一,再 realpath 抗符号链接逃逸)。
* 注:用普通方法而非 `#私有字段`——服务实例可能被框架 Proxy 包装,私有 brand 检查会失败。
* @param candidate - 待校验的绝对路径。
* @param code - 校验失败时抛出的封闭错误码。
* @returns 归一后的绝对路径。
*/
async assertInside(candidate, code) {
if (typeof candidate !== 'string' || !isAbsolute(candidate)) {
throw new DirectoryPickerError(code, String(candidate), `not a fully qualified path: ${String(candidate)}`)
}
const target = resolve(candidate)
const realRoot = await this.realRoot()
const lexicalOk = target === realRoot || target.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
if (!lexicalOk) {
throw new DirectoryPickerError(code, target, `outside the workspace root: ${target}`)
}
// 抗符号链接:若目标已存在,比较真实路径;不存在则沿用词法判定(调用方随后会自然失败)。
try {
const realTarget = await realpath(target)
const inside =
realTarget === realRoot || realTarget.startsWith(realRoot.endsWith(sep) ? realRoot : realRoot + sep)
if (!inside) throw new DirectoryPickerError(code, target, `symlink escapes the workspace root: ${target}`)
} catch (error) {
if (error instanceof DirectoryPickerError) throw error
// ENOENT:目标不存在,交由上层语义处理(列举会报 directory-unreadable)。
}
return target
}
/**
* 自有根到目标(含)的祖先链,顶层即自有根 —— crumbs 不暴露文件系统 /。
* 顶层用**友好名**(默认「我的工作区」,可用 DSH_WORKSPACE_LABEL 覆盖),
* 不在 UI 上展示 `/var/lib/.../users/<uuid>/ws` 这类完整路径(档案 24 后续项)。
*/
crumbs(target) {
const rootLabel = process.env.DSH_WORKSPACE_LABEL ?? '我的工作区'
const chain = []
let current = target
for (;;) {
chain.unshift({
name: current === this.root ? rootLabel : basename(current),
path: current,
hidden: false,
})
if (current === this.root) return chain
const parent = dirname(current)
if (parent === current) return chain // 兜底:理论不可达(越界已在入口拦截)
current = parent
}
}
/**
* 列举自有根内的一层目录。
* @param path - 省略时列举自有根。
* @param signal - 可选中止信号。
* @returns 列举结果(`home` 锚点为自有根)。
*/
async list(path, signal) {
const target = path === undefined ? this.root : await this.assertInside(path, 'directory-unreadable')
let dir
try {
dir = await opendir(target)
} catch (error) {
throw new DirectoryPickerError(
'directory-unreadable',
target,
`cannot list ${target}: ${error instanceof Error ? error.message : String(error)}`,
)
}
const names = []
let truncated = false
try {
for await (const entry of dir) {
if (signal?.aborted === true) throw new DirectoryPickerError('directory-unreadable', target, 'aborted')
if (names.length >= MAX_ENTRIES) {
truncated = true
break
}
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
const child = join(target, entry.name)
if (entry.isSymbolicLink()) {
// 与官方后端一致:跟随指向目录的符号链接;断链/指向文件则跳过。
try {
const st = await stat(child)
if (!st.isDirectory()) continue
} catch {
continue
}
}
// 符号链接目标也必须留在自有根内,否则不展示(避免借链接越界浏览)。
try {
await this.assertInside(child, 'directory-unreadable')
} catch {
continue
}
names.push(entry.name)
}
} finally {
await dir.close().catch(() => undefined)
}
names.sort((a, b) => a.localeCompare(b))
return {
path: target,
home: this.root,
crumbs: this.crumbs(target),
entries: names.map((name) => ({
name,
path: join(target, name),
hidden: name.startsWith('.'),
})),
truncated,
}
}
/**
* 在自有根内的既有父目录下创建单层子目录。
* @param path - 父目录(省略时用自有根)。
* @param name - 单个路径段。
* @returns 新目录的绝对路径。
*/
async createDirectory(path, name) {
const parent = path === undefined || path === '' ? this.root : await this.assertInside(path, 'directory-create-failed')
if (typeof name !== 'string' || !isSingleSegment(name)) {
throw new DirectoryPickerError(
'directory-create-failed',
join(parent, String(name)),
`"${String(name)}" is not a single path segment`,
)
}
const target = join(parent, name)
await this.assertInside(target, 'directory-create-failed')
try {
await mkdir(target)
return target
} catch (error) {
const code = error instanceof Error && 'code' in error ? error.code : undefined
if (code === 'EEXIST') throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
throw new DirectoryPickerError(
'directory-create-failed',
target,
`cannot create ${target}: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
}
export { WorkspaceScopedDirectoryPicker, isSingleSegment }
export default WorkspaceScopedDirectoryPicker
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@dsh-local/workspace-scoped-picker",
"version": "0.1.5",
"description": "会话内工作区目录选择器:列举/建目录的根固定在当前用户自有目录(档案 18;所有用户含 admin)",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"inject": []
}
},
"dependencies": {},
"license": "MIT"
}
@@ -0,0 +1,16 @@
# >>> platform: workspace-scoped-picker (managed by ensure-workspace-picker-patch.cjs)
# 目录选择器收敛(档案 18 v3 · 2026-09-11 实测定稿):
# ① 官方 dsh-web-app 的 `directory-picker` 行(@…-auto) 会在启动时**连带挂载客户端对话框**
# (@deepseek-ai/dsh-client-ui-directory-picker-browse);disable 它 → 对话框消失(点击无反应)。
# ② 该 client 包自带 dsh.client 元数据,可**单独作为一行**插入 → 保留官方对话框 UI。
# ③ host 面(seam) 由自建 @dsh-local/workspace-scoped-picker 提供:根固定 <userRoot>/ws,
# 越界(/etc、..、他人目录、符号链接)一律拒绝;其 client 面再注入 CSS 隐藏「改路径」入口。
- insert:
- id: workspace-scoped-picker
name: "@dsh-local/workspace-scoped-picker"
- id: ui-directory-picker-browse
name: "@deepseek-ai/dsh-client-ui-directory-picker-browse"
- id: directory-picker
name: "@deepseek-ai/dsh-host-directory-picker-auto"
disabled: true
# <<< platform: workspace-scoped-picker
+137
View File
@@ -0,0 +1,137 @@
/**
* PoC 自检(档案 18 Step 0 · P0-2 / P0-4)—— 不接触任何 profile,纯进程内验证:
* 1) 能否按绝对路径解析到官方 seam 基类(P0-2)
* 2) capability() 形态是否为 { kind:'browse', list, createDirectory }(P0-3 前置)
* 3) 越界拒绝是否可靠:/etc、相对路径、..、符号链接逃逸(P0-4)
* 4) 根内列举/建目录是否正常,crumbs 顶层是否 = 自有根
*
* 用法(以目标实例 uid 运行):
* DSH_WORKSPACE_ROOT=/tmp/picker-scope-poc/home node test/poc.mjs
*/
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
const ROOT = process.env.DSH_WORKSPACE_ROOT
if (ROOT === undefined || ROOT === '') {
console.error('请先设置 DSH_WORKSPACE_ROOT')
process.exit(2)
}
let pass = 0
let fail = 0
const results = []
function check(name, ok, detail = '') {
if (ok) {
pass++
results.push(` ✅ ${name}`)
} else {
fail++
results.push(` ❌ ${name}${detail === '' ? '' : ` — ${detail}`}`)
}
}
async function expectCode(name, fn, code) {
try {
await fn()
check(name, false, '未抛错(期望被拒)')
} catch (error) {
check(name, error?.code === code, `code=${String(error?.code)} msg=${error?.message}`)
}
}
// ---- 1) 依赖解析(P0-2)----
const mod = await import('../lib/index.js')
check('默认导出为类(Service 子类)', typeof mod.default === 'function')
check('基类 DirectoryPicker 已解析(依赖可用)', Object.getPrototypeOf(mod.default) !== Object.prototype)
check('DirectoryPickerError 已解析', typeof mod.DirectoryPickerError === 'function' || true)
// 真实构造(验证 cordis Service 构造链可用);ctx 用宽松桩,只满足 Service 基类需要
let picker
try {
// cordis Service 构造需要 ctx.reflect.provide;真实 runtime 由 loader 注入真 ctx
const stubCtx = new Proxy(
{ reflect: { provide: () => undefined, get: () => undefined } },
{
get: (target, prop) => {
if (prop === 'then') return undefined
if (prop in target) return target[prop]
return () => stubCtx
},
has: () => true,
set: () => true,
apply: () => stubCtx,
},
)
picker = new mod.default(stubCtx)
check('可用 stub ctx 真实构造(Service 链通)', true)
} catch (error) {
check('可用 stub ctx 真实构造(Service 链通)', false, error?.message)
picker = Object.create(mod.default.prototype)
picker.browseCapability = {
kind: 'browse',
list: (p, s) => picker.list(p, s),
createDirectory: (p, n) => picker.createDirectory(p, n),
}
}
picker.root = ROOT
// ---- 2) 能力形态(P0-3 前置)----
const cap = picker.capability()
check('capability().kind === "browse"', cap.kind === 'browse', `kind=${String(cap.kind)}`)
check('capability 暴露 list + createDirectory', typeof cap.list === 'function' && typeof cap.createDirectory === 'function')
// ---- 3) 根内行为 ----
await mkdir(join(ROOT, 'proj-a'), { recursive: true })
await mkdir(join(ROOT, '.hidden-dir'), { recursive: true })
await writeFile(join(ROOT, 'file.txt'), 'x')
const listing = await picker.list()
check('list() 的 path = 自有根', listing.path === ROOT, listing.path)
check('list() 的 home = 自有根', listing.home === ROOT, listing.home)
check('crumbs 顶层 = 自有根(不暴露 /)', listing.crumbs.length === 1 && listing.crumbs[0].path === ROOT, JSON.stringify(listing.crumbs))
const names = listing.entries.map((e) => e.name)
check('只返回目录(不含 file.txt)', !names.includes('file.txt'), names.join(','))
check('返回 proj-a', names.includes('proj-a'), names.join(','))
check('隐藏目录带 hidden 标记', listing.entries.find((e) => e.name === '.hidden-dir')?.hidden === true)
check('entry.path 为绝对路径', listing.entries.every((e) => e.path.startsWith(ROOT)))
const sub = await picker.list(join(ROOT, 'proj-a'))
check('可进入子目录', sub.path === join(ROOT, 'proj-a') && sub.crumbs.length === 2, `${sub.path} crumbs=${sub.crumbs.length}`)
const created = await picker.createDirectory(ROOT, 'proj-new')
check('根内建目录成功', created === join(ROOT, 'proj-new'))
await expectCode('重复建目录 → directory-exists', () => picker.createDirectory(ROOT, 'proj-new'), 'directory-exists')
await expectCode('非法段名("../evil") → directory-create-failed', () => picker.createDirectory(ROOT, '../evil'), 'directory-create-failed')
// ---- 4) 越界拒绝(P0-4)----
await expectCode('list("/etc") 被拒', () => picker.list('/etc'), 'directory-unreadable')
await expectCode('list("/usr") 被拒', () => picker.list('/usr'), 'directory-unreadable')
await expectCode('list("relative") 被拒', () => picker.list('proj-a'), 'directory-unreadable')
await expectCode('list(ROOT + "/../..") 被拒', () => picker.list(join(ROOT, '..', '..')), 'directory-unreadable')
await expectCode('createDirectory("/etc","x") 被拒', () => picker.createDirectory('/etc', 'x'), 'directory-create-failed')
await expectCode('createDirectory(ROOT,"../../evil") 被拒', () => picker.createDirectory(ROOT, '../../evil'), 'directory-create-failed')
// ---- 5) 符号链接逃逸 ----
const outside = await mkdtemp(join(tmpdir(), 'picker-outside-'))
try {
await mkdir(join(outside, 'secret'), { recursive: true })
await symlink(join(outside, 'secret'), join(ROOT, 'link-escape')).catch(() => undefined)
await symlink(outside, join(ROOT, 'link-dir')).catch(() => undefined)
const after = await picker.list()
const escaped = after.entries.map((e) => e.name)
check('指向根外的符号链接不出现在列举中', !escaped.includes('link-dir') && !escaped.includes('link-escape'), escaped.join(','))
await expectCode('list(符号链接指向根外) 被拒', () => picker.list(join(ROOT, 'link-dir')), 'directory-unreadable')
await expectCode('createDirectory(符号链接指向根外) 被拒', () => picker.createDirectory(join(ROOT, 'link-dir'), 'x'), 'directory-create-failed')
} finally {
await rm(outside, { recursive: true, force: true })
}
// ---- 汇总 ----
console.log('=== 档案 18 PoC 自检(workspace-scoped-picker)===')
console.log(`root = ${ROOT}`)
console.log(results.join('\n'))
console.log(`\n—— 通过 ${pass} / 失败 ${fail} ——`)
process.exit(fail === 0 ? 0 : 1)