@mars.liu/dsh-cybernetics 0.8.4 → 0.8.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -4
- package/README.zh-CN.md +23 -4
- package/docs/CHANGELOG.md +31 -0
- package/docs/approval-relay-investigation.md +98 -0
- package/docs/diag-session-9e7abdf0-20261104.md +50 -0
- package/lib/client.js +84 -11
- package/package.json +1 -1
- package/src/index.ts +345 -16
package/README.md
CHANGED
|
@@ -42,9 +42,28 @@ flowchart TB
|
|
|
42
42
|
|
|
43
43
|
When enabled, injects the "ALL-GOALS MODE ACTIVE" routing protocol: **every user request first creates a goal, then executes**; the session agent is forbidden from completing tasks in place with read tools — even a single-file lookup or one calculation goes through the loop. Simple read-only requests → a single `quick` round; complex analysis / write tasks → progressive multi-round `full` (async `start` + round-by-round `collect` convergence). When the mode is off, the prompt segments and revise behavior are exactly the same as v0.6.
|
|
44
44
|
|
|
45
|
-
### Hard gate (`hardGate`, default
|
|
45
|
+
### Hard gate (`hardGate`, default true — active out of the box)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
**Effective mode (v0.8.6, default-safe).** When `hardGate` is on and `gateMode` is not configured, the gate runs in **`allowlist`** mode: the top-level session agent keeps only read + conversation + presentation + goal/orchestration verbs (the built-in `DEFAULT_GATE_ALLOWLIST`, extendable via `gateAllowlist` / `gateAllowlistExtra`), and every change-class tool is removed at assembly **and denied at dispatch**. This is the default stance because the old `denylist` default was a trap: it hid only the three names in `gatedTools` (bash/edit/write) and left `bsk_*`, `taskboard_*`, `schedule_*`, `farm_*`, `uno_*`, `ppt_*`, `config_restore` and the rest of the mutation long tail callable — a hard gate in name only. `hardGate: false` installs no gate, exactly as before. An explicitly configured `gateMode` is honored verbatim; the schema has **no default** for `gateMode`, so "unset" stays distinguishable from "explicitly denylist".
|
|
48
|
+
|
|
49
|
+
**Explicit `gateMode: 'denylist'` (residual risk, opt-in).** Only the names in `gatedTools` (default bash/edit/write) are removed. On a top-level agent the following remain available, at least: `bsk_*` (browser automation, including `bsk_session` start/stop), `taskboard_*` writes (create/update/move/comment/checklist/delete), `schedule_*` writes, `farm_*` service lifecycle (start/restart/stop/register/unregister), `uno_*` and `ppt_*` generators, `config_restore`, `subagent`/team spawning, `knowledge_save`, `job_kill` — anything not listed in `gatedTools`, and the set grows with every installed plugin, so a denylist can never be complete. Choose it only if you consciously accept that risk (e.g. the session agent legitimately manages services), and list every mutation tool you need in `gatedTools`. In this mode the injected HARD GATE guidance states the partial stance explicitly (only `gatedTools` removed) and no longer claims that all change tools are structurally unavailable.
|
|
50
|
+
|
|
51
|
+
The task/verify/triage sub agents are never subject to the gate and keep all tools.
|
|
52
|
+
|
|
53
|
+
### Sub-agent approval relay (`gateApprovalRelay`, default true)
|
|
54
|
+
|
|
55
|
+
The host pins every delegated child (workflow subagent, teammate) to the `never` approval policy at delegation — each ask is deterministically rejected **before any answerer sees it**, so a task agent that needs a sandbox escalation (typically writing outside the session workspace, e.g. `~/.dsh/profiles/web/cordis.patch.yml`) fails without the user ever being asked. With `gateApprovalRelay` on (default, hardGate companion), the plugin:
|
|
56
|
+
|
|
57
|
+
1. **Unpins the child policy at creation** — on `agent/created` for any delegated child it appends one durable `approval/policy {policy:'ask'}` event to the child session (a later event wins over the delegation-seeded pin, per the host's own fold rule). Without this the approval service short-circuits every ask to `rejected` before any answerer runs.
|
|
58
|
+
2. **Re-issues the ask on the top-level session agent** — the plugin registers its `approval/request` answerer **outermost** (`prepend: true`). That is the fix for the last hop: the host's `api-remotes` registers an `approval/request` forwarder at boot, before every user plugin, and the browser's approval answerer claims a child request and awaits a per-session panel that is invisible while the user watches the parent conversation, never calling `next()` — so a relay registered later can never run at all. Running first, the relay resolves the child's **top-level live ancestor** (via the durable `parentSession` chain) and calls the host's own `ApprovalService.request` on it: the request surfaces as the native **Allow once / Reject** panel in the conversation the user is already watching, and is audited as `approval/asked` + `approval/decided` on that session. The child's tool-call id is deliberately not forwarded, and the panel headline names the sub-agent (localized `displayReason`).
|
|
59
|
+
|
|
60
|
+
**Fallback chain and `unavailable` semantics (v0.8.8)**: if the top-level turn is already closed (a continuable child outliving it), or the native panel answers `unavailable` (no answering client claimed the frame — e.g. the browser never saw it), the relay falls through to a visible `ask_user_question` card ("子代理审批透传") on the same top-level agent — a native `unavailable` is **not** terminal. Only when the question channel is unavailable too does the request end as a terminal `unavailable`. With no live top-level agent at all it falls through to the native chain. Denial semantics stay explicit: a relay failure answers `rejected`, an aborted child run answers `cancelled`, and the child model always receives a real outcome through its approval audit pair (never a silent success).
|
|
61
|
+
|
|
62
|
+
**Observability / troubleshooting (v0.8.9)**: every relay decision is logged twice — one host-logger line, `cybernetics approval-relay: path=… outcome=… root=… elapsed=…ms` (`console.debug` fallback), and one JSON line appended to `<workspace>/.cybernetics/relay-log.jsonl` (`{ts, root, path, outcome, elapsedMs}`). The file is the acceptance ground truth, readable without host stdout or browser console access. `path` is `native-panel` (the top-level native Allow once / Reject panel), `question-card` (the visible fallback card) or `next-passthrough` (no live top-level agent → native chain). If a child fails fast with `decided unavailable` and **no** entry at all appears in `relay-log.jsonl`, the request never reached the relay (first confirm the host actually reloaded the plugin build); a `path=native-panel outcome=unavailable` entry followed by a `path=question-card` entry means the native frame left the host but no browser claimed it — the user still sees the question card. Note: the question-card fallback resolves the host's `userQuestions` service via `ctx.get` (the inject-free reflective read, same as `dsh-plan-mode`); earlier builds resolved it by plain property access, which cordis rejects for non-injected services — the v0.8.8 live failure ("card never appeared, terminal `unavailable` within 12 ms") was exactly this.
|
|
63
|
+
|
|
64
|
+
Set `gateApprovalRelay: false` to keep the strict deterministic-rejection stance (CI/unattended deployments). This field is a hardGate companion and is **non-volatile / config-file maintained**: edit it in the profile config document; a save triggers a plugin reload.
|
|
65
|
+
|
|
66
|
+
Host-side evidence (file:line), the rejected alternatives and the reproduction are recorded in [docs/approval-relay-investigation.md](docs/approval-relay-investigation.md); `test/relay-host-probe.mjs` re-runs the real cordis waterfall + real `ApprovalService` + this plugin's `apply()` as a regression probe.
|
|
48
67
|
|
|
49
68
|
### Append-only goals and the revise decision tree
|
|
50
69
|
|
|
@@ -126,7 +145,7 @@ Configure directly on this plugin's card on the Web editor's "Plugins" page —
|
|
|
126
145
|
- **All-goals mode** (`allGoalsMode`, volatile): toggle, takes effect immediately on save, prompt segments remount instantly;
|
|
127
146
|
- **task/verify/triage agent models** (`taskAgentModel` / `verifyAgentModel` / `triageAgentModel`, volatile): dropdown-backed text fields — pick from the live system model list (shown as `providerName · provider/model`) or type "provider/model" / a bare model name; Enter or blur saves, empty inherits from the session agent (triage = the convergence-decision role, full rounds only). A configured value missing from the live model list shows an unavailable warning (runtime falls back to the session agent model) with a one-click Clear;
|
|
128
147
|
- **Recursion depth / parallel cap** (`roundsPerSubgoal` integer ≥1 / `maxConcurrentRounds` integer ≥0, volatile): number fields, Enter or blur saves, blank restores the default (an `unset` write);
|
|
129
|
-
- **Hard gate fields** (`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`, non-volatile): not served to the card; edit them in the profile config document (saving triggers a plugin reload).
|
|
148
|
+
- **Hard gate fields** (`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`, non-volatile): not served to the card; edit them in the profile config document (saving triggers a plugin reload). `gateMode` has no default: unset + `hardGate: true` runs `allowlist`; an explicit `denylist` is honored and carries the residual risk documented above.
|
|
130
149
|
|
|
131
150
|
### Advanced configuration: roles (profile yaml, agent-guided)
|
|
132
151
|
|
|
@@ -185,7 +204,7 @@ Companion auditing: each round's structured result lands in `.cybernetics/round-
|
|
|
185
204
|
## Known Limitations
|
|
186
205
|
|
|
187
206
|
1. **Host defect in settings writes**: as described in the "Plugin-page configuration" section, on this runtime, card saves may be rejected by the host before reaching disk (upstream [#12045](https://github.com/deepseek-ai/dsh/discussions/12045)); workarounds: restart the host + hard-refresh the browser, or manual patch rows + restart.
|
|
188
|
-
2. **Save-semantics difference between volatile and non-volatile fields**: volatile fields (`allGoalsMode`, `taskAgentModel`, `verifyAgentModel`, `triageAgentModel`, `roundsPerSubgoal`, `maxConcurrentRounds`) take effect immediately on save without remount; non-volatile fields (`roles` since v0.8.4, `hardGate`, `gateMode`, `gatedTools`, `gateAllowlist*`, `toolName`, `implicitTrigger`) trigger a plugin-row remount on save, requiring a host reload to complete. Moreover, the host settings form only serves and only accepts volatile paths — if the running host's schema is too old, even volatile writes are rejected (a restart fixes it).
|
|
207
|
+
2. **Save-semantics difference between volatile and non-volatile fields**: volatile fields (`allGoalsMode`, `taskAgentModel`, `verifyAgentModel`, `triageAgentModel`, `roundsPerSubgoal`, `maxConcurrentRounds`) take effect immediately on save without remount; non-volatile fields (`roles` since v0.8.4, `hardGate`, `gateMode`, `gatedTools`, `gateAllowlist*`, `gateApprovalRelay`, `toolName`, `implicitTrigger`) trigger a plugin-row remount on save, requiring a host reload to complete. Moreover, the host settings form only serves and only accepts volatile paths — if the running host's schema is too old, even volatile writes are rejected (a restart fixes it).
|
|
189
208
|
3. **`link:` installation caveats**: the client bundle's registration id must equal the package.json `name` (`@mars.liu/dsh-cybernetics`); the loader row id may use the short name `dsh-cybernetics`. The plugin must hang at the **top level** of the profile's loader entries (the client scan enumerates only top-level rows); `src/` changes require a host reload, `lib/` changes require a host restart + browser refresh.
|
|
190
209
|
4. Other protocol boundaries (the hard gate operating at the assembly layer rather than the execution layer, dsh-goal's focus slot not migrating on supersede, etc.) are covered in the "Known Boundaries" subsections of [docs/protocol.md](docs/protocol.md).
|
|
191
210
|
|
package/README.zh-CN.md
CHANGED
|
@@ -42,9 +42,28 @@ flowchart TB
|
|
|
42
42
|
|
|
43
43
|
开启后注入「ALL-GOALS MODE ACTIVE」路由协议:**任何用户请求一律先建 goal 再执行**,session agent 禁止用读工具就地完成任务——哪怕单文件查询或一次计算也走回路。简单只读请求 → `quick` 单轮;复杂分析/写任务 → `full` 多轮渐进(`start` 异步启动 + 逐轮 `collect` 收敛)。关闭模式时提示段与 revise 行为与 v0.6 完全一致。
|
|
44
44
|
|
|
45
|
-
### 硬门(`hardGate`,默认
|
|
45
|
+
### 硬门(`hardGate`,默认 true——安装即激活)
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
**生效语义(v0.8.6,默认安全)**:`hardGate` 开启且未配置 `gateMode` 时,门按 **`allowlist`** 运行——顶层 session agent 只保留读 + 对话 + 展示 + goal/编排动词(内置 `DEFAULT_GATE_ALLOWLIST`,可经 `gateAllowlist`/`gateAllowlistExtra` 扩展),所有变更类工具在装配期被**移除**,并在**调用分发层被拒绝**。之所以改为这个默认:旧的 `denylist` 默认是个陷阱——它只隐藏 `gatedTools` 的三个名字(bash/edit/write),`bsk_*`、`taskboard_*`、`schedule_*`、`farm_*`、`uno_*`、`ppt_*`、`config_restore` 以及整个变更工具长尾全都仍可调用,名为硬门、实为半门。`hardGate: false` 与以前完全一致(不装门);显式配置的 `gateMode` 一律原样尊重;schema 不再给 `gateMode` 设默认值,因此「未设置」与「显式 denylist」可以被区分。
|
|
48
|
+
|
|
49
|
+
**显式 `gateMode: 'denylist'`(残留风险,需主动选择)**:只移除 `gatedTools` 中的工具名(默认 bash/edit/write)。顶层 agent 至少仍可使用:`bsk_*`(浏览器自动化,含 `bsk_session` 启停)、`taskboard_*` 写操作(create/update/move/comment/checklist/delete)、`schedule_*` 写操作、`farm_*` 服务生命周期(start/restart/stop/register/unregister)、`uno_*` 与 `ppt_*` 生成器、`config_restore`、`subagent`/team 派生、`knowledge_save`、`job_kill`——凡未被列入 `gatedTools` 的都在,且这个集合随每个新装插件增长,denylist 永远不可能完备。只有在你清醒接受该风险时才显式选择它(例如确实要 session agent 管理服务),并把需要的变更工具逐项补进 `gatedTools`。该模式下注入的 HARD GATE 提示会如实说明「只摘 `gatedTools`」的半门立场,不再假称所有改世界的工具都结构性不可用。
|
|
50
|
+
|
|
51
|
+
task/verify/triage 子 agent 从不在门禁范围内,保留全部工具。
|
|
52
|
+
|
|
53
|
+
### 子代理审批透传(`gateApprovalRelay`,默认 true)
|
|
54
|
+
|
|
55
|
+
宿主在委派边界把每个子代理(workflow 子代理、teammate)的审批策略**钉死为 `never`**——每次审批请求在任何 answerer 看到之前就被确定性拒绝,因此 task agent 需要沙箱越权(典型:写会话工作区之外的路径,如 `~/.dsh/profiles/web/cordis.patch.yml`)时用户根本没机会被询问。`gateApprovalRelay` 开启时(默认,hardGate 伴随项),插件做两件事:
|
|
56
|
+
|
|
57
|
+
1. **创建时解除子代理策略钉死**——监听 `agent/created`,对委派子代理在其 session 上追加一条持久 `approval/policy {policy:'ask'}` 事件(宿主的折叠规则保证后写事件覆盖委派种下的钉死值)。没有这一步,审批服务会在任何 answerer 运行之前就把请求确定性拒绝成 `rejected`。
|
|
58
|
+
2. **在顶层 session agent 上重新发起请求**——插件把 `approval/request` answerer 注册为**最外层**(`prepend: true`),这正是最后一跳的修复点:宿主的 `api-remotes` 在启动时(早于所有用户插件)注册了 `approval/request` 转发器,浏览器审批 answerer 会 claim 子代理请求并等待一张"用户根本看不见"的子会话面板、永不调用 `next()`——因此注册在其后的转发器永远轮不到执行。抢在最外层运行后,插件沿持久 `parentSession` 链找到子代理的**顶层活祖先**,直接调用宿主的 `ApprovalService.request`:请求以原生**允许一次 / 拒绝**面板出现在用户正在看的那个会话里,并在该 session 的 `approval/asked` + `approval/decided` 审计对中留痕。子代理的 tool call id 不向上转发;面板标题(本地化 `displayReason`)会写明是哪个子代理发的请求。
|
|
59
|
+
|
|
60
|
+
**回退链与 `unavailable` 语义(v0.8.8)**:若顶层回合已关闭(continuable 子代理比父回合活得久),或原生面板应答 `unavailable`(没有应答端 claim 该请求——例如 frame 根本没被浏览器认领),relay 会继续回退到同一顶层 agent 上**可见的** `ask_user_question` 卡片(「子代理审批透传」)——原生 `unavailable` **不是**终态。仅当问题卡片通道也不可用时才以终态 `unavailable` 结束;完全没有活的顶层 agent 则回落原生应答链。拒绝语义始终显式:转发失败答 `rejected`、子代理已中止答 `cancelled`,子代理模型总能经自己的审批审计对拿到真实结果,绝不会静默成功。
|
|
61
|
+
|
|
62
|
+
**可观测性 / 排障(v0.8.9)**:relay 每次决策记录两处——宿主 logger 一行 `cybernetics approval-relay: path=… outcome=… root=… elapsed=…ms`(无 logger 时 `console.debug` 兜底),以及 `<workspace>/.cybernetics/relay-log.jsonl` 追加一条 JSON(`{ts, root, path, outcome, elapsedMs}`)。该文件是验收时的**落盘事实**,无需宿主 stdout 或浏览器 console 即可直接读取。`path` 取值:`native-panel`(顶层原生允许一次/拒绝面板)、`question-card`(可见的回退卡片)、`next-passthrough`(无活顶层 agent → 原生链)。若子代理快速失败且审计为 `decided unavailable` 而 `relay-log.jsonl` **一条记录都没有**,说明请求根本没到 relay(先确认宿主确实重载了插件构建);若出现 `path=native-panel outcome=unavailable` 后跟 `path=question-card` 两条,说明 frame 离开了宿主但浏览器没有认领——此时用户仍能看到问题卡片。另注:问题卡片回退通过 `ctx.get` 解析宿主的 `userQuestions` 服务(免 inject 的反射式读取,与 `dsh-plan-mode` 同法);旧构建用普通属性访问解析,而 cordis 对未 inject 的服务属性访问会直接抛错——v0.8.8 真机失败(「卡片从未出现、12ms 内终态 unavailable」)正是这个原因。
|
|
63
|
+
|
|
64
|
+
设 `gateApprovalRelay: false` 可回到严格确定性拒绝的立场(CI/无人值守部署)。该字段是 hardGate 伴随项,属**非 volatile / 配置文件维护**字段:请直接在 profile 配置文档中维护,保存触发插件重载。
|
|
65
|
+
|
|
66
|
+
宿主侧证据(文件:行号)、被否决的方案与复现方式记录在 [docs/approval-relay-investigation.md](docs/approval-relay-investigation.md);`test/relay-host-probe.mjs` 用真机 cordis waterfall + 真机 `ApprovalService` + 本插件 `apply()` 做回归探针。
|
|
48
67
|
|
|
49
68
|
### append-only goals 与 revise 决策树
|
|
50
69
|
|
|
@@ -127,7 +146,7 @@ Web 编辑器「插件」页的本插件卡片上直接配置——v0.8.1 起卡
|
|
|
127
146
|
- **task/verify/triage agent 模型**(`taskAgentModel` / `verifyAgentModel` / `triageAgentModel`,volatile):下拉加持的文本框——从实时系统模型列表选择(显示 `providerName · provider/model`)或手工输入「provider/model」/裸模型名,回车或移开焦点即保存,留空继承 session agent(triage = 收敛决策角色,仅 full 轮启用)。配置值不在实时模型列表中时显示不可用警告(运行时回退到 session agent 模型)并附一键清除;
|
|
128
147
|
- **递归深度 / 并行上限**(`roundsPerSubgoal` 整数 ≥1 / `maxConcurrentRounds` 整数 ≥0,volatile):数字框,回车或失焦保存,留空恢复默认(`unset` 写入);
|
|
129
148
|
- **roles 高级配置**(`roles`,非 volatile):profile 配置 yaml 直接维护——见下方小节;
|
|
130
|
-
- **硬门相关**(`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`,非 volatile):不进入卡片,请在 profile
|
|
149
|
+
- **硬门相关**(`hardGate`/`gateMode`/`gatedTools`/`gateAllowlist`/`gateAllowlistExtra`/`gateApprovalRelay`,非 volatile):不进入卡片,请在 profile 配置文档中维护(保存触发插件重载)。`gateMode` 无默认值:未设置 + `hardGate: true` 按 `allowlist` 运行;显式 `denylist` 会被尊重,并带有上文记录的残留风险。
|
|
131
150
|
|
|
132
151
|
### roles 高级配置(profile yaml,agent 指导)
|
|
133
152
|
|
|
@@ -186,7 +205,7 @@ session agent 在会话内通过 `cybernetics` 工具(`toolName` 可配置)
|
|
|
186
205
|
## 已知限制
|
|
187
206
|
|
|
188
207
|
1. **settings 写入的宿主缺陷**:如「插件页配置」一节所述,该运行时上卡片保存可能被宿主在落盘前拒绝(上游 [#12045](https://github.com/deepseek-ai/dsh/discussions/12045));workaround:重启宿主 + 强刷浏览器,或手工 patch 行 + 重启。
|
|
189
|
-
2. **volatile 与非 volatile 字段的保存语义差异**:volatile 字段(`allGoalsMode`、`taskAgentModel`、`verifyAgentModel`、`triageAgentModel`、`roundsPerSubgoal`、`maxConcurrentRounds`)保存即时生效、不 remount;非 volatile 字段(v0.8.4 起的 `roles`、`hardGate`、`gateMode`、`gatedTools`、`gateAllowlist*`、`toolName`、`implicitTrigger`)保存触发插件行 remount,需宿主重载完成。且宿主设置表单只服务也只接受 volatile 路径——运行中宿主的 schema 过旧时,volatile 写也会被拒(重启即愈)。
|
|
208
|
+
2. **volatile 与非 volatile 字段的保存语义差异**:volatile 字段(`allGoalsMode`、`taskAgentModel`、`verifyAgentModel`、`triageAgentModel`、`roundsPerSubgoal`、`maxConcurrentRounds`)保存即时生效、不 remount;非 volatile 字段(v0.8.4 起的 `roles`、`hardGate`、`gateMode`、`gatedTools`、`gateAllowlist*`、`gateApprovalRelay`、`toolName`、`implicitTrigger`)保存触发插件行 remount,需宿主重载完成。且宿主设置表单只服务也只接受 volatile 路径——运行中宿主的 schema 过旧时,volatile 写也会被拒(重启即愈)。
|
|
190
209
|
3. **`link:` 安装注意事项**:客户端 bundle 的注册 id 必须等于 package.json 的 `name`(`@mars.liu/dsh-cybernetics`);loader 行 id 可用短名 `dsh-cybernetics`。插件需挂在本 profile 的**顶层** loader entries(客户端扫描只枚举顶层行);`src/` 改动需宿主重载、`lib/` 改动需重启宿主 + 刷新浏览器。
|
|
191
210
|
4. 其他协议边界(硬门在装配层不在执行层、dsh-goal 焦点槽不随 superseded 迁移等)见 [docs/protocol.md](docs/protocol.md) 各「已知边界」小节。
|
|
192
211
|
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,37 @@
|
|
|
4
4
|
> 当下的功能与使用方式以 [README.md](../README.md) 为准;协议细节见 [protocol.md](protocol.md)。
|
|
5
5
|
> 约定:`src/` 改动需宿主重载生效;`lib/client.js` 改动需重启宿主(客户端模块图在宿主启动时构建)并刷新浏览器;`workflows/round.js` 改动热生效。
|
|
6
6
|
|
|
7
|
+
## v0.8.7(2026-11-04):模型下拉 focus-parking(lib/client.js,仅客户端)
|
|
8
|
+
|
|
9
|
+
**问题**:原生 datalist 按输入框当前文本过滤选项——已配置模型的字段聚焦后下拉只剩当前那一个模型,用户看不到完整目录,容易误以为没有别的选择。改进方向不是自绘下拉,而是顺应原生 datalist 的过滤语义。
|
|
10
|
+
|
|
11
|
+
- **聚焦占位(focus parking)**:model 字段(task/verify/session/triage 共四个)聚焦或点击时,把已有文本收进 `placeholder`(value 收进占位符、输入框清空),datalist 因此展开全部系统模型;焦点离开(blur/Enter/Escape)且未编辑时恢复占位文本并**不写任何东西**——只是看看列表永远不会把已配置模型置空。
|
|
12
|
+
- **编辑语义保持**:占位状态下输入或选中选项即视为真实编辑,走 v0.8.1 的常规提交路径(blank = unset,回车提交);`hadDraft` 区分占位文本是未提交草稿(保留草稿)还是快照值(恢复即清草稿)。Escape 关闭列表后值已恢复但字段仍持有焦点,此时 `onClick` 也挂 parkCurrent,保证再点一次仍能展开全列表(不再触发 onFocus)。
|
|
13
|
+
- **提示文案**(双语 `settings.modelFieldHint` / `settings.triageModelHint`)同步改写:点开输入框即可看到全部可选模型,已有值先收进占位符、离开且未改动不会保存。
|
|
14
|
+
- **生效手段**:`lib/client.js` 改动,重启宿主 + 刷新浏览器后生效。`lib/` 是源码同目录(构建产物即源),无需额外构建。
|
|
15
|
+
|
|
16
|
+
## v0.8.9(2026-11-04):relay 卡片回退真因修复 + relay-log 落盘
|
|
17
|
+
|
|
18
|
+
- **根因修复**(src/index.ts):问题卡片回退改用 `ctx.get('userQuestions')` 解析宿主服务(免 inject 的反射式读取,与 dsh-plan-mode 同法)。旧构建用普通属性访问 `ctx.userQuestions`——cordis 对未 inject 服务的属性访问直接抛 "cannot get property without inject"(cordis/lib/index.js:88-96),try/catch 吞掉后立即返回终态 `unavailable`,这就是 v0.8.8 真机「原生面板 unavailable 后卡片从未出现、12ms 内终结」的直接原因。
|
|
19
|
+
- **relay-log 落盘**:每次 relay 决策在 host logger 行之外追加一条 JSON 到 `<workspace>/.cybernetics/relay-log.jsonl`(`{ts, root, path, outcome, elapsedMs}`,fire-and-forget 不影响审批链路),验收无需宿主 stdout / 浏览器 console 即可读取 path/outcome。
|
|
20
|
+
- **测试**:test/relay-host-probe.mjs 新增场景 5——cordis 忠实 stub(属性访问抛错、仅 ctx.get 可达)下卡片回退依然可达用户 + relay-log.jsonl 落盘断言;test/v03-offline.mjs 全部通过。
|
|
21
|
+
- **文档**:README 双语排障指引更新(relay-log 读取方法与「零记录 = 请求没到 relay」判据)。
|
|
22
|
+
|
|
23
|
+
## v0.8.6(2026-11-04):hardGate 门禁语义默认安全 + guidance 与模式一致(P1)
|
|
24
|
+
|
|
25
|
+
**问题(cyb-3e1ab616 诊断核实)**:`gateMode` 在 schema 里 `.default('denylist')`,而 `DEFAULT_GATED_TOOLS` 只有 `['bash','edit','write']`——任何只写 `hardGate: true` 的部署都得到一道半门:`bsk_*`、`taskboard_create`、`schedule_create`、`farm_start`、`uno_*`、`ppt_create`、`config_restore` 等写工具全部存活;另一会话因此退化成 810 次 `bsk_session` start/stop 死循环。同时 `HARD_GATE_GUIDANCE` 在 denylist 模式下仍宣称“所有改世界的工具结构性不可用”,属假陈述。
|
|
26
|
+
|
|
27
|
+
- **`src/index.ts` schema**:`gateMode` 去掉 `.default('denylist')`,改为无默认的可选字段(schemastery 对未设置值保留 `undefined`,离线实测 `validate({})` 结果为 `{hardGate:true}`,不含 gateMode)。**用户沉默不再被默认值抹掉**,apply() 才做推导。
|
|
28
|
+
- **`src/index.ts` apply()**:新增 `resolveGateMode(config, hardGate)`——显式 `'denylist'`/`'allowlist'` 原样尊重;未设置时 `hardGate ? 'allowlist' : 'denylist'`。`hardGate=false` 时不装任何门禁监听,行为与旧版完全一致。装配过滤与 `tools/pre-execute` 分发拒绝共用同一个 `allowlistMode`。
|
|
29
|
+
- **`HARD_GATE_GUIDANCE` 按模式生成**:拆成 `HARD_GATE_GUIDANCE_ALLOWLIST`(保留“所有改世界的工具 STRUCTURALLY unavailable”的强表述)与 `HARD_GATE_GUIDANCE_DENYLIST`(如实说明“只摘 gatedTools,bsk_*/taskboard_*/schedule_*/farm_*/uno_*/ppt_*/config_restore 等仍可调用,这是 PARTIAL 而非结构性门”,并明确禁止“换一个写工具绕过编排流程”)。`registerGuidanceSection()` 在生成时按解析后的模式拼接;hardGate/gateMode 非 volatile,重载后重新生成即可(沿用 volatile 重建模式,不受影响)。
|
|
30
|
+
- **文档**:README / README.zh-CN「硬门」小节重写——默认语义(hardGate 伴生 allowlist)、`gateMode` 无默认值、显式 denylist 的残留风险与仍可用工具清单(bsk_*/taskboard_*/schedule_*/farm_*/uno_*/ppt_*/config_restore/subagent 等)、以及“denylist 永远不可能完备(集合随插件增长)”。
|
|
31
|
+
- **测试**:离线测试增至 **71 项**——新增 16b 覆盖三种组合:(a) `hardGate:true` 无 `gateMode` → allowlist 生效(长尾写工具在装配期移除且分发期 deny);(b) `hardGate:true + gateMode:'denylist'` → 仅 bash/edit/write 被摘,farm_start 仍在分发层放行(尊重显式配置、不静默升级);(c) `hardGate:false` → 无门禁监听、无 HARD GATE guidance。用例同时断言双模式 guidance 文案:allowlist 含 `(allowlist mode)` + `STRUCTURALLY unavailable`;denylist 含 `(denylist mode)` + `gatedTools` + `PARTIAL, not structural`,且**不含** `STRUCTURALLY unavailable` 假陈述;另加 schema 断言 `validate({})` 的 `gateMode` 为 `undefined`、显式 denylist 经校验仍为 denylist。既有 70 项全过。
|
|
32
|
+
- **生效手段**:`src/` 改动,**重启 dsh web(宿主重载)后生效**。profile 端 `/Users/mars/.dsh/profiles/web/node_modules/dsh-cybernetics` 是指向源码目录的软链,无需复制;`cordis.patch.yml` 未改动(其中已显式 `gateMode: allowlist`)。
|
|
33
|
+
|
|
34
|
+
## v0.8.5(2026-11-04):hardGate 子代理审批透传(`gateApprovalRelay`,最后一跳修复)
|
|
35
|
+
|
|
36
|
+
**问题**:宿主在委派边界把子代理审批策略钉死为 `never`(`dsh-subagent/lib/index.js:413` `captureDelegatedPolicyOverrides`;`dsh-user-approval/lib/index.js:175` 在 waterfall 之前直接 `rejected`),task agent 的沙箱越权请求到不了用户。**第一跳**:`agent/created` 时给子代理 session 追加 `approval/policy {policy:'ask'}`(真机证据:本任务子代理 session 日志 seq 2 为 `never`/`source:delegation`,seq 3 为插件追加的 `ask`,相隔 52 ms)。**最后一跳(本轮修复)**:原实现把 `approval/request` answerer 注册在内层、先 `next()` 给原生链一个限时窗口——但宿主 `api-remotes` 在启动时(早于所有用户插件)注册了 `approval/request` 转发器(`dsh-api-remotes/lib/index.js:21-28,147,233`),浏览器审批 answerer(`dsh-client-ui-approval/lib/client.js:284`)claim 子代理请求后等待一张"用户看不见的子会话面板"且永不 `next()`;cordis waterfall 由外向内执行(`cordis/lib/index.js:317-325`),内层 relay 因此**永远轮不到执行**,子代理只能挂死。改为 `{ prepend: true }` 最外层接管:沿 `parentSession` 链解析顶层活祖先,用宿主自己的 `ApprovalService.request` 在**用户正在看的会话**上重新发起请求(原生"允许一次/拒绝"面板 + 该 session 的 `approval/asked`/`approval/decided` 审计对,不转发子代理 callId),顶层回合已关闭时回退同一 agent 的 `ask_user_question` 卡片,无活顶层 agent 再回落原生链。旧的 `gateApprovalRelayTimeoutMs` 竞态窗口字段删除(relay 不再与原生链赛跑,等待语义与普通审批一致,由调用方的 signal/超时取消)。**验证**:离线测试 70 重写为最外层注册 + child→mid→root 攀爬 + 原生面板/问题/原生链三层回退 + 排序回归与负向对照;新增 `test/relay-host-probe.mjs` 用真机 cordis + 真机 `ApprovalService` + 真实插件 `apply()` 端到端跑通(子代理请求只落到顶层会话面板、隐藏子会话面板 0 次 claim;顶层拒绝/回合关闭回退均验证)。**生效手段**:`src/` 改动,宿主重载后生效(运行中的宿主仍加载旧构建,需重载/重启才能实测 GUI 弹窗)。
|
|
37
|
+
|
|
7
38
|
## v0.8.4(2026-11-04):配置卡片移除 roles JSON 编辑项(roles 改经 profile yaml 高级配置)
|
|
8
39
|
|
|
9
40
|
**决策理由(记录在案)**:v0.8.1 起 roles 以 JSON 文本域 +「应用」按钮的形式上卡片,用户实测反馈:在 GUI 卡片里手工编辑一个 JSON 对象(`role → {provider, model}`)对用户不友善——JSON 语法错误校验、转义、role 名拼写全暴露给用户。roles 能力本身保留在 schema/运行时(命名角色进阶场景仍可用),但 GUI 不再暴露;确需配置时由 agent 指导用户直接编辑 profile 配置 yaml(用户 patch 行),README/README.zh-CN 各新增「roles 高级配置」小节记录字段位置示例与重载要求。
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# 子代理审批透传(gateApprovalRelay)调查与设计 — 2026-11-04 v0.8.5
|
|
2
|
+
|
|
3
|
+
> 目标:hardGate 模式下,委派子代理(workflow task/verify/triage、teammate)的沙箱越权请求
|
|
4
|
+
> 必须能真正弹到用户 GUI,而不是被宿主/客户端的既有应答链静默吞掉。
|
|
5
|
+
> 本文记录代码级证据(宿主版本 0.2.1-rc.3,路径 `~/.dsh/profiles/web/node_modules/@deepseek-ai/`)、
|
|
6
|
+
> 根因判定、取舍与验证方式。
|
|
7
|
+
|
|
8
|
+
## 1. 现象与事实
|
|
9
|
+
|
|
10
|
+
- 子代理访问会话工作区之外的路径(如 `~/.dsh/profiles/web/cordis.patch.yml`)时,工具层
|
|
11
|
+
`sandbox_permissions` 升级会走 `ctx.approval.request(...)`(`dsh-tools/lib/index.js:3551`、
|
|
12
|
+
`dsh-sandbox/lib/index.js:99-120`),但用户看不到任何提示,子代理只能失败/回退人工。
|
|
13
|
+
- 真机证据(本任务子代理 session `18168d3c` 的 `session.v4.jsonl.zstd`):seq 2 是
|
|
14
|
+
`approval/policy {policy:'never', source:'delegation'}`,seq 3 是插件 `agent/created`
|
|
15
|
+
监听追加的 `{policy:'ask'}`(相隔 52 ms)——**第一跳(解除钉死)在真实宿主上有效**;
|
|
16
|
+
失败点在第二跳(请求送达用户)。
|
|
17
|
+
|
|
18
|
+
## 2. 宿主机制(代码证据)
|
|
19
|
+
|
|
20
|
+
| 环节 | 证据 |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| 委派时审批策略被**钉死为 `never`** | `dsh-subagent/lib/index.js:413` `approvalPolicy: parent.ctx.get("approval") === undefined ? undefined : "never"`;`dsh-subagent/lib/types/child-agent.js:185,202`;`appendDelegatedPolicyOverrides` 把它写成 `approval/policy {source:'delegation'}`(`:430-432`) |
|
|
23
|
+
| `never` 在**任何 answerer 之前**确定性拒绝 | `dsh-user-approval/lib/index.js:172-176`:`decide()` 先 `if (effectivePolicy === 'never') return 'rejected'`,之后才 `waterfall('approval/request', ...)` |
|
|
24
|
+
| 审批请求必须落在**打开的回合**内 | `dsh-user-approval/lib/index.js:130`(`hasOpenTurn` 检查,audit 对必须 turn-enclosed) |
|
|
25
|
+
| 宿主把 `approval/request` 转发给浏览器 | `dsh-api-remotes/lib/index.js:21-28`(`API_REMOTE_FORWARDED_EVENTS` 含 `approval/request` waterfall)、`:147`(boot 时 `registerRemoteEvents`)、`:233-252`(`forwardWaterfall`:推入 client 队列并等待结果,client 不 delegate 就不 `next()`);`dsh-api-gateway/lib/index.js:1105` 仅投递给 `answers` 的 owner client |
|
|
26
|
+
| 浏览器审批 answerer 的行为 | `dsh-client-ui-approval/lib/client.js:284-320`:`sessions.scopeOf(owner)` → 构造 `PendingApproval` → `uiSession.registerPendingInteraction` → **等待用户**;面板只渲染"当前打开会话"的 pendingInteraction(`dsh-client-ui-conversation/lib/client.js:17814`) |
|
|
27
|
+
| waterfall 由外向内执行 | `cordis/lib/index.js:317-325`:`dispatch()` 按注册顺序取监听器,`waterfall` 逐个 `shift`,外层不调用 `next()` 则内层永不执行 |
|
|
28
|
+
|
|
29
|
+
**结论(宿主没有现成的"审批委托"机制)**:`captureDelegatedPolicyOverrides` 只有
|
|
30
|
+
`'never'` 一种委派策略,没有 `inherit`/`approval: 'parent'` 之类的开关;子代理请求也不会自动改投父会话。
|
|
31
|
+
因此只能在插件侧中转。
|
|
32
|
+
|
|
33
|
+
## 3. 根因(最后一跳)
|
|
34
|
+
|
|
35
|
+
- 宿主 `api-remotes` 在 **boot 时**(bundle 顺序早于所有用户插件:
|
|
36
|
+
`dsh-web-app/cordis.patch.yml:248-249`,profile `package.json` bundles 中 `dsh-cybernetics` 排在其后)
|
|
37
|
+
就注册了 `approval/request` 监听器;cybernetics 的 relay 注册得更晚 ⇒ 在 waterfall 中处于**内层**。
|
|
38
|
+
- 子代理请求先到 `api-remotes` → 推给浏览器 → 浏览器 answerer **claim 子会话请求**并等待一张
|
|
39
|
+
"用户根本看不见的 per-session 面板"(当前打开的是父会话),且**永不 `next()`**。
|
|
40
|
+
- 于是内层 relay 从来不会被执行:上一版"限时 `next()` + 超时转 userQuestions"的窗口逻辑是**死代码**,
|
|
41
|
+
子代理只会挂在被 claim 的请求上。用户看到的现象就是"没有弹窗、任务失败"。
|
|
42
|
+
|
|
43
|
+
最小复现(真实 cordis,`new Context()`):
|
|
44
|
+
外层监听器返回一个永不 settle 的 Promise、内层返回 `'allowed-once'`,`waterfall` 的 Promise 永不 settle;
|
|
45
|
+
给内层加 `{ prepend: true }` 后内层成为外层,立即返回 `'allowed-once'`(见 `test/relay-host-probe.mjs` 的
|
|
46
|
+
排序断言与 `test/v03-offline.mjs` 检查 70 的负向对照)。
|
|
47
|
+
|
|
48
|
+
## 4. 设计取舍
|
|
49
|
+
|
|
50
|
+
**采用:最外层 relay + 在顶层 session agent 上复用宿主的原生审批通道。**
|
|
51
|
+
|
|
52
|
+
- `ctx.on('approval/request', handler, { prepend: true })` —— 必须先于 `api-remotes` 拿到请求;
|
|
53
|
+
这是唯一不依赖插件加载顺序的可靠位置。
|
|
54
|
+
- 只接管**委派子代理**(`session.header.origin === 'subagent'`;fork 有 `parentSession` 但独立成根,
|
|
55
|
+
用 `delegationDepth > 0` 兜底区分)。
|
|
56
|
+
- 沿持久 `parentSession` 链向上解析**顶层活祖先**(`ctx.agents.get(parentSession)`,
|
|
57
|
+
与 `dsh-experimental-agent-team/lib/index.js:873-876` 的同款解析一致),把请求用
|
|
58
|
+
`ctx.get('approval').request({ agent: <顶层>, toolName, reason, displayReason, signal })`
|
|
59
|
+
在该 agent 的会话上**重新发起**:
|
|
60
|
+
- 面板就是用户熟悉的原生 "Allow once / Reject"(`dsh-client-ui-approval/lib/client.js:38-120`),
|
|
61
|
+
渲染在用户正在看的会话里;该会话同时得到 `approval/asked` + `approval/decided` 审计对。
|
|
62
|
+
- **不转发子代理的 `callId`**:那是子会话里的 tool call,不能与父会话 transcript 关联。
|
|
63
|
+
- `displayReason`(本地化)写明 `Sub-agent <id> requests privileged execution of <tool>`,
|
|
64
|
+
审计 `reason` 带 `[sub-agent <id>]` 前缀。
|
|
65
|
+
- 回退链:顶层回合已关闭(`ApprovalService.request` 抛 open-turn 错)→ 同一 agent 的
|
|
66
|
+
`ask_user_question` 卡片(`ctx.userQuestions.ask`,不要求 open turn)→ 没有活顶层 agent 则 `next()`
|
|
67
|
+
交给原生链(fail-closed 语义不变)。
|
|
68
|
+
- 失败语义:转发抛错 → 显式 `rejected`;子代理 signal 已中止 → `cancelled`;
|
|
69
|
+
顶层审批返回 `unavailable`(无客户端)→ `unavailable`。绝不静默成功。
|
|
70
|
+
- **删除** `gateApprovalRelayTimeoutMs`:relay 不再与原生链赛跑,"等用户"就是普通审批的语义;
|
|
71
|
+
调用方的 `signal`/工具超时负责取消。
|
|
72
|
+
|
|
73
|
+
**否决的方案**:
|
|
74
|
+
1. *保留限时窗口、抢在原生链之后* —— 内层永远轮不到,且 15s 空窗期内用户什么都看不到。
|
|
75
|
+
2. *只用 `ask_user_question` 转发* —— 能用,但问题卡片不是审批面板(措辞/按键/审计都不对),
|
|
76
|
+
且不落在审批审计链上;作为"顶层回合已关闭"的回退保留。
|
|
77
|
+
3. *修改宿主 `api-remotes` 或客户端 answerer* —— 越出插件边界,且会改变所有插件的审批语义。
|
|
78
|
+
4. *把子代理策略改成 `never` 之外的持久继承* —— 宿主没有该能力,且子代理自身仍是"看不见的面板"。
|
|
79
|
+
|
|
80
|
+
## 5. 验证
|
|
81
|
+
|
|
82
|
+
- `node --experimental-strip-types test/v03-offline.mjs` → **ALL 71 CHECKS PASSED**(检查 70 重写:
|
|
83
|
+
最外层注册、child→mid→root 攀爬、原生面板/问题/原生链三层回退、无 callId、
|
|
84
|
+
排序回归 + 内层负向对照、opt-out、schema/README)。
|
|
85
|
+
- `node test/relay-host-probe.mjs` → 用**真机 cordis + 真机 `ApprovalService` + 真实插件 `apply()`**
|
|
86
|
+
跑通:子代理 escalation 只落到顶层会话的审批通道(模拟浏览器只 claim 顶层、对子会话永不 delegate),
|
|
87
|
+
顶层会话留下 `approval/asked`/`approval/decided`;顶层回合关闭时回退问题通道;顶层拒绝显式回传。
|
|
88
|
+
- 真机证据:本任务子代理 session 的 `approval/policy` seq 2/3(钉死 → ask),父会话在子代理运行期间
|
|
89
|
+
处于 open turn(`turn/start` 无对应 `turn/end`)。
|
|
90
|
+
|
|
91
|
+
## 6. 已知边界
|
|
92
|
+
|
|
93
|
+
- **运行中的宿主仍加载旧构建**:`src/` 改动需宿主重载/重启后才生效;本轮无法在不打断当前
|
|
94
|
+
goal round 的前提下重启宿主,因此 GUI 实测弹窗需在重载后复验一次(发一条越权子代理任务,
|
|
95
|
+
看父会话是否出现 "Allow once / Reject" 面板)。
|
|
96
|
+
- 子代理**自己的会话面板不再被使用**(请求一律改投顶层会话)——这是有意的:那张面板正是"看不见的
|
|
97
|
+
最后一跳"的成因;需要时仍可打开子会话,但那不是本次的交付路径。
|
|
98
|
+
- relay 只在 `hardGate: true` 时安装(`gateApprovalRelay` 是 hardGate 伴随项)。
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# 诊断报告:session-9e7abdf0 绕过 goal 模式的根因(2026-11-04)
|
|
2
|
+
|
|
3
|
+
## 结论(根因判定)
|
|
4
|
+
|
|
5
|
+
**主根因 = d(其他):硬门禁对该会话实际生效,但生效的是 denylist 模式(只摘 bash/edit/write 三个工具),配置中的 `gateMode: allowlist` 从未应用到 web profile。** bsk_* 浏览器自动化工具全部保留,所有"写操作"都从这些门禁盲区工具走掉了。a/b/c 三个假设均被证据排除或弱化。
|
|
6
|
+
|
|
7
|
+
## 证据链
|
|
8
|
+
|
|
9
|
+
### 1. 门禁在作用域内(排除假设 a:spawn 链不同)
|
|
10
|
+
该会话每轮 request/header 中被广告的工具数与内容(session.v4.jsonl.zstd 解压到 /tmp/s9e7.jsonl):
|
|
11
|
+
|
|
12
|
+
| seq | 时间 | 工具数 | bash | write | bsk_session | 说明 |
|
|
13
|
+
|-----|------|--------|------|-------|-------------|------|
|
|
14
|
+
| 13 | 15:19 | 124 | 有 | 有 | 有 | 门禁配置前 |
|
|
15
|
+
| 495 | 15:53 | 124 | 有 | 有 | 有 | 门禁配置前 |
|
|
16
|
+
| 1317| 17:25 | 124 | 有 | 有 | 有 | 17:28 写入 hardGate 前 |
|
|
17
|
+
| 1346| 17:36 | **121** | **无** | **无** | 有 | 门禁生效:恰好只少了 bash/edit/write |
|
|
18
|
+
| 1360| 17:37 | 121 | 无 | 无 | 有 | |
|
|
19
|
+
| 5102| 19:50 | 121 | 无 | 无 | 有 | **19:47 新宿主(pid 90659)重启后首个 header**,摘除延续 |
|
|
20
|
+
| 5266| 19:53 | 121 | 无 | 无 | 有 | 切到 kimi/k3 后同样摘除 |
|
|
21
|
+
|
|
22
|
+
121 = 124 − {bash, edit, write},与 `DEFAULT_GATED_TOOLS = ['bash','edit','write']`(src/index.ts:494)精确吻合。
|
|
23
|
+
→ 会话被视为顶层 agent(assembly filter 应用)、宿主重启后装配已刷新(排除假设 b 装配缓存问题)。全程日志中 **0 条** "cybernetics hard gate: tool … not available" 拒绝(grep 计 0)——因为模型从未调用被摘的三个工具,其余 118 个工具全 open。
|
|
24
|
+
|
|
25
|
+
### 2. 生效配置丢失 allowlist(根因所在)
|
|
26
|
+
- 插件仓库自带的 `/Users/mars/jobs/dsh-cybernetics/cordis.patch.yml` 配置了 `gateMode: allowlist` + `gateAllowlistExtra: [spawn_teammate, send_message, interrupt_agent]`。
|
|
27
|
+
- 但 web profile 的用户 patch 行 `/Users/mars/.dsh/profiles/web/cordis.patch.yml:441-448` 的 config **只有 5 个键**:taskAgentModel / verifyAgentModel / allGoalsMode / triageAgentModel / hardGate —— **没有 gateMode / gateAllowlist / gateAllowlistExtra**。
|
|
28
|
+
- 同 id 的 patch 行是**整体替换 config 而非合并**(cordis-plugin-loader/lib/index.js:78-80 `this.data = config`,按 id 重建 map),profile 层的行覆盖后 allowlist 配置消失。
|
|
29
|
+
- 运行时判定:src/index.ts:594 `const allowlistMode = config.gateMode === 'allowlist'` → false → denylist 分支;src/index.ts:601 未配置 gatedTools → 落到 DEFAULT_GATED_TOOLS。工具表证据(121 枚)与该路径完全一致。
|
|
30
|
+
|
|
31
|
+
### 3. allGoalsMode 指引正常下发(排除装配路径问题)
|
|
32
|
+
system prompt 全量重播(system/message 事件 seq 7 / 1343 / 5264)均包含 cybernetics section、ALL-GOALS 指引与 HARD_GATE_GUIDANCE 文本(seq 5264 解码验证:has cybernetics=True / ALL-GOALS=True / hard gate=True)。19:53 换 kimi/k3 后的 5264 号 system prompt 也带。模型只是无视指引。
|
|
33
|
+
|
|
34
|
+
### 4. 模型回退假设(c)不成立,但确有一次 NO_ADAPTER
|
|
35
|
+
- request/header 全部 8 次中 7 次 provider=zai-coding-cn/glm-5.3-flash;19:53 起才切 kimi/k3。不存在反复回退。
|
|
36
|
+
- 唯一失败:seq 1362,17:37:10,turn 23 step 1,`finish reason error: "no adapter registered for provider \"zai-coding-cn\""(NO_ADAPTER)`——GLM off 键窗口的一次性失败,此后同一模型继续正常出 token。
|
|
37
|
+
- ~/.dsh/dsh-usage/usage-ledger.json 存在但按 days 组织、无 session 维度记录(按 9e7abdf0 检索 0 命中);provider-snapshots.json 显示 zai-coding-cn pro 档 5h 窗口 98% 用量。死循环与模型不可用无因果。
|
|
38
|
+
|
|
39
|
+
### 5. "写操作"的真实通道
|
|
40
|
+
18:26 重启后(cut=18:26:00),该会话工具调用统计:bsk_session 772 / browser_navigate 3 / bsk_page 3,**bash/write/edit 均为 0**(门禁摘除生效)。建频道、API 发消息、PDF/pptx 生成(bash 41、write 10、uno_create 3)全部发生在 17:36 门禁生效**之前**。重启后唯一的大量行为就是 bsk_session start/stop 死循环(18:42–19:53,每次约 5 秒一个 start+stop,共 772 次调用)——这是门禁盲区工具上的可靠性事故,而非 goal 绕过写文件。
|
|
41
|
+
|
|
42
|
+
## 修复建议(未执行任何写操作)
|
|
43
|
+
1. **主修复**:把 `gateMode: allowlist`(必要时连同 gateAllowlistExtra)补进 `/Users/mars/.dsh/profiles/web/cordis.patch.yml:441` 的 dsh-cybernetics config 块,重启宿主。验证方式:新回合 request/header 工具数应骤降到 ~45(allowlist+cybernetics),且 bsk_session 不再出现在广告列表(allowlist 里没有它)。
|
|
44
|
+
2. **决策项**:bsk_* 浏览器工具是否进 allowlist。读类(browser_tabs/snapshot/console/network/screenshot/navigate/scroll)已在 DEFAULT_GATE_ALLOWLIST;bsk_session/bsk_interact/bsk_page/bsk_assist/bsk_tabs 具备创建会话、点击、replay 等写能力,建议默认挡在门外,确有只读浏览需求时按最小集放行。
|
|
45
|
+
3. **防复发**:profile 用户 patch 行整体覆盖插件 config 是脚枪——要么在插件 README/面板提示"用户行必须携带完整门禁字段",要么把 loader 改为对非 volatile 字段深合并。另建议 GUI volatile 保存生成的用户行保留原 config 键而非重写。
|
|
46
|
+
4. **独立问题**:bsk_session 772 连环 start/stop 说明 repeat-tool-reminder / 循环熔断未起作用,值得单独排查(超出本轮)。
|
|
47
|
+
|
|
48
|
+
## 偏差说明
|
|
49
|
+
- 委托书称宿主 18:26 重启(pid 19488);磁盘 running/web.json 显示当前宿主 pid 90659、19:47:37 启动——期间又重启过一次,本诊断两条宿主实例的证据都已覆盖(1346 与 5102 分别对应两代宿主的摘除生效)。
|
|
50
|
+
- referenced 快照所称"重启后建频道/发消息/生成 PDF"实际发生在 17:36 门禁生效前;重启后并无被禁工具调用,只有门禁盲区的 bsk_* 循环。
|
package/lib/client.js
CHANGED
|
@@ -95,7 +95,7 @@ window.__ModuleLoader__.load({
|
|
|
95
95
|
// v0.8.3: the three model fields are dropdown-backed (datalist over the
|
|
96
96
|
// host's live model catalog, remote.session.modelCatalog) with free
|
|
97
97
|
// manual entry preserved — the datalist input accepts any text.
|
|
98
|
-
'settings.modelFieldHint': '可从下拉选择当前系统模型(显示 provider · provider/model
|
|
98
|
+
'settings.modelFieldHint': '可从下拉选择当前系统模型(显示 provider · provider/model);点开输入框(聚焦)即可看到全部可选模型——已有值会先收进占位符,离开且未改动不会保存。也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。回车或移开焦点即保存。',
|
|
99
99
|
// v0.8.3: configured value no longer in the live catalog = deleted
|
|
100
100
|
// subscription. Warn + one-click clear; NEVER auto-unset (the value
|
|
101
101
|
// may be valid again after resubscribing, and silent rewrites of
|
|
@@ -107,7 +107,7 @@ window.__ModuleLoader__.load({
|
|
|
107
107
|
// task/verify results and emits the round's convergence decision
|
|
108
108
|
// (met/continue/human-input/...); quick rounds never invoke it.
|
|
109
109
|
'settings.triageAgentModel': 'Triage agent 模型',
|
|
110
|
-
'settings.triageModelHint': '
|
|
110
|
+
'settings.triageModelHint': '可从下拉选择当前系统模型;点开输入框(聚焦)即可看到全部可选模型(已有值先收进占位符,离开且未改动不会保存)。也可手工输入「provider/model」或裸模型名;留空 = 与 session agent 一致(继承)。triage 角色读 task/verify 结果输出本轮收敛决策(met/continue/human-input 等),quick 轮不启用。回车或移开焦点即保存。',
|
|
111
111
|
'settings.roundsPerSubgoal': '子目标轮数上限',
|
|
112
112
|
'settings.roundsPerSubgoalHint': '同一子目标的最大连续轮数(整数 ≥1,缺省 3);留空恢复默认。回车或移开焦点即保存。',
|
|
113
113
|
'settings.maxConcurrentRounds': '并行轮数上限',
|
|
@@ -169,12 +169,12 @@ window.__ModuleLoader__.load({
|
|
|
169
169
|
// v0.8.1: the remaining five volatile fields (see zh for the rationale).
|
|
170
170
|
'settings.taskAgentModel': 'Task agent model',
|
|
171
171
|
'settings.verifyAgentModel': 'Verify agent model',
|
|
172
|
-
'settings.modelFieldHint': 'Pick from the dropdown of currently available system models (shown as provider · provider/model)
|
|
172
|
+
'settings.modelFieldHint': 'Pick from the dropdown of currently available system models (shown as provider · provider/model); click into the input to see the whole list — an existing value is parked as the placeholder and restored on blur without saving. You can also type "provider/model" or a bare model name; empty = inherit the session agent. Enter or blur saves.',
|
|
173
173
|
'settings.modelUnavailable': 'This model is no longer in the system model list; at runtime the round falls back to the session agent model.',
|
|
174
174
|
'settings.modelClear': 'Clear',
|
|
175
175
|
// v0.8.2: the triage role's flat model field (see zh for the rationale).
|
|
176
176
|
'settings.triageAgentModel': 'Triage agent model',
|
|
177
|
-
'settings.triageModelHint': 'Pick from the dropdown of available system models
|
|
177
|
+
'settings.triageModelHint': 'Pick from the dropdown of available system models; click into the input to see the whole list (an existing value is parked as the placeholder and restored on blur without saving). You can also type "provider/model" or a bare model name; empty = inherit the session agent. The triage role reads the task/verify results and emits the round\'s convergence decision (met/continue/human-input/...); quick rounds never invoke it. Enter or blur saves.',
|
|
178
178
|
'settings.roundsPerSubgoal': 'Rounds per subgoal',
|
|
179
179
|
'settings.roundsPerSubgoalHint': 'Max consecutive rounds on one subgoal (integer ≥1, default 3); empty restores the default. Enter or blur saves.',
|
|
180
180
|
'settings.maxConcurrentRounds': 'Concurrent round cap',
|
|
@@ -696,6 +696,36 @@ window.__ModuleLoader__.load({
|
|
|
696
696
|
delete next[field];
|
|
697
697
|
return next;
|
|
698
698
|
});
|
|
699
|
+
// v0.8.7: focus parking for the model fields. A native datalist
|
|
700
|
+
// filters its options by the input's CURRENT text, so a field that
|
|
701
|
+
// already holds a configured model unfolded to exactly that one
|
|
702
|
+
// option. Focusing such a field PARKS its text here and blanks the
|
|
703
|
+
// input, which makes the datalist unfold the whole catalog; leaving
|
|
704
|
+
// the field (blur/Enter) with no edit restores the parked text and
|
|
705
|
+
// writes nothing — merely looking at the list can never unset a
|
|
706
|
+
// configured model. `edited` records that the user typed or picked
|
|
707
|
+
// an option while parked: only then does the regular commit run
|
|
708
|
+
// (and a deliberate clear still lands as an unset op).
|
|
709
|
+
// `hadDraft` remembers whether the parked text was an uncommitted
|
|
710
|
+
// draft (keep it) or the snapshot value (restore = clear the draft).
|
|
711
|
+
const [focus, setFocus] = react.useState({});
|
|
712
|
+
const parkFocus = (field, value, hadDraft) => setFocus((f) => ({ ...f, [field]: { value, hadDraft, edited: false } }));
|
|
713
|
+
const markFocusEdited = (field) => setFocus((f) => {
|
|
714
|
+
const entry = f[field];
|
|
715
|
+
if (entry === undefined || entry.edited === true) return f;
|
|
716
|
+
return { ...f, [field]: { value: entry.value, hadDraft: entry.hadDraft, edited: true } };
|
|
717
|
+
});
|
|
718
|
+
const dropFocus = (field) => setFocus((f) => {
|
|
719
|
+
if (!(field in f)) return f;
|
|
720
|
+
const next = { ...f };
|
|
721
|
+
delete next[field];
|
|
722
|
+
return next;
|
|
723
|
+
});
|
|
724
|
+
/** Undo a focus-only visit: the parked text comes back, nothing is written. */
|
|
725
|
+
const restoreParked = (field, entry) => {
|
|
726
|
+
if (entry.hadDraft === true) setDraft(field, entry.value);
|
|
727
|
+
else clearDraft(field);
|
|
728
|
+
};
|
|
699
729
|
|
|
700
730
|
// One write for any volatile field; the toggle is
|
|
701
731
|
// writeField('allGoalsMode', !on). Both write paths land in the same
|
|
@@ -792,6 +822,20 @@ window.__ModuleLoader__.load({
|
|
|
792
822
|
const modelUnavailable = spec.kind === 'model'
|
|
793
823
|
&& !isConfiguredModelAvailable(configuredValue, catalogSnap);
|
|
794
824
|
const listId = 'cybernetics-models-' + spec.field;
|
|
825
|
+
// v0.8.7: a focused model field is "parked" — its text moved to
|
|
826
|
+
// the placeholder and the input blanked so the datalist unfolds
|
|
827
|
+
// every option instead of filtering down to the current value.
|
|
828
|
+
const parked = focus[spec.field];
|
|
829
|
+
const inputText = drafts[spec.field] !== undefined ? drafts[spec.field] : configuredValue;
|
|
830
|
+
// Emptying the box is what lets the native datalist list every
|
|
831
|
+
// model (it filters by the input text); the parked text stays
|
|
832
|
+
// visible as the placeholder. Guarded so a second click into an
|
|
833
|
+
// already-parked field never clears text the user just typed.
|
|
834
|
+
const parkCurrent = () => {
|
|
835
|
+
if (focus[spec.field] !== undefined) return;
|
|
836
|
+
const text = drafts[spec.field] !== undefined ? String(drafts[spec.field]) : configuredValue;
|
|
837
|
+
if (text !== '') { parkFocus(spec.field, text, drafts[spec.field] !== undefined); setDraft(spec.field, ''); }
|
|
838
|
+
};
|
|
795
839
|
return jsx.jsxs('div', { style: settingsStyles.field, children: [
|
|
796
840
|
jsx.jsx('div', { style: settingsStyles.title, children: t(spec.labelKey) }),
|
|
797
841
|
jsx.jsx('div', { style: settingsStyles.desc, children: t(spec.hintKey) }),
|
|
@@ -807,16 +851,45 @@ window.__ModuleLoader__.load({
|
|
|
807
851
|
'aria-label': t(spec.labelKey),
|
|
808
852
|
'aria-invalid': invalid === spec.field || undefined,
|
|
809
853
|
disabled,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
854
|
+
placeholder: parked !== undefined ? parked.value : undefined,
|
|
855
|
+
value: inputText,
|
|
856
|
+
onChange: (e) => {
|
|
857
|
+
setDraft(spec.field, e && e.target ? String(e.target.value) : '');
|
|
858
|
+
// Typing/picking while parked turns the visit into a real
|
|
859
|
+
// edit, so the commit paths below apply unchanged.
|
|
860
|
+
markFocusEdited(spec.field);
|
|
861
|
+
},
|
|
862
|
+
onFocus: spec.kind === 'model' && catalogOptions.length > 0 ? parkCurrent : undefined,
|
|
863
|
+
// A click on the already-focused input must also unfold the
|
|
864
|
+
// list (Escape, or a list closed without blur, restores the
|
|
865
|
+
// value and leaves the field focused — no second onFocus).
|
|
866
|
+
onClick: spec.kind === 'model' && catalogOptions.length > 0 ? parkCurrent : undefined,
|
|
814
867
|
onKeyDown: (e) => {
|
|
815
868
|
if (!e) return;
|
|
816
|
-
if (e.key === 'Enter')
|
|
817
|
-
|
|
869
|
+
if (e.key === 'Enter') {
|
|
870
|
+
const entry = focus[spec.field];
|
|
871
|
+
if (entry !== undefined) {
|
|
872
|
+
dropFocus(spec.field);
|
|
873
|
+
// Enter on an untouched parked field = no edit: restore
|
|
874
|
+
// instead of writing an unset. A parked field the user
|
|
875
|
+
// really edited keeps the v0.8.1 commit path (blank =
|
|
876
|
+
// unset).
|
|
877
|
+
if (entry.edited !== true) { restoreParked(spec.field, entry); return; }
|
|
878
|
+
}
|
|
879
|
+
commitField(spec);
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
if (e.key === 'Escape') { dropFocus(spec.field); clearDraft(spec.field); setInvalid(''); }
|
|
883
|
+
},
|
|
884
|
+
onBlur: () => {
|
|
885
|
+
const entry = focus[spec.field];
|
|
886
|
+
if (entry !== undefined) {
|
|
887
|
+
dropFocus(spec.field);
|
|
888
|
+
const text = (drafts[spec.field] ?? '').trim();
|
|
889
|
+
if (text === '' && entry.edited !== true) { restoreParked(spec.field, entry); return; }
|
|
890
|
+
}
|
|
891
|
+
commitField(spec);
|
|
818
892
|
},
|
|
819
|
-
onBlur: () => commitField(spec),
|
|
820
893
|
style: { ...settingsStyles.input, ...(invalid === spec.field ? settingsStyles.inputInvalid : {}) },
|
|
821
894
|
}),
|
|
822
895
|
spec.kind === 'model' && catalogOptions.length > 0
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { randomUUID } from 'node:crypto'
|
|
25
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { mkdir, readFile, rename, writeFile, appendFile } from 'node:fs/promises'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
import { fileURLToPath } from 'node:url'
|
|
28
28
|
import z from '@deepseek-ai/schemastery'
|
|
@@ -96,6 +96,30 @@ export interface Config extends ControllerConfig {
|
|
|
96
96
|
gateAllowlist?: string[]
|
|
97
97
|
/** Extra tool names unioned onto the allowlist (default or gateAllowlist). */
|
|
98
98
|
gateAllowlistExtra?: string[]
|
|
99
|
+
/**
|
|
100
|
+
* Approval relay (v0.8.5, default true, hardGate companion): delegated
|
|
101
|
+
* children (workflow subagents, teammates) inherit the host's pinned
|
|
102
|
+
* `never` approval policy, so a sandbox escalation a task agent needs
|
|
103
|
+
* (e.g. writing outside the session workspace) fails deterministically
|
|
104
|
+
* before any answerer sees it. When on, the plugin flips each delegated
|
|
105
|
+
* child's effective policy to `ask` at creation (one durable
|
|
106
|
+
* `approval/policy` session event — later events win over the
|
|
107
|
+
* delegation-seeded pin) and registers an OUTERMOST `approval/request`
|
|
108
|
+
* answerer that re-issues a child's request ON THE TOP-LEVEL SESSION AGENT
|
|
109
|
+
* through the host's own approval service — i.e. the native Allow once /
|
|
110
|
+
* Reject panel of the conversation the user is watching. Outermost is not
|
|
111
|
+
* an optimization: the host's api-remotes registers its `approval/request`
|
|
112
|
+
* browser forwarder at boot, before every user plugin; once the browser's
|
|
113
|
+
* approval answerer claims a child request, that per-session panel never
|
|
114
|
+
* calls `next()`, so an inner relay listener would never run at all (the
|
|
115
|
+
* last-hop defect this field fixes). When the top-level turn is closed (a
|
|
116
|
+
* continuable child outliving it) the relay falls back to the
|
|
117
|
+
* user-questions (ask_user_question) channel on the same agent, then to the
|
|
118
|
+
* native chain. Denials stay explicit: relay failures answer `rejected`,
|
|
119
|
+
* an aborted child run answers `cancelled`, and a child with no live
|
|
120
|
+
* top-level agent falls through to the native chain.
|
|
121
|
+
*/
|
|
122
|
+
gateApprovalRelay?: boolean
|
|
99
123
|
/**
|
|
100
124
|
* All-goals mode (v0.7, default false): EVERY user request — including
|
|
101
125
|
* trivial read-only questions and lightweight analysis — must go through a
|
|
@@ -182,16 +206,26 @@ export const Config = z.object({
|
|
|
182
206
|
* Volatile: enforced at every launch, live on the next tool call.
|
|
183
207
|
*/
|
|
184
208
|
maxConcurrentRounds: z.number().default(0).volatile().description('全局并行轮数上限(缺省 0 = 不限):在跑轮数达到上限时拒绝启动新一轮,防止并行风暴。保存后即时生效,无需重载。'),
|
|
185
|
-
/** Hard orchestration gate (default
|
|
186
|
-
hardGate: z.boolean().default(
|
|
209
|
+
/** Hard orchestration gate (default true). */
|
|
210
|
+
hardGate: z.boolean().default(true).description('硬编排门(默认激活):装配期从顶层 session agent 摘除执行工具,所有改动必须经 cybernetics goal + task agent。修改会触发插件重载。'),
|
|
187
211
|
/** Tool names hidden in denylist mode (default DEFAULT_GATED_TOOLS). */
|
|
188
212
|
gatedTools: z.array(z.string()).description('denylist 模式下隐藏的工具名(缺省内置 DEFAULT_GATED_TOOLS)。'),
|
|
189
|
-
/**
|
|
190
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Gate semantics. v0.8.6: NO schema-level default — an absent value means
|
|
215
|
+
* "derive from hardGate" in apply() (`hardGate ? 'allowlist' : 'denylist'`).
|
|
216
|
+
* The old `default('denylist')` erased the user's silence and silently made
|
|
217
|
+
* every hardGate deployment a partial gate (only bash/edit/write), which is
|
|
218
|
+
* how the bsk_ / taskboard_ / schedule_ / farm_ / uno_ / ppt_ writes stayed
|
|
219
|
+
* alive. Explicit configurations are still honored verbatim, denylist
|
|
220
|
+
* included.
|
|
221
|
+
*/
|
|
222
|
+
gateMode: z.union(['denylist', 'allowlist'] as const).description('门语义(缺省随 hardGate 推导):hardGate 开启且未显式配置时按 allowlist 生效;hardGate 关闭时按 denylist。allowlist 只保留 gateAllowlist(+gateAllowlistExtra)与 cybernetics 工具本身;denylist 只隐藏 gatedTools(默认 bash/edit/write),其余写工具(bsk_*/taskboard_*/schedule_*/farm_*/uno_*/ppt_*/config_restore 等)仍然可用——属残留风险,仅在明确接受该风险时才显式配置。'),
|
|
191
223
|
/** Allowlist-mode survival set (default DEFAULT_GATE_ALLOWLIST). */
|
|
192
224
|
gateAllowlist: z.array(z.string()).description('allowlist 模式生存工具集(缺省内置 DEFAULT_GATE_ALLOWLIST)。'),
|
|
193
225
|
/** Extra tool names unioned onto the allowlist. */
|
|
194
226
|
gateAllowlistExtra: z.array(z.string()).description('并集追加进 allowlist 的额外工具名。'),
|
|
227
|
+
/** Approval relay for delegated children (default true). */
|
|
228
|
+
gateApprovalRelay: z.boolean().default(true).description('子代理审批透传(默认开启,hardGate 伴随项):把委派子代理的权限升级请求以"最外层 answerer"接管,改用顶层 session agent 的原生审批面板(Allow once / Reject)弹给用户裁决——请求落在用户正在看的会话里,而不是子会话那张看不见的面板;顶层回合已关闭时回退到 user-questions(ask_user_question)通道,再回退原生应答链。转发失败按显式拒绝、子代理跑飞按 cancelled 处理。属配置文件维护字段(GUI 面板不展示),修改需插件重载。'),
|
|
195
229
|
/**
|
|
196
230
|
* All-goals mode (v0.7, default false): every user request must go through
|
|
197
231
|
* a cybernetics goal first. Volatile: the settings-panel toggle commits
|
|
@@ -514,19 +548,53 @@ const DEFAULT_GATE_ALLOWLIST = [
|
|
|
514
548
|
'browser_screenshot', 'browser_navigate', 'browser_scroll',
|
|
515
549
|
]
|
|
516
550
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
551
|
+
/**
|
|
552
|
+
* Hard-gate guidance is mode-specific (v0.8.6). In allowlist mode the strong
|
|
553
|
+
* claim is true: every change-class tool is absent from the assembly and
|
|
554
|
+
* denied at dispatch. In denylist mode it was FALSE — only `gatedTools` is
|
|
555
|
+
* removed while bsk_ / taskboard_ / schedule_ / farm_ / uno_ / ppt_ /
|
|
556
|
+
* config_restore remain callable — so the section names the partial stance
|
|
557
|
+
* instead of pretending the loop is structurally enforced.
|
|
558
|
+
*/
|
|
559
|
+
const HARD_GATE_GUIDANCE_ALLOWLIST = `
|
|
560
|
+
HARD GATE ACTIVE (allowlist mode): your tool list is restricted to reading, searching, conversation,
|
|
561
|
+
presentation and goal/orchestration verbs — every tool that CHANGES the world (edit/write/bash,
|
|
562
|
+
service lifecycle, board/knowledge/schedule writes, spawning other workers, ...) is STRUCTURALLY
|
|
563
|
+
unavailable to you. Answering a read-only question directly is fine and needs no goal; but the
|
|
564
|
+
moment the user asks for a change — however small — that is your signal to delegate: create a
|
|
565
|
+
cybernetics goal (action 'start' with title+brief) and let a task agent execute it. Never tell the
|
|
566
|
+
user a change is "too small for the loop": the loop is the only way changes get done. If you look
|
|
567
|
+
for a tool to perform a change and do not find it, that is the gate working as intended — delegate
|
|
568
|
+
instead.`
|
|
569
|
+
|
|
570
|
+
const HARD_GATE_GUIDANCE_DENYLIST = `
|
|
571
|
+
HARD GATE ACTIVE (denylist mode): only the tools listed in gatedTools (default bash, edit, write)
|
|
572
|
+
are removed from your tool list. This mode is PARTIAL, not structural: other change-class tools are
|
|
573
|
+
still present and callable — bsk_* (browser automation), taskboard_* (board writes), schedule_*
|
|
574
|
+
(cron reminders), farm_* (service lifecycle), uno_* / ppt_* (document generators), config_restore,
|
|
575
|
+
subagent/team spawning, and the rest of the mutation long tail. Their availability is NOT
|
|
576
|
+
permission to do task work yourself — calling one directly bypasses the goal loop this deployment
|
|
577
|
+
requires. The moment the user asks for a change, however small, create a cybernetics goal (action
|
|
578
|
+
'start' with title+brief) and let a task agent execute it. Do not route around a missing tool with
|
|
579
|
+
another write tool, and do not describe the gate as total: it hides gatedTools only.`
|
|
580
|
+
|
|
581
|
+
/** Effective gate semantics: explicit config wins; otherwise follow hardGate. */
|
|
582
|
+
function resolveGateMode(config: Partial<Config>, hardGate: boolean): 'denylist' | 'allowlist' {
|
|
583
|
+
if (config.gateMode === 'denylist' || config.gateMode === 'allowlist') return config.gateMode
|
|
584
|
+
return hardGate ? 'allowlist' : 'denylist'
|
|
585
|
+
}
|
|
526
586
|
|
|
527
587
|
export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
528
588
|
const toolName = config.toolName ?? 'cybernetics'
|
|
529
589
|
const hardGate = config.hardGate === true
|
|
590
|
+
// v0.8.6 default-safe gate semantics: a user who turns hardGate on but says
|
|
591
|
+
// nothing about gateMode gets the structural allowlist, not the leaky
|
|
592
|
+
// denylist. hardGate=false resolves to denylist but no gate is installed,
|
|
593
|
+
// so its behavior is unchanged.
|
|
594
|
+
const gateMode = resolveGateMode(config, hardGate)
|
|
595
|
+
const hardGateGuidance = hardGate
|
|
596
|
+
? (gateMode === 'allowlist' ? HARD_GATE_GUIDANCE_ALLOWLIST : HARD_GATE_GUIDANCE_DENYLIST)
|
|
597
|
+
: ''
|
|
530
598
|
// allGoalsMode is a volatile field: under the loader's schema resolution it
|
|
531
599
|
// arrives as a live reference whose value the settings panel commits in
|
|
532
600
|
// place (no remount), so read it at use time instead of capturing it once.
|
|
@@ -559,7 +627,7 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
559
627
|
disposeSection = ctx.systemPrompt.section({
|
|
560
628
|
name: `tool:${toolName}`,
|
|
561
629
|
order: ctx.systemPrompt.getSectionOrder('TOOL_WORKFLOW'),
|
|
562
|
-
text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') +
|
|
630
|
+
text: IMPLICIT_GUIDANCE + (allGoalsMode() ? ALL_GOALS_GUIDANCE : '') + hardGateGuidance,
|
|
563
631
|
})
|
|
564
632
|
}
|
|
565
633
|
registerGuidanceSection()
|
|
@@ -571,7 +639,9 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
571
639
|
// execution tools from TOP-LEVEL agents' assemblies. Workflow subagents
|
|
572
640
|
// (task/verify/triage) and team teammates are never in agents.roots(),
|
|
573
641
|
// so their assemblies pass through untouched with full tool access.
|
|
574
|
-
|
|
642
|
+
// hardGate/gateMode are non-volatile: the resolved mode is fixed for this
|
|
643
|
+
// plugin instance and re-derived on reload (gateMode was resolved above).
|
|
644
|
+
const allowlistMode = gateMode === 'allowlist'
|
|
575
645
|
// NOTE: schema-resolved config turns an unset z.array field into [] (not
|
|
576
646
|
// undefined), so fall back on length, not on ?? — otherwise the gate
|
|
577
647
|
// would silently keep/gate nothing.
|
|
@@ -630,6 +700,265 @@ export function apply(ctx: any, config: Partial<Config> = {}) {
|
|
|
630
700
|
})
|
|
631
701
|
})
|
|
632
702
|
disposers.push(() => preDisposer?.())
|
|
703
|
+
|
|
704
|
+
// v0.8.5 approval relay (gateApprovalRelay, default true): delegated
|
|
705
|
+
// children (workflow subagents, teammates) inherit the host's PINNED
|
|
706
|
+
// `never` approval policy (dsh-subagent/lib/index.js:413
|
|
707
|
+
// captureDelegatedPolicyOverrides pins 'never' for every delegation;
|
|
708
|
+
// dsh-user-approval/lib/index.js:175 decide() then short-circuits the ask
|
|
709
|
+
// to 'rejected' BEFORE any answerer runs), so a task agent's sandbox
|
|
710
|
+
// escalation (e.g. writing outside the session workspace) can never reach
|
|
711
|
+
// the user. Relay = two listeners:
|
|
712
|
+
//
|
|
713
|
+
// (1) agent/created — flip the child's effective policy to 'ask' by
|
|
714
|
+
// appending one plain `approval/policy {policy:'ask'}` event to the
|
|
715
|
+
// child session (exactly what the host's setApprovalPolicy appends;
|
|
716
|
+
// later child switches win over the delegation-seeded pin). Live
|
|
717
|
+
// evidence: a real delegated session log carries the pin
|
|
718
|
+
// {policy:'never', source:'delegation'} at seq 2 and the plugin's
|
|
719
|
+
// {policy:'ask'} at seq 3 (52 ms later).
|
|
720
|
+
// (2) approval/request — OUTERMOST answerer (prepend:true) for delegated
|
|
721
|
+
// children only. It does NOT give the native chain the first turn:
|
|
722
|
+
// the host's api-remotes registers an approval/request waterfall
|
|
723
|
+
// listener at boot (dsh-api-remotes/lib/index.js:21-28,147
|
|
724
|
+
// API_REMOTE_FORWARDED_EVENTS → forwardWaterfall at :233) and
|
|
725
|
+
// forwards every request to the connected browser, where
|
|
726
|
+
// dsh-client-ui-approval/lib/client.js:284 answerApproval claims it
|
|
727
|
+
// and awaits a per-session panel that is INVISIBLE whenever the user
|
|
728
|
+
// is watching the parent conversation. A listener registered after
|
|
729
|
+
// api-remotes is INNER and is never reached (cordis waterfall runs
|
|
730
|
+
// outermost-first — cordis/lib/index.js:317-325), so the previous
|
|
731
|
+
// bounded-window `next()` race could not fire at all: the child hung
|
|
732
|
+
// on a panel nobody could see. prepend makes the relay the FIRST
|
|
733
|
+
// listener; it re-issues the request ON THE TOP-LEVEL LIVE ANCESTOR
|
|
734
|
+
// AGENT through the host's own ApprovalService
|
|
735
|
+
// (ctx.get('approval').request) — the native Allow once / Reject
|
|
736
|
+
// panel of the conversation the user is actually watching, audited as
|
|
737
|
+
// approval/asked + approval/decided on that session. The child's
|
|
738
|
+
// callId is deliberately NOT forwarded (a child ToolCallId must not
|
|
739
|
+
// correlate with a tool call in the parent transcript). When the
|
|
740
|
+
// top-level turn is closed (ApprovalService.request requires an open
|
|
741
|
+
// turn: dsh-user-approval/lib/index.js:130) the relay falls back to
|
|
742
|
+
// the user-questions channel on the same agent (an ask_user_question
|
|
743
|
+
// card in the same composer). A native 'unavailable' (no answering
|
|
744
|
+
// client) ALSO falls through to the question card — v0.8.8 fix: the
|
|
745
|
+
// live failure was asked → decided unavailable in 9 ms with no visible
|
|
746
|
+
// prompt. Denial semantics stay explicit: relay errors answer
|
|
747
|
+
// 'rejected', an aborted child run answers 'cancelled', a question
|
|
748
|
+
// channel that is also unavailable answers terminal 'unavailable',
|
|
749
|
+
// and a child with no live top-level agent falls through to next()
|
|
750
|
+
// (the host's fail-closed native semantics) — never a silent success.
|
|
751
|
+
const gateApprovalRelay = config.gateApprovalRelay !== false
|
|
752
|
+
if (gateApprovalRelay) {
|
|
753
|
+
// Delegated children carry `origin: 'subagent'` (dsh-subagent sets it on
|
|
754
|
+
// the child header; the live session log of this very task agent shows
|
|
755
|
+
// it). Forks keep a parentSession for lineage but are independent roots,
|
|
756
|
+
// so they are excluded here: `delegationDepth > 0` is the fallback when
|
|
757
|
+
// a host build omits `origin`.
|
|
758
|
+
const isDelegatedChild = (agent: any): boolean => {
|
|
759
|
+
const header = agent?.session?.header
|
|
760
|
+
if (header === undefined || header === null) return false
|
|
761
|
+
if (header.origin === 'subagent') return true
|
|
762
|
+
return header.parentSession !== undefined && typeof header.delegationDepth === 'number' && header.delegationDepth > 0
|
|
763
|
+
}
|
|
764
|
+
const agentById = (id: string): any => {
|
|
765
|
+
try {
|
|
766
|
+
const direct = ctx.agents?.get?.(id)
|
|
767
|
+
if (direct !== undefined && direct !== null) return direct
|
|
768
|
+
} catch { /* registry lookup is best-effort */ }
|
|
769
|
+
try {
|
|
770
|
+
return (ctx.agents?.roots?.() ?? []).find((r: any) =>
|
|
771
|
+
r?.id === id || r?.session?.header?.id === id)
|
|
772
|
+
} catch { return undefined }
|
|
773
|
+
}
|
|
774
|
+
// Walk the durable parentSession chain up to the live TOP-LEVEL agent:
|
|
775
|
+
// the conversation the user watches. Undefined when a link is dead (a
|
|
776
|
+
// continuable child that outlived its parent) — the relay then leaves
|
|
777
|
+
// the request to the native chain.
|
|
778
|
+
const topLevelAgentOf = (agent: any): any => {
|
|
779
|
+
let cursor = agent
|
|
780
|
+
const visited = new Set<string>()
|
|
781
|
+
while (true) {
|
|
782
|
+
const parentId = cursor?.session?.header?.parentSession
|
|
783
|
+
if (parentId === undefined) return cursor
|
|
784
|
+
if (visited.has(parentId)) return undefined
|
|
785
|
+
visited.add(parentId)
|
|
786
|
+
const parent = agentById(parentId)
|
|
787
|
+
if (parent === undefined) return undefined
|
|
788
|
+
cursor = parent
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const approvalServiceOf = (agent: any): any => {
|
|
792
|
+
try {
|
|
793
|
+
const fromCtx = typeof ctx.get === 'function' ? ctx.get('approval') : undefined
|
|
794
|
+
if (fromCtx !== undefined) return fromCtx
|
|
795
|
+
} catch { /* service not provided in this deployment */ }
|
|
796
|
+
try { return agent?.ctx?.get?.('approval') } catch { return undefined }
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const createdDisposer = ctx.on('agent/created', (payload: any) => {
|
|
800
|
+
const agent = payload?.agent
|
|
801
|
+
if (!isDelegatedChild(agent)) return
|
|
802
|
+
try {
|
|
803
|
+
agent.session.append('approval/policy', { policy: 'ask' })
|
|
804
|
+
} catch {
|
|
805
|
+
// agent/created is a serial event: a throw fails child creation.
|
|
806
|
+
// A relay must never break delegation — leave the pinned policy.
|
|
807
|
+
}
|
|
808
|
+
})
|
|
809
|
+
disposers.push(() => createdDisposer?.())
|
|
810
|
+
|
|
811
|
+
const RELAY_QUESTION_ID = 'cybernetics-approval-relay'
|
|
812
|
+
const RELAY_ALLOW = '允许一次(Allow once)'
|
|
813
|
+
const RELAY_REJECT = '拒绝(Reject)'
|
|
814
|
+
// 'unavailable' is deliberately NOT terminal here: it means "the native
|
|
815
|
+
// panel had no answering client" (dsh-user-approval/lib/index.js:176),
|
|
816
|
+
// which is exactly the v0.8.7 live failure (asked → decided unavailable
|
|
817
|
+
// in 9 ms). The relay then still falls through to the question card so
|
|
818
|
+
// the user gets a VISIBLE prompt; only when the question channel is also
|
|
819
|
+
// unavailable does the request end as a terminal 'unavailable'.
|
|
820
|
+
const APPROVAL_OUTCOMES = new Set(['allowed-once', 'rejected', 'cancelled'])
|
|
821
|
+
|
|
822
|
+
// Observability (v0.8.8, persisted v0.8.9): every relay decision logs
|
|
823
|
+
// the path taken, outcome, latency and the resolved top-level target —
|
|
824
|
+
// to the host logger AND to `<workspace>/.cybernetics/relay-log.jsonl`.
|
|
825
|
+
// The file is the ground truth a later acceptance run can read without
|
|
826
|
+
// host stdout or browser console access (the v0.8.8 live failure was
|
|
827
|
+
// indistinguishable between "stale build" and "both channels down").
|
|
828
|
+
const relayLog = (agent: any, path: string, outcome: string, startedAt: number): void => {
|
|
829
|
+
const rootId = agent?.session?.header?.id ?? agent?.id ?? 'unknown'
|
|
830
|
+
const line = `cybernetics approval-relay: path=${path} outcome=${outcome} ` +
|
|
831
|
+
`root=${rootId} elapsed=${Date.now() - startedAt}ms`
|
|
832
|
+
try {
|
|
833
|
+
const logger = (ctx as { logger?: { debug?: (m: string) => void; info?: (m: string) => void } }).logger
|
|
834
|
+
if (typeof logger?.debug === 'function') logger.debug(line)
|
|
835
|
+
else if (typeof logger?.info === 'function') logger.info(line)
|
|
836
|
+
else console.debug(line)
|
|
837
|
+
} catch { console.debug(line) }
|
|
838
|
+
// Fire-and-forget disk append; a relay must never fail because its own
|
|
839
|
+
// audit trail could not be written.
|
|
840
|
+
try {
|
|
841
|
+
const wd = (ctx as { workingDirectory?: { get?: (s: unknown) => unknown } }).workingDirectory
|
|
842
|
+
const ws = typeof wd?.get === 'function' ? wd.get(agent?.session) : undefined
|
|
843
|
+
if (typeof ws !== 'string' || ws.length === 0) return
|
|
844
|
+
const entry = JSON.stringify({
|
|
845
|
+
ts: new Date().toISOString(),
|
|
846
|
+
root: rootId,
|
|
847
|
+
path,
|
|
848
|
+
outcome,
|
|
849
|
+
elapsedMs: Date.now() - startedAt,
|
|
850
|
+
}) + '\n'
|
|
851
|
+
void mkdir(join(ws, '.cybernetics'), { recursive: true })
|
|
852
|
+
.then(() => appendFile(join(ws, '.cybernetics', 'relay-log.jsonl'), entry, 'utf8'))
|
|
853
|
+
.catch(() => { /* observability only */ })
|
|
854
|
+
} catch { /* observability only */ }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Ask the top-level agent through the host's native approval channel,
|
|
858
|
+
// falling back to the user-questions channel, then to a terminal
|
|
859
|
+
// 'unavailable'. Returns a closed approval outcome.
|
|
860
|
+
const relayToSessionAgent = async (req: any, root: any): Promise<string> => {
|
|
861
|
+
const childId = req.agent?.session?.header?.id ?? req.agent?.id ?? 'unknown'
|
|
862
|
+
const rootId = root?.session?.header?.id ?? root?.id ?? 'unknown'
|
|
863
|
+
const startedAt = Date.now()
|
|
864
|
+
const reason = typeof req.reason === 'string' && req.reason.length > 0 ? req.reason : undefined
|
|
865
|
+
const approval = approvalServiceOf(root)
|
|
866
|
+
if (approval !== undefined && typeof approval.request === 'function') {
|
|
867
|
+
try {
|
|
868
|
+
const outcome = await approval.request({
|
|
869
|
+
agent: root,
|
|
870
|
+
toolName: req.toolName,
|
|
871
|
+
reason: `[sub-agent ${childId}] ${reason ?? `tool "${req.toolName}" requires approval`}`,
|
|
872
|
+
displayReason: {
|
|
873
|
+
en: `Sub-agent ${childId} requests privileged execution of ${req.toolName}${reason === undefined ? '' : `: ${reason}`}`,
|
|
874
|
+
zh: `子代理 ${childId} 请求越权执行工具 ${req.toolName}${reason === undefined ? '' : `:${reason}`}`,
|
|
875
|
+
},
|
|
876
|
+
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
877
|
+
})
|
|
878
|
+
if (APPROVAL_OUTCOMES.has(outcome)) {
|
|
879
|
+
relayLog(root, 'native-panel', outcome, startedAt)
|
|
880
|
+
return outcome
|
|
881
|
+
}
|
|
882
|
+
// 'unavailable' (or any unknown value): the native panel found no
|
|
883
|
+
// answering client — keep falling through to the question card.
|
|
884
|
+
} catch {
|
|
885
|
+
// No open turn on the session agent (continuable child outliving
|
|
886
|
+
// it) or the audit pair failed: the question channel does not
|
|
887
|
+
// require an open turn, so try it before giving up.
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// v0.8.9 ROOT-CAUSE FIX: plain property access `ctx.userQuestions` on
|
|
891
|
+
// this plugin's context throws cordis' "cannot get property without
|
|
892
|
+
// inject" (the plugin's `inject` list does not declare the service),
|
|
893
|
+
// so the previous `try { ctx.userQuestions }` always fell into the
|
|
894
|
+
// catch and the question-card fallback answered terminal 'unavailable'
|
|
895
|
+
// within milliseconds — the user never saw a card. Resolution now
|
|
896
|
+
// mirrors dsh-plan-mode/lib/index.js:257 (`ctx.get("userQuestions")`,
|
|
897
|
+
// the inject-free reflective read that walks the composition store),
|
|
898
|
+
// with the property access kept as a secondary attempt for host builds
|
|
899
|
+
// where the service is injected under a different graph shape.
|
|
900
|
+
let uq: any
|
|
901
|
+
try {
|
|
902
|
+
const viaGet = typeof (ctx as { get?: (n: string) => unknown }).get === 'function'
|
|
903
|
+
? (ctx as { get: (n: string) => unknown }).get('userQuestions')
|
|
904
|
+
: undefined
|
|
905
|
+
if (viaGet !== undefined && typeof (viaGet as { ask?: unknown }).ask === 'function') uq = viaGet
|
|
906
|
+
} catch { uq = undefined }
|
|
907
|
+
if (uq === undefined) {
|
|
908
|
+
try { uq = ctx.userQuestions } catch { uq = undefined }
|
|
909
|
+
if (uq !== undefined && typeof uq.ask !== 'function') uq = undefined
|
|
910
|
+
}
|
|
911
|
+
if (uq === undefined || typeof uq.ask !== 'function') {
|
|
912
|
+
relayLog(root, 'question-card', 'unavailable', startedAt)
|
|
913
|
+
return 'unavailable'
|
|
914
|
+
}
|
|
915
|
+
if (req.signal?.aborted) {
|
|
916
|
+
relayLog(root, 'question-card', 'cancelled', startedAt)
|
|
917
|
+
return 'cancelled'
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
const answer = await uq.ask({
|
|
921
|
+
questions: [{
|
|
922
|
+
id: RELAY_QUESTION_ID,
|
|
923
|
+
question: `${reason ?? `tool ${req.toolName}`}(子代理 ${childId} 请求越权执行工具 ${req.toolName})`,
|
|
924
|
+
detail: reason,
|
|
925
|
+
header: '子代理审批透传',
|
|
926
|
+
options: [{ label: RELAY_ALLOW }, { label: RELAY_REJECT }],
|
|
927
|
+
multiSelect: false,
|
|
928
|
+
}],
|
|
929
|
+
agent: root,
|
|
930
|
+
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
|
931
|
+
})
|
|
932
|
+
const selected = (answer?.answers ?? []).find((a: any) => a?.id === RELAY_QUESTION_ID)?.selected ?? []
|
|
933
|
+
const outcome = selected.includes(RELAY_ALLOW) ? 'allowed-once' : 'rejected'
|
|
934
|
+
relayLog(root, 'question-card', outcome, startedAt)
|
|
935
|
+
return outcome
|
|
936
|
+
} catch {
|
|
937
|
+
// Question delivery failed (client gone, answerer refused, run
|
|
938
|
+
// aborted): answer 'cancelled' when the child's own signal fired,
|
|
939
|
+
// otherwise an explicit rejection — the child stops and explains.
|
|
940
|
+
const outcome = req.signal?.aborted ? 'cancelled' : 'rejected'
|
|
941
|
+
relayLog(root, 'question-card', outcome, startedAt)
|
|
942
|
+
return outcome
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const relayDisposer = ctx.on('approval/request', async (req: any, next: () => Promise<string>) => {
|
|
947
|
+
if (!isDelegatedChild(req?.agent)) return next()
|
|
948
|
+
if (req?.signal?.aborted) return 'cancelled'
|
|
949
|
+
const root = topLevelAgentOf(req.agent)
|
|
950
|
+
// No live ancestor, or a malformed child whose chain loops back to
|
|
951
|
+
// itself: never re-ask on the requesting agent (that would re-enter
|
|
952
|
+
// this same waterfall forever).
|
|
953
|
+
if (root === undefined || root === req.agent) {
|
|
954
|
+
relayLog(req.agent, 'next-passthrough', 'next', Date.now())
|
|
955
|
+
return next()
|
|
956
|
+
}
|
|
957
|
+
const outcome = await relayToSessionAgent(req, root)
|
|
958
|
+
return outcome === undefined ? next() : outcome
|
|
959
|
+
}, { prepend: true })
|
|
960
|
+
disposers.push(() => relayDisposer?.())
|
|
961
|
+
}
|
|
633
962
|
}
|
|
634
963
|
|
|
635
964
|
const disposer = (ctx.tools as { register: (d: unknown) => () => void }).register({
|