dsh-root-planner-preset 1.1.8 → 1.3.0
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/INSTALL.md +20 -0
- package/README.md +21 -7
- package/README.zh.md +11 -1
- package/build-patch.mjs +11 -0
- package/cordis.patch.yml +11 -0
- package/guard.js +190 -0
- package/package.json +26 -3
- package/release.mjs +17 -3
- package/verify-patch.mjs +20 -1
- package/.gitattributes +0 -10
- package/.github/CONTRIBUTING.md +0 -126
- package/.github/workflows/verify.yml +0 -51
package/INSTALL.md
CHANGED
|
@@ -44,6 +44,26 @@ dsh plugin --profile web add dsh-root-planner-preset
|
|
|
44
44
|
|
|
45
45
|
> 也可以钉版本:`dsh plugin --profile web add dsh-root-planner-preset@1.0.0`
|
|
46
46
|
|
|
47
|
+
### 已内置:子代理工作区围栏
|
|
48
|
+
|
|
49
|
+
预设自带一个守卫行,**不需要另装**。它把"子代理只写自己的 `_agents/` 目录"从人设规定变成**调用级拒绝**:
|
|
50
|
+
|
|
51
|
+
- 子代理 `write` / `edit` / `ops_replace` 的目标路径落在 `_agents/` 之外 → **调用被拒,代码根本没跑**;
|
|
52
|
+
- 子代理的 shell 调用 `workdir` 不在工作区内、或命令里写着指向你文件的绝对路径 → **被拒**;
|
|
53
|
+
- 根 agent **不受限制**(它必须能在自己工作区任意位置写,才能把产物并入)。
|
|
54
|
+
|
|
55
|
+
被告拒时它收到的是一句说明边界的错误,例如:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
write: outside your workspace. You may write only inside "...\_agents"; everything else
|
|
59
|
+
in the parent's directory is read-only to you. Send your parent a change request instead
|
|
60
|
+
of writing "...\report.md" directly.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**一个仍然存在的限制**:shell 命令能在运行时拼出路径(`$env:X` 拼 `'..'`、脚本内部写、Here-String),参数扫描看不见。要真正关住子代理的 shell 需要给它更窄的沙箱写域,而 DSH 给不了(`sandboxPolicy.resolve()` 只接受 `{session, mode}`,`workspaceRoot` 恒等于不可变的会话 cwd)。所以父 agent 的时间戳核验仍是兜底——人设里已经写了。
|
|
64
|
+
|
|
65
|
+
> 守卫以**预设行**形式挂载,所以只有用 `root-planner` 预设的 agent 才受约束:`_agents/` 这套约定只有这个预设的人设定义过。
|
|
66
|
+
|
|
47
67
|
### 方式 2:GitHub 源码
|
|
48
68
|
|
|
49
69
|
```sh
|
package/README.md
CHANGED
|
@@ -36,13 +36,27 @@ the root decides which to copy in — merging into an existing file where that i
|
|
|
36
36
|
change means, keeping them separate where it is not. `_agents/` stays (and belongs in
|
|
37
37
|
`.gitignore`) so the same agent can be continued later.
|
|
38
38
|
|
|
39
|
-
**The boundary is
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
39
|
+
**The boundary is enforced, not just agreed.** This preset ships the fence as its own
|
|
40
|
+
plugin row (`dsh-root-planner-preset/guard`), so one install carries the protocol and its
|
|
41
|
+
enforcement together. It refuses a `write`, `edit` or `ops_replace` whose target resolves
|
|
42
|
+
outside `<workspace>/_agents/`, and refuses a shell call whose `workdir` is outside it or
|
|
43
|
+
whose command names an absolute path into your files:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
write: outside your workspace. You may write only inside "...\_agents"; everything else
|
|
47
|
+
in the parent's directory is read-only to you. Send your parent a change request instead
|
|
48
|
+
of writing "...\report.md" directly.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The root session is never fenced: it owns the workspace and has to be able to write
|
|
52
|
+
anywhere in it to integrate a child's artifacts.
|
|
53
|
+
|
|
54
|
+
One limitation survives, and it is worth knowing before you rely on this: a shell command
|
|
55
|
+
can build a path at runtime (`$env:X` + `'..'`, a script that writes, a Here-String), and
|
|
56
|
+
no argument scan can see it. Confining a child's shell properly would need a narrower
|
|
57
|
+
sandbox write root, which DSH cannot give a child — `sandboxPolicy.resolve()` accepts only
|
|
58
|
+
`{ session, mode }` and derives `workspaceRoot` from the immutable, inherited
|
|
59
|
+
`session.header.cwd`. So keep the parent's check as the backstop:
|
|
46
60
|
|
|
47
61
|
```sh
|
|
48
62
|
# before delegating
|
package/README.zh.md
CHANGED
|
@@ -30,7 +30,17 @@
|
|
|
30
30
|
|
|
31
31
|
并发问题是用**隔离**解决的,不是用协调。派活之前,根创建 `_agents/<label>/` 并把**绝对路径**写进任务;子代理**只往那里写**,父目录的文件随便读,要改就发申请。完成后它报告产物清单,由根决定复制哪些进自己的工作区——该合并进现有文件的就合并,该保持独立的就独立。`_agents/` 会保留(并应进 `.gitignore`),以便日后继续使用同一个子代理。
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
**这条边界是强制执行的,不只是约定。** 预设自带围栏(自己的插件行 `dsh-root-planner-preset/guard`),一次安装同时得到协议和它的执行。它拒绝目标落在 `<工作区>/_agents/` 之外的 `write`、`edit`、`ops_replace`;也拒绝 `workdir` 在工作区之外的 shell 调用,以及命令里写着你文件的绝对路径:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
write: outside your workspace. You may write only inside "...\_agents"; everything else
|
|
37
|
+
in the parent's directory is read-only to you. Send your parent a change request instead
|
|
38
|
+
of writing "...\report.md" directly.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
根会话**不受围栏限制**——它必须能在自己工作区任意位置写,才能把子代理的产物并入。
|
|
42
|
+
|
|
43
|
+
**有一个限制必须知道**:shell 命令能在运行时拼出路径(`$env:X` 拼 `'..'`、脚本内部写、Here-String),参数扫描看不见。要真正关住子代理的 shell,需要给它一个更窄的沙箱写域,而 DSH 给不了——`sandboxPolicy.resolve()` 只接受 `{ session, mode }`,`workspaceRoot` 恒等于不可变、从父会话继承的 `session.header.cwd`。所以父 agent 的核验仍然是兜底:
|
|
34
44
|
|
|
35
45
|
```sh
|
|
36
46
|
# 派活前
|
package/build-patch.mjs
CHANGED
|
@@ -226,6 +226,17 @@ const patch = `# Generated by build-patch.mjs from persona.txt. Do not hand-edit
|
|
|
226
226
|
- id: tool-plugin-manager
|
|
227
227
|
name: '@deepseek-ai/dsh-plugin-manager/tools'
|
|
228
228
|
disabled: true
|
|
229
|
+
# The workspace fence. Mounted as a preset row so one install carries the
|
|
230
|
+
# protocol and its enforcement together: the persona tells a child to write
|
|
231
|
+
# under its own _agents/ directory, and this refuses anything else before
|
|
232
|
+
# the call runs. It is preset-scoped rather than a separate profile bundle
|
|
233
|
+
# because the _agents/ convention only exists here — a guard without this
|
|
234
|
+
# persona would fence agents that were never told where they may write.
|
|
235
|
+
- id: root-planner-guard
|
|
236
|
+
name: 'dsh-root-planner-preset/guard'
|
|
237
|
+
config:
|
|
238
|
+
directoryName: _agents
|
|
239
|
+
guardShell: true
|
|
229
240
|
`;
|
|
230
241
|
|
|
231
242
|
// Substitution happens after the template so the emitted indentation is owned by
|
package/cordis.patch.yml
CHANGED
|
@@ -289,3 +289,14 @@
|
|
|
289
289
|
- id: tool-plugin-manager
|
|
290
290
|
name: '@deepseek-ai/dsh-plugin-manager/tools'
|
|
291
291
|
disabled: true
|
|
292
|
+
# The workspace fence. Mounted as a preset row so one install carries the
|
|
293
|
+
# protocol and its enforcement together: the persona tells a child to write
|
|
294
|
+
# under its own _agents/ directory, and this refuses anything else before
|
|
295
|
+
# the call runs. It is preset-scoped rather than a separate profile bundle
|
|
296
|
+
# because the _agents/ convention only exists here — a guard without this
|
|
297
|
+
# persona would fence agents that were never told where they may write.
|
|
298
|
+
- id: root-planner-guard
|
|
299
|
+
name: 'dsh-root-planner-preset/guard'
|
|
300
|
+
config:
|
|
301
|
+
directoryName: _agents
|
|
302
|
+
guardShell: true
|
package/guard.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Keeps a delegated subagent inside the workspace its parent gave it.
|
|
2
|
+
//
|
|
3
|
+
// This module ships inside `dsh-root-planner-preset` and is mounted as one of the
|
|
4
|
+
// preset's own plugin rows (`dsh-root-planner-preset/guard`), so installing the preset
|
|
5
|
+
// brings the protocol and its enforcement together. That placement is deliberate: the
|
|
6
|
+
// `_agents/` convention is defined by this preset's persona, and a guard installed
|
|
7
|
+
// without it would fence agents that were never told where they may write.
|
|
8
|
+
//
|
|
9
|
+
// One guard, registered once on the plugin's own context, inspects every tool call
|
|
10
|
+
// and decides from the calling agent's session header. That is deliberately simpler
|
|
11
|
+
// than wiring per-agent listeners: `ToolExecution` already carries `agent`, so the
|
|
12
|
+
// guard needs neither the `agent/created` payload shape nor a per-agent context, and
|
|
13
|
+
// nothing has to be registered or torn down when a child appears or disappears.
|
|
14
|
+
//
|
|
15
|
+
// Why a guard rather than a prompt rule: `tools.guard()` is synchronous and monotonic.
|
|
16
|
+
// It refuses the call instead of asking the model not to make it, and no later guard
|
|
17
|
+
// can force-allow a call this one denied.
|
|
18
|
+
//
|
|
19
|
+
// Two strengths are in play and the difference is not cosmetic:
|
|
20
|
+
//
|
|
21
|
+
// * File tools carry the target path in their arguments, so a write, edit or
|
|
22
|
+
// replace outside `_agents/` is refused structurally — the call never runs.
|
|
23
|
+
// * Shell tools carry a command string, and a command can name a path no argument
|
|
24
|
+
// scan will see (`$env:HOME` + '..', a script that writes, a Here-String). For
|
|
25
|
+
// those the guard enforces the two things it can prove: the working directory
|
|
26
|
+
// must be inside the workspace, and an absolute path written into the command
|
|
27
|
+
// must not point at the parent's files. Anything assembled at runtime still gets
|
|
28
|
+
// past — the parent's timestamp check is the backstop for that, and this file
|
|
29
|
+
// says so rather than pretending the shell is confined.
|
|
30
|
+
|
|
31
|
+
import { appendFileSync } from 'node:fs'
|
|
32
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
33
|
+
|
|
34
|
+
/** Tools that take a target file path as an argument. */
|
|
35
|
+
const FILE_TOOLS = new Set(['write', 'edit', 'ops_replace'])
|
|
36
|
+
|
|
37
|
+
/** Tools that take a command string, where the guard is best-effort. */
|
|
38
|
+
const SHELL_TOOLS = new Set(['pwsh', 'ops_bash'])
|
|
39
|
+
|
|
40
|
+
/** Argument keys the file tools use for their target. */
|
|
41
|
+
const PATH_KEYS = ['path', 'file_path', 'filePath', 'target', 'targetPath']
|
|
42
|
+
|
|
43
|
+
/** Argument keys the shell tools use for the working directory. */
|
|
44
|
+
const WORKDIR_KEYS = ['workdir', 'cwd']
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compare two absolute paths for containment, case-insensitively because this runs
|
|
48
|
+
* on Windows where `D:\X` and `d:\x` are the same directory.
|
|
49
|
+
* @param parent - the directory that must contain `child`.
|
|
50
|
+
* @param child - the candidate path.
|
|
51
|
+
* @returns whether `child` is `parent` or below it.
|
|
52
|
+
*/
|
|
53
|
+
function contains(parent, child) {
|
|
54
|
+
const p = parent.replace(/[\\/]+$/, '').toLowerCase()
|
|
55
|
+
const c = child.replace(/[\\/]+$/, '').toLowerCase()
|
|
56
|
+
if (c === p) return true
|
|
57
|
+
return c.startsWith(`${p}${sep}`) || c.startsWith(`${p}/`) || c.startsWith(`${p}\\`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pull the first string argument under any of the given keys.
|
|
62
|
+
* @param args - the parsed call arguments.
|
|
63
|
+
* @param keys - candidate keys in priority order.
|
|
64
|
+
* @returns the value, or `undefined` when none is a string.
|
|
65
|
+
*/
|
|
66
|
+
function firstString(args, keys) {
|
|
67
|
+
if (typeof args !== 'object' || args === null) return undefined
|
|
68
|
+
for (const key of keys) {
|
|
69
|
+
const value = args[key]
|
|
70
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Resolve one guard for a single agent.
|
|
77
|
+
* @param cwd - the session working directory, used to resolve relative paths.
|
|
78
|
+
* @param workspace - the absolute directory the agent may write in.
|
|
79
|
+
* @returns a synchronous guard.
|
|
80
|
+
*/
|
|
81
|
+
function makeGuard(cwd, workspace) {
|
|
82
|
+
const describe = (target) =>
|
|
83
|
+
`outside your workspace. You may write only inside ${JSON.stringify(workspace)}; ` +
|
|
84
|
+
`everything else in the parent's directory is read-only to you. ` +
|
|
85
|
+
`Send your parent a change request instead of writing ${JSON.stringify(target)} directly.`
|
|
86
|
+
|
|
87
|
+
return (exec) => {
|
|
88
|
+
const name = exec?.name
|
|
89
|
+
const args = exec?.arguments
|
|
90
|
+
if (typeof name !== 'string') return undefined
|
|
91
|
+
|
|
92
|
+
if (FILE_TOOLS.has(name)) {
|
|
93
|
+
const raw = firstString(args, PATH_KEYS)
|
|
94
|
+
// No path argument at all is the tool's own validation problem, not a boundary
|
|
95
|
+
// violation — refusing here would only hide the real error message.
|
|
96
|
+
if (raw === undefined) return undefined
|
|
97
|
+
const abs = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw)
|
|
98
|
+
if (!contains(workspace, abs)) return `${name}: ${describe(abs)}`
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (SHELL_TOOLS.has(name)) {
|
|
103
|
+
// A shell with no explicit workdir runs in the session cwd, which is the
|
|
104
|
+
// parent's directory — precisely the place the child may not write. Requiring
|
|
105
|
+
// the workspace as workdir makes every relative path in the command land where
|
|
106
|
+
// it belongs.
|
|
107
|
+
const workdir = firstString(args, WORKDIR_KEYS)
|
|
108
|
+
const effective = workdir === undefined ? cwd : isAbsolute(workdir) ? resolve(workdir) : resolve(cwd, workdir)
|
|
109
|
+
if (!contains(workspace, effective)) {
|
|
110
|
+
return (
|
|
111
|
+
`${name}: the working directory ${JSON.stringify(effective)} is outside your workspace. ` +
|
|
112
|
+
`Pass workdir: ${JSON.stringify(workspace)} (or a directory inside it) and use relative paths ` +
|
|
113
|
+
`so everything you create stays in your workspace.`
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
// Best effort: refuse an absolute path in the command that points at the
|
|
117
|
+
// parent's files. A path assembled at runtime defeats this; the parent's
|
|
118
|
+
// timestamp check is the backstop.
|
|
119
|
+
const command = typeof args?.command === 'string' ? args.command : ''
|
|
120
|
+
const escaped = [...command.matchAll(/[A-Za-z]:[\\/][^\s"'`;|&)]*/g)].map((match) => match[0])
|
|
121
|
+
const outside = escaped.find((candidate) => {
|
|
122
|
+
const abs = resolve(candidate)
|
|
123
|
+
return !contains(workspace, abs) && contains(cwd, abs)
|
|
124
|
+
})
|
|
125
|
+
if (outside !== undefined) return `${name}: the command writes ${JSON.stringify(outside)}, ${describe(outside)}`
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const name = 'root-planner-guard'
|
|
134
|
+
|
|
135
|
+
export const inject = ['tools']
|
|
136
|
+
|
|
137
|
+
// No `Config` export on purpose: Cordis validates a declared Config with a zod schema
|
|
138
|
+
// (`schema.validate`), and a plain JSON Schema object throws at activation with
|
|
139
|
+
// "Cannot read properties of undefined (reading 'validate')". Shipping no schema and
|
|
140
|
+
// coercing the optional fields in `apply` keeps the row usable without pulling a
|
|
141
|
+
// dependency into a plugin that is installed by directory link.
|
|
142
|
+
const DEFAULTS = { enabled: true, directoryName: '_agents', guardShell: true }
|
|
143
|
+
|
|
144
|
+
/** Exported for the unit test: the boundary logic is worth testing without a session. */
|
|
145
|
+
export { contains, makeGuard }
|
|
146
|
+
|
|
147
|
+
export function apply(ctx, config = {}) {
|
|
148
|
+
const settings = { ...DEFAULTS, ...(typeof config === 'object' && config !== null ? config : {}) }
|
|
149
|
+
if (settings.enabled === false) return
|
|
150
|
+
const directoryName =
|
|
151
|
+
typeof settings.directoryName === 'string' && settings.directoryName.length > 0
|
|
152
|
+
? settings.directoryName
|
|
153
|
+
: DEFAULTS.directoryName
|
|
154
|
+
const guardShell = settings.guardShell !== false
|
|
155
|
+
|
|
156
|
+
const trace = (line) => {
|
|
157
|
+
if (settings.debug !== true) return
|
|
158
|
+
try {
|
|
159
|
+
appendFileSync(
|
|
160
|
+
settings.debugFile ?? `${process.cwd()}\\guard-debug.log`,
|
|
161
|
+
`${new Date().toISOString()} ${line}\n`,
|
|
162
|
+
)
|
|
163
|
+
} catch {
|
|
164
|
+
/* a probe must never break the plugin */
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
trace(`apply: listening on the plugin context (directoryName=${directoryName})`)
|
|
169
|
+
|
|
170
|
+
// Registered on the plugin's own context: a plain-context guard applies globally,
|
|
171
|
+
// and the decision is made per call from `exec.agent`, so no per-agent wiring is
|
|
172
|
+
// needed and no registration leaks when an agent is disposed.
|
|
173
|
+
ctx.effect(() => {
|
|
174
|
+
const dispose = ctx.tools.guard((exec) => {
|
|
175
|
+
const header = exec?.agent?.session?.header
|
|
176
|
+
// Only delegated sessions are fenced: the root owns the workspace and must be
|
|
177
|
+
// able to write anywhere in it, or it could not integrate a child's artifacts.
|
|
178
|
+
if (header?.origin !== 'subagent') return undefined
|
|
179
|
+
const cwd = header.cwd ?? process.cwd()
|
|
180
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return undefined
|
|
181
|
+
const workspace = join(cwd, directoryName)
|
|
182
|
+
const guard = makeGuard(cwd, workspace)
|
|
183
|
+
const verdict = guardShell ? guard(exec) : SHELL_TOOLS.has(exec?.name) ? undefined : guard(exec)
|
|
184
|
+
trace(`call ${exec?.name} from ${String(header.id)} -> ${verdict === undefined ? 'allow' : 'DENY'}`)
|
|
185
|
+
return verdict
|
|
186
|
+
})
|
|
187
|
+
trace('guard registered')
|
|
188
|
+
return dispose
|
|
189
|
+
})
|
|
190
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-root-planner-preset",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "DSH agent preset
|
|
5
|
+
"description": "DSH agent preset: the root agent plans and decides, subagent workers read, write and run commands inside their own _agents/ workspace, and nothing outlives its task.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "XiaoOu2024",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./guard.js",
|
|
10
|
+
"./guard": "./guard.js",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"guard.js",
|
|
15
|
+
"persona.txt",
|
|
16
|
+
"child-tools.mjs",
|
|
17
|
+
"build-patch.mjs",
|
|
18
|
+
"verify-patch.mjs",
|
|
19
|
+
"verify-depth-override.mjs",
|
|
20
|
+
"lib-yaml.mjs",
|
|
21
|
+
"release.mjs",
|
|
22
|
+
"cordis.patch.yml",
|
|
23
|
+
"tools/",
|
|
24
|
+
"docs/",
|
|
25
|
+
"README.md",
|
|
26
|
+
"README.zh.md",
|
|
27
|
+
"INSTALL.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
8
30
|
"homepage": "https://github.com/XiaoOu2024/dsh-root-planner-preset#readme",
|
|
9
31
|
"repository": {
|
|
10
32
|
"type": "git",
|
|
@@ -20,7 +42,8 @@
|
|
|
20
42
|
"cordis",
|
|
21
43
|
"subagent",
|
|
22
44
|
"delegation",
|
|
23
|
-
"
|
|
45
|
+
"workspace-isolation",
|
|
46
|
+
"guard"
|
|
24
47
|
],
|
|
25
48
|
"dsh": {
|
|
26
49
|
"bundle": {
|
package/release.mjs
CHANGED
|
@@ -166,15 +166,29 @@ if (dryRun) {
|
|
|
166
166
|
// * The output must be shown even on failure. The first version filtered it
|
|
167
167
|
// down to matching lines and printed a blank line when the command never
|
|
168
168
|
// ran, hiding the real problem behind a successful-looking step.
|
|
169
|
-
const
|
|
170
|
-
|
|
169
|
+
const publishRun = spawnSync(PUBLISHER, ['publish', '--registry', OFFICIAL_REGISTRY, '--access', 'public'], {
|
|
170
|
+
cwd: here,
|
|
171
|
+
env,
|
|
172
|
+
encoding: 'utf8',
|
|
171
173
|
shell: true,
|
|
172
174
|
});
|
|
175
|
+
const published = `${publishRun.stdout ?? ''}${publishRun.stderr ?? ''}`;
|
|
173
176
|
const summary = published
|
|
174
177
|
.split('\n')
|
|
175
178
|
.map((line) => line.trim())
|
|
176
179
|
.filter((line) => line.length > 0 && !/^Progress:|^Packages:|^\+?$/.test(line));
|
|
177
|
-
process.stdout.write(`${summary.map((line) => ` ${line}`).join('\n') || ' (the publisher printed nothing
|
|
180
|
+
process.stdout.write(`${summary.map((line) => ` ${line}`).join('\n') || ' (the publisher printed nothing)'}\n`);
|
|
181
|
+
// Stop here on a failed publish. Continuing to the registry poll turned a network
|
|
182
|
+
// blip into ten minutes of waiting and then a confident but wrong diagnosis
|
|
183
|
+
// ("staged publishing?"), when the publish command had simply not run.
|
|
184
|
+
if (publishRun.status !== 0) {
|
|
185
|
+
const reason = publishRun.error ? `${publishRun.error.code}: ${publishRun.error.message}` : `exit ${publishRun.status}`;
|
|
186
|
+
throw new Error(
|
|
187
|
+
`the publisher failed (${reason}), so nothing was published and the registry check would only time out.\n` +
|
|
188
|
+
`Retry the publish by hand once the cause is clear:\n` +
|
|
189
|
+
` ${PUBLISHER} publish --registry ${OFFICIAL_REGISTRY} --access public`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
178
192
|
}
|
|
179
193
|
|
|
180
194
|
// ── Confirm on the registry ──────────────────────────────────────────────────
|
package/verify-patch.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Usage: node verify-patch.mjs
|
|
11
11
|
|
|
12
|
-
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { CHILD_TOOLS, FORBIDDEN_CAPABILITIES, SCOPE_LOCAL_TOOLS } from './child-tools.mjs';
|
|
@@ -114,6 +114,25 @@ check(
|
|
|
114
114
|
|
|
115
115
|
const delegation = plugins.find((row) => row.id === 'delegation');
|
|
116
116
|
check(delegation?.group === true, 'delegation is a group row');
|
|
117
|
+
|
|
118
|
+
// The workspace fence travels with the preset, so one install carries the protocol and
|
|
119
|
+
// its enforcement. These assertions keep that promise honest: the row must exist, must
|
|
120
|
+
// resolve to this package's own module, and must ship that module.
|
|
121
|
+
const guardRow = plugins.find((row) => row.id === 'root-planner-guard');
|
|
122
|
+
check(guardRow !== undefined, 'the preset mounts the workspace guard as its own row');
|
|
123
|
+
check(
|
|
124
|
+
guardRow?.name === 'dsh-root-planner-preset/guard',
|
|
125
|
+
`the guard row resolves to this package's subpath (found ${JSON.stringify(guardRow?.name)})`,
|
|
126
|
+
);
|
|
127
|
+
check(guardRow?.disabled !== true, 'the guard row is enabled');
|
|
128
|
+
check(guardRow?.config?.directoryName === '_agents', 'the guard fences the _agents/ directory');
|
|
129
|
+
check(guardRow?.config?.guardShell === true, 'the guard also applies the shell rules');
|
|
130
|
+
check(existsSync(join(here, 'guard.js')), 'guard.js ships inside the package');
|
|
131
|
+
{
|
|
132
|
+
const manifest = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8'));
|
|
133
|
+
check(manifest.exports?.['./guard'] === './guard.js', 'package.json exports ./guard');
|
|
134
|
+
check((manifest.files ?? []).includes('guard.js'), 'guard.js is in the published files list');
|
|
135
|
+
}
|
|
117
136
|
const delegationRows = delegation?.config ?? [];
|
|
118
137
|
|
|
119
138
|
// Loader row id -> the model-facing tool name that row publishes.
|
package/.gitattributes
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# This bundle is verified by BYTE identity: verify-patch.mjs asserts that the
|
|
2
|
-
# persona parsed out of cordis.patch.yml equals persona.txt exactly. Git's
|
|
3
|
-
# autocrlf would rewrite both files on checkout and silently break that check on
|
|
4
|
-
# every Windows clone, so line endings are pinned to LF for the whole repository.
|
|
5
|
-
* text=auto eol=lf
|
|
6
|
-
|
|
7
|
-
# Binary if anyone ever adds artwork.
|
|
8
|
-
*.png binary
|
|
9
|
-
*.jpg binary
|
|
10
|
-
*.webp binary
|
package/.github/CONTRIBUTING.md
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
# Contributing
|
|
2
|
-
|
|
3
|
-
Thanks for looking. This repository is meant to be maintained by more than its
|
|
4
|
-
author — the license is [MIT](../LICENSE), and issues and pull requests are open
|
|
5
|
-
to anyone.
|
|
6
|
-
## The one thing to know first
|
|
7
|
-
|
|
8
|
-
**`cordis.patch.yml` is generated. Do not edit it by hand.**
|
|
9
|
-
|
|
10
|
-
It is produced from `persona.txt` by `build-patch.mjs`, and it has to be indented
|
|
11
|
-
into a YAML block scalar exactly — a single mis-indented line silently truncates
|
|
12
|
-
the ~6 KB persona, which is very hard to spot in review and easy to introduce by
|
|
13
|
-
hand. That is the whole reason the generator exists.
|
|
14
|
-
|
|
15
|
-
So to change what the agent is told:
|
|
16
|
-
|
|
17
|
-
```sh
|
|
18
|
-
$EDITOR persona.txt
|
|
19
|
-
node build-patch.mjs # regenerates cordis.patch.yml
|
|
20
|
-
node verify-patch.mjs # must print ALL CHECKS PASSED
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Commit **both** files. CI re-runs the generator and fails if the committed patch
|
|
24
|
-
does not match `persona.txt` byte for byte, so forgetting the second command is
|
|
25
|
-
caught for you.
|
|
26
|
-
|
|
27
|
-
## Setup
|
|
28
|
-
|
|
29
|
-
The bundle itself ships no dependencies. The repository's scripts need one YAML
|
|
30
|
-
parser, which `lib-yaml.mjs` resolves from your own DSH installation — or from a
|
|
31
|
-
local install:
|
|
32
|
-
|
|
33
|
-
```sh
|
|
34
|
-
npm ci # installs js-yaml, the only devDependency
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## What the checks mean
|
|
38
|
-
|
|
39
|
-
`node verify-patch.mjs` asserts, among other things:
|
|
40
|
-
|
|
41
|
-
- the patch parses as loader YAML (including the `!!js` scalars, read as opaque);
|
|
42
|
-
- the persona parsed out of the patch is **byte-identical** to `persona.txt`;
|
|
43
|
-
- both delegation tools publish `subagent` / `subagent_fork`;
|
|
44
|
-
- both `toolFilter.allow` masks exclude every mutation entry point
|
|
45
|
-
(`write`, `edit`, `ops_replace`, `ops_bash`, `pwsh`) and name **only tools that
|
|
46
|
-
exist** — a name that is not a registered global tool makes
|
|
47
|
-
`tools.restrict()` throw the first time a subagent is created.
|
|
48
|
-
|
|
49
|
-
That last point is the most likely thing to need changing on your machine: the
|
|
50
|
-
mask was written against the author's profile. If your profile registers a
|
|
51
|
-
different set of global tools, the mask is what has to adapt — edit the list near
|
|
52
|
-
the bottom of `verify-patch.mjs` in the same commit.
|
|
53
|
-
|
|
54
|
-
`node verify-depth-override.mjs` checks the delegation depth override. Depth is a
|
|
55
|
-
**Host setting, not part of this bundle**, so on a machine that never set it the
|
|
56
|
-
script prints `SKIP` and explains why. That is a pass, not a failure.
|
|
57
|
-
|
|
58
|
-
## Most valuable contributions, in order
|
|
59
|
-
|
|
60
|
-
1. **Bug reports with real error text**, plus your DSH version and which global
|
|
61
|
-
tools your profile registers. The `Not verified` section of the
|
|
62
|
-
[README](../README.md) names exactly where this matters: no subagent has been
|
|
63
|
-
spawned under this preset in the author's environment to date.
|
|
64
|
-
2. **Mask fixes for other environments** — see above.
|
|
65
|
-
3. **Persona improvements** — the text itself, when you have an observed failure
|
|
66
|
-
it would have prevented.
|
|
67
|
-
4. **Documentation and translations** — [README.zh.md](../README.zh.md) and
|
|
68
|
-
[INSTALL.md](../INSTALL.md) especially.
|
|
69
|
-
|
|
70
|
-
## Ground rules
|
|
71
|
-
|
|
72
|
-
- **One concern per pull request.** A persona change and a mask change are two
|
|
73
|
-
pull requests unless one requires the other.
|
|
74
|
-
- Say which preset you tested with, and on which platform.
|
|
75
|
-
- Do not weaken the **process bound**. `enableRunInBackground: false` must stay on
|
|
76
|
-
the preset's `tool-pwsh` row: a change that lets a subagent detach a server drops
|
|
77
|
-
the one property this preset enforces structurally rather than by asking nicely.
|
|
78
|
-
`verify-patch.mjs` fails if it is missing.
|
|
79
|
-
- A subagent keeps `write`, `edit`, `ops_replace` and `pwsh`. They are workers —
|
|
80
|
-
taking their tools away turns every executable step back into the root's own work,
|
|
81
|
-
which is the failure mode this preset exists to avoid.
|
|
82
|
-
- Keep `main` linear — the branch protection refuses merge commits, so rebase
|
|
83
|
-
rather than merge.
|
|
84
|
-
|
|
85
|
-
## Releasing
|
|
86
|
-
|
|
87
|
-
`node release.mjs <patch|minor|major|X.Y.Z>` does the whole sequence. Every step in
|
|
88
|
-
it exists because the manual version went wrong at least once:
|
|
89
|
-
|
|
90
|
-
1. regenerates `cordis.patch.yml` and refuses to continue unless
|
|
91
|
-
`verify-patch.mjs` passes;
|
|
92
|
-
2. bumps `package.json` and commits with a local-time stamp;
|
|
93
|
-
3. pushes (the owner bypasses branch protection; a collaborator's push is refused,
|
|
94
|
-
so open a pull request instead);
|
|
95
|
-
4. publishes to `https://registry.npmjs.org/` **explicitly** — the configured
|
|
96
|
-
registry is a read-only mirror that also lags behind, which is how an install
|
|
97
|
-
right after a release fails with *No matching version found*;
|
|
98
|
-
5. **polls the registry for the exact version** rather than trusting the exit code,
|
|
99
|
-
because publishing on this account is staged and `npm publish` can print success
|
|
100
|
-
without the version ever appearing;
|
|
101
|
-
6. upgrades the local profile so the machine runs what was published.
|
|
102
|
-
|
|
103
|
-
`--dry-run` walks the whole thing without changing anything; `--skip-install` and
|
|
104
|
-
`--skip-gh` drop the last two steps.
|
|
105
|
-
|
|
106
|
-
Run it from a terminal. Inside DSH's own sandbox its `spawn` calls fail with
|
|
107
|
-
`EPERM` on Windows — that is the sandbox's piped-stdio boundary, not a defect in
|
|
108
|
-
the script.
|
|
109
|
-
|
|
110
|
-
**A release is not a verification.** The script can put a version on npm while the
|
|
111
|
-
preset stays unproven. Restart `dsh web`, open a new session on Root Planner, and
|
|
112
|
-
confirm the *root* actually composed with it — `node tools/verify-session.mjs
|
|
113
|
-
<session-dir>` — before believing anything about behaviour.
|
|
114
|
-
|
|
115
|
-
## How `main` is protected
|
|
116
|
-
|
|
117
|
-
`main` requires the `verify` status check (strict: the branch must be up to date),
|
|
118
|
-
refuses force pushes and deletions, requires linear history, and requires
|
|
119
|
-
conversations to be resolved. The repository owner can bypass these for a direct
|
|
120
|
-
push in an emergency; **collaborators cannot** — send a pull request and let CI
|
|
121
|
-
run.
|
|
122
|
-
|
|
123
|
-
If you are added as a collaborator and want to be able to merge your own pull
|
|
124
|
-
requests without waiting for the owner, say so: review requirements are off
|
|
125
|
-
deliberately, because a single-maintainer repository that requires an approval it
|
|
126
|
-
cannot get would deadlock.
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# Verifies the bundle on every push and pull request.
|
|
2
|
-
#
|
|
3
|
-
# Two failure modes this exists to catch, both of which are silent locally:
|
|
4
|
-
#
|
|
5
|
-
# 1. `cordis.patch.yml` is generated from `persona.txt`. Someone edits the
|
|
6
|
-
# generated file by hand (or edits persona.txt and forgets to regenerate), and
|
|
7
|
-
# the persona the preset actually ships no longer matches the source. The
|
|
8
|
-
# generator is deterministic, so re-running it and diffing the result catches
|
|
9
|
-
# exactly that drift.
|
|
10
|
-
# 2. A change to the child tool mask names a tool that is not a registered global
|
|
11
|
-
# tool. `tools.restrict()` throws on an unknown name at child-creation time, so
|
|
12
|
-
# this only surfaces the first time a subagent is actually spawned — never in a
|
|
13
|
-
# local syntax check.
|
|
14
|
-
#
|
|
15
|
-
# The bundle itself ships no dependencies; `js-yaml` is a devDependency of this
|
|
16
|
-
# repository only, and `lib-yaml.mjs` picks it up from the checkout.
|
|
17
|
-
|
|
18
|
-
name: verify
|
|
19
|
-
|
|
20
|
-
on:
|
|
21
|
-
push:
|
|
22
|
-
branches: [main]
|
|
23
|
-
pull_request:
|
|
24
|
-
workflow_dispatch:
|
|
25
|
-
|
|
26
|
-
permissions:
|
|
27
|
-
contents: read
|
|
28
|
-
|
|
29
|
-
jobs:
|
|
30
|
-
verify:
|
|
31
|
-
runs-on: ubuntu-latest
|
|
32
|
-
steps:
|
|
33
|
-
- uses: actions/checkout@v4
|
|
34
|
-
|
|
35
|
-
- uses: actions/setup-node@v4
|
|
36
|
-
with:
|
|
37
|
-
node-version: '22'
|
|
38
|
-
|
|
39
|
-
- name: Install the local YAML parser
|
|
40
|
-
run: npm ci --ignore-scripts
|
|
41
|
-
|
|
42
|
-
- name: The committed patch matches persona.txt
|
|
43
|
-
run: |
|
|
44
|
-
node build-patch.mjs
|
|
45
|
-
if ! git diff --exit-code -- cordis.patch.yml; then
|
|
46
|
-
echo "::error file=cordis.patch.yml::cordis.patch.yml is out of date. Run 'node build-patch.mjs' and commit the result instead of editing it by hand."
|
|
47
|
-
exit 1
|
|
48
|
-
fi
|
|
49
|
-
|
|
50
|
-
- name: Patch structure, persona byte-identity and the child tool mask
|
|
51
|
-
run: node verify-patch.mjs
|