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