@elinpf/dsh-ops-shell-tool 0.1.7 → 0.2.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 +2 -1
- package/README.zh.md +2 -1
- package/lib/index.d.ts +8 -0
- package/lib/index.js +66 -4
- package/lib/types.d.ts +20 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@ A pure library (not a plugin) — the single home for the boilerplate every ops
|
|
|
10
10
|
- **Resolve per call** — the profile is resolved through `ctx.get('opsAccess')` inside execute, never statically injected, never cached.
|
|
11
11
|
- **Credential tokens** — `buildCommand` marks file-bearing fields with `ref(field)`, which mints a display token `<id@tier:field>`; the executed command carries the shell-quoted real value, while the displayed command and all captured stdout/stderr are scrubbed back to tokens. Credential paths never reach the model or the session event log.
|
|
12
12
|
- **Honest kill reporting** — 30 s default timeout; a signal death normalizes to `exitCode: -1` with the cause (timeout / caller abort / signal name) spelled out in the `error` field, never a bare -1.
|
|
13
|
+
- **Environment-failure translation** — when stderr matches a known local-environment signature (e.g. ssh's startup `Couldn't open /dev/null` — the sandbox/host made /dev/null unwritable, so the command never left the machine), the `error` field carries the diagnosis and remediation instead of letting the model suspect credentials/network/remote.
|
|
13
14
|
- **stderr noise filtering** — consumer-declared regexes drop known-noise stderr lines (e.g. ceph keyring chatter) after scrubbing.
|
|
14
15
|
- **`shellQuote`** — exported for consumers that must embed a whole remote command as one argument (ops-tool-ssh).
|
|
15
16
|
|
|
@@ -21,7 +22,7 @@ A pure library (not a plugin) — the single home for the boilerplate every ops
|
|
|
21
22
|
|
|
22
23
|
## Configuration
|
|
23
24
|
|
|
24
|
-
None — the package has no plugin `Config`. All behavior is parameterized per consumer through `ProfiledShellToolSpec`: `name`, `kind`, `targetParam`, the three description strings, `buildCommand`, `timeoutMs` (default 30000), and `stderrNoise`.
|
|
25
|
+
None — the package has no plugin `Config`. All behavior is parameterized per consumer through `ProfiledShellToolSpec`: `name`, `kind`, `targetParam`, the three description strings, `buildCommand`, `timeoutMs` (default 30000), `perCallTimeout` (adds a 1–600s `timeoutSec` call parameter), `rejectShellComposition` (rejects `;` `&&` `||` backticks `$()` and newlines with a teaching error — a single `|` pipe stays allowed), and `stderrNoise`.
|
|
25
26
|
|
|
26
27
|
## Testing
|
|
27
28
|
|
package/README.zh.md
CHANGED
|
@@ -10,6 +10,7 @@ ops-access 消费方工具共享的工厂:统一的 shell 结果形状 `{ exit
|
|
|
10
10
|
- **按调用解析** — profile 在 execute 内通过 `ctx.get('opsAccess')` 解析,不做静态 inject,不缓存。
|
|
11
11
|
- **凭据 token** — `buildCommand` 用 `ref(field)` 标记含文件的字段,生成展示 token `<id@tier:field>`;实际执行的命令携带 shell 转义后的真实值,而展示命令和捕获的 stdout/stderr 全部洗回 token。凭据路径永远不进入模型上下文或会话事件日志。
|
|
12
12
|
- **诚实的 kill 报告** — 默认 30s 超时;信号死亡归一为 `exitCode: -1`,并在 `error` 字段写明原因(超时 / 调用方取消 / 信号名),绝不留一个光秃秃的 -1。
|
|
13
|
+
- **环境故障翻译** — stderr 命中已知环境签名(如 ssh 启动期 `Couldn't open /dev/null`——沙箱/宿主机把 /dev/null 变不可写,命令根本没出门)时,`error` 字段追加定位结论与修复指引,避免模型误查凭证/网络/远端。
|
|
13
14
|
- **stderr 噪音过滤** — 消费方声明的正则在清洗之后丢弃已知噪音行(如 ceph keyring 唠叨)。
|
|
14
15
|
- **`shellQuote`** — 导出给需要把整条远端命令作为单个参数嵌入的消费方(ops-tool-ssh)。
|
|
15
16
|
|
|
@@ -21,7 +22,7 @@ ops-access 消费方工具共享的工厂:统一的 shell 结果形状 `{ exit
|
|
|
21
22
|
|
|
22
23
|
## 配置项
|
|
23
24
|
|
|
24
|
-
无 —— 本包没有插件 `Config`。所有行为由消费方通过 `ProfiledShellToolSpec` 参数化:`name`、`kind`、`targetParam`、三段描述文案、`buildCommand`、`timeoutMs`(默认 30000)、`stderrNoise`。
|
|
25
|
+
无 —— 本包没有插件 `Config`。所有行为由消费方通过 `ProfiledShellToolSpec` 参数化:`name`、`kind`、`targetParam`、三段描述文案、`buildCommand`、`timeoutMs`(默认 30000)、`perCallTimeout`(为工具增加 1–600 秒的 `timeoutSec` 调用参数)、`rejectShellComposition`(拒绝 `;` `&&` `||` 反引号 `$()` 与换行并给出教学式报错——单个 `|` 管道仍然允许)、`stderrNoise`。
|
|
25
26
|
|
|
26
27
|
## 测试方式
|
|
27
28
|
|
package/lib/index.d.ts
CHANGED
|
@@ -35,6 +35,14 @@ export type { CredentialRef, ProfiledShellToolSpec, ShellToolExec, ShellToolResu
|
|
|
35
35
|
* command as ONE argument (ops-tool-ssh).
|
|
36
36
|
*/
|
|
37
37
|
export declare function shellQuote(value: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Reject a composed command with a teaching error. Returns the message when
|
|
40
|
+
* the command is rejected, undefined when it is clean. Names the operator
|
|
41
|
+
* and the exact failure it would have caused — the bare 'xxx: command not
|
|
42
|
+
* found' the shell would produce sends the model suspecting the cluster
|
|
43
|
+
* instead of its own command shape.
|
|
44
|
+
*/
|
|
45
|
+
export declare function shellCompositionError(toolName: string, command: string): string | undefined;
|
|
38
46
|
/**
|
|
39
47
|
* Register a profiled shell tool on `ctx.tools`, disposed with the plugin's
|
|
40
48
|
* fiber. The caller's plugin must declare `inject = ['shell', 'tools']`.
|
package/lib/index.js
CHANGED
|
@@ -86,6 +86,44 @@ function dropNoiseLines(text, patterns) {
|
|
|
86
86
|
return text;
|
|
87
87
|
return text.split('\n').filter((line) => !patterns.some((p) => p.test(line))).join('\n');
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Recognize a broken LOCAL execution environment from stderr and translate it
|
|
91
|
+
* into actionable guidance. The trigger signature is ssh's startup open of
|
|
92
|
+
* /dev/null (O_RDWR) being denied — the command never reached the network,
|
|
93
|
+
* so without this note the model burns steps suspecting the credential, the
|
|
94
|
+
* network, and the remote host (it did, 2026-09-10). The real causes are a
|
|
95
|
+
* clobbered /dev/null on the dsh host (a regular file where the char device
|
|
96
|
+
* should be — classic in badly-built containers) or a sandbox profile that
|
|
97
|
+
* made /dev/null read-only (old-kernel Landlock partial enforcement, or a
|
|
98
|
+
* custom runnerOverride). Neither is fixable from here — the fix is the
|
|
99
|
+
* host's.
|
|
100
|
+
*/
|
|
101
|
+
function diagnoseSandboxEnv(stderr) {
|
|
102
|
+
if (!stderr.includes('Couldn\'t open /dev/null'))
|
|
103
|
+
return undefined;
|
|
104
|
+
return 'local execution environment failure (NOT the credential, the network, or the remote host): a process on the dsh host could not open /dev/null read-write at startup. Check the host: `ls -la /dev/null` must be a `crw-rw-rw-` character device — if it is a regular file, recreate it: `rm -f /dev/null && mknod -m 666 /dev/null c 1 3`. If /dev/null is healthy, the execution sandbox made it read-only (old-kernel Landlock partial enforcement, or a custom sandbox runnerOverride) — fix the sandbox policy; retrying the command will not help.';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Shell composition operators that would split the model's command into a
|
|
108
|
+
* second LOCAL command without the tool's binary prefix and credentials.
|
|
109
|
+
* A single `|` is deliberately absent: piping the wrapped command's output
|
|
110
|
+
* to a local filter (get pods | grep Running) is a documented, useful case.
|
|
111
|
+
*/
|
|
112
|
+
const SHELL_COMPOSITION = /;|&&|\|\||`|\$\(|\r|\n/;
|
|
113
|
+
/**
|
|
114
|
+
* Reject a composed command with a teaching error. Returns the message when
|
|
115
|
+
* the command is rejected, undefined when it is clean. Names the operator
|
|
116
|
+
* and the exact failure it would have caused — the bare 'xxx: command not
|
|
117
|
+
* found' the shell would produce sends the model suspecting the cluster
|
|
118
|
+
* instead of its own command shape.
|
|
119
|
+
*/
|
|
120
|
+
export function shellCompositionError(toolName, command) {
|
|
121
|
+
const m = SHELL_COMPOSITION.exec(command);
|
|
122
|
+
if (!m)
|
|
123
|
+
return undefined;
|
|
124
|
+
const op = m[0] === '\n' || m[0] === '\r' ? 'a newline' : `'${m[0]}'`;
|
|
125
|
+
return `the command contains ${op} — everything after it would run as a NEW local command WITHOUT the ${toolName} prefix and injected credentials, failing with a misleading 'xxx: command not found'. One call = one ${toolName} command: split this into separate tool calls. (A single | pipe is allowed — it filters the output locally.)`;
|
|
126
|
+
}
|
|
89
127
|
/** The shared output contract: schema + render, both pure. */
|
|
90
128
|
const output = {
|
|
91
129
|
schema: {
|
|
@@ -128,11 +166,32 @@ export function registerProfiledShellTool(ctx, spec) {
|
|
|
128
166
|
parameters: {
|
|
129
167
|
[spec.targetParam]: { type: 'string', required: true, description: spec.targetParamDescription },
|
|
130
168
|
command: { type: 'string', required: true, description: spec.commandDescription },
|
|
169
|
+
...(spec.perCallTimeout
|
|
170
|
+
? { timeoutSec: { type: 'number', description: `Optional per-call timeout in seconds (default ${Math.round((spec.timeoutMs ?? 30000) / 1000)}, max 600). Use only for a command you KNOW is slow (e.g. listing a very large pool) — a longer wait does not fix a hung remote end.` } }
|
|
171
|
+
: {}),
|
|
131
172
|
},
|
|
132
173
|
output,
|
|
133
174
|
async execute(args, exec) {
|
|
134
175
|
let fullCommand = '';
|
|
135
176
|
try {
|
|
177
|
+
// Reject shell composition before anything else — cheap, side-effect
|
|
178
|
+
// free, and the teaching message is most useful BEFORE the model has
|
|
179
|
+
// burned a credential resolve on a malformed call.
|
|
180
|
+
const command = args.command;
|
|
181
|
+
if (spec.rejectShellComposition) {
|
|
182
|
+
const message = shellCompositionError(spec.name, command);
|
|
183
|
+
if (message)
|
|
184
|
+
return { error: message, exitCode: -1, stdout: '', stderr: message, command: '' };
|
|
185
|
+
}
|
|
186
|
+
// Per-call timeout override (opt-in via spec.perCallTimeout):
|
|
187
|
+
// 1s–600s, anything else falls back to the configured ceiling.
|
|
188
|
+
let timeoutMs = spec.timeoutMs ?? 30000;
|
|
189
|
+
if (spec.perCallTimeout) {
|
|
190
|
+
const override = args.timeoutSec;
|
|
191
|
+
if (typeof override === 'number' && Number.isFinite(override) && override >= 1 && override <= 600) {
|
|
192
|
+
timeoutMs = Math.round(override * 1000);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
136
195
|
// Resolve the seam per call through ctx.get: the preset mounts the
|
|
137
196
|
// group concurrently, so 'opsAccess' must not be a static inject
|
|
138
197
|
// (deadlock risk against the definition row), and by tool-call time
|
|
@@ -150,8 +209,8 @@ export function registerProfiledShellTool(ctx, spec) {
|
|
|
150
209
|
// ref(); the display command (model-visible, logged) keeps the tokens,
|
|
151
210
|
// only the executed command carries the real values.
|
|
152
211
|
const tokens = createCredentialTokens(profile.name, profile.tier, profile.fields);
|
|
153
|
-
fullCommand = tokens.scrub(spec.buildCommand(profile.fields,
|
|
154
|
-
const request = { command: tokens.executable(fullCommand), timeoutMs
|
|
212
|
+
fullCommand = tokens.scrub(spec.buildCommand(profile.fields, command, tokens.ref));
|
|
213
|
+
const request = { command: tokens.executable(fullCommand), timeoutMs, signal: exec.signal };
|
|
155
214
|
const resolved = ctx.shell.resolve(request);
|
|
156
215
|
const result = await ctx.shell.run(resolved);
|
|
157
216
|
// exitCode is null when the process died from a signal — normalize to
|
|
@@ -169,12 +228,15 @@ export function registerProfiledShellTool(ctx, spec) {
|
|
|
169
228
|
: result.exitCode === null
|
|
170
229
|
? 'killed by signal ' + (result.signal ?? 'unknown') + ' — no normal exit code; the process was terminated externally (OOM killer, sandbox policy, or a deliberate kill).'
|
|
171
230
|
: undefined;
|
|
231
|
+
const stderrText = dropNoiseLines(tokens.scrub(result.stderr.text), spec.stderrNoise);
|
|
232
|
+
const envNote = diagnoseSandboxEnv(stderrText);
|
|
233
|
+
const errorNote = [killNote, envNote].filter(Boolean).join('\n');
|
|
172
234
|
return {
|
|
173
235
|
exitCode: result.exitCode ?? -1,
|
|
174
236
|
stdout: tokens.scrub(result.stdout.text),
|
|
175
|
-
stderr:
|
|
237
|
+
stderr: stderrText,
|
|
176
238
|
command: fullCommand,
|
|
177
|
-
...(
|
|
239
|
+
...(errorNote !== '' ? { error: errorNote } : {}),
|
|
178
240
|
};
|
|
179
241
|
}
|
|
180
242
|
catch (e) {
|
package/lib/types.d.ts
CHANGED
|
@@ -44,6 +44,26 @@ export interface ProfiledShellToolSpec {
|
|
|
44
44
|
* kill note tells the model exactly which ceiling it hit.
|
|
45
45
|
*/
|
|
46
46
|
timeoutMs?: number;
|
|
47
|
+
/**
|
|
48
|
+
* When true the tool gains an optional `timeoutSec` parameter (1–600s) so
|
|
49
|
+
* the model can extend the per-call ceiling for one known-slow command
|
|
50
|
+
* (e.g. `rados ls` on a large pool) instead of being hard-killed at the
|
|
51
|
+
* configured timeoutMs. Absent/0/out-of-range values fall back to
|
|
52
|
+
* timeoutMs.
|
|
53
|
+
*/
|
|
54
|
+
perCallTimeout?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* When true, a command containing shell composition operators (`;`, `&&`,
|
|
57
|
+
* `||`, backticks, `$(`, newlines) is rejected with a teaching error
|
|
58
|
+
* BEFORE execution: everything after such an operator runs as a NEW local
|
|
59
|
+
* command without the tool's binary prefix and credentials, which the
|
|
60
|
+
* model reads as a mysterious 'get: command not found' and misattributes
|
|
61
|
+
* to the cluster (it did, repeatedly, 2026-09-10). A single `|` pipe is
|
|
62
|
+
* still allowed — it filters the wrapped command's output locally. Leave
|
|
63
|
+
* unset for tools whose command legitimately contains composition
|
|
64
|
+
* (ops-tool-ssh passes the whole string to the REMOTE shell).
|
|
65
|
+
*/
|
|
66
|
+
rejectShellComposition?: boolean;
|
|
47
67
|
/**
|
|
48
68
|
* Known-noise stderr line patterns: any captured stderr line matching one
|
|
49
69
|
* of these regexes is dropped from the result. For warnings the CLI prints
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elinpf/dsh-ops-shell-tool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shared factory for ops-access consumer tools: standard shell result shape, output schema, render, and the resolve-per-call execute template.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"peerDependencies": {
|
|
30
30
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
31
|
-
"@elinpf/dsh-ops-access": "^0.
|
|
31
|
+
"@elinpf/dsh-ops-access": "^0.2.0",
|
|
32
32
|
"@deepseek-ai/dsh-shell": "^0.1.0-rc.8",
|
|
33
33
|
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8"
|
|
34
34
|
},
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"@deepseek-ai/dsh-tools": "0.0.1-rc.1",
|
|
39
39
|
"typescript": "^5.4.0",
|
|
40
40
|
"vitest": "^4.1.11",
|
|
41
|
-
"@elinpf/dsh-ops-access": "0.
|
|
41
|
+
"@elinpf/dsh-ops-access": "0.2.0"
|
|
42
42
|
},
|
|
43
43
|
"license": "MIT",
|
|
44
44
|
"publishConfig": {
|