@minidoll/agentkit-dsh-plugin 0.0.0-stage → 0.1.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/README.md +59 -2
- package/cordis.patch.yml +5 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +288 -0
- package/dist/execute.d.ts +27 -0
- package/dist/execute.js +51 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +667 -0
- package/dist/inject.d.ts +74 -0
- package/dist/inject.js +248 -0
- package/dist/install.d.ts +81 -0
- package/dist/install.js +213 -0
- package/dist/payload.d.ts +37 -0
- package/dist/payload.js +64 -0
- package/package.json +51 -4
package/README.md
CHANGED
|
@@ -1,3 +1,60 @@
|
|
|
1
|
-
#
|
|
1
|
+
# agentkit-dsh-plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> DeepSeek Harness 插件:**项目自包含**的 lifecycle hooks —— 配置随仓库走,
|
|
4
|
+
> 按会话 cwd 发现,不需要为每个项目改宿主目录。
|
|
5
|
+
|
|
6
|
+
## 它做什么
|
|
7
|
+
|
|
8
|
+
在 DSH 的两个执行缝(`tools/pre-execute` / `tools/post-execute`)上跑命令钩子:
|
|
9
|
+
|
|
10
|
+
- **配置按会话 cwd 发现**:`<项目>/.agent/hooks/hooks.json`(CC 方言),
|
|
11
|
+
其次 `.claude/settings.json`、`.claude/hooks.json`;无配置 = 静默跳过
|
|
12
|
+
- **退出码语义**:`PreToolUse` 任何非零 → **阻断**(deny);`PostToolUse` → 只记录
|
|
13
|
+
- 配置坏掉**报错**,不会伪装成"没有配置"(否则等于静默放行)
|
|
14
|
+
- payload 走 stdin(`{event, hookId, toolName, args, sessionId, cwd}`),
|
|
15
|
+
env 含 `CLAUDE_PROJECT_DIR` / `DSH_HOOK_ID` / `DSH_SESSION_ID`
|
|
16
|
+
|
|
17
|
+
## 安装(宿主侧,显式部署动作)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 装进某个 profile(会写 ~/.dsh/profiles/<p>/package.json + node_modules)
|
|
21
|
+
pnpm add agentkit-dsh-plugin --dir ~/.dsh/profiles/web
|
|
22
|
+
# 或用 agentkit 的落地命令(幂等、带备份与审计)
|
|
23
|
+
dsh-landing install --home ~/.dsh --profile web
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
条目本身由 bundle 自带的 `cordis.patch.yml` 插入;`dsh-landing rollback` 可撤销。
|
|
27
|
+
|
|
28
|
+
## 项目侧
|
|
29
|
+
|
|
30
|
+
项目里**零改动**:agentkit 的 `dsh` 适配器会生成 `.agent/hooks/hooks.json`。
|
|
31
|
+
想手写也可以,CC 方言即可。
|
|
32
|
+
|
|
33
|
+
## 安全默认:观察模式
|
|
34
|
+
|
|
35
|
+
**装上即只记录、不阻断**(`observe`)。要真正拦截,显式开:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
AGENTKIT_HOOKS_ENFORCE=1 # 在 profile 的插件 env 里配置
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
这条默认来自一次真实事故(0.0.2):插件的 seam handler 在"无决策"路径上
|
|
42
|
+
**返回 `undefined`**,而 harness 直接读 `gate.kind`(无判空),于是
|
|
43
|
+
**每次工具调用**都抛 `Cannot read properties of undefined (reading 'kind')` ——
|
|
44
|
+
整个会话、所有工具、跨会话全挂。修复见 `src/index.ts` 的 seam 契约注释。
|
|
45
|
+
|
|
46
|
+
**由此确立的三条规则**(都已由测试钉住):
|
|
47
|
+
|
|
48
|
+
1. seam handler **必须始终返回带 `kind` 的对象**(`ALLOW` 常量 + 测试不变式)
|
|
49
|
+
2. **fail-safe**:插件任何故障都不得外溢到工具层 —— 捕获后放行并记日志
|
|
50
|
+
(连 `ctx.write` 抛错也不能让兜底二次抛出)
|
|
51
|
+
3. **未经真实会话验证的决策行为不得默认生效** —— 所以默认观察模式
|
|
52
|
+
|
|
53
|
+
## 版本跟随
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
0.0.x ↔ DeepSeek Harness 0.2.x
|
|
57
|
+
seam:tools/pre-execute · tools/post-execute · ctx.shell
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
设计与论证见 agentkit 仓的 `docs/design/0001`(为什么自己做)与 `0002`(Target 模型)。
|
package/cordis.patch.yml
ADDED
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { homedir as homedir2 } from "node:os";
|
|
5
|
+
import { join as join2 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/install.ts
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
var ROW_ID = "project-hooks";
|
|
13
|
+
var ROW_NAME = "@minidoll/agentkit-dsh-plugin";
|
|
14
|
+
var STATE_DIR = ".agentkit-landing";
|
|
15
|
+
function landingPaths(home2, profile2) {
|
|
16
|
+
const patch = join(home2, "profiles", profile2, "cordis.patch.yml");
|
|
17
|
+
return { home: home2, profile: profile2, patch, stateDir: join(home2, "profiles", profile2, STATE_DIR) };
|
|
18
|
+
}
|
|
19
|
+
function rowPresent(patchText) {
|
|
20
|
+
return patchText.includes(`id: ${ROW_ID}`) || patchText.includes(ROW_NAME);
|
|
21
|
+
}
|
|
22
|
+
function renderRow(configFile2) {
|
|
23
|
+
const lines = [`- id: ${ROW_ID}`, ` name: ${ROW_NAME}`];
|
|
24
|
+
if (configFile2 !== void 0) {
|
|
25
|
+
lines.push(" config:", ` configFile: ${configFile2}`);
|
|
26
|
+
}
|
|
27
|
+
return `${lines.join("\n")}
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
function audit(paths, action, detail) {
|
|
31
|
+
mkdirSync(paths.stateDir, { recursive: true });
|
|
32
|
+
const rec = { at: (/* @__PURE__ */ new Date()).toISOString(), action, profile: paths.profile, ...detail };
|
|
33
|
+
appendFileSync(join(paths.stateDir, "ledger.jsonl"), `${JSON.stringify(rec)}
|
|
34
|
+
`, "utf8");
|
|
35
|
+
}
|
|
36
|
+
function install(opts) {
|
|
37
|
+
const paths = landingPaths(opts.home, opts.profile);
|
|
38
|
+
if (!existsSync(join(opts.home, "profiles", opts.profile))) {
|
|
39
|
+
return { ok: false, changed: false, path: paths.patch, reason: `profile not found: ${opts.home}/profiles/${opts.profile}` };
|
|
40
|
+
}
|
|
41
|
+
if (!existsSync(paths.patch)) {
|
|
42
|
+
mkdirSync(paths.stateDir, { recursive: true });
|
|
43
|
+
writeFileSync(paths.patch, `${renderRow(opts.configFile)}`, "utf8");
|
|
44
|
+
audit(paths, "install", { backup: null, configFile: opts.configFile ?? null, created: true });
|
|
45
|
+
return { ok: true, changed: true, path: paths.patch, reason: "created" };
|
|
46
|
+
}
|
|
47
|
+
const before = readFileSync(paths.patch, "utf8");
|
|
48
|
+
if (rowPresent(before)) {
|
|
49
|
+
audit(paths, "install-noop", { reason: "already present" });
|
|
50
|
+
return { ok: true, changed: false, path: paths.patch, reason: "already present" };
|
|
51
|
+
}
|
|
52
|
+
mkdirSync(paths.stateDir, { recursive: true });
|
|
53
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
54
|
+
const backup = join(paths.stateDir, `backup-${stamp}.yml`);
|
|
55
|
+
copyFileSync(paths.patch, backup);
|
|
56
|
+
const next = before.endsWith("\n") || before === "" ? `${before}${renderRow(opts.configFile)}` : `${before}
|
|
57
|
+
${renderRow(opts.configFile)}`;
|
|
58
|
+
writeFileSync(paths.patch, next, "utf8");
|
|
59
|
+
audit(paths, "install", { backup, configFile: opts.configFile ?? null });
|
|
60
|
+
return { ok: true, changed: true, path: paths.patch, backup };
|
|
61
|
+
}
|
|
62
|
+
function rollback(opts) {
|
|
63
|
+
const paths = landingPaths(opts.home, opts.profile);
|
|
64
|
+
if (!existsSync(paths.stateDir)) {
|
|
65
|
+
return { ok: false, changed: false, path: paths.patch, reason: "no state directory \u2014 nothing to roll back" };
|
|
66
|
+
}
|
|
67
|
+
const backups = readdirSync(paths.stateDir).filter((f) => f.startsWith("backup-") && f.endsWith(".yml")).sort();
|
|
68
|
+
const last = backups.at(-1);
|
|
69
|
+
if (last === void 0) {
|
|
70
|
+
return { ok: false, changed: false, path: paths.patch, reason: "no backup recorded" };
|
|
71
|
+
}
|
|
72
|
+
copyFileSync(join(paths.stateDir, last), paths.patch);
|
|
73
|
+
audit(paths, "rollback", { restoredFrom: last });
|
|
74
|
+
return { ok: true, changed: true, path: paths.patch, backup: join(paths.stateDir, last) };
|
|
75
|
+
}
|
|
76
|
+
function status(opts) {
|
|
77
|
+
const paths = landingPaths(opts.home, opts.profile);
|
|
78
|
+
if (!existsSync(paths.patch)) {
|
|
79
|
+
return { ok: true, installed: false, path: paths.patch, backups: [] };
|
|
80
|
+
}
|
|
81
|
+
const text = readFileSync(paths.patch, "utf8");
|
|
82
|
+
const m = new RegExp(`configFile:\\s*(\\S+)`).exec(text);
|
|
83
|
+
const backups = existsSync(paths.stateDir) ? readdirSync(paths.stateDir).filter((f) => f.startsWith("backup-")).sort() : [];
|
|
84
|
+
return { ok: true, installed: rowPresent(text), path: paths.patch, backups, ...m?.[1] !== void 0 ? { configFile: m[1] } : {} };
|
|
85
|
+
}
|
|
86
|
+
function readProfileJson(home2, profile2) {
|
|
87
|
+
const p = join(home2, "profiles", profile2, "package.json");
|
|
88
|
+
if (!existsSync(p)) return null;
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function installBundle(opts) {
|
|
96
|
+
const profileDir = join(opts.home, "profiles", opts.profile);
|
|
97
|
+
if (!existsSync(join(opts.home, "profiles", opts.profile))) {
|
|
98
|
+
return { ok: false, added: false, alreadyInstalled: false, error: `profile not found: ${profileDir}` };
|
|
99
|
+
}
|
|
100
|
+
const pkg = readProfileJson(opts.home, opts.profile);
|
|
101
|
+
if (pkg === null) return { ok: false, added: false, alreadyInstalled: false, error: `cannot read ${join(profileDir, "package.json")}` };
|
|
102
|
+
const deps = pkg.dependencies ?? {};
|
|
103
|
+
const dsh = pkg.dsh ?? {};
|
|
104
|
+
const bundles = dsh.profile?.bundles ?? [];
|
|
105
|
+
const installed = Object.keys(deps).some((k) => k === ROW_NAME) || bundles.includes(ROW_NAME);
|
|
106
|
+
if (installed) return { ok: true, added: false, alreadyInstalled: true };
|
|
107
|
+
if (opts.skipPackageManager !== true) {
|
|
108
|
+
const pm = resolvePnpm();
|
|
109
|
+
if (pm === null) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
added: false,
|
|
113
|
+
alreadyInstalled: false,
|
|
114
|
+
error: "pnpm not found (tried PATH, ~/.local/share/pnpm/pnpm, npx). Install pnpm, or rerun with --skip-pnpm and install the package into the profile by hand."
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const r = spawnSync(pm, ["add", opts.from], { cwd: profileDir, encoding: "utf8", timeout: 18e4 });
|
|
118
|
+
if (r.status !== 0) {
|
|
119
|
+
const detail = [r.error?.message, r.stdout, r.stderr].filter((x) => typeof x === "string").join("\n").trim();
|
|
120
|
+
return { ok: false, added: false, alreadyInstalled: false, error: `${pm} add failed (exit ${String(r.status)})
|
|
121
|
+
${detail.slice(-600)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const next = {
|
|
125
|
+
...pkg,
|
|
126
|
+
dependencies: { ...deps, [ROW_NAME]: opts.skipPackageManager === true ? "file:." : opts.from },
|
|
127
|
+
dsh: { ...dsh, profile: { ...dsh.profile ?? {}, bundles: [...bundles, ROW_NAME] } }
|
|
128
|
+
};
|
|
129
|
+
writeFileSync(join(profileDir, "package.json"), `${JSON.stringify(next, null, 2)}
|
|
130
|
+
`, "utf8");
|
|
131
|
+
audit(landingPaths(opts.home, opts.profile), "install-bundle", { from: opts.from });
|
|
132
|
+
return { ok: true, added: true, alreadyInstalled: false };
|
|
133
|
+
}
|
|
134
|
+
function resolvePnpm() {
|
|
135
|
+
const candidates = ["pnpm", join(homedirPath(), ".local/share/pnpm/pnpm")];
|
|
136
|
+
for (const c of candidates) {
|
|
137
|
+
const probe = spawnSync(c, ["--version"], { encoding: "utf8", timeout: 15e3 });
|
|
138
|
+
if (probe.status === 0) return c;
|
|
139
|
+
}
|
|
140
|
+
const viaNpx = spawnSync("npx", ["--no-install", "pnpm", "--version"], { encoding: "utf8", timeout: 2e4 });
|
|
141
|
+
if (viaNpx.status === 0) return "npx pnpm";
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function homedirPath() {
|
|
145
|
+
try {
|
|
146
|
+
return homedir();
|
|
147
|
+
} catch {
|
|
148
|
+
return process.env.HOME ?? "";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function rollbackBundle(opts) {
|
|
152
|
+
const patchRollback = rollback({ home: opts.home, profile: opts.profile });
|
|
153
|
+
const profileDir = join(opts.home, "profiles", opts.profile);
|
|
154
|
+
const pkg = readProfileJson(opts.home, opts.profile);
|
|
155
|
+
if (pkg === null) {
|
|
156
|
+
return { ok: false, removed: false, patchRollback, error: `cannot read ${join(profileDir, "package.json")}` };
|
|
157
|
+
}
|
|
158
|
+
const deps = pkg.dependencies ?? {};
|
|
159
|
+
const dsh = pkg.dsh ?? {};
|
|
160
|
+
const bundles = dsh.profile?.bundles ?? [];
|
|
161
|
+
const had = ROW_NAME in deps || bundles.includes(ROW_NAME);
|
|
162
|
+
if (!had) {
|
|
163
|
+
audit(landingPaths(opts.home, opts.profile), "rollback-bundle-noop", { reason: "not registered" });
|
|
164
|
+
return { ok: true, removed: false, patchRollback };
|
|
165
|
+
}
|
|
166
|
+
const nextDeps = { ...deps };
|
|
167
|
+
delete nextDeps[ROW_NAME];
|
|
168
|
+
const nextBundles = bundles.filter((b) => b !== ROW_NAME);
|
|
169
|
+
const next = { ...pkg, dependencies: nextDeps, dsh: { ...dsh, profile: { ...dsh.profile ?? {}, bundles: nextBundles } } };
|
|
170
|
+
writeFileSync(join(profileDir, "package.json"), `${JSON.stringify(next, null, 2)}
|
|
171
|
+
`, "utf8");
|
|
172
|
+
if (opts.skipPackageManager !== true) {
|
|
173
|
+
const pm = resolvePnpm();
|
|
174
|
+
if (pm !== null) {
|
|
175
|
+
const r = spawnSync(pm, ["remove", ROW_NAME], { cwd: profileDir, encoding: "utf8", timeout: 18e4 });
|
|
176
|
+
if (r.status !== 0) {
|
|
177
|
+
const detail = [r.stdout, r.stderr].filter((x) => typeof x === "string").join("\n").trim();
|
|
178
|
+
audit(landingPaths(opts.home, opts.profile), "rollback-bundle-partial", { detail: detail.slice(-300) });
|
|
179
|
+
return { ok: false, removed: false, patchRollback, error: `package removal failed: ${detail.slice(-300)}` };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
audit(landingPaths(opts.home, opts.profile), "rollback-bundle", {});
|
|
184
|
+
return { ok: true, removed: true, patchRollback };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/cli.ts
|
|
188
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
189
|
+
stream.on("error", (err) => {
|
|
190
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
191
|
+
throw err;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
var USAGE = `dsh-landing <install|rollback|status> [--profile <name>] [--home <path>] [--configFile <abs>]
|
|
195
|
+
|
|
196
|
+
install \u628A\u63D2\u4EF6\u6761\u76EE\u8FFD\u52A0\u8FDB <home>/profiles/<profile>/cordis.patch.yml\uFF08\u5E42\u7B49\uFF1B\u5199\u524D\u5907\u4EFD\uFF09
|
|
197
|
+
rollback \u6062\u590D\u6700\u8FD1\u4E00\u6B21\u5907\u4EFD\uFF08\u64A4\u9500\u6211\u4EEC\u8FFD\u52A0\u7684\u6761\u76EE\uFF0C\u4E0D\u52A8\u522B\u4EBA\u7684\u5185\u5BB9\uFF09
|
|
198
|
+
status \u62A5\u544A\u662F\u5426\u5DF2\u5B89\u88C5\u3001\u5907\u4EFD\u6570\u3001configFile
|
|
199
|
+
|
|
200
|
+
\u5BBF\u4E3B\u6539\u52A8\u662F**\u663E\u5F0F\u90E8\u7F72\u52A8\u4F5C**\uFF1A\u6BCF\u6B21\u52A8\u4F5C\u5199\u5BA1\u8BA1\u5230 <profile>/.agentkit-landing/ledger.jsonl\u3002`;
|
|
201
|
+
function flag(args2, name) {
|
|
202
|
+
const i = args2.indexOf(`--${name}`);
|
|
203
|
+
return i >= 0 ? args2[i + 1] : void 0;
|
|
204
|
+
}
|
|
205
|
+
var args = process.argv.slice(2);
|
|
206
|
+
var cmd = args[0];
|
|
207
|
+
if (cmd === void 0 || cmd === "--help" || cmd === "-h") {
|
|
208
|
+
process.stdout.write(`${USAGE}
|
|
209
|
+
`);
|
|
210
|
+
process.exit(cmd === void 0 ? 1 : 0);
|
|
211
|
+
}
|
|
212
|
+
var home = flag(args, "home") ?? process.env.DSH_HOME ?? join2(homedir2(), ".dsh");
|
|
213
|
+
var profile = flag(args, "profile") ?? "web";
|
|
214
|
+
var configFile = flag(args, "configFile");
|
|
215
|
+
if (cmd === "install") {
|
|
216
|
+
const from = flag(args, "from");
|
|
217
|
+
if (from !== void 0) {
|
|
218
|
+
const b = installBundle({
|
|
219
|
+
home,
|
|
220
|
+
profile,
|
|
221
|
+
from,
|
|
222
|
+
...args.includes("--skip-pnpm") ? { skipPackageManager: true } : {}
|
|
223
|
+
});
|
|
224
|
+
if (!b.ok) {
|
|
225
|
+
process.stderr.write(`dsh-landing: bundle install failed \u2014 ${b.error}
|
|
226
|
+
`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
process.stdout.write(
|
|
230
|
+
b.alreadyInstalled ? `dsh-landing: plugin already installed in profile ${profile}
|
|
231
|
+
` : `dsh-landing: plugin installed into ${home}/profiles/${profile}/node_modules and registered in dsh.profile.bundles
|
|
232
|
+
`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (from !== void 0 && configFile === void 0) {
|
|
236
|
+
process.stdout.write(" row: provided by the bundle's own cordis.patch.yml (no override needed)\n");
|
|
237
|
+
process.exit(0);
|
|
238
|
+
}
|
|
239
|
+
const r = install({ home, profile, ...configFile !== void 0 ? { configFile } : {} });
|
|
240
|
+
if (!r.ok) {
|
|
241
|
+
process.stderr.write(`dsh-landing: install failed \u2014 ${r.reason}
|
|
242
|
+
`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
process.stdout.write(`dsh-landing: ${r.changed ? "installed" : "already present"} \u2192 ${r.path}
|
|
246
|
+
`);
|
|
247
|
+
if (r.backup !== void 0) process.stdout.write(` backup: ${r.backup}
|
|
248
|
+
`);
|
|
249
|
+
if (r.changed) {
|
|
250
|
+
process.stdout.write(" next: restart DSH (or /hooks-reload) \u2014 \u63D2\u4EF6\u6761\u76EE\u9700\u8981 profile \u91CD\u65B0\u5408\u6210\u624D\u751F\u6548\n");
|
|
251
|
+
process.stdout.write(" undo: dsh-landing rollback\n");
|
|
252
|
+
}
|
|
253
|
+
} else if (cmd === "rollback") {
|
|
254
|
+
const r = args.includes("--bundle") ? rollbackBundle({ home, profile, ...args.includes("--skip-pnpm") ? { skipPackageManager: true } : {} }) : (() => {
|
|
255
|
+
const only = rollback({ home, profile });
|
|
256
|
+
return { ok: only.ok, removed: false, patchRollback: only, ...only.reason !== void 0 ? { error: only.reason } : {} };
|
|
257
|
+
})();
|
|
258
|
+
if (!r.ok) {
|
|
259
|
+
process.stderr.write(`dsh-landing: rollback failed \u2014 ${r.error ?? "unknown"}
|
|
260
|
+
`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
process.stdout.write(`dsh-landing: rolled back patch from ${r.patchRollback.backup ?? "(no backup)"}
|
|
264
|
+
`);
|
|
265
|
+
if (r.removed) process.stdout.write(" bundle \u6CE8\u518C\u4E0E\u5305\u5DF2\u79FB\u9664 \u2014\u2014 \u91CD\u542F DSH \u751F\u6548\n");
|
|
266
|
+
process.exit(0);
|
|
267
|
+
} else if (cmd === "status") {
|
|
268
|
+
const s = status({ home, profile });
|
|
269
|
+
const p = landingPaths(home, profile);
|
|
270
|
+
process.stdout.write(`dsh-landing: profile ${profile} (${home}/profiles/${profile})
|
|
271
|
+
`);
|
|
272
|
+
process.stdout.write(` installed: ${s.installed ? "yes" : "no"}
|
|
273
|
+
`);
|
|
274
|
+
process.stdout.write(` patch: ${s.path}
|
|
275
|
+
`);
|
|
276
|
+
process.stdout.write(` configFile:${s.configFile ?? " (project-discovered default)"}
|
|
277
|
+
`);
|
|
278
|
+
process.stdout.write(` backups: ${s.backups.length}
|
|
279
|
+
`);
|
|
280
|
+
process.stdout.write(` audit: ${join2(p.stateDir, "ledger.jsonl")}
|
|
281
|
+
`);
|
|
282
|
+
} else {
|
|
283
|
+
process.stderr.write(`dsh-landing: unknown command ${cmd}
|
|
284
|
+
|
|
285
|
+
${USAGE}
|
|
286
|
+
`);
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface ExecRequest {
|
|
2
|
+
command: string;
|
|
3
|
+
cwd: string;
|
|
4
|
+
timeoutMs: number;
|
|
5
|
+
stdin: string;
|
|
6
|
+
env: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export type ShellLike = {
|
|
9
|
+
resolve: (req: unknown) => unknown;
|
|
10
|
+
run: (req: unknown) => Promise<{
|
|
11
|
+
exitCode: number;
|
|
12
|
+
stderr?: {
|
|
13
|
+
text?: string;
|
|
14
|
+
};
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
export declare function runHookCommand(req: ExecRequest, shell?: ShellLike): Promise<{
|
|
18
|
+
exitCode: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* 取 shell 服务必须**防御式**:cordis 的服务属性在未 inject 时**直接抛错**
|
|
23
|
+
* (`cannot get property "shell" without inject`),而不是返回 undefined。
|
|
24
|
+
* 真实 seam 里就是这么炸的:`shell?.run` 在属性访问那一步就抛了。
|
|
25
|
+
* 取不到就退回 spawnSync(这条路径已被证明能正确写台账)。
|
|
26
|
+
*/
|
|
27
|
+
export declare function safeShell(ctx: unknown): ShellLike | undefined;
|
package/dist/execute.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 命令执行:优先用 DSH 的 `ctx.shell`(沙箱 + 超时 + stdin payload),
|
|
3
|
+
* 没有 shell 服务时退回 child_process(插件 README 的诚实边界:
|
|
4
|
+
* "若 ctx.shell 未挂载,插件照常加载但 hook 不会运行" —— 我们选择**退回**
|
|
5
|
+
* 而不是静默不跑,但会在 stderr 说明)。
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
export async function runHookCommand(req, shell) {
|
|
9
|
+
if (shell !== undefined && typeof shell.run === 'function' && typeof shell.resolve === 'function') {
|
|
10
|
+
try {
|
|
11
|
+
const resolved = shell.resolve({
|
|
12
|
+
command: req.command,
|
|
13
|
+
workdir: req.cwd,
|
|
14
|
+
timeoutMs: req.timeoutMs,
|
|
15
|
+
stdin: req.stdin,
|
|
16
|
+
env: req.env,
|
|
17
|
+
});
|
|
18
|
+
const r = await shell.run(resolved);
|
|
19
|
+
return { exitCode: r.exitCode };
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// infra 故障不能伪装成"钩子失败" —— 交给调用方区分
|
|
23
|
+
return { exitCode: 1, error: `ctx.shell failure: ${err.message}` };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const r = spawnSync('sh', ['-c', req.command], {
|
|
27
|
+
cwd: req.cwd,
|
|
28
|
+
timeout: req.timeoutMs,
|
|
29
|
+
input: req.stdin,
|
|
30
|
+
env: { ...process.env, ...req.env },
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
});
|
|
33
|
+
return { exitCode: r.status ?? 1, ...(r.status === null ? { error: `signal: ${String(r.signal)}` } : {}) };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 取 shell 服务必须**防御式**:cordis 的服务属性在未 inject 时**直接抛错**
|
|
37
|
+
* (`cannot get property "shell" without inject`),而不是返回 undefined。
|
|
38
|
+
* 真实 seam 里就是这么炸的:`shell?.run` 在属性访问那一步就抛了。
|
|
39
|
+
* 取不到就退回 spawnSync(这条路径已被证明能正确写台账)。
|
|
40
|
+
*/
|
|
41
|
+
export function safeShell(ctx) {
|
|
42
|
+
try {
|
|
43
|
+
const shell = ctx?.shell;
|
|
44
|
+
return shell !== undefined && typeof shell.run === 'function' && typeof shell.resolve === 'function'
|
|
45
|
+
? shell
|
|
46
|
+
: undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type UserMessageLike } from './inject.js';
|
|
2
|
+
import { type ShellLike } from './execute.js';
|
|
3
|
+
/**
|
|
4
|
+
* seam 收到的**真实** exec 形状(读 dsh-tools 的 createExecution 得来):
|
|
5
|
+
* { token, callId, rootCallId, name, signal, agent, parent?, schema?, arguments }
|
|
6
|
+
* 字段名是 **name** 与 **arguments** —— 不是 toolName/args。
|
|
7
|
+
* 0.0.4 之所以在真实会话里毫无动静:我把字段名猜错了,toolName 恒为 '',
|
|
8
|
+
* matcher('Edit|Write|NotebookEdit') 永不命中 → 静默放行(观察模式下也不报错)。
|
|
9
|
+
*/
|
|
10
|
+
export interface HookExec {
|
|
11
|
+
/** 工具名(真实字段)。 */
|
|
12
|
+
name?: unknown;
|
|
13
|
+
/** 工具参数(真实字段,deep-frozen JSON)。 */
|
|
14
|
+
arguments?: unknown;
|
|
15
|
+
agent?: {
|
|
16
|
+
session?: {
|
|
17
|
+
header?: {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
};
|
|
20
|
+
id?: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* post 缝的返回(`PostToolDecision`):只有它才认 `additionalContexts`。
|
|
27
|
+
* pre 缝用 {@link Decision}(allow/deny),塞 additionalContexts 是无效字段(0016 §1.2)。
|
|
28
|
+
*/
|
|
29
|
+
export interface PostDecision {
|
|
30
|
+
kind: 'accept';
|
|
31
|
+
additionalContexts?: UserMessageLike[];
|
|
32
|
+
}
|
|
33
|
+
export interface Decision {
|
|
34
|
+
kind: 'allow' | 'deny';
|
|
35
|
+
reason?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface PluginCtx {
|
|
38
|
+
on: (seam: string, cb: (exec: HookExec) => Promise<Decision | PostDecision | undefined>) => void;
|
|
39
|
+
shell?: ShellLike;
|
|
40
|
+
write?: (text: string) => void;
|
|
41
|
+
}
|
|
42
|
+
/** 一次执行的可观测记录(v0:最近 20 条,供 /hooks 或后续命令展示)。 */
|
|
43
|
+
export interface HookRun {
|
|
44
|
+
hookId: string;
|
|
45
|
+
event: string;
|
|
46
|
+
toolName: string;
|
|
47
|
+
exitCode: number;
|
|
48
|
+
at: number;
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface PluginState {
|
|
52
|
+
runs: HookRun[];
|
|
53
|
+
}
|
|
54
|
+
export declare function createProjectHooksPlugin(ctx: PluginCtx): PluginState;
|
|
55
|
+
/** DSH 插件入口形态(apply(ctx))—— 供 loader 挂载。 */
|
|
56
|
+
export declare function apply(ctx: PluginCtx): PluginState;
|