初始提交:DSH 多租户平台(dshs)
This commit is contained in:
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
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: dsh-users
|
||||
namespace: dsh-poc
|
||||
spec:
|
||||
accessModes: [ReadWriteMany]
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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'))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 = 拒绝一切入站
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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'))
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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
|
||||
@@ -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'
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user