初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

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 回退方案调整后重跑。
+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'
+895
View File
@@ -0,0 +1,895 @@
// @dsh-local/business-plugins — client half (dsh 0.1.2-rc.1, v0.2.4)
//
// dsh client bundles 以普通脚本形式执行,向 window.__ModuleLoader__ 注册 factory;
// 首次 import 时惰性物化 factory(require) -> exports。require 解析平台 seed 词
// (react/jsx-runtime)与图行(其他 dsh.client 包)。**严格镜像官方 client bundle
// 的 CJS-style factory 形态,只导出 apply + inject,绝不 exports.default**(R3:
// exports.default 会让 loader 的 ESM/CJS interop 把 bare apply FUNCTION 当作插件体,
// 无 inject 声明点 → ctx.slots 抛 cannot get property "slots" without inject)。
//
// v0.2.0(档案 37 · 批次 3):
// · 配色全部改用官方 design token `--dsw-*`(原来硬编码深色)→ **自动跟随 dsh 主题**
// (亮/暗/第三方主题都适配),不再需要自己实现主题切换。
// · 文案走官方 locale:ctx.locale.register(NS,{zh,en}) + ctx.locale.bind(NS) → 中英双语。
// · 交互:搜索过滤 + 每行状态徽章(已启用/未启用)+ **被自动禁用插件的行内提示与重试**
// + 空态/加载态。
// · 应用成功且实例重启后,**自动探活并刷新页面**(重启会换端口,刷新即可接上新实例)。
//
// v0.2.4(档案 67 · 对齐 06-工作台UI规范.md):
// · **信息层次反转**:主视觉改为插件的**用途说明**(候选池入库时已优先取官方目录的中文),
// 插件名 / 版本退为副行小字 —— 与门户 plugins 页同一决策(「一眼知道这插件干什么」)。
// · 字号对齐规范 §2.2 阶梯(section 语境取 14 / 13 / 12,不照搬页面级 16px 基准);
// 徽章改 `2px 8px` r10、按钮改 `.btn-sm` 量级、行距与内边距按规范 §2.4 序列。
// · 空态/加载态按规范 §4.9 改为「标题 + 说明」层次,不再是一行灰字。
// · 新增注入式 CSS 提供行 hover 反馈与按钮 hover(规范 §4.3 / §5);disposer 随 fiber 移除。
//
// v0.2.5(档案 67 补 · 按 `impeccable` 的 craft-floor 复核后修正;检测器未随技能包提供,人工过检):
// · **去掉 rejected 提示的彩色 `border-left`** —— craft-floor 明令:卡片/列表项/提示上
// 不得出现 >1px 的彩色 border-left/right。改为整块浅底 + 圆角,危险语义由标题文字色承担。
// · **对比度**:所有必读文字从 `--dsw-alias-label-tertiary` 提到 `secondary`
// (tertiary 在亮色下约 3.5:1,低于 craft-floor 的 4.5:1 底线)。
// · **层次**:主视觉加 `fontWeight: 500`,与 12px 副行拉开 weight 台阶(原先只差 2px 字号,
// 属于 craft-floor 点名的「scale and weight steps 不明显」)。
// · **动效**:transition 由 `.15s ease` 改为 `.18s cubic-bezier(.16,1,.3,1)`(exponential ease-out)。
//
// v0.2.7(档案 75 维度 B 落地 · 2026-09-13 用户要求):
// · **卡片加内存预估**:每张卡片多一行「预估内存 ≈ N MiB」小徽章,卡片加高(minHeight 112)
// 让「用途说明 / 包名 / 预估内存 / 状态徽章」四层排得开。
// · **列表上方新增「内存预估」状态条**:实心段 = 当前已启用插件的预估占用,
// 半透明段 = 本次勾选带来的增量;超过单实例上限(384 MiB)时整条转红并提示。
// · **口径**:`MEM_BASE_MIB`(-dsh 本体+官方插件基线) + `MEM_TABLE`(逐插件实测加载成本);
// **是预估值,不是实时读数**(客户端拿不到 cgroup 实测值,需平台加只读路由才能做实时 —— 见档案 75)。
//
// 功能:在 dsh 设置面板注册「功能管理」section —— 列出候选池 + 每个插件启用状态,
// 批量勾选后点「应用更改」,确认弹窗(提示重启会中断会话)→ POST 门户
// /api/plugins/mine/apply(跨子域 fetch,credentials include;门户已加 CORS)。
// 门户端启用=复制 bundle 到该用户 profile 并加入 bundles,禁用=从 bundles 移除,
// 然后重启该用户实例生效;**启用会让实例起不来的插件会被门户单独摘掉并标记**。
window.__ModuleLoader__.load({
id: "@dsh-local/business-plugins",
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");
// 门户主域:去掉实例子域 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;
}
// 官方 design token(dsh 主题变量)。刻意**不硬编码颜色**:dsw-* 由主题提供,
// 亮/暗/第三方主题自动适配;fallback 仅在该 token 缺失时兜底。
// 注:06-工作台UI规范 §2.1 给的是亮色基线色值,本 section 嵌在 dsh 面板内,
// 跟随宿主主题优先,故取 token + 规范色值作 fallback。
var T = {
text: "var(--dsw-alias-label-primary, #24292f)",
sub: "var(--dsw-alias-label-secondary, #4e5969)",
dim: "var(--dsw-alias-label-tertiary, #6b7280)",
border: "var(--dsw-alias-border-l2, #e3e6ea)",
field: "var(--dsw-alias-bg-layer-3, #f5f6f8)",
primary: "var(--dsw-alias-brand-primary, #2f6fed)",
danger: "var(--dsw-alias-state-error-primary, #d93026)",
warn: "var(--dsw-alias-state-warning-primary, #bf8700)",
success: "var(--dsw-alias-state-success-primary, #1a7f37)"
};
// ── 内存预估模型(档案 75「维度 B 资源成本」的前端落地)────────────────────
// 口径:**预估值** = 空载基线 + Σ(已启用插件的加载成本)。客户端拿不到 cgroup 实测值,
// 因此这里是估算而非实时读数;数值来源 = 2026-09-13 隔离 cgroup 实测
//(`node --expose-gc` 逐项 import 的 rss 增量,见 `.workbuddy/memory/2026-09-13.md`)。
/** 单实例 cgroup 上限(MiB)—— 档案 58 定档;改它必须同时改编排器的 `MemoryMax`。 */
var MEM_LIMIT_MIB = 384;
/** 空载基线(MiB):dsh 本体 + 148 个官方插件 + 平台自研 ×3(smaps 实测 ≈ 285)。 */
var MEM_BASE_MIB = 285;
/** 逐插件加载成本(MiB,实测 rss 增量)。不在此表的按 MEM_FALLBACK_MIB 计。 */
var MEM_TABLE = {
"dsh-univer-office": 64,
"dsh-plugin-mcn-suite": 39
};
/** 未实测插件的兜底值 —— 平台自研轻量插件实测合计仅 1.7 MiB。 */
var MEM_FALLBACK_MIB = 2;
/** 越过该比例即提前警示,给页缓存与用户自己的任务留余量。 */
var MEM_TIGHT_RATIO = 0.85;
function memMiB(p) {
if (p && p.name && Object.prototype.hasOwnProperty.call(MEM_TABLE, p.name)) {
return MEM_TABLE[p.name];
}
return MEM_FALLBACK_MIB;
}
/** 把一组插件里 `key` 为真的那些的预估内存求和。 */
function sumMiB(list, key) {
var total = 0;
for (var i = 0; i < list.length; i++) {
if (list[i][key]) total += memMiB(list[i]);
}
return total;
}
/** 本插件的 locale 命名空间(官方 i18n)。 */
var NS = "business-plugins";
/** 简体中文字典(key 集的事实来源)。 */
var zh = {
"section.label": "功能管理",
"loading": "加载中…",
"loadingDesc": "正在从门户读取候选池…",
"emptyTitle": "暂无功能插件",
"empty": "需管理员先在门户「插件管理」里投放,之后可在此启用",
"noMatch": "没有匹配的插件",
"search": "搜索插件名或用途",
"selectAll": "全选",
"clear": "清空",
"enabled": "已启用",
"disabled": "未启用",
"selected": "已选",
"apply": "应用更改",
"applying": "应用中…",
"confirm.title": "确认应用更改?",
"confirm": "将重启当前 dsh 实例以生效。重启会中断当前会话(正在进行的对话会断开)。",
"confirm.cancel": "取消",
"confirm.ok": "确认应用",
"confirm.overTitle": "⚠ 本次勾选将超出单实例内存上限",
"nochange": "没有需要更改的插件",
"queued": "排队中…",
"installing": "正在安装",
"configuring": "正在配置",
"restarting": "正在重启实例",
"probing": "正在检查实例",
"isolating": "正在定位问题插件",
"applied": "✓ 已应用",
"reloading": "实例重启中,页面将自动刷新…",
"rejected.title": "以下插件与实例不兼容,已自动禁用",
"retry": "重试",
"requestFailed": "请求失败",
"loadFailed": "加载失败",
"mem.title": "内存预估",
"mem.est": "预估(实测基准,非实时读数)",
"mem.now": "当前",
"mem.planned": "勾选后",
"mem.limit": "上限",
"mem.left": "剩余",
"mem.over": "将超出上限",
"mem.tight": "接近上限",
"mem.safe": "余量充足",
"mem.overWarn": "勾选后将超出单实例内存上限,实例可能起不来。建议先停用部分插件再应用。",
"mem.perCard": "预估内存"
};
/** English dictionary, key-set complete against zh. */
var en = {
"section.label": "Feature management",
"loading": "Loading…",
"loadingDesc": "Reading the candidate pool from the portal…",
"emptyTitle": "No feature plugins yet",
"empty": "An admin must publish them in the portal's plugin management first",
"noMatch": "No matching plugins",
"search": "Search by name or purpose",
"selectAll": "Select all",
"clear": "Clear",
"enabled": "Enabled",
"disabled": "Disabled",
"selected": "selected",
"apply": "Apply changes",
"applying": "Applying…",
"confirm.title": "Apply the changes?",
"confirm": "The dsh instance will restart, which interrupts the current session (any running conversation is disconnected).",
"confirm.cancel": "Cancel",
"confirm.ok": "Apply",
"confirm.overTitle": "⚠ This selection exceeds the per-instance memory limit",
"nochange": "No changes to apply",
"queued": "Queued…",
"installing": "Installing",
"configuring": "Configuring",
"restarting": "Restarting instance",
"probing": "Checking instance health",
"isolating": "Locating the failing plugin",
"applied": "✓ Applied",
"reloading": "Instance is restarting — the page will refresh automatically…",
"rejected.title": "These plugins are incompatible with the instance and were disabled automatically",
"retry": "Retry",
"requestFailed": "Request failed",
"loadFailed": "Failed to load",
"mem.title": "Memory estimate",
"mem.est": "estimate from measured baselines, not a live reading",
"mem.now": "now",
"mem.planned": "after selection",
"mem.limit": "limit",
"mem.left": "left",
"mem.over": "over the limit",
"mem.tight": "close to the limit",
"mem.safe": "headroom is fine",
"mem.overWarn": "The selection exceeds the per-instance memory limit and the instance may fail to start. Disable some plugins first.",
"mem.perCard": "est. memory"
};
/**
* Required services (cordis fiber inject). `locale` is required for the
* dictionaries; the slot registry for the settings section.
*/
var inject = ["slots", "locale"];
/** 应用成功后探活并刷新(重启会换端口;探通了再 reload,避免白屏)。 */
function waitAndReload(attempts) {
if (attempts <= 0) {
window.location.reload();
return;
}
fetch(window.location.origin + "/", { cache: "no-store" })
.then(function (r) {
if (r.ok) window.location.reload();
else setTimeout(function () { waitAndReload(attempts - 1); }, 1500);
})
.catch(function () { setTimeout(function () { waitAndReload(attempts - 1); }, 1500); });
}
function apply(ctx) {
ctx.effect(function () {
return ctx.locale.register(NS, { zh: zh, en: en });
}, "business-plugins: dictionaries");
// 内联样式表达不了 `:hover`,而 06-工作台UI规范要求「列表行 hover 反馈」(§4.3)
// 与「hover 才提示可点」(§5)。这里注入一小段 CSS,disposer 随 fiber 移除 ——
// 不引外部样式表,也不污染宿主全局(选择器统一带 bp- 前缀)。
ctx.effect(function () {
var el = document.createElement("style");
el.setAttribute("data-bp-style", "1");
el.textContent = [
".bp-row{transition:background .18s cubic-bezier(.16,1,.3,1),border-color .18s cubic-bezier(.16,1,.3,1),box-shadow .18s cubic-bezier(.16,1,.3,1)}",
".bp-row:hover{border-color:var(--dsw-alias-brand-primary, #2f6fed);box-shadow:0 4px 12px rgba(47,111,237,.15)}",
".bp-btn{transition:border-color .18s cubic-bezier(.16,1,.3,1),color .18s cubic-bezier(.16,1,.3,1)}",
".bp-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary, #2f6fed);color:var(--dsw-alias-brand-primary, #2f6fed)}",
".bp-row input[type=checkbox]{cursor:pointer;margin:0}"
].join("");
document.head.appendChild(el);
return function () { el.remove(); };
}, "business-plugins: styles");
var t = ctx.locale.bind(NS);
function BusinessPluginsSection() {
var useState = React.useState;
var useEffect = React.useEffect;
var pluginsState = useState([]);
var plugins = pluginsState[0];
var setPlugins = pluginsState[1];
var loadingState = useState(true);
var loading = loadingState[0];
var setLoading = loadingState[1];
var busyState = useState(false);
var busy = busyState[0];
var setBusy = busyState[1];
var msgState = useState("");
var msg = msgState[0];
var setMsg = msgState[1];
var queryState = useState("");
var query = queryState[0];
var setQuery = queryState[1];
var rejectedState = useState([]);
var rejected = rejectedState[0];
var setRejected = rejectedState[1];
// 「应用更改」的页内确认弹窗开关(替代 window.confirm)。
var confirmState = useState(false);
var confirmOpen = confirmState[0];
var setConfirmOpen = confirmState[1];
// 「服务端当前已启用」的 id 快照 —— 用于把「当前占用」与「勾选后会有多少」分开算。
// 没有它就没法在用户勾选时显示「增量」(p.enabled 会被 toggle 就地改掉)。
var savedState = useState([]);
var savedIds = savedState[0];
var setSavedIds = savedState[1];
function load() {
setLoading(true);
fetch(portalHost() + "/api/plugins/mine", { credentials: "include" })
.then(function (r) { return r.json(); })
.then(function (d) {
var list = d.plugins || [];
setPlugins(list);
// 记下「服务端认为已启用」的那批 → 内存条据此区分「当前」与「勾选后」。
setSavedIds(list.filter(function (p) { return p.enabled; }).map(function (p) { return p.id; }));
setLoading(false);
})
.catch(function () { setLoading(false); setMsg(t("loadFailed")); });
}
useEffect(function () { load(); }, []);
function toggle(id) {
setPlugins(plugins.map(function (p) {
if (p.id === id) return Object.assign({}, p, { enabled: !p.enabled });
return p;
}));
}
/**
* 打开**页内确认弹窗**(不再用 `window.confirm`)。
*
* 2026-09-13 用户要求:所有弹窗改成页面弹窗,视觉参考 MCN 工作台
* (`dsh-plugin-mcn` 的 `fixed inset:0` 遮罩 + 居中面板 + 点遮罩关闭 +
* `stopPropagation` + ✕,见其 `modalTitle/modalText/modalBtns` 三段结构)。
*/
function askApply() {
setConfirmOpen(true);
}
/** 用户已在弹窗里点「确认应用」:真正的提交(原 `applyChanges` 主体)。 */
function doApply() {
setConfirmOpen(false);
setBusy(true);
setMsg(t("queued"));
setRejected([]);
fetch(portalHost() + "/api/plugins/mine/apply", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plugins: plugins.map(function (p) { return { id: p.id, enabled: p.enabled }; }) })
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d.error) { setBusy(false); setMsg(t("requestFailed") + ":" + d.error); return; }
if (d.noop) { setBusy(false); setMsg(t("nochange")); return; }
pollTask(d.taskId);
})
.catch(function () { setBusy(false); setMsg(t("requestFailed")); });
}
// 异步任务:轮询「阶段 + 已用秒数」(B 方案,不滚日志)。
function pollTask(taskId) {
var start = Date.now();
var timer = setInterval(function () {
fetch(portalHost() + "/api/plugins/mine/task/" + taskId, { credentials: "include" })
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (task) {
var secs = Math.floor((Date.now() - start) / 1000);
var stage = task.stage || t("configuring");
if (task.status === "pending" || task.status === "running") {
setMsg("⏳ " + stage + "(" + secs + "s)");
} else if (task.status === "success") {
clearInterval(timer);
var bad = task.rejected || [];
setRejected(bad);
setBusy(false);
if (bad.length > 0) {
setMsg(t("applied") + " — " + t("rejected.title"));
load();
} else {
setMsg(t("applied") + " · " + t("reloading"));
waitAndReload(3);
}
} else {
clearInterval(timer);
setBusy(false);
setMsg("✗ " + (task.error || t("requestFailed")) + "(已回滚到改动前的状态)");
// 2026-09-12 修复:失败后必须重新拉取真实状态。否则本地 plugins 仍停留在
// 用户勾选后的样子 → 徽章显示「已启用」,与「安装失败」自相矛盾(用户实测报障)。
// 后端失败分支已 restoreProfile() 回滚,load() 即可取回正确状态。
load();
}
})
.catch(function () { /* 网络抖动忽略,继续下一轮 */ });
}, 1500);
}
// 06-工作台UI规范 §2.2 字号阶梯:section 语境取「正文 14 / 次要 13 / 辅助 12」三档
//(不照搬页面级 16px 基准 —— 本 section 嵌在 dsh 设置面板内,非独立页)。
var wrap = {
display: "flex",
flexDirection: "column",
gap: "10px",
padding: "4px 2px",
color: T.text,
fontSize: "14px"
};
/** 规范 §4.9 空态/加载态:应有「标题 + 说明」层次,而非一行灰字。 */
function StateBlock(props) {
return jsxRuntime.jsxs("div", {
style: {
padding: "28px 20px", display: "flex", flexDirection: "column",
alignItems: "center", gap: "6px", textAlign: "center"
},
children: [
jsxRuntime.jsx("div", {
key: "t",
style: { fontSize: "15px", fontWeight: 500, color: T.text },
children: props.title
}),
jsxRuntime.jsx("div", {
key: "d",
style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 },
children: props.desc
})
]
});
}
/**
* 内存预估状态条(置于列表上方 —— 2026-09-13 用户要求)。
*
* 三段视觉:**实心** = 「服务端当前已启用」那批的预估占用;**半透明** = 本次勾选
* 带来的增量;超过单实例上限时整条转红并给出文字警告。全程只读本地数据、不发请求。
*/
function MemBar(props) {
var limit = props.limit;
var nowMiB = props.nowMiB;
var plannedMiB = props.plannedMiB;
var over = plannedMiB > limit;
var tight = !over && plannedMiB > limit * MEM_TIGHT_RATIO;
var accent = over ? T.danger : (tight ? T.warn : T.success);
var nowPct = Math.max(0, Math.min(100, (nowMiB / limit) * 100));
var planPct = Math.max(0, Math.min(100, (plannedMiB / limit) * 100));
var delta = plannedMiB - nowMiB;
var stateText = over ? t("mem.over") : (tight ? t("mem.tight") : t("mem.safe"));
var line = t("mem.now") + " " + nowMiB + " MiB";
if (delta !== 0) {
line += " → " + t("mem.planned") + " " + plannedMiB + " MiB";
} else {
line += " / " + t("mem.limit") + " " + limit + " MiB";
}
return jsxRuntime.jsxs("div", {
style: {
display: "flex", flexDirection: "column", gap: "6px", padding: "10px 12px",
borderRadius: "10px", background: T.field,
border: "1px solid " + (over ? T.danger : T.border)
},
children: [
jsxRuntime.jsxs("div", {
key: "h",
style: { display: "flex", alignItems: "baseline", gap: "8px", flexWrap: "wrap" },
children: [
jsxRuntime.jsx("span", {
key: "t",
style: { fontSize: "13px", fontWeight: 500, color: T.text },
children: t("mem.title")
}),
jsxRuntime.jsx("span", {
key: "n",
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
children: line
}),
jsxRuntime.jsx("span", {
key: "s",
style: { fontSize: "12px", color: accent, marginLeft: "auto", flexShrink: 0 },
children: (over ? "⚠ " : "") + stateText
}),
jsxRuntime.jsx("span", {
key: "e",
style: { fontSize: "12px", color: T.dim, flexBasis: "100%" },
children: t("mem.est")
})
]
}),
jsxRuntime.jsxs("div", {
key: "bar",
style: {
position: "relative", height: "8px", borderRadius: "4px",
background: T.border, overflow: "hidden"
},
children: [
jsxRuntime.jsx("div", {
key: "plan",
style: {
position: "absolute", left: 0, top: 0, bottom: 0,
width: planPct + "%", background: accent, opacity: 0.28
}
}),
jsxRuntime.jsx("div", {
key: "now",
style: {
position: "absolute", left: 0, top: 0, bottom: 0,
width: nowPct + "%", background: accent, opacity: 0.85
}
})
]
}),
over
? jsxRuntime.jsx("div", {
key: "w",
style: { fontSize: "12px", color: T.danger, lineHeight: 1.6 },
children: t("mem.overWarn")
})
: null
]
});
}
if (loading) {
return jsxRuntime.jsx("div", {
style: wrap,
children: jsxRuntime.jsx(StateBlock, { title: t("loading"), desc: t("loadingDesc") })
});
}
if (plugins.length === 0) {
return jsxRuntime.jsx("div", {
style: wrap,
children: jsxRuntime.jsx(StateBlock, { title: t("emptyTitle"), desc: t("empty") })
});
}
var kw = query.trim().toLowerCase();
var shown = kw === ""
? plugins
: plugins.filter(function (p) {
return (p.name || "").toLowerCase().indexOf(kw) >= 0 || (p.description || "").toLowerCase().indexOf(kw) >= 0;
});
var selected = plugins.filter(function (p) { return p.enabled; }).length;
// 内存预估:基线 + Σ 插件成本。
// · nowMiB = 服务端快照里「已启用」的那批(savedIds)→ 代表**当前**占用;
// · plannedMiB = 当前勾选状态(用户点一下就变)→ 代表**应用后会是多少**。
var nowMiB = MEM_BASE_MIB + plugins.reduce(function (sum, p) {
return sum + (savedIds.indexOf(p.id) >= 0 ? memMiB(p) : 0);
}, 0);
var plannedMiB = MEM_BASE_MIB + sumMiB(plugins, "enabled");
var memOver = plannedMiB > MEM_LIMIT_MIB;
var inputStyle = {
flex: "1",
minWidth: "180px",
padding: "6px 10px",
borderRadius: "8px",
border: "1px solid " + T.border,
background: T.field,
color: T.text,
fontSize: "14px",
outline: "none"
};
// 规范 §4.1:次要动作用白底 + border(`.btn` / `.btn-sm` 量级),非主色实心。
var ghostBtnStyle = {
padding: "5px 12px", borderRadius: "8px", cursor: "pointer",
border: "1px solid " + T.border, background: "transparent", color: T.sub, fontSize: "13px"
};
var rows = [];
var cards = [];
// 内存预估状态条:**卡片列表上方**(2026-09-13 用户要求)——
// 当前占用 / 勾选后预估 / 是否超上限,让用户在点「应用」之前就知道后果。
rows.push(jsxRuntime.jsx(MemBar, {
key: "__membar",
limit: MEM_LIMIT_MIB,
nowMiB: nowMiB,
plannedMiB: plannedMiB
}));
// 工具行:搜索 + 全选/清空 + 计数(规范 §2.4:工具条内部 gap 8~10px)
rows.push(jsxRuntime.jsxs("div", {
key: "__toolbar",
style: { display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" },
children: [
jsxRuntime.jsx("input", {
key: "q",
value: query,
placeholder: t("search"),
onChange: function (e) { setQuery(e.target.value); },
style: inputStyle
}),
jsxRuntime.jsx("button", {
key: "all",
type: "button",
className: "bp-btn",
onClick: function () {
setPlugins(plugins.map(function (p) { return Object.assign({}, p, { enabled: true }); }));
},
style: ghostBtnStyle,
children: t("selectAll")
}),
jsxRuntime.jsx("button", {
key: "none",
type: "button",
className: "bp-btn",
onClick: function () {
setPlugins(plugins.map(function (p) { return Object.assign({}, p, { enabled: false }); }));
},
style: ghostBtnStyle,
children: t("clear")
}),
jsxRuntime.jsx("span", {
key: "cnt",
style: { color: T.sub, fontSize: "13px", marginLeft: "auto" },
children: selected + " / " + plugins.length + " " + t("selected")
})
]
}));
if (shown.length === 0) {
rows.push(jsxRuntime.jsx("div", {
key: "__nomatch",
style: { padding: "20px 0", textAlign: "center", color: T.sub, fontSize: "13px" },
children: t("noMatch")
}));
}
for (var i = 0; i < shown.length; i++) {
(function (p) {
var bad = rejected.filter(function (r) { return r.id === p.id; })[0];
var desc = p.description && String(p.description).trim() !== "" ? String(p.description).trim() : "";
// 主视觉:用途说明(候选池入库时已优先取官方目录中文);无说明时回退为包名。
var primary = desc !== "" ? desc : p.name + (p.version ? " v" + p.version : "");
// 副行:包名 + 版本(有说明时才渲染,避免与主视觉重复)。
var meta = p.name + (p.version ? " v" + p.version : "");
cards.push(jsxRuntime.jsxs("label", {
key: p.id,
className: "bp-row",
style: {
display: "flex", alignItems: "flex-start", gap: "10px", cursor: "pointer",
padding: "14px 16px", borderRadius: "12px", minWidth: 0, minHeight: "112px",
background: "var(--dsw-alias-bg-layer-1, #ffffff)",
border: "1px solid " + (bad ? T.danger : T.border)
},
children: [
jsxRuntime.jsx("input", {
key: "cb",
type: "checkbox",
checked: !!p.enabled,
onChange: function () { toggle(p.id); }
}),
jsxRuntime.jsxs("span", {
key: "nm",
style: { flex: "1", minWidth: "0", display: "flex", flexDirection: "column", gap: "2px" },
children: [
// 主视觉 = 用途说明(规范 §5「信息层次」;与门户 plugins 页同一决策)
jsxRuntime.jsx("span", {
key: "p1",
style: {
fontSize: "14px", fontWeight: 500, color: T.text, lineHeight: 1.45,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
},
title: primary,
children: primary
}),
// 副行 = 包名 + 版本(长文本截断三件套 + title,规范 §5)
desc !== ""
? jsxRuntime.jsx("span", {
key: "p2",
style: {
fontSize: "12px", color: T.sub,
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"
},
title: meta,
children: meta
})
: null,
// 预估内存(2026-09-13 用户要求):数值越大越"贵",用文字色分级
// (≥60 MiB 红 / ≥30 MiB 黄 / 其余次要色),一眼能看出谁是大头。
jsxRuntime.jsx("span", {
key: "mem",
style: { marginTop: "4px" },
children: jsxRuntime.jsx("span", {
style: {
padding: "1px 8px", borderRadius: "10px",
border: "1px solid " + T.border, background: T.field,
color: memMiB(p) >= 60 ? T.danger : (memMiB(p) >= 30 ? T.warn : T.sub)
},
children: t("mem.perCard") + " ≈ " + memMiB(p) + " MiB"
})
})
]
}),
jsxRuntime.jsx("span", {
key: "bd",
style: {
fontSize: "12px", padding: "2px 8px", borderRadius: "10px", flexShrink: 0,
color: p.enabled ? T.success : T.dim,
border: "1px solid " + (p.enabled ? T.success : T.border)
},
children: p.enabled ? t("enabled") : t("disabled")
}),
bad
? jsxRuntime.jsx("span", {
key: "bad",
style: { fontSize: "12px", color: T.danger, flexShrink: 0 },
title: String(bad.reason || ""),
children: "⚠"
})
: null
]
}));
})(shown[i]);
}
// 卡片网格:一行 2 个(用户 2026-09-12 要求「插件列表改为卡片形式,一行放 2 个卡片」)
// 单独包一层 grid —— 不能直接改外层 wrap(它同时装标题/搜索/按钮/提示等纵向块)。
rows.push(jsxRuntime.jsx("div", {
key: "__grid",
style: { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "10px" },
children: cards
}));
rows.push(jsxRuntime.jsxs("div", {
key: "__actions",
style: { display: "flex", alignItems: "center", gap: "10px", marginTop: "4px" },
children: [
// 规范 §4.1:主行动用 primary 实心(`.btn` 量级,section 内 14px)
jsxRuntime.jsx("button", {
key: "apply",
type: "button",
onClick: askApply,
disabled: busy,
style: {
display: "inline-flex", alignItems: "center", justifyContent: "center",
padding: "7px 16px", borderRadius: "8px", border: "1px solid " + T.primary,
background: T.primary, color: "#ffffff", cursor: busy ? "default" : "pointer",
opacity: busy ? 0.5 : 1, fontSize: "14px"
},
children: busy ? t("applying") : t("apply")
}),
rejected.length > 0
? jsxRuntime.jsx("button", {
key: "retry",
type: "button",
className: "bp-btn",
onClick: askApply,
disabled: busy,
style: Object.assign({}, ghostBtnStyle, { opacity: busy ? 0.5 : 1 }),
children: t("retry")
})
: null
]
}));
if (rejected.length > 0) {
// impeccable craft-floor 明令禁止「卡片/列表项/提示上 >1px 的彩色 border-left/right」
// —— 改为整块浅底 + 圆角,危险语义由标题文字色承担(不靠侧边色条)。
rows.push(jsxRuntime.jsxs("div", {
key: "__rejected",
style: {
padding: "10px 12px", borderRadius: "8px",
color: T.sub, fontSize: "13px", lineHeight: "1.6", background: T.field
},
children: [
jsxRuntime.jsxs("div", {
key: "h",
style: { fontWeight: 500, color: T.danger, marginBottom: "2px" },
children: ["⚠ ", t("rejected.title")]
}),
jsxRuntime.jsx("div", { key: "b", children: rejected.map(function (r) { return r.id; }).join("、") })
]
}));
}
if (msg) {
rows.push(jsxRuntime.jsx("div", { key: "__msg", style: { color: T.sub, fontSize: "13px" }, children: msg }));
}
// ── 页内确认弹窗(替代 window.confirm;视觉对齐 MCN 工作台弹窗)──────────
// 结构照 MCN:遮罩(fixed inset:0 + rgba(0,0,0,.35) + zIndex 2000 + 居中)→ 面板
// (radius 12 / padding 18·22 / maxHeight 82vh / 阴影)→ 标题行(✕ 关闭)→ 正文 →
// 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。
// 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。
if (confirmOpen) {
var overNow = plannedMiB > MEM_LIMIT_MIB;
rows.push(jsxRuntime.jsxs("div", {
key: "__confirm",
style: {
position: "fixed", inset: 0, background: "rgba(0,0,0,.35)", zIndex: 2000,
display: "flex", alignItems: "center", justifyContent: "center", padding: "24px"
},
onClick: function () { setConfirmOpen(false); },
children: jsxRuntime.jsxs("div", {
style: {
background: "var(--dsw-alias-bg-layer-1, #ffffff)",
borderRadius: "12px", maxWidth: "480px", width: "100%", maxHeight: "82vh",
overflow: "auto", padding: "18px 22px",
boxShadow: "0 8px 30px rgba(0,0,0,.18)",
display: "flex", flexDirection: "column", gap: "10px"
},
onClick: function (e) { e.stopPropagation(); },
children: [
jsxRuntime.jsxs("div", {
key: "t",
style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" },
children: [
jsxRuntime.jsx("span", {
key: "s",
style: { fontSize: "15px", fontWeight: 500, color: T.text },
children: t("confirm.title")
}),
jsxRuntime.jsx("button", {
key: "x",
type: "button",
title: t("confirm.cancel"),
onClick: function () { setConfirmOpen(false); },
style: {
border: "none", background: "none", fontSize: "18px", lineHeight: 1,
cursor: "pointer", color: T.dim, padding: "2px 4px"
},
children: "✕"
})
]
}),
jsxRuntime.jsx("div", {
key: "b",
style: { fontSize: "13px", color: T.sub, lineHeight: 1.6 },
children: t("confirm")
}),
jsxRuntime.jsxs("div", {
key: "m",
style: {
padding: "10px 12px", borderRadius: "10px",
background: overNow ? "rgba(217,48,38,.06)" : T.field,
border: "1px solid " + (overNow ? T.danger : T.border),
display: "flex", flexDirection: "column", gap: "4px"
},
children: [
jsxRuntime.jsx("div", {
key: "h",
style: { fontSize: "13px", fontWeight: 500, color: overNow ? T.danger : T.text },
children: overNow ? t("confirm.overTitle") : t("mem.title")
}),
jsxRuntime.jsx("div", {
key: "n",
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
children: t("mem.now") + " " + nowMiB + " MiB → " + t("mem.planned") + " " + plannedMiB
+ " MiB / " + t("mem.limit") + " " + MEM_LIMIT_MIB + " MiB"
}),
overNow
? jsxRuntime.jsx("div", {
key: "w",
style: { fontSize: "12px", color: T.danger, lineHeight: 1.6 },
children: t("mem.overWarn")
})
: null,
jsxRuntime.jsx("div", {
key: "e",
style: { fontSize: "12px", color: T.dim },
children: t("mem.est")
})
]
}),
jsxRuntime.jsxs("div", {
key: "f",
style: {
display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "8px",
paddingTop: "10px", borderTop: "1px solid " + T.border
},
children: [
jsxRuntime.jsx("button", {
key: "c",
type: "button",
className: "bp-btn",
onClick: function () { setConfirmOpen(false); },
style: ghostBtnStyle,
children: t("confirm.cancel")
}),
jsxRuntime.jsx("button", {
key: "k",
type: "button",
onClick: doApply,
disabled: busy,
style: {
padding: "7px 16px", borderRadius: "8px", cursor: busy ? "default" : "pointer",
border: "1px solid " + (overNow ? T.danger : T.primary),
background: overNow ? T.danger : T.primary, color: "#ffffff",
fontSize: "14px", opacity: busy ? 0.5 : 1
},
children: busy ? t("applying") : t("confirm.ok")
})
]
})
]
})
}));
}
return jsxRuntime.jsx("div", { style: wrap, children: rows });
}
ctx.slots.inject("settings.section", function () {
return ctx.slots.register(
{
name: "settings.section",
id: "business-plugins",
order: 101,
label: function () { return t("section.label"); }
},
BusinessPluginsSection
);
});
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
}
});
+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.2.8",
"description": "功能管理(原「功能插件」)section for dsh web profile — v0.2.8(2026-09-13):**所有弹窗改页内弹窗** —— 弃用 window.confirm,改为 fixed 遮罩 + 居中面板 + 点遮罩/✕ 关闭(视觉与交互对齐 MCN 工作台 dsh-plugin-mcn 的 modalTitle/modalText/modalBtns 三段结构);**超限时弹窗内红底警告块**(标题 + 说明 + 内存数字),确认按钮转危险色,未点确认不发请求。v0.2.7(档案 75 维度 B 落地 · 2026-09-13):卡片新增「预估内存 ≈ N MiB」徽章(按实测成本分色:≥60 红 / ≥30 黄)、卡片加高(minHeight 112 + padding 14/16)便于四层信息排布;**列表上方新增「内存预估」状态条** —— 实心段=当前已启用插件预估占用、半透明段=本次勾选增量、超单实例上限(384 MiB)整条转红并给文字警告,确认弹窗也带上内存预估。口径 = 空载基线 285 MiB + 逐插件实测加载成本(隔离 cgroup 的 rss 增量),**是预估值不是实时读数**。候选池列表 + 搜索/状态徽章 + 批量启用/禁用 + 确认弹窗 + 重启后自动刷新。v0.2.4(档案 67):对齐 06-工作台UI规范 —— **信息层次反转**(主视觉改为插件用途说明,插件名/版本退为副行小字;中文说明由候选池入库时优先取官方目录)、字号阶梯 14/13/12、徽章与按钮改规范量级、空态改「标题 + 说明」、新增列表行 hover 反馈。v0.2.2:分区名由「功能插件」改为「功能管理」。v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。",
"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"
}
+45
View File
@@ -0,0 +1,45 @@
# @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** 取得(不复制/不改官方包):
`/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js`,
可用 `DSH_SEAM_DIRECTORY_PICKER` 覆盖。
## 自检
```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;
},
});
+253
View File
@@ -0,0 +1,253 @@
/**
* @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 { 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
/** 官方 seam 基类的候选绝对路径(按序尝试首个可导入者)。 */
const DEFAULT_SEAM_CANDIDATES = [
'/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
'/usr/local/lib/node_modules/@deepseek-ai/dsh-host-directory-picker/lib/index.js',
]
/** 解析并导入官方 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.4",
"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)