diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml
index 8aa225ed..03f6b001 100644
--- a/config/hwlab-node-lanes.yaml
+++ b/config/hwlab-node-lanes.yaml
@@ -259,16 +259,13 @@ lanes:
scrapeMode: pod-loopback
publicRawMetrics: denied
webProbe:
- sentinel:
- enabled: true
- configRefs:
- runtime: config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml#sentinel.runtime
- scenarios: config/hwlab-web-probe-sentinel/scenarios.workbench.yaml#sentinel.scenarios
- promptSet: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet
- reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews
- publicExposure: config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml#sentinel.publicExposure
- cicd: config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml#sentinel.cicd
- secrets: config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml#sentinel.secrets
+ sentinels:
+ - id: workbench-dsflash-go-tool-call-10x
+ enabled: true
+ configRef: config/hwlab-web-probe-sentinels/d601-v03/workbench-dsflash-go-tool-call-10x.yaml#sentinel
+ - id: workbench-auth-session-switch-2users
+ enabled: true
+ configRef: config/hwlab-web-probe-sentinels/d601-v03/workbench-auth-session-switch-2users.yaml#sentinel
workbench:
enabled: true
summaryPath: /v1/web-performance/summary
diff --git a/config/hwlab-web-probe-sentinel/cicd.auth-session-switch.d601-v03.yaml b/config/hwlab-web-probe-sentinel/cicd.auth-session-switch.d601-v03.yaml
new file mode 100644
index 00000000..05396272
--- /dev/null
+++ b/config/hwlab-web-probe-sentinel/cicd.auth-session-switch.d601-v03.yaml
@@ -0,0 +1,49 @@
+version: 1
+kind: HwlabWebProbeSentinelCicd
+metadata:
+ id: d601-v03-web-probe-sentinel-auth-session-switch-cicd
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ cicd:
+ controlPlaneConfigRef: config/hwlab-node-control-plane.yaml#targets[0]
+ source:
+ repository: pikasTech/unidesk
+ branch: master
+ gitSshUrl: ssh://git@ssh.github.com:443/pikasTech/unidesk.git
+ gitMirrorReadUrl: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/unidesk.git
+ buildContext: .
+ entrypoint: scripts/web-probe-sentinel-service.ts
+ checkoutPaths:
+ - scripts
+ - config
+ - package.json
+ - bun.lock
+ - bun.lockb
+ builder:
+ namespace: devops-infra
+ sourceMode: sparse-git-checkout
+ jobPrefix: web-probe-sentinel-auth-switch-publish
+ gitSshSecretName: git-mirror-github-ssh
+ dockerSocketPath: /var/run/docker.sock
+ activeDeadlineSeconds: 900
+ ttlSecondsAfterFinished: 3600
+ gitopsPath: deploy/gitops/node/d601/web-probe-sentinel-auth-switch
+ argo:
+ namespace: argocd
+ projectName: hwlab-d601
+ applicationName: hwlab-web-probe-sentinel-auth-switch
+ repoURL: http://git-mirror-http.devops-infra.svc.cluster.local:8080/pikasTech/HWLAB.git
+ targetRevision: v0.3-gitops
+ image:
+ repository: 127.0.0.1:5000/hwlab/web-probe-sentinel-auth-switch
+ tagSource: source-commit
+ baseImageRef: config/hwlab-node-control-plane.yaml#targets[0].tekton.toolsImage.output
+ envRecipeRef: config/hwlab-web-probe-sentinel/runtime.auth-session-switch.d601-v03.yaml#sentinel.runtime
+ maintenance:
+ startCommand: sentinel maintenance start
+ stopCommand: sentinel maintenance stop
+ targetValidation:
+ scenarioId: workbench-auth-session-switch-2users
+ maxSeconds: 300
+ serviceUnavailablePolicy: structured-failure
diff --git a/config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml b/config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml
new file mode 100644
index 00000000..402ccec2
--- /dev/null
+++ b/config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml
@@ -0,0 +1,15 @@
+version: 1
+kind: HwlabWebProbeSentinelPromptSet
+metadata:
+ id: d601-v03-web-probe-sentinel-auth-session-switch-prompt-set
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ promptSet:
+ id: auth-session-switch-no-prompt
+ providerProfile: session-switch-sentinel
+ providerProfileMode: exact
+ promptSourceRef: hwlab/web-probe-sentinel-auth-switch.env
+ promptSourceKey: AUTH_SWITCH_UNUSED_PROMPTS_JSON
+ promptCount: 0
+ redaction: hash-and-byte-count
diff --git a/config/hwlab-web-probe-sentinel/public-exposure.auth-session-switch.d601-v03.yaml b/config/hwlab-web-probe-sentinel/public-exposure.auth-session-switch.d601-v03.yaml
new file mode 100644
index 00000000..716a6744
--- /dev/null
+++ b/config/hwlab-web-probe-sentinel/public-exposure.auth-session-switch.d601-v03.yaml
@@ -0,0 +1,37 @@
+version: 1
+kind: HwlabWebProbeSentinelPublicExposure
+metadata:
+ id: d601-v03-web-probe-sentinel-auth-session-switch-public-exposure
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ publicExposure:
+ enabled: true
+ mode: pk01-caddy-frp-path
+ publicBaseUrl: https://monitor.pikapython.com/sentinels/workbench-auth-session-switch-2users
+ hostname: monitor.pikapython.com
+ routePrefix: /sentinels/workbench-auth-session-switch-2users
+ expectedA: 82.156.23.220
+ frpc:
+ deploymentName: hwlab-web-probe-sentinel-auth-switch-frpc
+ image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
+ serverAddr: 82.156.23.220
+ serverPort: 22000
+ tokenSourceRef: platform-infra/pk01-frp.env
+ tokenSourceKey: FRP_TOKEN
+ secretName: hwlab-web-probe-sentinel-auth-switch-frpc
+ secretKey: frpc.toml
+ tokenKey: token
+ httpProxy:
+ name: hwlab-d601-v03-web-probe-sentinel-auth-switch
+ remotePort: 22091
+ localIP: hwlab-web-probe-sentinel-auth-switch.hwlab-v03.svc.cluster.local
+ localPort: 8080
+ caddy:
+ route: PK01
+ configPath: /etc/caddy/Caddyfile
+ serviceName: caddy
+ email: ops@pikapython.com
+ tls: auto
+ responseHeaderTimeoutSeconds: 600
+ managedBlockOwner: hwlab-web-probe-sentinel-auth-switch-d601-v03
diff --git a/config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml b/config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml
index cd2ed3fe..2a2622f7 100644
--- a/config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml
+++ b/config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml
@@ -10,6 +10,7 @@ sentinel:
mode: pk01-caddy-frp
publicBaseUrl: https://monitor.pikapython.com
hostname: monitor.pikapython.com
+ routePrefix: /
expectedA: 82.156.23.220
frpc:
deploymentName: hwlab-web-probe-sentinel-frpc
diff --git a/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml
new file mode 100644
index 00000000..c292f1e4
--- /dev/null
+++ b/config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml
@@ -0,0 +1,22 @@
+version: 1
+kind: HwlabWebProbeSentinelReportViews
+metadata:
+ id: d601-v03-web-probe-sentinel-auth-session-switch-report-views
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ reportViews:
+ defaultView: auth-session-switch-summary
+ views:
+ - summary
+ - auth-session-switch-summary
+ - findings
+ - trace-frame
+ pageSize: 20
+ maxPageSize: 100
+ rawAccess: explicit-only
+ redaction:
+ prompt: hash-and-byte-count
+ assistantFinal: summary-and-hash
+ providerPayload: denied
+ secrets: denied
diff --git a/config/hwlab-web-probe-sentinel/runtime.auth-session-switch.d601-v03.yaml b/config/hwlab-web-probe-sentinel/runtime.auth-session-switch.d601-v03.yaml
new file mode 100644
index 00000000..7a040d56
--- /dev/null
+++ b/config/hwlab-web-probe-sentinel/runtime.auth-session-switch.d601-v03.yaml
@@ -0,0 +1,33 @@
+version: 1
+kind: HwlabWebProbeSentinelRuntime
+metadata:
+ id: d601-v03-web-probe-sentinel-auth-session-switch-runtime
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ runtime:
+ target:
+ node: D601
+ lane: v03
+ publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.public.webUrl
+ observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinels[1]
+ namespace: hwlab-v03
+ serviceAccountName: hwlab-web-probe-sentinel-auth-switch
+ deploymentName: hwlab-web-probe-sentinel-auth-switch
+ serviceName: hwlab-web-probe-sentinel-auth-switch
+ listenHost: 0.0.0.0
+ servicePort: 8080
+ pvcName: hwlab-web-probe-sentinel-auth-switch-state
+ pvcStorage: 10Gi
+ stateRoot: /var/lib/web-probe-sentinel-auth-switch
+ imageRef: 127.0.0.1:5000/hwlab/web-probe-sentinel-auth-switch:source-commit
+ replicas: 1
+ healthPath: /api/health
+ metricsPath: /metrics
+ scheduler:
+ intervalMs: 600000
+ heartbeatStaleSeconds: 900
+ maxConcurrentRuns: 1
+ sqlite:
+ path: /var/lib/web-probe-sentinel-auth-switch/index.sqlite
+ busyTimeoutMs: 2000
diff --git a/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml b/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml
index 21375887..9857686e 100644
--- a/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml
+++ b/config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml
@@ -10,7 +10,7 @@ sentinel:
node: D601
lane: v03
publicOriginRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.public.webUrl
- observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinel
+ observeWrapperRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.D601.observability.webProbe.sentinels[0]
namespace: hwlab-v03
serviceAccountName: hwlab-web-probe-sentinel
deploymentName: hwlab-web-probe-sentinel
@@ -25,8 +25,8 @@ sentinel:
healthPath: /api/health
metricsPath: /metrics
scheduler:
- intervalMs: 30000
- heartbeatStaleSeconds: 120
+ intervalMs: 600000
+ heartbeatStaleSeconds: 900
maxConcurrentRuns: 1
sqlite:
path: /var/lib/web-probe-sentinel/index.sqlite
diff --git a/config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml b/config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml
new file mode 100644
index 00000000..55463618
--- /dev/null
+++ b/config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml
@@ -0,0 +1,47 @@
+version: 1
+kind: HwlabWebProbeSentinelSecrets
+metadata:
+ id: d601-v03-web-probe-sentinel-auth-session-switch-secrets
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ secrets:
+ sources:
+ - purpose: bootstrap-admin
+ sourceRef: hwlab/d601-v03-bootstrap-admin.env
+ sourceKey: HWLAB_BOOTSTRAP_ADMIN_PASSWORD
+ - purpose: account-a
+ sourceRef: hwlab/web-probe-sentinel-auth-switch-account-a.env
+ sourceKey: ACCOUNT_A_JSON
+ - purpose: account-b
+ sourceRef: hwlab/web-probe-sentinel-auth-switch-account-b.env
+ sourceKey: ACCOUNT_B_JSON
+ - purpose: prompt-set
+ sourceRef: hwlab/web-probe-sentinel-auth-switch.env
+ sourceKey: AUTH_SWITCH_UNUSED_PROMPTS_JSON
+ - purpose: frp-token
+ sourceRef: platform-infra/pk01-frp.env
+ sourceKey: FRP_TOKEN
+ runtimeSecrets:
+ - name: hwlab-web-probe-sentinel-auth-switch-bootstrap
+ namespace: hwlab-v03
+ data:
+ - sourcePurpose: bootstrap-admin
+ targetKey: bootstrap-admin-password
+ - name: hwlab-web-probe-sentinel-auth-switch-accounts
+ namespace: hwlab-v03
+ data:
+ - sourcePurpose: account-a
+ targetKey: account-a.json
+ - sourcePurpose: account-b
+ targetKey: account-b.json
+ - name: hwlab-web-probe-sentinel-auth-switch-prompt-set
+ namespace: hwlab-v03
+ data:
+ - sourcePurpose: prompt-set
+ targetKey: prompts.json
+ - name: hwlab-web-probe-sentinel-auth-switch-frpc
+ namespace: hwlab-v03
+ data:
+ - sourcePurpose: frp-token
+ targetKey: token
diff --git a/config/hwlab-web-probe-sentinel/workflow.auth-session-switch.yaml b/config/hwlab-web-probe-sentinel/workflow.auth-session-switch.yaml
new file mode 100644
index 00000000..ec63e199
--- /dev/null
+++ b/config/hwlab-web-probe-sentinel/workflow.auth-session-switch.yaml
@@ -0,0 +1,41 @@
+version: 1
+kind: HwlabWebProbeSentinelWorkflow
+metadata:
+ id: d601-v03-web-probe-sentinel-auth-session-switch-workflow
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ workflow:
+ id: workbench-auth-session-switch-2users
+ enabled: true
+ cadence: 10m
+ observeTargetPath: /workbench
+ sampleIntervalMs: 1000
+ screenshotIntervalMs: 60000
+ maxRunSeconds: 900
+ providerProfile: session-switch-sentinel
+ providerProfileMode: exact
+ promptSetRef: config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml#sentinel.promptSet
+ reportViewRef: config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml#sentinel.reportViews
+ accounts:
+ - id: account-a
+ sourcePurpose: account-a
+ usernameKey: username
+ passwordKey: password
+ - id: account-b
+ sourcePurpose: account-b
+ usernameKey: username
+ passwordKey: password
+ commandSequence:
+ - type: loginAccount
+ accountId: account-a
+ - type: listSessions
+ - type: logout
+ - type: loginAccount
+ accountId: account-b
+ - type: listSessions
+ - type: switchSessions
+ fromAccountId: account-b
+ toAccountId: account-a
+ - type: listSessions
+ - type: logout
diff --git a/config/hwlab-web-probe-sentinels/d601-v03/workbench-auth-session-switch-2users.yaml b/config/hwlab-web-probe-sentinels/d601-v03/workbench-auth-session-switch-2users.yaml
new file mode 100644
index 00000000..e2188d3d
--- /dev/null
+++ b/config/hwlab-web-probe-sentinels/d601-v03/workbench-auth-session-switch-2users.yaml
@@ -0,0 +1,18 @@
+version: 1
+kind: HwlabWebProbeSentinel
+metadata:
+ id: d601-v03-workbench-auth-session-switch-2users
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ id: workbench-auth-session-switch-2users
+ enabled: true
+ mode: web-probe-observe-wrapper
+ configRefs:
+ runtime: config/hwlab-web-probe-sentinel/runtime.auth-session-switch.d601-v03.yaml#sentinel.runtime
+ workflow: config/hwlab-web-probe-sentinel/workflow.auth-session-switch.yaml#sentinel.workflow
+ promptSet: config/hwlab-web-probe-sentinel/prompt-set.auth-session-switch.yaml#sentinel.promptSet
+ reportViews: config/hwlab-web-probe-sentinel/report-views.auth-session-switch.yaml#sentinel.reportViews
+ publicExposure: config/hwlab-web-probe-sentinel/public-exposure.auth-session-switch.d601-v03.yaml#sentinel.publicExposure
+ cicd: config/hwlab-web-probe-sentinel/cicd.auth-session-switch.d601-v03.yaml#sentinel.cicd
+ secrets: config/hwlab-web-probe-sentinel/secrets.auth-session-switch.d601-v03.yaml#sentinel.secrets
diff --git a/config/hwlab-web-probe-sentinels/d601-v03/workbench-dsflash-go-tool-call-10x.yaml b/config/hwlab-web-probe-sentinels/d601-v03/workbench-dsflash-go-tool-call-10x.yaml
new file mode 100644
index 00000000..4f3d7c06
--- /dev/null
+++ b/config/hwlab-web-probe-sentinels/d601-v03/workbench-dsflash-go-tool-call-10x.yaml
@@ -0,0 +1,18 @@
+version: 1
+kind: HwlabWebProbeSentinel
+metadata:
+ id: d601-v03-workbench-dsflash-go-tool-call-10x
+ owner: UniDesk
+ specRef: PJ2026-01060508
+sentinel:
+ id: workbench-dsflash-go-tool-call-10x
+ enabled: true
+ mode: web-probe-observe-wrapper
+ configRefs:
+ runtime: config/hwlab-web-probe-sentinel/runtime.d601-v03.yaml#sentinel.runtime
+ scenarios: config/hwlab-web-probe-sentinel/scenarios.workbench.yaml#sentinel.scenarios
+ promptSet: config/hwlab-web-probe-sentinel/prompt-set.dsflash-go.yaml#sentinel.promptSet
+ reportViews: config/hwlab-web-probe-sentinel/report-views.yaml#sentinel.reportViews
+ publicExposure: config/hwlab-web-probe-sentinel/public-exposure.d601-v03.yaml#sentinel.publicExposure
+ cicd: config/hwlab-web-probe-sentinel/cicd.d601-v03.yaml#sentinel.cicd
+ secrets: config/hwlab-web-probe-sentinel/secrets.d601-v03.yaml#sentinel.secrets
diff --git a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md
index f48dd39f..c2977f1f 100644
--- a/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md
+++ b/project-management/PJ2026-01/specs/PJ2026-01060508-web-probe-sentinel.md
@@ -21,6 +21,7 @@
| 状态 | 已生效 |
| 实现引用版本 | draft-2026-06-25-p0-web-probe-sentinel |
| Dashboard 实现引用版本 | draft-2026-06-26-p8-web-probe-sentinel-recovery |
+| 多实例实现引用版本 | draft-2026-06-26-p9-multi-web-probe-sentinel |
| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) |
| 上级规格 | [PJ2026-010605 运维监控](PJ2026-010605-observability-monitoring.md) |
| 关联规格 | [PJ2026-010401 Web工作台](PJ2026-010401-web-workbench.md)、[PJ2026-0104010803 Workbench唯一投影](PJ2026-0104010803-workbench-unique-projection.md)、[PJ2026-010403 API契约](PJ2026-010403-api-contract.md)、[PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md)、[PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-010604 公开入口](PJ2026-010604-public-entry.md)、[PJ2026-01060505 Workbench性能](PJ2026-01060505-workbench-performance.md) |
@@ -41,12 +42,13 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
### 2.2 范围内
- `web-probe observe` CLI 的 wrapper/adapter 边界,使常驻服务能够稳定调用 start/status/command/collect/analyze。
-- YAML `observability.webProbe.sentinel.enabled/configRefs` 与 runtime、scenario、promptSet、reportView、publicExposure、Secret、CI/CD owning YAML 的引用图。
+- YAML `observability.webProbe.sentinel.enabled/configRefs` 的 legacy 单实例入口,以及 `observability.webProbe.sentinels[]` 多实例 registry 与每个 sentinel 管理 YAML 的引用图。
- 常驻 TypeScript 单 Pod wrapper 服务、scheduler、scenario runner、PVC/SQLite index、health、metrics、maintenance API 和 dashboard。
- `sentinel plan|apply|status|validate|report|maintenance` 与 `sentinel image|control-plane` 等受控 CLI 入口。
- 发布流水 maintenance start/stop、quick verify、targetValidation、GitOps/Argo/git-mirror closeout 和 public exposure 验证。
- Dashboard 信息架构、规范化 API、前端组件分层、自动刷新、筛选、深链和 trace/turn 两层阅读视图。
- `workbench-dsflash-go-tool-call-10x` 生产 canary 和 24 小时 dry-run 收口。
+- `workbench-auth-session-switch-2users` 账号切换链路哨兵,覆盖账号 A/B 登录、登出、session 列表和 session 切换命令链。
- Secret、prompt、provider payload、artifact 和 dashboard 的脱敏边界。
### 2.3 范围外
@@ -56,6 +58,7 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
- API path、错误 envelope、route policy 和用户身份语义归 [PJ2026-010403 API契约](PJ2026-010403-api-contract.md)。
- 第一阶段不交付分布式压测;loadtest 只保留同镜像、同 wrapper 的配置和命令扩展点。
- in-cluster 哨兵不是外部公网监控节点。若后续需要真正外网用户路径监控,应另行定义外部或边缘哨兵,不把当前服务伪装成外部观测点。
+- 多实例 Web 哨兵不得用一个 Pod、一个 PVC 或一个 SQLite index 伪装隔离。共享 dashboard domain 可以按 route prefix 暴露,但 runtime Deployment、Service、PVC、SQLite、GitOps path、Argo Application、metrics label 和 report index 必须按 sentinel id 独立。
- SQLite index、dashboard、metrics 和 maintenance 状态不得替代 `samples.jsonl`、`control.jsonl`、network/artifact JSONL 或 `analysis/report.json` 成为探针事实源。
- Dashboard 不负责修复 Workbench projection、trace timing、runner/envreuse 或 git mirror 慢路径;它只把 observe/analyze 已采集事实组织成可读的值守和分析入口。
@@ -64,6 +67,10 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
| 术语 | 定义 |
| --- | --- |
| Web哨兵 | 常驻 wrapper/orchestrator,按 YAML 调度现有 `web-probe observe` CLI,对 HWLAB Web public origin 做持续 canary、分析、展示和发布联动。 |
+| sentinel registry | node/lane root YAML 下的 `observability.webProbe.sentinels[]`,只声明 sentinel id、enabled 和管理 configRef。 |
+| sentinel 管理 YAML | registry 项指向的 owning YAML,声明单个 sentinel 的 id、enabled、mode 和 runtime/workflow/promptSet/reportViews/publicExposure/cicd/secrets configRefs。 |
+| sentinel id | 多实例哨兵的稳定小写标识,必须进入 CLI `--sentinel`、Kubernetes/GitOps label、metrics label、report index 和 dashboard route。 |
+| 账号切换哨兵 | `workbench-auth-session-switch-2users`,使用两组 YAML Secret sourceRef 驱动登录、登出、session 列表和 session 切换链路。 |
| observe runner | 现有 `web-probe observe start` 启动的浏览器采样器;它写入 DOM、network、control、screenshot、artifact 等 JSONL。 |
| observe artifact | `web-probe observe` 产生的 stateDir、JSONL、截图、analysis/report.md 和 analysis/report.json,是哨兵报告的事实来源。 |
| CLI wrapper adapter | 服务与 CLI 共享的命令适配层,把 start/status/command/collect/analyze 表达为稳定调用,不复制 runner/analyzer 实现。 |
@@ -106,25 +113,28 @@ Web哨兵必须遵循 UniDesk YAML-first ops。目标 node/lane、public origin
| PJ2026-0106050807 | 安全隔离 | 本规格 6.7 | Secret/prompt/provider redaction、NetworkPolicy、public dashboard auth | 用户管理、平台运维 | 安全 closeout |
| PJ2026-0106050808 | 代码引用 | 本规格 6.8 | SPEC 头部标注和生成/配置追溯 | 规格治理 | 后续 PR 审计 |
| PJ2026-0106050809 | Dashboard工作台 | 本规格 6.9 | overview、runs、findings、run detail、trace-frame viewer、前端分层 | 报告视图、常驻服务、Workbench性能 | 平台值守和问题分析 |
+| PJ2026-0106050810 | 多实例与账号切换 | 本规格 6.10 | sentinel registry、实例隔离、账号切换 command、route prefix 和 report label | YAML配置、Wrapper边界、安全隔离 | 多哨兵巡检、账号链路值守 |
### 5.1 目标架构图
```mermaid
flowchart LR
subgraph Config[UniDesk YAML source of truth]
- Lane[hwlab-node-lanes.yaml
sentinel enabled/configRefs]
+ Lane[hwlab-node-lanes.yaml
sentinels registry]
+ Mgmt[sentinel management YAML]
Runtime[runtime owning YAML]
Scenario[scenario owning YAML]
Prompts[synthetic prompt YAML]
Report[report view YAML]
Exposure[publicExposure YAML]
Secrets[Secret sourceRef YAML]
- Lane --> Runtime
- Lane --> Scenario
- Lane --> Prompts
- Lane --> Report
- Lane --> Exposure
- Lane --> Secrets
+ Lane --> Mgmt
+ Mgmt --> Runtime
+ Mgmt --> Scenario
+ Mgmt --> Prompts
+ Mgmt --> Report
+ Mgmt --> Exposure
+ Mgmt --> Secrets
end
subgraph Sentinel[Web哨兵 Pod]
@@ -171,8 +181,9 @@ flowchart LR
```mermaid
flowchart TD
- Root[config/hwlab-node-lanes.yaml
lanes.v03.targets.D601.observability.webProbe.sentinel] --> Enabled[enabled]
- Root --> Refs[configRefs]
+ Root[config/hwlab-node-lanes.yaml
lanes.v03.targets.D601.observability.webProbe.sentinels[]] --> Entry[id/enabled/configRef]
+ Entry --> Mgmt[sentinel management YAML#sentinel]
+ Mgmt --> Refs[configRefs]
Refs --> Runtime[runtime.d601-v03.yaml#sentinel.runtime]
Refs --> Scenarios[scenarios.workbench.yaml#sentinel.scenarios]
Refs --> PromptSets[prompt-set.dsflash-go.yaml#sentinel.promptSet]
@@ -440,11 +451,11 @@ Web哨兵必须只编排现有顶层 `web-probe observe start/status/command/col
| --- | --- | --- | --- |
| OPS-SENTINEL-REQ-002 | YAML配置 | PJ2026-0106050802 YAML配置 | [YAML运维](PJ2026-010603-yaml-first-ops.md)、[公开入口](PJ2026-010604-public-entry.md)、[源码同步](PJ2026-010602-source-sync.md) |
-Web哨兵配置必须通过 node/lane root YAML 的 `observability.webProbe.sentinel.enabled/configRefs` 进入,再引用 runtime、scenario、promptSet、reportView、publicExposure、CI/CD 和 Secret owning YAML。root YAML 不得承载所有 runtime/scenario/report/Secret 数值,也不得生成合并后的超级配置作为第二 source of truth。
+Web哨兵 legacy 单实例配置可通过 node/lane root YAML 的 `observability.webProbe.sentinel.enabled/configRefs` 进入;多实例配置必须通过 `observability.webProbe.sentinels[]` registry 进入。registry 项只能声明 `id`、`enabled` 和 `configRef`,再由 sentinel 管理 YAML 引用 runtime、workflow/scenario、promptSet、reportView、publicExposure、CI/CD 和 Secret owning YAML。root YAML 不得承载所有 runtime/scenario/report/Secret 数值,也不得生成合并后的超级配置作为第二 source of truth。
-parser 只负责解析 `path/to/file.yaml#object.path` 或规格确认的等价引用,校验文件存在、路径存在、字段形状、类型、枚举键名、必填字段和重复事实冲突。缺失字段应报告 YAML path 和下一步 drill-down;不得用代码默认值补 namespace、image、cadence、timeout、threshold、profile、Secret、public URL、report view 或 retention。
+parser 只负责解析 `path/to/file.yaml#object.path` 或规格确认的等价引用,校验文件存在、路径存在、字段形状、类型、枚举键名、必填字段和重复事实冲突。registry id 与 sentinel 管理 YAML 内的 id 必须一致;多实例 registry 下的非 config 操作必须显式选择 `--sentinel `,避免误操作默认实例。缺失字段应报告 YAML path 和下一步 drill-down;不得用代码默认值补 namespace、image、cadence、timeout、threshold、profile、Secret、public URL、report view 或 retention。
-`sentinel plan/status` 必须输出 redacted 配置引用图:每个 ref 的文件、path、presence、摘要 hash、缺失字段、冲突字段和下一步命令。默认输出不得 dump 完整展开 YAML、Secret 值、prompt 原文或 provider payload。
+`sentinel plan/status` 必须输出 redacted 配置引用图:无 `--sentinel` 且存在多个实例时输出 registry 表和逐实例 drill-down;指定实例时输出该实例每个 ref 的文件、path、presence、摘要 hash、缺失字段、冲突字段和下一步命令。默认输出不得 dump 完整展开 YAML、Secret 值、prompt 原文或 provider payload。
### 6.3 OPS-SENTINEL-REQ-003 常驻服务和 artifact 索引
@@ -494,7 +505,7 @@ HWLAB runtime 发布 Pipeline 应在 Argo sync 前调用当前哨兵 `maintenanc
哨兵服务不可用、首次安装未完成或配置未就绪时,CI/CD 必须结构化失败并输出缺失项、恢复建议和可重试命令;不得自动回退到原纯客户端 CLI、裸 Playwright、私有 API、read-side repair、reload 循环或 session repair 形成第二执行路径。人工排障可以显式运行原 `web-probe observe start/status/command/collect/analyze`,但不能被 targetValidation 当作自动通过证据。
-`sentinel validate --quick-verify --confirm --wait`、maintenance stop quick verify 和 control-plane targetValidation 的确认等待总耗时超过 120s 时,必须输出 warning,并在 quick verify run 摘要中记录可见警告。warning 文案必须指向严重超时和下一步调查方向:env-reuse、git mirror、source build path、运行路径和当前 wait 阶段;不得通过调大 timeout、减少业务轮次或 fallback 到第二执行路径来消除红灯。
+`sentinel validate --quick-verify --confirm --wait`、maintenance stop quick verify 和 control-plane targetValidation 的确认等待总耗时超过 120s 时,必须输出 warning,并在 quick verify run 摘要中记录可见警告。计时超限本身只作为非阻塞告警;只有真正影响 Code Agent 多轮业务链路、submit/command 执行、trace/final 可见性或 session 连续性的失败才构成 targetValidation blocker。control-plane publish/build 等非业务等待可通过 YAML 将确认等待预算放宽到 300s;不得通过减少业务轮次、吞掉 submit 失败、fallback 到第二执行路径或读侧 repair 来消除红灯。
### 6.6 OPS-SENTINEL-REQ-006 dsflash-go 十轮 canary
@@ -564,6 +575,24 @@ Dashboard 自动刷新只能读取 bounded API,不得发送 `observe command`
P8 中文运维页面必须以中文为默认用户可见语言:主标题、状态、筛选、运行历史、finding 分组、run detail、trace-frame、Final Response、空态、错误态、自动刷新和下一步动作均使用中文。原始英文 code、status 枚举、CLI 命令和 report SHA 可作为机器对照保留,但不得要求用户阅读英文 finding 才能判断 HWLAB 是否可用、卡在哪一层、下一步运行什么命令。
+### 6.10 OPS-SENTINEL-REQ-010 多实例巡检与账号切换链路
+
+| 编号 | 短名 | 主责模块 | 关联模块 |
+| --- | --- | --- | --- |
+| OPS-SENTINEL-REQ-010 | 多实例与账号切换 | PJ2026-0106050810 多实例与账号切换 | [YAML运维](PJ2026-010603-yaml-first-ops.md)、[用户管理](PJ2026-0105-user-management.md)、[公开入口](PJ2026-010604-public-entry.md) |
+
+Web 哨兵多实例必须以 node/lane root YAML 的 `observability.webProbe.sentinels[]` 为唯一 registry。每个 registry 项必须通过 `configRef` 指向独立 sentinel 管理 YAML,管理 YAML 再声明 runtime、workflow/scenario、promptSet、reportViews、publicExposure、cicd 和 secrets configRefs。legacy `observability.webProbe.sentinel` 只能作为单实例兼容入口;当同一 node/lane 存在多个 sentinel 时,除 `plan/status` registry 总览外,image、control-plane、validate、maintenance、report 和服务启动都必须显式携带 `--sentinel `。
+
+每个 sentinel 必须拥有独立的 Deployment、ServiceAccount、Service、PVC、SQLite path、GitOps path、Argo Application、FRP Secret/Deployment、metrics label、report index namespace 和 dashboard/report route label。不同 sentinel 可以共享同一 public hostname,但必须通过 YAML `routePrefix` 或等价 publicExposure 声明区分路径;不得共享同一个 PVC/SQLite 后再用 scenario id 伪装实例隔离。
+
+`workbench-dsflash-go-tool-call-10x` 是保留的生产 canary,迁移到多实例 registry 后其 observe/analyze、report view、dashboard root 和 targetValidation 语义不得回退。迁移 closeout 必须证明旧实例 `sentinel plan --sentinel workbench-dsflash-go-tool-call-10x` 仍能解析原 runtime/scenario/prompt/report/publicExposure/cicd/secrets 引用。
+
+`workbench-auth-session-switch-2users` 是账号切换链路哨兵。账号 A/B 的用户名、密码或 token 只能来自 Secret sourceRef 和 targetKey;CLI、服务日志、dashboard、report 和 issue evidence 只能展示 account id、sourcePurpose、presence、fingerprint 或 redacted 摘要。workflow 必须通过同一 `web-probe observe command` 控制队列支持 `loginAccount`、`logout`、`listSessions` 和 `switchSessions` 命令;submit 或命令失败必须作为结构化失败解决和上报,不能只写到备注里。
+
+账号切换 report view 至少要给出账号 A/B login/logout 成败、session 列表可见性、切换前后 active session/account id、trace/final 可见性、blocked finding 和同一 observer/run/report SHA。`auth-session-switch-summary` 视图只读取已有 artifact/report/index,不重新访问 Workbench、不保存第二套截图、不打印账号凭据。
+
+多实例 public exposure 复测必须通过受控 `web-probe screenshot` 或沉淀后的 command 远程截图能力完成,并把 PNG 保存到调用者 `/tmp` 下用于人工布局分析。修复 dashboard 布局问题后,复测截图必须覆盖 root dashboard 和至少一个 sentinel route prefix。
+
## 7. 过程控制
Web哨兵架构执行 issue 为 [#883](https://github.com/pikasTech/unidesk/issues/883)。阶段跟踪 issue 为 P0 [#885](https://github.com/pikasTech/unidesk/issues/885)、P1 [#886](https://github.com/pikasTech/unidesk/issues/886)、P2 [#887](https://github.com/pikasTech/unidesk/issues/887)、P3 [#888](https://github.com/pikasTech/unidesk/issues/888)、P4 [#889](https://github.com/pikasTech/unidesk/issues/889)、P5 [#890](https://github.com/pikasTech/unidesk/issues/890) 和 P6 [#891](https://github.com/pikasTech/unidesk/issues/891)。
@@ -577,3 +606,5 @@ P7 P6 收口必须区分 public dashboard validation 与 targetValidation quick
P8 哨兵恢复执行 issue 为 [#971](https://github.com/pikasTech/unidesk/issues/971)。P8 closeout 必须回写:SPEC P8 引用、120s warning 证据、`quick-verify-no-business-turn` 或等价业务触达证据、`browser-timeout` 分类修正、中文运维页面验证、`monitor.pikapython.com` 公网入口验证、k3s 内部 Service DNS quick verify 路径、D601/v03 用户入口 smoke 结果,以及仍未解除的真实业务 blocker 是否已单独拆出。
P8-P8 起,targetValidation 的 availability blocker 与 Workbench timing 架构治理必须分层。若同一 trace 在 terminal 前最后一帧仍为 running,随后 terminal commit 的 sealed `durationMs` 把可见 `totalElapsed` 小幅校正到更短值,且 drop 不超过 YAML `turnTimingSampleSlackSeconds`、final response 与完成行均已可见,则该现象作为 terminal-boundary timing correction 证据保留,不生成 `turn-timing-total-elapsed-decrease` red blocker。归零、running/running 下降、terminal 后增长、完成耗时与卡片耗时超出 slack、trace 乱序和完成行非最后仍保持 red;根因治理继续归 [HWLAB #2055](https://github.com/pikasTech/HWLAB/issues/2055) 和 [HWLAB #2125](https://github.com/pikasTech/HWLAB/issues/2125),不得在 UI、CLI renderer 或 analyzer 中做读侧 repair。
+
+P9 多实例巡检与账号切换链路执行 issue 为 [#1017](https://github.com/pikasTech/unidesk/issues/1017)。P9 closeout 必须回写:SPEC P9 引用、registry 和两条 sentinel drill-down、旧 dsflash canary 迁移验证、账号切换 workflow/Secret sourceRef 验证、独立 Deployment/PVC/Service/SQLite/GitOps/Argo/public route prefix 证据、submit/command 失败处理、非阻塞计时告警证据、远程 PNG 截图布局复测,以及未完成阶段是否已拆出后续 issue。
diff --git a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js
index 7c18df55..915dc870 100644
--- a/scripts/assets/web-probe-sentinel-dashboard/dashboard.js
+++ b/scripts/assets/web-probe-sentinel-dashboard/dashboard.js
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Browser-side API client, formatting, auto refresh, and base dashboard rendering.
// Desktop view redesign (issue #1025): 三栏 master-detail、低噪声、渐进披露、排查直觉化。
// 保持纯 vanilla JS + 原生 CSS,无框架、无构建步骤,所有渲染通过 innerHTML 拼接。
@@ -15,6 +16,7 @@
*/
const root = document.getElementById("sentinel-dashboard");
+const basePath = normalizeBasePath(root?.dataset?.basePath || "");
const refs = {
statusPill: document.getElementById("status-pill"),
loadingBanner: document.getElementById("loading-banner"),
@@ -178,21 +180,31 @@ loadDashboard({ silent: false }).catch((error) => renderError(error));
function createDashboardApi() {
return {
/** @returns {Promise} */
- overview: () => getJson("/api/overview"),
+ overview: () => getJson(apiPath("/api/overview")),
/** @returns {Promise} */
- runs: (filters) => getJson(`/api/runs?${runsQuery(filters)}`),
+ runs: (filters) => getJson(apiPath(`/api/runs?${runsQuery(filters)}`)),
/** @returns {Promise} */
- findings: (filters) => getJson(`/api/findings?${findingsQuery(filters)}`),
+ findings: (filters) => getJson(apiPath(`/api/findings?${findingsQuery(filters)}`)),
/** @returns {Promise} */
- runDetail: (runId) => getJson(`/api/runs/${encodeURIComponent(runId)}`),
+ runDetail: (runId) => getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}`)),
runViews: (runId, view = null) => {
const query = new URLSearchParams({ maxBytes: "24000" });
if (view) query.set("view", view);
- return getJson(`/api/runs/${encodeURIComponent(runId)}/views?${query.toString()}`);
+ return getJson(apiPath(`/api/runs/${encodeURIComponent(runId)}/views?${query.toString()}`));
},
};
}
+function apiPath(path) {
+ return `${basePath}${path}`;
+}
+
+function normalizeBasePath(value) {
+ const text = String(value || "").replace(/\/+$/u, "");
+ if (!text || text === "/") return "";
+ return text.startsWith("/") ? text : `/${text}`;
+}
+
async function getJson(path) {
const response = await fetch(path, { headers: { accept: "application/json" } });
const body = await response.json().catch(() => ({}));
diff --git a/scripts/src/hwlab-node-help.ts b/scripts/src/hwlab-node-help.ts
index 62415f49..0c1f1dcf 100644
--- a/scripts/src/hwlab-node-help.ts
+++ b/scripts/src/hwlab-node-help.ts
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Help payloads for HWLAB node/lane and web-probe CLI entries.
import { hwlabRuntimeLaneConfigPath } from "./hwlab-node-lanes";
@@ -59,7 +60,8 @@ export function hwlabNodeWebProbeHelp(): Record {
"bun scripts/cli.ts web-probe observe collect webobs-xxxx --view project-mdtodo-summary",
"bun scripts/cli.ts web-probe observe analyze webobs-xxxx",
"bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --dry-run",
- "bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --confirm --wait --release-id ",
+ "bun scripts/cli.ts web-probe sentinel plan --node D601 --lane v03 --sentinel workbench-auth-session-switch-2users",
+ "bun scripts/cli.ts web-probe sentinel maintenance stop --node D601 --lane v03 --sentinel workbench-dsflash-go-tool-call-10x --confirm --wait --release-id ",
],
actions: {
run: "Run the repo-owned scripts/web-live-dom-probe.mjs helper.",
@@ -75,6 +77,7 @@ export function hwlabNodeWebProbeHelp(): Record {
"After observe start, prefer observe status|command|stop|collect|analyze instead of repeating --node/--lane/--state-dir.",
"collect views render bounded summaries from existing artifacts and do not create a second source of truth.",
"analyze is offline-only: it reads artifact JSONL and writes analysis/report.md plus analysis/report.json.",
+ "When multiple web-probe sentinels are declared, sentinel image/control-plane/validate/maintenance/report require `--sentinel `; plan/status without it show the registry drill-down.",
"Issue evidence should cite observer id, stateDir, report SHA, screenshot SHA, command ids and concise summaries, not prompt/provider/secret payloads.",
],
};
diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts
index 9d19017f..7cb27116 100644
--- a/scripts/src/hwlab-node-lanes.ts
+++ b/scripts/src/hwlab-node-lanes.ts
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
// Responsibility: YAML source-of-truth parsing for HWLAB node/lane Workbench observability.
import { readFileSync } from "node:fs";
@@ -149,6 +150,12 @@ export interface HwlabRuntimeWebProbeSentinelSpec {
readonly configRefs: Record;
}
+export interface HwlabRuntimeWebProbeSentinelRegistryItemSpec {
+ readonly id: string;
+ readonly enabled: boolean;
+ readonly configRef: string;
+}
+
export interface HwlabRuntimeWebProbeAlertThresholdsSpec {
readonly sameOriginApiSlowMs: number;
readonly partialApiSlowMs: number;
@@ -189,6 +196,7 @@ export interface HwlabRuntimeObservabilitySpec {
export interface HwlabRuntimeObservabilityWebProbeSpec {
readonly sentinel?: HwlabRuntimeWebProbeSentinelSpec;
+ readonly sentinels?: readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[];
}
export interface HwlabRuntimeObservabilityMetricsEndpointSpec {
@@ -793,6 +801,35 @@ function webProbeSentinelConfig(value: unknown, path: string): HwlabRuntimeWebPr
};
}
+function webProbeSentinelRegistryItemConfig(value: unknown, path: string): HwlabRuntimeWebProbeSentinelRegistryItemSpec {
+ const raw = asRecord(value, path);
+ const allowed = new Set(["id", "enabled", "configRef"]);
+ for (const key of Object.keys(raw)) {
+ if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; sentinel registry items may only contain id/enabled/configRef`);
+ }
+ const id = stringField(raw, "id", path);
+ if (!/^[a-z0-9][a-z0-9-]{1,80}$/u.test(id)) throw new Error(`${path}.id must be a stable lowercase sentinel id`);
+ const configRef = stringField(raw, "configRef", path);
+ validateConfigRef(configRef, `${path}.configRef`);
+ return {
+ id,
+ enabled: booleanField(raw, "enabled", path),
+ configRef,
+ };
+}
+
+function webProbeSentinelRegistryConfig(value: unknown, path: string): readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[] {
+ if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
+ if (value.length === 0) throw new Error(`${path} must contain at least one sentinel`);
+ const items = value.map((item, index) => webProbeSentinelRegistryItemConfig(item, `${path}[${index}]`));
+ const ids = new Set();
+ for (const item of items) {
+ if (ids.has(item.id)) throw new Error(`${path} contains duplicate sentinel id ${item.id}`);
+ ids.add(item.id);
+ }
+ return items;
+}
+
function validateConfigRef(ref: string, path: string): void {
const [file, fragment, extra] = ref.split("#");
if (extra !== undefined || file === undefined || fragment === undefined || file.length === 0 || fragment.length === 0) {
@@ -937,12 +974,14 @@ function observabilityConfig(value: unknown, path: string): HwlabRuntimeObservab
function observabilityWebProbeConfig(value: unknown, path: string): HwlabRuntimeObservabilityWebProbeSpec | undefined {
if (value === undefined) return undefined;
const raw = asRecord(value, path);
- const allowed = new Set(["sentinel"]);
+ const allowed = new Set(["sentinel", "sentinels"]);
for (const key of Object.keys(raw)) {
- if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; observability.webProbe currently only owns sentinel`);
+ if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed; observability.webProbe currently only owns sentinel/sentinels`);
}
+ if (raw.sentinel !== undefined && raw.sentinels !== undefined) throw new Error(`${path} may declare sentinel or sentinels, not both`);
return {
...(raw.sentinel === undefined ? {} : { sentinel: webProbeSentinelConfig(raw.sentinel, `${path}.sentinel`) }),
+ ...(raw.sentinels === undefined ? {} : { sentinels: webProbeSentinelRegistryConfig(raw.sentinels, `${path}.sentinels`) }),
};
}
diff --git a/scripts/src/hwlab-node-web-observe-runner-source.ts b/scripts/src/hwlab-node-web-observe-runner-source.ts
index c295f20b..bb168897 100644
--- a/scripts/src/hwlab-node-web-observe-runner-source.ts
+++ b/scripts/src/hwlab-node-web-observe-runner-source.ts
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Source string for the pure-client HWLAB web-probe observer runner.
import { nodeWebObserveRunnerCommandActionsSource } from "./hwlab-node-web-observe-runner-actions-source";
@@ -340,6 +341,10 @@ async function processCommand(command) {
await appendJsonl(files.control, controlRecord(command, "started", commandInputSummary(command)));
switch (command.type) {
case "login": return authenticate(context, page);
+ case "loginAccount": return withObserverSync(await loginAccount(command), "loginAccount");
+ case "logout": return withObserverSync(await logoutAccount(command), "logout");
+ case "listSessions": return withObserverSync(await listSessions(command), "listSessions");
+ case "switchSessions": return withObserverSync(await switchSessions(command), "switchSessions");
case "preflight": return preflightSummary();
case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto");
case "newSession": return withObserverSync(await createSessionFromUi(), "newSession");
@@ -592,7 +597,7 @@ async function authenticate(browserContext, authPage) {
throw error;
}
-async function pageAuthLogin(authPage, loginUrl) {
+async function pageAuthLogin(authPage, loginUrl, credential = { username, password }) {
if (!authPage) throw new Error("auth page is not ready");
await authPage.goto(new URL("/assets/favicon.svg", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 12000 });
return authPage.evaluate(async (input) => {
@@ -608,7 +613,156 @@ async function pageAuthLogin(authPage, loginUrl) {
status: response.status,
statusText: response.statusText || "",
};
- }, { username, password, loginUrl });
+ }, { username: credential.username, password: credential.password, loginUrl });
+}
+
+async function loginAccount(command) {
+ const accountId = requiredAccountId(command, ["accountId", "account", "value", "text"]);
+ const credential = credentialForAccount(accountId);
+ const loginUrl = new URL("/auth/login", baseUrl).toString();
+ const before = await accountSessionSnapshot();
+ const response = await pageAuthLogin(page, loginUrl, credential);
+ const cookieState = await readAuthCookieState(context);
+ if (!response.ok || !cookieState.cookiePresent) {
+ const error = new Error("loginAccount failed for accountId=" + accountId + " status=" + response.status + " " + (response.statusText || ""));
+ error.details = { accountId, status: response.status, statusText: response.statusText, cookiePresent: cookieState.cookiePresent, credentialSource: credential.source, valuesRedacted: true };
+ throw error;
+ }
+ const target = isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "") ? safeUrlPath(currentPageUrl()) : targetPath;
+ const navigation = await gotoTarget(target || targetPath);
+ const after = await accountSessionSnapshot();
+ return { ok: true, type: "loginAccount", accountId, credentialSource: credential.source, before, after, navigation, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true };
+}
+
+async function logoutAccount(command = {}) {
+ const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
+ const before = await accountSessionSnapshot();
+ const logoutUrl = new URL("/logout", baseUrl).toString();
+ const response = await page.evaluate(async (input) => {
+ const res = await fetch(input.logoutUrl, { method: "POST", headers: { accept: "application/json" }, credentials: "include" });
+ await res.text().catch(() => "");
+ return { ok: res.ok, status: res.status, statusText: res.statusText || "" };
+ }, { logoutUrl });
+ await context.clearCookies().catch(() => {});
+ const cookieState = await readAuthCookieState(context);
+ const afterUrl = await page.goto(new URL("/auth/login", baseUrl).toString(), { waitUntil: "domcontentloaded", timeout: 15000 }).then(() => currentPageUrl()).catch(() => currentPageUrl());
+ const result = { ok: response.ok || response.status === 401 || !cookieState.cookiePresent, type: "logout", accountId, status: response.status, statusText: response.statusText, before, after: { url: afterUrl, cookiePresent: cookieState.cookiePresent, cookieNames: cookieState.cookieNames, valuesRedacted: true }, valuesRedacted: true };
+ if (!result.ok) {
+ const error = new Error("logout failed status=" + response.status + " " + (response.statusText || ""));
+ error.details = result;
+ throw error;
+ }
+ return result;
+}
+
+async function listSessions(command = {}) {
+ const accountId = commandValue(command, ["accountId", "account", "value", "text"]) || null;
+ if (!isWorkbenchPathname(safeUrlPath(currentPageUrl()) || "")) await gotoTarget(targetPath);
+ const snapshot = await workbenchSessionSnapshot();
+ const sessions = await page.evaluate(() => {
+ const seen = new Set();
+ const rows = [];
+ for (const element of Array.from(document.querySelectorAll("[data-session-id], .session-tab, a[href*='/workbench/sessions/']"))) {
+ const sessionId = element.getAttribute("data-session-id") || (element.getAttribute("href") || "").match(/\/workbench\/sessions\/([^/?#]+)/)?.[1] || "";
+ if (!sessionId || seen.has(sessionId)) continue;
+ seen.add(sessionId);
+ rows.push({
+ sessionId,
+ active: element.getAttribute("data-active") === "true" || element.getAttribute("aria-selected") === "true",
+ status: element.getAttribute("data-status") || null,
+ conversationId: element.getAttribute("data-conversation-id") || null,
+ });
+ }
+ return rows.slice(0, 50);
+ }).catch(() => []);
+ return { ok: true, type: "listSessions", accountId, sessionCount: sessions.length, activeSessionId: snapshot?.activeSessionId || snapshot?.routeSessionId || null, sessions, snapshot, valuesRedacted: true };
+}
+
+async function switchSessions(command) {
+ const fromAccountId = commandValue(command, ["fromAccountId", "fromAccount", "accountId"]);
+ const toAccountId = requiredAccountId(command, ["toAccountId", "toAccount", "value", "text"]);
+ const before = await accountSessionSnapshot();
+ if (fromAccountId) {
+ const beforeAccount = before.accountId || null;
+ await appendJsonl(files.control, eventRecord("switchSessions-from-account", { fromAccountId, observedAccountId: beforeAccount, valuesRedacted: true }));
+ }
+ const logout = await logoutAccount({ ...command, accountId: fromAccountId || null });
+ const login = await loginAccount({ ...command, accountId: toAccountId });
+ const sessions = await listSessions({ ...command, accountId: toAccountId });
+ return { ok: login.ok === true && sessions.ok === true, type: "switchSessions", fromAccountId: fromAccountId || null, toAccountId, before, logout, login, sessions, valuesRedacted: true };
+}
+
+async function accountSessionSnapshot() {
+ const cookieState = await readAuthCookieState(context);
+ const workbench = await workbenchSessionSnapshot().catch(() => null);
+ return {
+ url: currentPageUrl(),
+ path: safeUrlPath(currentPageUrl()),
+ cookiePresent: cookieState.cookiePresent,
+ cookieNames: cookieState.cookieNames,
+ activeSessionId: workbench?.activeSessionId || null,
+ routeSessionId: workbench?.routeSessionId || null,
+ tabCount: workbench?.tabCount ?? null,
+ messageCount: workbench?.messageCount ?? null,
+ valuesRedacted: true,
+ };
+}
+
+function requiredAccountId(command, keys) {
+ const accountId = commandValue(command, keys);
+ if (!isSafeAccountId(accountId)) throw new Error(command.type + " requires --account-id using lowercase account id");
+ return accountId;
+}
+
+function credentialForAccount(accountId) {
+ if (accountId === "bootstrap-admin" || accountId === "admin") {
+ if (!password) throw new Error("loginAccount accountId=" + accountId + " missing HWLAB_WEB_PASS");
+ return { username, password, source: "HWLAB_WEB_USER/HWLAB_WEB_PASS", valuesRedacted: true };
+ }
+ const env = accountCredentialEnvCandidates(accountId);
+ for (const jsonKey of env.jsonKeys) {
+ const raw = process.env[jsonKey];
+ if (!raw) continue;
+ const parsed = parseCredentialJson(raw);
+ if (parsed !== null) return { ...parsed, source: jsonKey, valuesRedacted: true };
+ }
+ for (const pair of env.pairs) {
+ const user = process.env[pair.userKey];
+ const pass = process.env[pair.passKey];
+ if (user && pass) return { username: user, password: pass, source: pair.userKey + "/" + pair.passKey, valuesRedacted: true };
+ }
+ throw new Error("loginAccount missing credential material for accountId=" + accountId + "; expected one of " + [...env.jsonKeys, ...env.pairs.flatMap((item) => [item.userKey, item.passKey])].join(","));
+}
+
+function accountCredentialEnvCandidates(accountId) {
+ const segment = accountId.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
+ return {
+ jsonKeys: [
+ "HWLAB_WEB_" + segment + "_JSON",
+ "HWLAB_WEB_ACCOUNT_" + segment + "_JSON",
+ ],
+ pairs: [
+ { userKey: "HWLAB_WEB_" + segment + "_USER", passKey: "HWLAB_WEB_" + segment + "_PASS" },
+ { userKey: "HWLAB_WEB_ACCOUNT_" + segment + "_USER", passKey: "HWLAB_WEB_ACCOUNT_" + segment + "_PASS" },
+ ],
+ };
+}
+
+function parseCredentialJson(raw) {
+ try {
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
+ const user = typeof parsed.username === "string" ? parsed.username : typeof parsed.user === "string" ? parsed.user : typeof parsed.email === "string" ? parsed.email : "";
+ const pass = typeof parsed.password === "string" ? parsed.password : typeof parsed.pass === "string" ? parsed.pass : "";
+ if (!user || !pass) return null;
+ return { username: user, password: pass, valuesRedacted: true };
+ } catch {
+ return null;
+ }
+}
+
+function isSafeAccountId(value) {
+ return /^[a-z0-9][a-z0-9-]{1,80}$/u.test(String(value || ""));
}
function publicAuth(value) {
diff --git a/scripts/src/hwlab-node-web-sentinel-cicd.ts b/scripts/src/hwlab-node-web-sentinel-cicd.ts
index 17a23fbc..2662508f 100644
--- a/scripts/src/hwlab-node-web-sentinel-cicd.ts
+++ b/scripts/src/hwlab-node-web-sentinel-cicd.ts
@@ -1,5 +1,6 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p8-web-probe-sentinel-recovery.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first CI/CD, image, GitOps and Argo command plan for the web-probe sentinel.
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
@@ -8,6 +9,7 @@ import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
import { startJob } from "./jobs";
import { webProbeSentinelConfigPlan, withWebProbeSentinelConfigRendered } from "./hwlab-node-web-sentinel-config";
+import { requireSentinelIdForRegistry, resolveWebProbeSentinel } from "./hwlab-node-web-sentinel-resolver";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
import type { RenderedCliResult } from "./output";
@@ -15,7 +17,7 @@ export type WebProbeSentinelConfigAction = "plan" | "status";
export type WebProbeSentinelImageAction = "status" | "build";
export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current";
export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop";
-export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame";
+export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary";
export type WebProbeSentinelOptions =
| {
@@ -23,6 +25,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelConfigAction;
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string | null;
readonly dryRun: boolean;
}
| {
@@ -30,6 +33,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelImageAction;
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -40,6 +44,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelControlPlaneAction;
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -50,6 +55,7 @@ export type WebProbeSentinelOptions =
readonly action: WebProbeSentinelMaintenanceAction;
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -63,6 +69,7 @@ export type WebProbeSentinelOptions =
readonly action: "validate";
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string | null;
readonly dryRun: boolean;
readonly confirm: boolean;
readonly wait: boolean;
@@ -74,6 +81,7 @@ export type WebProbeSentinelOptions =
readonly action: "report";
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string | null;
readonly view: WebProbeSentinelReportView;
readonly runId: string | null;
readonly latest: boolean;
@@ -85,6 +93,8 @@ export type WebProbeSentinelOptions =
interface SentinelCicdState {
readonly spec: HwlabRuntimeLaneSpec;
+ readonly sentinelId: string;
+ readonly configRefs: Record;
readonly configReady: boolean;
readonly runtime: Record;
readonly cicd: Record;
@@ -163,8 +173,9 @@ interface ChildCliResult {
const SPEC_REF = "PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel";
export function runWebProbeSentinelCommand(spec: HwlabRuntimeLaneSpec, options: WebProbeSentinelOptions): RenderedCliResult {
- if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action));
- const state = loadSentinelCicdState(spec, options.timeoutSeconds);
+ if (options.kind === "config") return withWebProbeSentinelConfigRendered(webProbeSentinelConfigPlan(spec, options.action, options.sentinelId));
+ requireSentinelIdForRegistry(spec, options.sentinelId, `web-probe sentinel ${options.kind}`);
+ const state = loadSentinelCicdState(spec, options.sentinelId, options.timeoutSeconds);
if (options.kind === "image") return runSentinelImage(state, options);
if (options.kind === "control-plane") return runSentinelControlPlane(state, options);
if (options.kind === "maintenance") return runSentinelMaintenance(state, options);
@@ -187,6 +198,7 @@ function runSentinelImage(state: SentinelCicdState, options: Extract Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
return {
spec,
+ sentinelId: sentinel.id,
+ configRefs: sentinel.configRefs,
configReady: configPlan.ok,
runtime,
cicd,
@@ -345,9 +360,11 @@ function sentinelDockerfile(baseImage: string, entrypoint: string): string {
function renderSentinelManifests(
spec: HwlabRuntimeLaneSpec,
+ sentinelId: string,
runtime: Record,
cicd: Record,
publicExposure: Record,
+ secrets: Record,
image: SentinelImagePlan,
): readonly Record[] {
const namespace = stringAt(runtime, "namespace");
@@ -358,12 +375,14 @@ function renderSentinelManifests(
"unidesk.ai/spec-ref": "PJ2026-01060508",
"unidesk.ai/node": spec.nodeId,
"unidesk.ai/lane": spec.lane,
+ "unidesk.ai/web-probe-sentinel-id": sentinelId,
};
const deploymentName = stringAt(runtime, "deploymentName");
const serviceName = stringAt(runtime, "serviceName");
const servicePort = numberAt(runtime, "servicePort");
const pvcStorage = stringAt(runtime, "pvcStorage");
const stateRoot = stringAt(runtime, "stateRoot");
+ const sentinelEnv = sentinelContainerEnv(sentinelId, secrets);
return [
{
apiVersion: "v1",
@@ -385,7 +404,9 @@ function renderSentinelManifests(
specRef: SPEC_REF,
node: spec.nodeId,
lane: spec.lane,
+ sentinelId,
publicBaseUrl: stringAt(publicExposure, "publicBaseUrl"),
+ routePrefix: stringAtNullable(publicExposure, "routePrefix") ?? "/",
gitopsPath: stringAt(cicd, "gitopsPath"),
valuesRedacted: true,
}, null, 2),
@@ -411,6 +432,8 @@ function renderSentinelManifests(
spec.nodeId,
"--lane",
spec.lane,
+ "--sentinel",
+ sentinelId,
"--state-root",
stateRoot,
"--host",
@@ -418,6 +441,7 @@ function renderSentinelManifests(
"--port",
String(servicePort),
],
+ env: sentinelEnv,
ports: [{ name: "http", containerPort: servicePort }],
readinessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
livenessProbe: { httpGet: { path: stringAt(runtime, "healthPath"), port: "http" } },
@@ -492,6 +516,34 @@ function renderSentinelManifests(
];
}
+function sentinelContainerEnv(sentinelId: string, secrets: Record): readonly Record[] {
+ const env: Record[] = [{ name: "UNIDESK_WEB_PROBE_SENTINEL_ID", value: sentinelId }];
+ for (const runtimeSecret of arrayAt(secrets, "runtimeSecrets").map(record)) {
+ const secretName = stringAtNullable(runtimeSecret, "name");
+ if (secretName === null) continue;
+ for (const item of arrayAt(runtimeSecret, "data").map(record)) {
+ const targetKey = stringAtNullable(item, "targetKey");
+ const sourcePurpose = stringAtNullable(item, "sourcePurpose");
+ const envName = sourcePurpose === null || targetKey === null ? null : accountSecretEnvName(sourcePurpose, targetKey);
+ if (envName === null) continue;
+ env.push({ name: envName, valueFrom: { secretKeyRef: { name: secretName, key: targetKey } } });
+ }
+ }
+ return env;
+}
+
+function accountSecretEnvName(sourcePurpose: string, targetKey: string): string | null {
+ if (!/^account-[a-z0-9-]+$/u.test(sourcePurpose) || !targetKey.endsWith(".json")) return null;
+ const segment = sourcePurpose.toUpperCase().replace(/[^A-Z0-9]+/gu, "_").replace(/^_+|_+$/gu, "");
+ return segment.length === 0 ? null : `HWLAB_WEB_${segment}_JSON`;
+}
+
+function normalizeRoutePrefix(value: string | null): string {
+ if (value === null || value.trim() === "" || value.trim() === "/") return "/";
+ const prefixed = value.trim().startsWith("/") ? value.trim() : `/${value.trim()}`;
+ return prefixed.replace(/\/+$/u, "") || "/";
+}
+
function probeImageRegistry(state: SentinelCicdState, timeoutSeconds: number): Record {
const endpoint = stringAt(state.controlPlaneNode, "registry.endpoint");
const repoTag = state.image.ref.replace(`${endpoint}/`, "");
@@ -555,8 +607,8 @@ function runSentinelImageBuildConfirmed(state: SentinelCicdState, options: Extra
? { code: "sentinel-image-publish-failed", reason: "remote image publish job failed before registry validation" }
: { code: "sentinel-image-registry-missing", reason: "image publish completed but expected registry tag is not visible" },
next: {
- status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}`,
- controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane} --confirm`,
+ status: `bun scripts/cli.ts web-probe sentinel image status --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)}`,
+ controlPlaneTrigger: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${state.spec.nodeId} --lane ${state.spec.lane}${sentinelCliSuffix(state)} --confirm`,
},
valuesRedacted: true,
};
@@ -663,9 +715,9 @@ function runSentinelControlPlaneConfirmed(state: SentinelCicdState, options: Ext
function renderAsyncSentinelJob(state: SentinelCicdState, domain: "image" | "control-plane", action: string, timeoutSeconds: number): RenderedCliResult {
const args = domain === "image"
- ? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]
- : ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
- const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${domain} ${action} for node ${state.spec.nodeId}`);
+ ? ["web-probe", "sentinel", "image", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)]
+ : ["web-probe", "sentinel", "control-plane", action, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--sentinel", state.sentinelId, "--confirm", "--wait", "--timeout-seconds", String(timeoutSeconds)];
+ const job = startJob(`hwlab_nodes_${state.spec.lane}_web_probe_sentinel_${safeJobSegment(state.sentinelId)}_${domain}_${action}`, ["bun", "scripts/cli.ts", ...args], `Run HWLAB ${state.spec.lane} web-probe sentinel ${state.sentinelId} ${domain} ${action} for node ${state.spec.nodeId}`);
const command = `web-probe sentinel ${domain} ${action}`;
const result = {
ok: true,
@@ -1415,11 +1467,12 @@ function confirmBlocked(action: string, state: SentinelCicdState): Record {
const node = state.spec.nodeId;
const lane = state.spec.lane;
+ const suffix = sentinelCliSuffix(state);
return {
- plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane} --dry-run`,
- status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}`,
- image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}`,
- triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane} --dry-run`,
+ plan: `bun scripts/cli.ts web-probe sentinel control-plane plan --node ${node} --lane ${lane}${suffix} --dry-run`,
+ status: `bun scripts/cli.ts web-probe sentinel control-plane status --node ${node} --lane ${lane}${suffix}`,
+ image: `bun scripts/cli.ts web-probe sentinel image status --node ${node} --lane ${lane}${suffix}`,
+ triggerCurrent: `bun scripts/cli.ts web-probe sentinel control-plane trigger-current --node ${node} --lane ${lane}${suffix} --dry-run`,
issue: "https://github.com/pikasTech/unidesk/issues/889",
currentAction: action,
};
@@ -1586,11 +1639,11 @@ function runSentinelReport(state: SentinelCicdState, options: Extract stringAt(item, "type") === "sendPrompt");
+ const prompts = needsPromptSet
+ ? readPromptSetForScenario(scenario)
+ : { ok: true as const, prompts: [], summary: { source: "not-required", promptCount: 0, valuesRedacted: true } };
if (!prompts.ok) return { ok: false, status: "blocked", reason: "prompt-source-unavailable", promptSource: prompts, valuesRedacted: true };
const sampleIntervalMs = numberAt(scenario, "sampleIntervalMs");
const budgetSeconds = Math.min(timeoutSeconds, maxSeconds);
@@ -1655,7 +1712,7 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
}
let promptIndex = 0;
const sessionInvarianceChecks = sessionInvarianceChecksByRound(scenario);
- for (const item of arrayAt(scenario, "commandSequence").map(record)) {
+ for (const item of commandSequence) {
const type = stringAt(item, "type");
const repeat = Math.max(1, typeof item.repeat === "number" && Number.isFinite(item.repeat) ? Math.trunc(item.repeat) : 1);
for (let index = 0; index < repeat; index += 1) {
@@ -1675,6 +1732,16 @@ function runSentinelQuickVerify(state: SentinelCicdState, reason: string, timeou
}
const args = ["web-probe", "observe", "command", observerId, "--node", state.spec.nodeId, "--lane", state.spec.lane, "--type", type, "--wait-ms", "55000", "--command-timeout-seconds", String(remainingSeconds(deadline, 55))];
if (type === "selectProvider") args.push("--provider", stringAt(item, "provider"));
+ if (type === "loginAccount" || type === "listSessions" || type === "logout") {
+ const accountId = stringAtNullable(item, "accountId");
+ if (accountId !== null) args.push("--account-id", accountId);
+ }
+ if (type === "switchSessions") {
+ const fromAccountId = stringAtNullable(item, "fromAccountId");
+ const toAccountId = stringAtNullable(item, "toAccountId");
+ if (fromAccountId !== null) args.push("--from-account-id", fromAccountId);
+ if (toAccountId !== null) args.push("--to-account-id", toAccountId);
+ }
if (type === "sendPrompt") {
args.push("--text", prompts.prompts[promptIndex % prompts.prompts.length] ?? "");
promptIndex += 1;
@@ -2152,16 +2219,29 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
const serviceName = stringAt(state.publicExposure, "caddy.serviceName");
const responseHeaderTimeoutSeconds = numberAt(state.publicExposure, "caddy.responseHeaderTimeoutSeconds");
const remotePort = numberAt(state.publicExposure, "frpc.httpProxy.remotePort");
- const block = [
- `${hostname} {`,
- ` reverse_proxy 127.0.0.1:${remotePort} {`,
- " transport http {",
- ` response_header_timeout ${responseHeaderTimeoutSeconds}s`,
- " }",
+ const routePrefix = normalizeRoutePrefix(stringAtNullable(state.publicExposure, "routePrefix"));
+ const proxyLines = [
+ `reverse_proxy 127.0.0.1:${remotePort} {`,
+ " transport http {",
+ ` response_header_timeout ${responseHeaderTimeoutSeconds}s`,
" }",
"}",
- "",
- ].join("\n");
+ ];
+ const block = routePrefix === "/"
+ ? [
+ `${hostname} {`,
+ ...proxyLines.map((line) => ` ${line}`),
+ "}",
+ "",
+ ].join("\n")
+ : [
+ `${hostname} {`,
+ ` handle_path ${routePrefix}* {`,
+ ...proxyLines.map((line) => ` ${line}`),
+ " }",
+ "}",
+ "",
+ ].join("\n");
const blockB64 = Buffer.from(block, "utf8").toString("base64");
const script = [
"set +e",
@@ -2229,7 +2309,7 @@ function applySentinelCaddyBlock(state: SentinelCicdState, timeoutSeconds: numbe
].join("\n");
const result = runCommand(["trans", stringAt(state.publicExposure, "caddy.route"), "sh", "--", script], repoRoot, { timeoutMs: Math.min(timeoutSeconds, 60) * 1000 });
const parsed = parseJsonObject(result.stdout);
- return { ok: result.exitCode === 0 && parsed?.ok === true, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
+ return { ok: result.exitCode === 0 && parsed?.ok === true, routePrefix, ...record(parsed), result: compactCommand(result), valuesRedacted: true };
}
function readAnalysisSummaryFromWorkspace(state: SentinelCicdState, stateDir: string, timeoutSeconds: number): Record {
@@ -2487,11 +2567,9 @@ function cliDataPayload(parsed: Record | null): Record | null {
- const sentinel = state.spec.observability.webProbe?.sentinel;
- if (sentinel === undefined) return null;
- const scenarios = readConfigRefTarget(sentinel.configRefs.scenarios);
- if (!Array.isArray(scenarios)) return null;
- return scenarios.map(record).find((item) => item.id === scenarioId) ?? null;
+ const scenarios = readConfigRefTarget(state.configRefs.scenarios);
+ const items = Array.isArray(scenarios) ? scenarios : isRecord(scenarios) ? [scenarios] : [];
+ return items.map(record).find((item) => item.id === scenarioId) ?? null;
}
function readPromptSetForScenario(scenario: Record): { ok: true; prompts: string[]; summary: Record } | { ok: false; error: string; summary: Record } {
@@ -2595,7 +2673,7 @@ function serviceUnavailableBlocker(state: SentinelCicdState): Record {
const node = state.spec.nodeId;
const lane = state.spec.lane;
+ const suffix = sentinelCliSuffix(state);
return {
- validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}`,
- quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane} --quick-verify --confirm --wait`,
- maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane} --confirm --wait`,
- maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane} --confirm --wait`,
- report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane} --view summary`,
+ validate: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix}`,
+ quickVerify: `bun scripts/cli.ts web-probe sentinel validate --node ${node} --lane ${lane}${suffix} --quick-verify --confirm --wait`,
+ maintenanceStart: `bun scripts/cli.ts web-probe sentinel maintenance start --node ${node} --lane ${lane}${suffix} --confirm --wait`,
+ maintenanceStop: `bun scripts/cli.ts web-probe sentinel maintenance stop --node ${node} --lane ${lane}${suffix} --confirm --wait`,
+ report: `bun scripts/cli.ts web-probe sentinel report --node ${node} --lane ${lane}${suffix} --view summary`,
};
}
@@ -2805,7 +2884,20 @@ function renderReportResult(result: Record): string {
function sentinelPipelineRunName(state: SentinelCicdState): string {
const commit = state.sourceHead.commit ?? "source";
- return `hwlab-web-probe-sentinel-${commit.slice(0, 12)}`;
+ return `hwlab-web-probe-sentinel-${safeKubernetesSegment(state.sentinelId, 24)}-${commit.slice(0, 12)}`;
+}
+
+function sentinelCliSuffix(state: SentinelCicdState): string {
+ return ` --sentinel ${state.sentinelId}`;
+}
+
+function safeJobSegment(value: string): string {
+ return value.replace(/[^A-Za-z0-9_]+/gu, "_").replace(/^_+|_+$/gu, "").slice(0, 48) || "sentinel";
+}
+
+function safeKubernetesSegment(value: string, maxLength: number): string {
+ const normalized = value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "");
+ return (normalized || "sentinel").slice(0, Math.max(1, maxLength)).replace(/-+$/u, "") || "sentinel";
}
function renderImageResult(result: Record): string {
diff --git a/scripts/src/hwlab-node-web-sentinel-config.ts b/scripts/src/hwlab-node-web-sentinel-config.ts
index 9a940d46..5f84bdf1 100644
--- a/scripts/src/hwlab-node-web-sentinel-config.ts
+++ b/scripts/src/hwlab-node-web-sentinel-config.ts
@@ -1,9 +1,11 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Redacted YAML configRef graph for web-probe sentinel plan/status.
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { rootPath } from "./config";
import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey } from "./hwlab-node-lanes";
+import { resolveWebProbeSentinel, webProbeSentinelRegistryRows, type WebProbeSentinelRegistryRow } from "./hwlab-node-web-sentinel-resolver";
import type { RenderedCliResult } from "./output";
export type WebProbeSentinelConfigAction = "plan" | "status";
@@ -15,7 +17,9 @@ export interface WebProbeSentinelConfigPlan {
readonly node: string;
readonly lane: string;
readonly rootPath: string;
+ readonly sentinelId: string | null;
readonly enabled: boolean;
+ readonly sentinels: readonly WebProbeSentinelRegistryRow[];
readonly refs: readonly WebProbeSentinelConfigRefStatus[];
readonly conflicts: readonly string[];
readonly next: Record;
@@ -165,41 +169,64 @@ const REQUIRED_TARGET_SHAPES: Record 1) {
+ const enabled = registry.some((item) => item.enabled);
+ return {
+ ok: enabled,
+ command,
+ status: enabled ? "ready" : "disabled",
+ node: spec.nodeId,
+ lane: spec.lane,
+ rootPath: registryPath,
+ sentinelId: null,
+ enabled,
+ sentinels: registry,
+ refs: [],
+ conflicts: [],
+ next: sentinelNext(spec.nodeId, spec.lane, registry[0]?.id ?? null),
+ valuesRedacted: true,
+ };
+ }
+ if (registry.length === 0) {
return {
ok: false,
command,
status: "blocked",
node: spec.nodeId,
lane: spec.lane,
- rootPath: rootConfigPath,
+ rootPath: registryPath,
+ sentinelId,
enabled: false,
+ sentinels: [],
refs: [],
- conflicts: [`${rootConfigPath} is missing`],
- next: sentinelNext(spec.nodeId, spec.lane),
+ conflicts: [`${registryPath} is missing`],
+ next: sentinelNext(spec.nodeId, spec.lane, sentinelId),
valuesRedacted: true,
};
}
- const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, sentinel.configRefs[key]));
- const conflicts = sentinel.enabled ? crossReferenceConflicts(spec, refs) : [];
+ const selected = resolveWebProbeSentinel(spec, sentinelId);
+ const refs = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => readSentinelConfigRef(key, selected.configRefs[key]));
+ const conflicts = selected.enabled ? crossReferenceConflicts(spec, refs) : [];
const refBlocked = refs.some((ref) => !ref.present || !ref.targetPresent || ref.missingFields.length > 0 || ref.conflicts.length > 0 || ref.error !== null);
- const ok = sentinel.enabled && !refBlocked && conflicts.length === 0;
+ const ok = selected.enabled && !refBlocked && conflicts.length === 0;
return {
ok,
command,
- status: sentinel.enabled ? ok ? "ready" : "blocked" : "disabled",
+ status: selected.enabled ? ok ? "ready" : "blocked" : "disabled",
node: spec.nodeId,
lane: spec.lane,
- rootPath: rootConfigPath,
- enabled: sentinel.enabled,
+ rootPath: selected.rootPath,
+ sentinelId: selected.id,
+ enabled: selected.enabled,
+ sentinels: registry,
refs: refs.map(stripInternalTarget),
conflicts,
- next: sentinelNext(spec.nodeId, spec.lane),
+ next: sentinelNext(spec.nodeId, spec.lane, selected.id),
valuesRedacted: true,
};
}
@@ -280,9 +307,10 @@ function emptyRefStatus(key: HwlabRuntimeWebProbeSentinelConfigRefKey, ref: stri
function missingFieldsForTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string[] {
const shape = REQUIRED_TARGET_SHAPES[key];
if (shape.kind === "array") {
- if (!Array.isArray(target)) return [`expected ${shape.kind}`];
- if (target.length === 0) return ["[0]"];
- return target.flatMap((item, index) => shape.requiredPaths
+ const items = key === "scenarios" && isRecord(target) ? [target] : Array.isArray(target) ? target : null;
+ if (items === null) return [`expected ${shape.kind}`];
+ if (items.length === 0) return ["[0]"];
+ return items.flatMap((item, index) => shape.requiredPaths
.filter((path) => valueAtPath(item, path) === undefined)
.map((path) => `[${index}].${path}`));
}
@@ -294,7 +322,7 @@ function crossReferenceConflicts(spec: HwlabRuntimeLaneSpec, refs: readonly Inte
const byKey = new Map(refs.map((ref) => [ref.key, ref]));
const conflicts: string[] = [];
const runtime = recordTarget(byKey.get("runtime"));
- const scenarios = arrayTarget(byKey.get("scenarios"));
+ const scenarios = scenarioTargets(byKey.get("scenarios"));
const promptSet = recordTarget(byKey.get("promptSet"));
const cicd = recordTarget(byKey.get("cicd"));
const secrets = recordTarget(byKey.get("secrets"));
@@ -369,10 +397,11 @@ function stripInternalTarget(ref: InternalConfigRefStatus): WebProbeSentinelConf
function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target: unknown): string {
if (target === undefined) return "target=missing";
- if (key === "scenarios" && Array.isArray(target)) {
- const ids = target.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4);
- const cadences = target.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4);
- const checks = target.flatMap((item) => arrayAt(item, "sessionInvarianceChecks"));
+ if (key === "scenarios") {
+ const items = isRecord(target) ? [target] : Array.isArray(target) ? target : [];
+ const ids = items.map((item) => stringAt(item, "id")).filter((item): item is string => item !== null).slice(0, 4);
+ const cadences = items.map((item) => stringAt(item, "cadence")).filter((item): item is string => item !== null).slice(0, 4);
+ const checks = items.flatMap((item) => arrayAt(item, "sessionInvarianceChecks"));
const afterRounds = checks
.map((item) => {
const value = isRecord(item) ? item.afterRound : null;
@@ -380,7 +409,7 @@ function summarizeTarget(key: HwlabRuntimeWebProbeSentinelConfigRefKey, target:
})
.filter((item): item is string => item !== null)
.slice(0, 8);
- return `items=${target.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`;
+ return `items=${items.length} ids=${ids.join(",") || "-"} cadence=${cadences.join(",") || "-"} sessionInvarianceChecks=${checks.length} afterRound=${afterRounds.join(",") || "-"}`;
}
if (!isRecord(target)) return `kind=${targetKindOf(target)}`;
if (key === "runtime") return `namespace=${textAt(target, "namespace")} service=${textAt(target, "serviceName")} image=${short(textAt(target, "imageRef"), 48)}`;
@@ -408,7 +437,19 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
return [
`web-probe sentinel ${commandAction(value.command)} (${value.status})`,
"",
- sentinelTable(["NODE", "LANE", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.enabled, value.ok, value.rootPath]]),
+ sentinelTable(["NODE", "LANE", "SENTINEL", "ENABLED", "OK", "ROOT"], [[value.node, value.lane, value.sentinelId ?? "registry", value.enabled, value.ok, value.rootPath]]),
+ ...(value.sentinels.length === 0 ? [] : [
+ "",
+ sentinelTable(
+ ["SENTINEL", "ENABLED", "CONFIG_REF"],
+ value.sentinels.map((item) => [item.id, item.enabled, short(item.configRef, 110)]),
+ ),
+ ]),
+ ...(value.refs.length === 0 ? [
+ "",
+ "DRILL_DOWN",
+ ...value.sentinels.map((item) => ` ${item.id}: bun scripts/cli.ts web-probe sentinel ${commandAction(value.command)} --node ${value.node} --lane ${value.lane} --sentinel ${item.id}`),
+ ] : [
"",
sentinelTable(
["KEY", "PRESENT", "TARGET", "TYPE", "HASH", "MISSING", "SUMMARY"],
@@ -427,6 +468,7 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
["KEY", "FILE", "PATH", "BYTES"],
value.refs.map((ref) => [ref.key, ref.file, ref.path, ref.byteCount ?? "-"]),
),
+ ]),
...blocked,
"",
"NEXT",
@@ -437,10 +479,11 @@ function renderWebProbeSentinelConfigPlan(value: WebProbeSentinelConfigPlan): st
].join("\n");
}
-function sentinelNext(node: string, lane: string): Record {
+function sentinelNext(node: string, lane: string, sentinelId: string | null): Record {
+ const suffix = sentinelId === null ? "" : ` --sentinel ${sentinelId}`;
return {
- plan: `bun scripts/cli.ts web-probe sentinel plan --node ${node} --lane ${lane} --dry-run`,
- status: `bun scripts/cli.ts web-probe sentinel status --node ${node} --lane ${lane}`,
+ plan: `bun scripts/cli.ts web-probe sentinel plan --node ${node} --lane ${lane}${suffix} --dry-run`,
+ status: `bun scripts/cli.ts web-probe sentinel status --node ${node} --lane ${lane}${suffix}`,
};
}
@@ -487,6 +530,12 @@ function arrayTarget(ref: InternalConfigRefStatus | undefined): Record[] {
+ if (ref === undefined) return [];
+ if (Array.isArray(ref.target)) return ref.target.filter(isRecord);
+ return isRecord(ref.target) ? [ref.target] : [];
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
diff --git a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts
index 5c038836..2a937103 100644
--- a/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts
+++ b/scripts/src/hwlab-node-web-sentinel-dashboard-assets.ts
@@ -1,4 +1,5 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Static dashboard shell and asset serving for the web-probe sentinel frontend.
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
@@ -6,6 +7,7 @@ import { rootPath } from "./config";
interface DashboardShellConfig {
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string;
readonly plan: { readonly ok: boolean };
readonly publicExposure: Record;
}
@@ -15,13 +17,14 @@ const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
export function renderWebProbeSentinelDashboardHtml(config: DashboardShellConfig): string {
const publicOrigin = stringOrNull(config.publicExposure.publicBaseUrl) ?? "";
+ const basePath = publicBasePath(publicOrigin);
return `
HWLAB Web哨兵
-
+
已复制
-
+
`;
}
+function publicBasePath(publicBaseUrl: string): string {
+ try {
+ const path = new URL(publicBaseUrl).pathname.replace(/\/+$/u, "");
+ return path === "/" ? "" : path;
+ } catch {
+ return "";
+ }
+}
+
export function webProbeSentinelDashboardAssetResponse(pathname: string): Response | null {
if (pathname === "/dashboard/assets/dashboard.css") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.css`, "text/css; charset=utf-8");
if (pathname === "/dashboard/assets/dashboard.js") return textAsset(`${DASHBOARD_ASSET_ROOT}/dashboard.js`, "application/javascript; charset=utf-8");
diff --git a/scripts/src/hwlab-node-web-sentinel-resolver.ts b/scripts/src/hwlab-node-web-sentinel-resolver.ts
new file mode 100644
index 00000000..727ab839
--- /dev/null
+++ b/scripts/src/hwlab-node-web-sentinel-resolver.ts
@@ -0,0 +1,154 @@
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
+// Responsibility: Resolve YAML-first web-probe sentinel registry entries into one selected sentinel config graph.
+import { existsSync, readFileSync } from "node:fs";
+import { rootPath } from "./config";
+import { HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS, type HwlabRuntimeLaneSpec, type HwlabRuntimeWebProbeSentinelConfigRefKey, type HwlabRuntimeWebProbeSentinelRegistryItemSpec } from "./hwlab-node-lanes";
+
+export interface ResolvedWebProbeSentinel {
+ readonly id: string;
+ readonly enabled: boolean;
+ readonly mode: "registry" | "legacy";
+ readonly rootPath: string;
+ readonly configRef: string | null;
+ readonly configRefs: Record;
+ readonly target: Record;
+}
+
+export interface WebProbeSentinelRegistryRow {
+ readonly id: string;
+ readonly enabled: boolean;
+ readonly configRef: string;
+}
+
+export function webProbeSentinelRegistryRows(spec: HwlabRuntimeLaneSpec): readonly WebProbeSentinelRegistryRow[] {
+ const registry = spec.observability.webProbe?.sentinels;
+ if (registry !== undefined) return registry.map((item) => ({ id: item.id, enabled: item.enabled, configRef: item.configRef }));
+ const legacy = spec.observability.webProbe?.sentinel;
+ if (legacy === undefined) return [];
+ return [{
+ id: "workbench-dsflash-go-tool-call-10x",
+ enabled: legacy.enabled,
+ configRef: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`,
+ }];
+}
+
+export function resolveWebProbeSentinel(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined): ResolvedWebProbeSentinel {
+ const registry = spec.observability.webProbe?.sentinels;
+ if (registry !== undefined) return resolveRegistrySentinel(spec, registry, sentinelId ?? null);
+ const legacy = spec.observability.webProbe?.sentinel;
+ if (legacy === undefined) {
+ throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels is missing`);
+ }
+ const id = sentinelId ?? "workbench-dsflash-go-tool-call-10x";
+ return {
+ id,
+ enabled: legacy.enabled,
+ mode: "legacy",
+ rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel`,
+ configRef: null,
+ configRefs: legacy.configRefs,
+ target: {
+ id,
+ configRefs: legacy.configRefs,
+ valuesRedacted: true,
+ },
+ };
+}
+
+export function requireSentinelIdForRegistry(spec: HwlabRuntimeLaneSpec, sentinelId: string | null | undefined, command: string): void {
+ const registry = spec.observability.webProbe?.sentinels;
+ if (registry !== undefined && registry.length > 1 && (sentinelId === null || sentinelId === undefined || sentinelId.length === 0)) {
+ const ids = registry.map((item) => item.id).join(", ");
+ throw new Error(`${command} requires --sentinel when multiple sentinels are declared; available: ${ids}`);
+ }
+}
+
+function resolveRegistrySentinel(spec: HwlabRuntimeLaneSpec, registry: readonly HwlabRuntimeWebProbeSentinelRegistryItemSpec[], sentinelId: string | null): ResolvedWebProbeSentinel {
+ const selected = sentinelId === null && registry.length === 1
+ ? registry[0]
+ : registry.find((item) => item.id === sentinelId);
+ if (selected === undefined) {
+ const ids = registry.map((item) => item.id).join(", ");
+ throw new Error(`unknown web-probe sentinel ${sentinelId ?? "-"} for ${spec.nodeId}/${spec.lane}; available: ${ids}`);
+ }
+ const target = readConfigRefRecord(selected.configRef);
+ const targetId = optionalStringAt(target, "id") ?? selected.id;
+ if (targetId !== selected.id) {
+ throw new Error(`${selected.configRef}.id=${targetId} does not match registry id ${selected.id}`);
+ }
+ return {
+ id: selected.id,
+ enabled: selected.enabled,
+ mode: "registry",
+ rootPath: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinels.${selected.id}`,
+ configRef: selected.configRef,
+ configRefs: normalizeSentinelConfigRefs(target, selected.configRef),
+ target,
+ };
+}
+
+function normalizeSentinelConfigRefs(target: Record, ref: string): Record {
+ const rawRefs = recordAt(target, "configRefs");
+ const normalized: Record = {};
+ for (const key of Object.keys(rawRefs)) {
+ const value = rawRefs[key];
+ if (typeof value === "string" && value.length > 0) normalized[key] = value;
+ }
+ if (normalized.scenarios === undefined && normalized.workflow !== undefined) normalized.scenarios = normalized.workflow;
+ const missing = HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.filter((key) => normalized[key] === undefined);
+ if (missing.length > 0) throw new Error(`${ref}.configRefs is missing ${missing.join(",")}`);
+ return Object.fromEntries(HWLAB_WEB_PROBE_SENTINEL_CONFIG_REF_KEYS.map((key) => [key, normalized[key]])) as Record;
+}
+
+function readConfigRefRecord(ref: string): Record {
+ const target = readConfigRefTarget(ref);
+ if (!isRecord(target)) throw new Error(`${ref} must point to a YAML object`);
+ return target;
+}
+
+export function readConfigRefTarget(ref: string): unknown {
+ const [file, path, extra] = ref.split("#");
+ if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) {
+ throw new Error(`${ref} must use path/to/file.yaml#object.path syntax`);
+ }
+ if (file.startsWith("/") || file.includes("\0") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) {
+ throw new Error(`${ref} must reference a repo-relative config/*.yaml file without ..`);
+ }
+ const absPath = rootPath(file);
+ if (!existsSync(absPath)) throw new Error(`${file} does not exist`);
+ const doc = Bun.YAML.parse(readFileSync(absPath, "utf8")) as unknown;
+ return valueAtPath(doc, path);
+}
+
+function recordAt(value: unknown, path: string): Record {
+ const found = valueAtPath(value, path);
+ if (!isRecord(found)) throw new Error(`${path} must be an object`);
+ return found;
+}
+
+function optionalStringAt(value: unknown, path: string): string | null {
+ const found = valueAtPath(value, path);
+ return typeof found === "string" && found.length > 0 ? found : null;
+}
+
+function valueAtPath(value: unknown, path: string): unknown {
+ let current: unknown = value;
+ for (const segment of path.split(".")) {
+ if (segment.length === 0) return undefined;
+ const match = /^(?:([A-Za-z0-9_-]+))?(?:\[(\d+)\])?$/u.exec(segment);
+ if (match === null) return undefined;
+ if (match[1] !== undefined) {
+ if (!isRecord(current)) return undefined;
+ current = current[match[1]];
+ }
+ if (match[2] !== undefined) {
+ if (!Array.isArray(current)) return undefined;
+ current = current[Number(match[2])];
+ }
+ }
+ return current;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
diff --git a/scripts/src/hwlab-node-web-sentinel-service.ts b/scripts/src/hwlab-node-web-sentinel-service.ts
index e340fc9a..67ba9dc0 100644
--- a/scripts/src/hwlab-node-web-sentinel-service.ts
+++ b/scripts/src/hwlab-node-web-sentinel-service.ts
@@ -1,6 +1,7 @@
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p7-web-probe-sentinel-dashboard.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-desktop-view-density.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Persistent HTTP wrapper service for web-probe observe scheduling, index, health, metrics, maintenance, and dashboard.
import { Buffer } from "node:buffer";
import { createHash, randomUUID } from "node:crypto";
@@ -11,6 +12,7 @@ import { rootPath } from "./config";
import { renderWebProbeSentinelDashboardHtml, webProbeSentinelDashboardAssetResponse } from "./hwlab-node-web-sentinel-dashboard-assets";
import { webProbeSentinelConfigPlan, type WebProbeSentinelConfigPlan } from "./hwlab-node-web-sentinel-config";
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
+import { resolveWebProbeSentinel, readConfigRefTarget as readSentinelConfigRefTarget } from "./hwlab-node-web-sentinel-resolver";
const DASHBOARD_CONTRACT_VERSION = "draft-2026-06-26-p9-desktop-view-density";
const DASHBOARD_MAX_TEXT_BYTES = 16_000;
@@ -18,6 +20,7 @@ const DASHBOARD_MAX_TEXT_BYTES = 16_000;
export interface WebProbeSentinelServiceConfig {
readonly node: string;
readonly lane: string;
+ readonly sentinelId: string;
readonly plan: WebProbeSentinelConfigPlan;
readonly runtime: Record;
readonly scenarios: readonly Record[];
@@ -35,6 +38,7 @@ export interface WebProbeSentinelServiceConfig {
export interface WebProbeSentinelServiceOptions {
readonly spec: HwlabRuntimeLaneSpec;
+ readonly sentinelId?: string | null;
readonly stateRootOverride?: string;
readonly portOverride?: number;
readonly hostOverride?: string;
@@ -83,19 +87,19 @@ export interface WebProbeSentinelService {
}
export function loadWebProbeSentinelServiceConfig(spec: HwlabRuntimeLaneSpec, options: Omit = {}): WebProbeSentinelServiceConfig {
- const sentinel = spec.observability.webProbe?.sentinel;
- if (sentinel === undefined) throw new Error(`config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}.observability.webProbe.sentinel is missing`);
- const plan = webProbeSentinelConfigPlan(spec, "status");
- const runtime = recordTarget(readConfigRefTarget(sentinel.configRefs.runtime));
- const scenarios = arrayTarget(readConfigRefTarget(sentinel.configRefs.scenarios));
- const reportViews = recordTarget(readConfigRefTarget(sentinel.configRefs.reportViews));
- const publicExposure = recordTarget(readConfigRefTarget(sentinel.configRefs.publicExposure));
- const cicd = recordTarget(readConfigRefTarget(sentinel.configRefs.cicd));
+ const sentinel = resolveWebProbeSentinel(spec, options.sentinelId ?? null);
+ const plan = webProbeSentinelConfigPlan(spec, "status", sentinel.id);
+ const runtime = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.runtime));
+ const scenarios = scenarioArrayTarget(readSentinelConfigRefTarget(sentinel.configRefs.scenarios));
+ const reportViews = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.reportViews));
+ const publicExposure = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.publicExposure));
+ const cicd = recordTarget(readSentinelConfigRefTarget(sentinel.configRefs.cicd));
const stateRoot = options.stateRootOverride ?? stringAt(runtime, "stateRoot");
const yamlSqlitePath = stringAt(runtime, "sqlite.path");
return {
node: spec.nodeId,
lane: spec.lane,
+ sentinelId: sentinel.id,
plan,
runtime,
scenarios,
@@ -167,6 +171,7 @@ export function createWebProbeSentinelService(options: WebProbeSentinelServiceOp
ok: true,
node: config.node,
lane: config.lane,
+ sentinelId: config.sentinelId,
status: "observed",
configReady: config.plan.ok,
scheduler: schedulerSummary(config, db),
@@ -388,7 +393,7 @@ function serviceHealth(config: WebProbeSentinelServiceConfig, db: Database, sche
command: `bun scripts/cli.ts web-probe observe analyze --node ${config.node} --lane ${config.lane} --state-dir `,
};
const ok = Object.values(checks).every((check) => check.ok === true);
- return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, checks, valuesRedacted: true };
+ return { ok, status: ok ? "healthy" : "degraded", node: config.node, lane: config.lane, sentinelId: config.sentinelId, checks, valuesRedacted: true };
}
function checkWritable(stateRoot: string): Record {
@@ -432,6 +437,16 @@ function buildObserveCommandPlan(config: WebProbeSentinelServiceConfig, scenario
const argv = ["bun", "scripts/cli.ts", "web-probe", "observe", "command", "", "--type", type];
if (type === "selectProvider") argv.push("--provider", stringAt(item, "provider"));
if (type === "sendPrompt") argv.push("--text-stdin");
+ if (type === "loginAccount" || type === "listSessions" || type === "logout") {
+ const accountId = stringOrNull(item.accountId);
+ if (accountId !== null) argv.push("--account-id", accountId);
+ }
+ if (type === "switchSessions") {
+ const fromAccountId = stringOrNull(item.fromAccountId);
+ const toAccountId = stringOrNull(item.toAccountId);
+ if (fromAccountId !== null) argv.push("--from-account-id", fromAccountId);
+ if (toAccountId !== null) argv.push("--to-account-id", toAccountId);
+ }
return { phase: `observe-command-${type}`, argv, stdinSource: type === "sendPrompt" ? "prompt-source" : "none" } satisfies CommandPlanStep;
});
const analyze: CommandPlanStep = {
@@ -459,28 +474,29 @@ function renderMetrics(config: WebProbeSentinelServiceConfig, db: Database, heal
const heartbeat = record(readMetadata(db, "scheduler.heartbeat"));
const heartbeatAt = stringOrNull(heartbeat.at);
const heartbeatAge = heartbeatAt === null ? -1 : Math.max(0, Math.round((Date.now() - Date.parse(heartbeatAt)) / 1000));
+ const labels = `node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}",sentinel="${metricLabel(config.sentinelId)}"`;
const lines = [
"# HELP web_probe_sentinel_config_ready Config reference graph is ready.",
"# TYPE web_probe_sentinel_config_ready gauge",
- `web_probe_sentinel_config_ready{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${config.plan.ok ? 1 : 0}`,
+ `web_probe_sentinel_config_ready{${labels}} ${config.plan.ok ? 1 : 0}`,
"# HELP web_probe_sentinel_health Healthy status of the sentinel service.",
"# TYPE web_probe_sentinel_health gauge",
- `web_probe_sentinel_health{node="${metricLabel(config.node)}",lane="${metricLabel(config.lane)}"} ${health.ok === true ? 1 : 0}`,
+ `web_probe_sentinel_health{${labels}} ${health.ok === true ? 1 : 0}`,
"# HELP web_probe_sentinel_runs_total Runs indexed by status.",
"# TYPE web_probe_sentinel_runs_total gauge",
- ...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{status="${metricLabel(status)}"} ${count}`),
+ ...Object.entries(counts).map(([status, count]) => `web_probe_sentinel_runs_total{${labels},status="${metricLabel(status)}"} ${count}`),
"# HELP web_probe_sentinel_active_runs Active observe runs known to the sentinel index.",
"# TYPE web_probe_sentinel_active_runs gauge",
- `web_probe_sentinel_active_runs ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
+ `web_probe_sentinel_active_runs{${labels}} ${countWhere(db, "status IN ('queued', 'running', 'analyzing')")}`,
"# HELP web_probe_sentinel_recent_findings Findings indexed from recent reports.",
"# TYPE web_probe_sentinel_recent_findings gauge",
- `web_probe_sentinel_recent_findings ${sumColumn(db, "runs", "finding_count")}`,
+ `web_probe_sentinel_recent_findings{${labels}} ${sumColumn(db, "runs", "finding_count")}`,
"# HELP web_probe_sentinel_maintenance_active Maintenance window active flag.",
"# TYPE web_probe_sentinel_maintenance_active gauge",
- `web_probe_sentinel_maintenance_active ${maintenance.active ? 1 : 0}`,
+ `web_probe_sentinel_maintenance_active{${labels}} ${maintenance.active ? 1 : 0}`,
"# HELP web_probe_sentinel_scheduler_heartbeat_age_seconds Scheduler heartbeat age.",
"# TYPE web_probe_sentinel_scheduler_heartbeat_age_seconds gauge",
- `web_probe_sentinel_scheduler_heartbeat_age_seconds ${heartbeatAge}`,
+ `web_probe_sentinel_scheduler_heartbeat_age_seconds{${labels}} ${heartbeatAge}`,
];
return `${lines.join("\n")}\n`;
}
@@ -525,6 +541,7 @@ function dashboardOverview(config: WebProbeSentinelServiceConfig, db: Database,
status: dashboardOverallStatus(health, latestRun, severityCounts),
node: config.node,
lane: config.lane,
+ sentinelId: config.sentinelId,
publicOrigin: stringOrNull(config.publicExposure.publicBaseUrl),
configReady: config.plan.ok,
health,
@@ -566,6 +583,7 @@ function dashboardRunList(config: WebProbeSentinelServiceConfig, db: Database, u
contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node,
lane: config.lane,
+ sentinelId: config.sentinelId,
filters,
page: {
limit: page.limit,
@@ -594,6 +612,7 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
return {
ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION,
+ sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row),
summary: record(stored.summary),
findings,
@@ -605,9 +624,9 @@ function dashboardRunDetail(config: WebProbeSentinelServiceConfig, db: Database,
publicOrigin: stringOrNull(stored.publicOrigin),
},
commands: {
- summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view summary`,
- turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view turn-summary`,
- traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --run ${runId} --view trace-frame`,
+ summary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view summary`,
+ turnSummary: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view turn-summary`,
+ traceFrame: `bun scripts/cli.ts web-probe sentinel report --node ${config.node} --lane ${config.lane} --sentinel ${config.sentinelId} --run ${runId} --view trace-frame`,
},
redaction: record(config.reportViews.redaction),
traceability: runTraceability(config, row),
@@ -651,6 +670,7 @@ function dashboardFindings(config: WebProbeSentinelServiceConfig, db: Database,
contractVersion: DASHBOARD_CONTRACT_VERSION,
node: config.node,
lane: config.lane,
+ sentinelId: config.sentinelId,
filters,
page: {
limit,
@@ -677,6 +697,7 @@ function dashboardRunViews(config: WebProbeSentinelServiceConfig, db: Database,
return {
ok: true,
contractVersion: DASHBOARD_CONTRACT_VERSION,
+ sentinelId: config.sentinelId,
run: dashboardRunSummary(config, db, row),
views,
view: view ?? null,
@@ -717,6 +738,7 @@ function dashboardRunSummary(config: WebProbeSentinelServiceConfig, db: Database
status: stringOrNull(row.status),
node: stringOrNull(row.node) ?? config.node,
lane: stringOrNull(row.lane) ?? config.lane,
+ sentinelId: config.sentinelId,
observer_id: stringOrNull(row.observer_id),
observerId: stringOrNull(row.observer_id),
state_dir: stringOrNull(row.state_dir),
@@ -1233,6 +1255,11 @@ function arrayTarget(value: unknown): Record[] {
return value.map(recordTarget);
}
+function scenarioArrayTarget(value: unknown): Record[] {
+ if (Array.isArray(value)) return value.map(recordTarget);
+ return [recordTarget(value)];
+}
+
function valueAtPath(value: unknown, path: string): unknown {
let current: unknown = value;
for (const segment of path.split(".")) {
diff --git a/scripts/src/hwlab-node/entry.ts b/scripts/src/hwlab-node/entry.ts
index 7979ba62..5b464156 100644
--- a/scripts/src/hwlab-node/entry.ts
+++ b/scripts/src/hwlab-node/entry.ts
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -113,6 +114,10 @@ export type NodeWebProbeObserveAction = "start" | "status" | "command" | "stop"
export type NodeWebProbeObserveCommandType =
| "login"
+ | "loginAccount"
+ | "logout"
+ | "listSessions"
+ | "switchSessions"
| "preflight"
| "goto"
| "gotoProjectMdtodo"
@@ -199,6 +204,9 @@ export interface NodeWebProbeObserveOptions {
commandRequireComposerReady: boolean;
commandFindingId: string | null;
commandBlocking: boolean | null;
+ commandAccountId: string | null;
+ commandFromAccountId: string | null;
+ commandToAccountId: string | null;
commandSourceId: string | null;
commandFileRef: string | null;
commandFilename: string | null;
diff --git a/scripts/src/hwlab-node/web-probe-observe.ts b/scripts/src/hwlab-node/web-probe-observe.ts
index a4caabe2..d955f795 100644
--- a/scripts/src/hwlab-node/web-probe-observe.ts
+++ b/scripts/src/hwlab-node/web-probe-observe.ts
@@ -4,6 +4,7 @@
// SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: YAML-first node/lane operations, including Workbench observability control commands.
import { createHash, randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
@@ -60,11 +61,15 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
"--run-id",
"--trace-id",
"--sample-seq",
+ "--sentinel",
+ "--sentinel-id",
]), new Set(["--dry-run", "--confirm", "--wait", "--quick-verify", "--raw", "--latest"]));
const node = requiredOption(args, "--node");
assertNodeId(node);
const lane = requiredOption(args, "--lane");
assertLane(lane);
+ const sentinelId = optionValue(args, "--sentinel") ?? optionValue(args, "--sentinel-id") ?? null;
+ if (sentinelId !== null && !/^[a-z0-9][a-z0-9-]{1,80}$/u.test(sentinelId)) throw new Error(`--sentinel must be a stable lowercase sentinel id, got ${sentinelId}`);
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe only supports HWLAB runtime lanes, got ${lane}`);
const confirm = args.includes("--confirm");
const dryRun = args.includes("--dry-run");
@@ -72,17 +77,17 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
const timeoutSeconds = positiveIntegerOption(args, "--timeout-seconds", 900, 3600);
let sentinel: WebProbeSentinelOptions;
if (sentinelActionRaw === "plan" || sentinelActionRaw === "status") {
- sentinel = { kind: "config", action: sentinelActionRaw, node, lane, dryRun };
+ sentinel = { kind: "config", action: sentinelActionRaw, node, lane, sentinelId, dryRun };
} else if (sentinelActionRaw === "image") {
const imageAction = args[1];
if (imageAction !== "status" && imageAction !== "build") throw new Error("web-probe sentinel image usage: image status|build --node NODE --lane vNN [--dry-run|--confirm]");
- sentinel = { kind: "image", action: imageAction, node, lane, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
+ sentinel = { kind: "image", action: imageAction, node, lane, sentinelId, dryRun: imageAction === "build" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
} else if (sentinelActionRaw === "control-plane") {
const controlPlaneAction = args[1];
if (controlPlaneAction !== "plan" && controlPlaneAction !== "apply" && controlPlaneAction !== "status" && controlPlaneAction !== "trigger-current") {
throw new Error("web-probe sentinel control-plane usage: control-plane plan|apply|status|trigger-current --node NODE --lane vNN [--dry-run|--confirm]");
}
- sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
+ sentinel = { kind: "control-plane", action: controlPlaneAction, node, lane, sentinelId, dryRun: controlPlaneAction === "apply" || controlPlaneAction === "trigger-current" ? dryRun || !confirm : dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds };
} else if (sentinelActionRaw === "maintenance") {
const maintenanceAction = args[1];
if (maintenanceAction !== "status" && maintenanceAction !== "start" && maintenanceAction !== "stop") {
@@ -93,6 +98,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
action: maintenanceAction,
node,
lane,
+ sentinelId,
dryRun: maintenanceAction === "status" ? dryRun : dryRun || !confirm,
confirm,
wait: args.includes("--wait"),
@@ -102,7 +108,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
quickVerify: maintenanceAction === "stop" || args.includes("--quick-verify"),
};
} else if (sentinelActionRaw === "validate") {
- sentinel = { kind: "validate", action: "validate", node, lane, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") };
+ sentinel = { kind: "validate", action: "validate", node, lane, sentinelId, dryRun, confirm, wait: args.includes("--wait"), timeoutSeconds, quickVerify: args.includes("--quick-verify") };
} else {
const view = parseWebProbeSentinelReportView(optionValue(args, "--view") ?? "summary");
const latest = args.includes("--latest");
@@ -116,6 +122,7 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
action: "report",
node,
lane,
+ sentinelId,
view,
runId,
latest,
@@ -134,8 +141,8 @@ export function parseNodeWebProbeSentinelOptions(args: string[]): NodeWebProbeSe
}
function parseWebProbeSentinelReportView(value: string): WebProbeSentinelReportView {
- if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame") return value;
- throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, or trace-frame; got ${value}`);
+ if (value === "summary" || value === "turn-summary" || value === "findings" || value === "trace-frame" || value === "auth-session-switch-summary") return value;
+ throw new Error(`web-probe sentinel report --view must be summary, turn-summary, findings, trace-frame, or auth-session-switch-summary; got ${value}`);
}
export function normalizeNodeWebProbeObserveArgs(args: string[]): { args: string[]; id: string | null } {
@@ -202,6 +209,10 @@ export function parseNodeWebProbeObserveOptions(
"--label",
"--session-id",
"--provider",
+ "--account-id",
+ "--account",
+ "--from-account-id",
+ "--to-account-id",
"--after-round",
"--severity",
"--alternate-session-strategy",
@@ -272,6 +283,9 @@ export function parseNodeWebProbeObserveOptions(
}
const commandText = commandTextFromStdin ? readFileSync(0, "utf8") : commandTextOption;
const commandSourceId = optionValue(args, "--source-id") ?? null;
+ const commandAccountId = optionValue(args, "--account-id") ?? optionValue(args, "--account") ?? null;
+ const commandFromAccountId = optionValue(args, "--from-account-id") ?? null;
+ const commandToAccountId = optionValue(args, "--to-account-id") ?? null;
const commandFileRef = optionValue(args, "--file-ref") ?? null;
const commandFilename = optionValue(args, "--filename") ?? null;
const commandTaskRef = optionValue(args, "--task-ref") ?? null;
@@ -301,6 +315,9 @@ export function parseNodeWebProbeObserveOptions(
["--expected-sentinel-range", commandExpectedSentinelRange],
["--finding-id", commandFindingId],
["--source-id", commandSourceId],
+ ["--account-id/--account", commandAccountId],
+ ["--from-account-id", commandFromAccountId],
+ ["--to-account-id", commandToAccountId],
["--file-ref", commandFileRef],
["--filename", commandFilename],
["--task-ref", commandTaskRef],
@@ -366,6 +383,9 @@ export function parseNodeWebProbeObserveOptions(
commandRequireComposerReady: args.includes("--require-composer-ready"),
commandFindingId,
commandBlocking,
+ commandAccountId,
+ commandFromAccountId,
+ commandToAccountId,
commandSourceId,
commandFileRef,
commandFilename,
@@ -386,6 +406,10 @@ export function parseNodeWebProbeObserveOptions(
export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbeObserveCommandType {
if (
value === "login"
+ || value === "loginAccount"
+ || value === "logout"
+ || value === "listSessions"
+ || value === "switchSessions"
|| value === "preflight"
|| value === "goto"
|| value === "newSession"
@@ -423,7 +447,7 @@ export function parseNodeWebProbeObserveCommandType(value: string): NodeWebProbe
|| value === "mark"
|| value === "stop"
) return value;
- throw new Error(`web-probe observe command --type must be login, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`);
+ throw new Error(`web-probe observe command --type must be login, loginAccount, logout, listSessions, switchSessions, preflight, goto, gotoProjectMdtodo, newSession, sendPrompt, steer, cancel, selectProvider, clickSession, refreshCurrentSession, switchAwayAndBack, assertSessionInvariant, selectProjectSource, selectMdtodoSource, selectMdtodoFile, selectMdtodoTask, expandMdtodoTask, openMdtodoReportPreview, toggleMdtodoReportFullscreen, openMdtodoSourceConfig, configureMdtodoHwpodSource, probeMdtodoSource, reindexMdtodoSource, editMdtodoTaskInline, editMdtodoTaskTitle, editMdtodoTaskBody, toggleMdtodoTaskStatus, addMdtodoRootTask, addMdtodoSubTask, continueMdtodoTask, deleteMdtodoTask, launchWorkbenchFromTask, launchWorkbenchFromMdtodo, screenshot, mark, or stop; got ${value}`);
}
export function parseWebProbeBrowserProxyMode(value: string | undefined): WebProbeBrowserProxyMode {
@@ -1186,6 +1210,12 @@ export function normalizedProxyUrl(parsed: URL): string {
return value;
}
+function webProbeAccountEnvAssignments(): string[] {
+ return Object.entries(process.env)
+ .filter(([key, value]) => value !== undefined && /^HWLAB_WEB_(?:ACCOUNT_)?[A-Z0-9_]+_(?:JSON|USER|PASS)$/u.test(key))
+ .map(([key, value]) => `${key}=${shellQuote(value ?? "")}`);
+}
+
export function runNodeWebProbeObserve(
options: NodeWebProbeObserveOptions,
spec: HwlabRuntimeLaneSpec,
@@ -1223,6 +1253,7 @@ export function runNodeWebProbeObserveStart(
const projectManagement = nodeWebProbeProjectManagementConfig(spec);
const runnerEnvAssignments = [
...webProbeProxy.envAssignments,
+ ...webProbeAccountEnvAssignments(),
`HWLAB_WEB_BASE_URL=${shellQuote(options.url)}`,
`HWLAB_WEB_USER=${shellQuote(secretSpec.bootstrapAdminUsername)}`,
`HWLAB_WEB_PASS=${shellQuote(material.password)}`,
@@ -1382,6 +1413,9 @@ export function runNodeWebProbeObserveCommand(options: NodeWebProbeObserveOption
requireComposerReady: options.commandRequireComposerReady,
findingId: options.commandFindingId,
blocking: options.commandBlocking,
+ accountId: options.commandAccountId,
+ fromAccountId: options.commandFromAccountId,
+ toAccountId: options.commandToAccountId,
sourceId: options.commandSourceId,
fileRef: options.commandFileRef,
filename: options.commandFilename,
diff --git a/scripts/web-probe-sentinel-service.ts b/scripts/web-probe-sentinel-service.ts
index 27f0d739..81a3efd0 100644
--- a/scripts/web-probe-sentinel-service.ts
+++ b/scripts/web-probe-sentinel-service.ts
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-25-p0-web-probe-sentinel.
+// SPEC: PJ2026-01060508 Web哨兵 draft-2026-06-26-p9-multi-web-probe-sentinel.
// Responsibility: Bun entrypoint for the web-probe sentinel HTTP wrapper service.
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./src/hwlab-node-lanes";
import { createWebProbeSentinelService, startWebProbeSentinelHttpService } from "./src/hwlab-node-web-sentinel-service";
@@ -7,6 +8,7 @@ import { createWebProbeSentinelService, startWebProbeSentinelHttpService } from
const args = process.argv.slice(2);
const node = requiredOption("--node");
const lane = requiredOption("--lane");
+const sentinelId = optionValue("--sentinel") ?? optionValue("--sentinel-id") ?? null;
if (!isHwlabRuntimeLane(lane)) throw new Error(`web-probe sentinel service only supports HWLAB runtime lanes, got ${lane}`);
const spec = hwlabRuntimeLaneSpecForNode(lane, node);
const stateRootOverride = optionValue("--state-root");
@@ -15,7 +17,7 @@ const portRaw = optionValue("--port");
const portOverride = portRaw === undefined ? undefined : Number(portRaw);
if (portOverride !== undefined && (!Number.isInteger(portOverride) || portOverride <= 0 || portOverride > 65535)) throw new Error("--port must be 1-65535");
const schedulerEnabled = !args.includes("--scheduler-disabled");
-const service = createWebProbeSentinelService({ spec, stateRootOverride, hostOverride, portOverride, schedulerEnabled });
+const service = createWebProbeSentinelService({ spec, sentinelId, stateRootOverride, hostOverride, portOverride, schedulerEnabled });
if (args.includes("--once")) {
const health = service.health();
@@ -30,6 +32,7 @@ console.log(JSON.stringify({
command: "web-probe-sentinel-service",
node,
lane,
+ sentinelId: service.config.sentinelId,
url: server.url,
schedulerEnabled,
health: service.health(),