@miphamai/cli 0.81.7 → 0.81.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 +1 -1
- package/bin/mipham.ts +35 -1
- package/package.json +1 -1
- package/src/agent/message-bus.ts +10 -3
- package/src/agent/sub-agent.ts +60 -12
- package/src/agent/types.ts +14 -1
- package/src/artifacts/manifest.ts +90 -34
- package/src/artifacts/paths.ts +19 -0
- package/src/artifacts/server.ts +48 -8
- package/src/config/credential-crypto.ts +28 -5
- package/src/config/defaults.ts +18 -10
- package/src/config/keys-manager.ts +14 -9
- package/src/config/loader.ts +202 -63
- package/src/config/preferences.ts +5 -2
- package/src/core/credential-masker/output-scrub.ts +16 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/engine.ts +7 -2
- package/src/core/hooks-executor.ts +30 -2
- package/src/core/hooks.ts +51 -4
- package/src/core/paths.ts +44 -1
- package/src/core/permission-config.ts +146 -14
- package/src/core/permission-rules.ts +157 -6
- package/src/core/permission.ts +81 -13
- package/src/core/rules-loader.ts +35 -5
- package/src/core/session-log.ts +49 -2
- package/src/core/session-store.ts +11 -1
- package/src/core/workspace-trust.ts +42 -4
- package/src/daemon/auth.ts +15 -14
- package/src/daemon/engine-capabilities.ts +12 -2
- package/src/daemon/remote-engine.ts +9 -4
- package/src/daemon/server.ts +29 -1
- package/src/daemon/session-worker.ts +15 -0
- package/src/i18n-core/locales/en-US.json +12 -8
- package/src/i18n-core/locales/zh-CN.json +12 -8
- package/src/index.tsx +47 -19
- package/src/mcp/client.ts +24 -0
- package/src/mcp/http-transport.ts +35 -3
- package/src/plugin/plugin-manager.ts +30 -8
- package/src/providers/anthropic.ts +74 -13
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/gate.ts +18 -0
- package/src/security/path.ts +25 -2
- package/src/shared/arg-validation.ts +37 -2
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/sanitize.ts +27 -2
- package/src/shared/types.ts +17 -0
- package/src/shared/update.ts +22 -5
- package/src/tools/agent/agent.ts +3 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +146 -24
- package/src/tools/exec/enter-worktree.ts +9 -3
- package/src/tools/exec/exit-worktree.ts +6 -3
- package/src/tools/exec/git.ts +83 -3
- package/src/tools/file/glob.ts +19 -3
- package/src/tools/file/grep.ts +70 -16
- package/src/tools/file/read.ts +151 -45
- package/src/tools/index.ts +12 -4
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/app.tsx +47 -11
- package/src/ui/commands.ts +187 -41
- package/src/workflow/primitives/agent.ts +4 -0
- package/src/artifacts/versioning.ts +0 -127
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
-
import { worktreeRoots } from '../../core/paths.ts'
|
|
2
|
+
import { listsWorktree, worktreeRoots } from '../../core/paths.ts'
|
|
3
3
|
|
|
4
4
|
export const exitWorktreeTool: ToolDefinition = {
|
|
5
5
|
name: 'ExitWorktree',
|
|
@@ -39,7 +39,10 @@ export const exitWorktreeTool: ToolDefinition = {
|
|
|
39
39
|
// Validate the path is under a worktree root(新目录优先,兼容历史 .claude/)
|
|
40
40
|
const cwd = ctx.cwd
|
|
41
41
|
const { resolve } = await import('node:path')
|
|
42
|
-
|
|
42
|
+
// 基数必须是 `cwd`(= ctx.cwd):下面每一个 `Bun.spawn` 都带 `cwd: ctx.cwd`,
|
|
43
|
+
// 单参数 `resolve()` 却拿 `process.cwd()` 当归宿 —— 相对路径于是「校验的是 A、
|
|
44
|
+
// 执行的是 B」。两者在 daemon(ctx.cwd 是会话工作区、不是进程启动目录)下分叉。
|
|
45
|
+
const resolvedPath = resolve(cwd, worktreePath)
|
|
43
46
|
const roots = worktreeRoots(cwd).map((root) => resolve(root))
|
|
44
47
|
const inWorktree = roots.some(
|
|
45
48
|
(root) => resolvedPath === root || resolvedPath.startsWith(root + '/'),
|
|
@@ -64,7 +67,7 @@ export const exitWorktreeTool: ToolDefinition = {
|
|
|
64
67
|
})
|
|
65
68
|
const listOutput = await new Response(listProc.stdout).text()
|
|
66
69
|
|
|
67
|
-
if (!listOutput
|
|
70
|
+
if (!listsWorktree(listOutput, resolvedPath)) {
|
|
68
71
|
return {
|
|
69
72
|
success: false,
|
|
70
73
|
content: '',
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
1
2
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
3
|
import { findWorktreeMarker } from '../../core/paths.ts'
|
|
4
|
+
import { isWithin } from '../../security/path.ts'
|
|
5
|
+
|
|
6
|
+
/** Generous enough for a clone or fetch, short enough to bound a hung git. */
|
|
7
|
+
const GIT_TIMEOUT_MS = 120_000
|
|
3
8
|
|
|
4
9
|
// P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
|
|
5
10
|
// fragile substring matching. Each pattern describes what it blocks.
|
|
@@ -75,8 +80,11 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
|
|
|
75
80
|
let match: RegExpExecArray | null
|
|
76
81
|
while ((match = pathPattern.exec(command)) !== null) {
|
|
77
82
|
const refPath = match[1]!
|
|
78
|
-
//
|
|
79
|
-
|
|
83
|
+
// 归一后按**路径分段**判归属,不用字符串前缀:此前 `refPath.startsWith(cwd)`
|
|
84
|
+
// 从不解析 `..`,`--work-tree=/proj/../etc` 因为「以 /proj/ 开头」被放行,
|
|
85
|
+
// 而 git 拿到的是 /etc。判据与 Bash 守卫(resolveWorktreeEscape)同一套。
|
|
86
|
+
const resolved = resolve(cwd, refPath)
|
|
87
|
+
if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) {
|
|
80
88
|
return `Git command references path outside worktree: ${refPath}`
|
|
81
89
|
}
|
|
82
90
|
}
|
|
@@ -85,6 +93,60 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
|
|
|
85
93
|
return null
|
|
86
94
|
}
|
|
87
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Git options whose value is a program git will execute. The regex list above
|
|
98
|
+
* covers command execution reached through config keys; these are the ones it
|
|
99
|
+
* does not name at all, and each was confirmed to run an arbitrary local
|
|
100
|
+
* program: `ls-remote --upload-pack=/tmp/x.sh <path>`, `push --receive-pack=…`,
|
|
101
|
+
* and `--exec-path=<dir>` followed by a planted `<dir>/git-<subcommand>`.
|
|
102
|
+
*/
|
|
103
|
+
const PROGRAM_EXECUTING_OPTIONS = ['--upload-pack', '--receive-pack', '--exec-path']
|
|
104
|
+
|
|
105
|
+
/** Config keys whose value git runs as a program. */
|
|
106
|
+
const PROGRAM_EXECUTING_CONFIG_KEY =
|
|
107
|
+
/^(?:core\.(?:sshCommand|pager|askpass|editor)|alias\.|credential\.helper)/i
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find an argv entry that makes git execute a program the caller named.
|
|
111
|
+
*
|
|
112
|
+
* This runs on the parsed argv rather than the raw command string. The regex
|
|
113
|
+
* list above is organised by *spelling*, so it has to anticipate every way the
|
|
114
|
+
* text can be written — `" -c " + "core.pager=…" ` written with quotes between
|
|
115
|
+
* the two halves never matches it, yet git receives the same two tokens. argv
|
|
116
|
+
* is what git actually gets, so it has no such gap.
|
|
117
|
+
*/
|
|
118
|
+
export function findProgramExecutingArg(argv: string[]): string | null {
|
|
119
|
+
for (let i = 0; i < argv.length; i++) {
|
|
120
|
+
const arg = argv[i]!
|
|
121
|
+
|
|
122
|
+
for (const opt of PROGRAM_EXECUTING_OPTIONS) {
|
|
123
|
+
// `--upload-pack=<exec>` and `--upload-pack <exec>` are both accepted.
|
|
124
|
+
if (arg === opt) return `${arg} ${argv[i + 1] ?? ''}`
|
|
125
|
+
if (arg.startsWith(`${opt}=`)) return arg
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// `-c<key>=<value>` / `--config=<key>=<value>`. `--config=…` is checked
|
|
129
|
+
// first so `/^-c[^-]/` cannot swallow it.
|
|
130
|
+
const attached = arg.startsWith('--config=')
|
|
131
|
+
? arg.slice('--config='.length)
|
|
132
|
+
: /^-c[^-]/.test(arg)
|
|
133
|
+
? arg.slice(2)
|
|
134
|
+
: null
|
|
135
|
+
if (attached !== null && PROGRAM_EXECUTING_CONFIG_KEY.test(attached)) return arg
|
|
136
|
+
|
|
137
|
+
// `-c <key>=<value>` / `--config <key>=<value>`. A bare `-c` also means
|
|
138
|
+
// "reuse this commit" for `git commit`, but that value never contains `=`,
|
|
139
|
+
// so the config read cannot swallow it.
|
|
140
|
+
if ((arg === '-c' || arg === '--config') && i + 1 < argv.length) {
|
|
141
|
+
const next = argv[i + 1]!
|
|
142
|
+
if (next.includes('=') && PROGRAM_EXECUTING_CONFIG_KEY.test(next)) {
|
|
143
|
+
return `${arg} ${next}`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
|
|
88
150
|
/**
|
|
89
151
|
* Split a git command string into argv tokens, honoring shell-style quoting
|
|
90
152
|
* (single quotes, double quotes, and backslash escapes). Unlike a naive
|
|
@@ -178,14 +240,32 @@ export const gitTool: ToolDefinition = {
|
|
|
178
240
|
}
|
|
179
241
|
}
|
|
180
242
|
|
|
243
|
+
// Git runs without an approval prompt (`permission: 'auto'`), so an option
|
|
244
|
+
// that names a program to execute is a code-execution path Bash would have
|
|
245
|
+
// had to ask for. Checked on argv, which is what git is handed below.
|
|
246
|
+
const argv = splitCommand(command)
|
|
247
|
+
const execArg = findProgramExecutingArg(argv)
|
|
248
|
+
if (execArg) {
|
|
249
|
+
return {
|
|
250
|
+
success: false,
|
|
251
|
+
content: '',
|
|
252
|
+
error: `Dangerous git option blocked: "${execArg}" makes git run an arbitrary program. Run manually if intended.`,
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
181
256
|
try {
|
|
182
|
-
const proc = Bun.spawn(['git', ...
|
|
257
|
+
const proc = Bun.spawn(['git', ...argv], {
|
|
183
258
|
cwd: ctx.cwd,
|
|
184
259
|
stdout: 'pipe',
|
|
185
260
|
stderr: 'pipe',
|
|
186
261
|
})
|
|
262
|
+
|
|
263
|
+
// Bounded like Bash: a git command that blocks on a pager, a credential
|
|
264
|
+
// prompt, or an unreachable remote would otherwise hang the turn forever.
|
|
265
|
+
const timer = setTimeout(() => proc.kill(), GIT_TIMEOUT_MS)
|
|
187
266
|
const output = await new Response(proc.stdout).text()
|
|
188
267
|
const exitCode = await proc.exited
|
|
268
|
+
clearTimeout(timer)
|
|
189
269
|
|
|
190
270
|
if (exitCode !== 0) {
|
|
191
271
|
const stderr = await new Response(proc.stderr).text()
|
package/src/tools/file/glob.ts
CHANGED
|
@@ -6,6 +6,10 @@ import { toolKey } from '../seam'
|
|
|
6
6
|
import { withValidation } from '../validation'
|
|
7
7
|
import { maskGlobOutput } from '../../core/credential-masker'
|
|
8
8
|
|
|
9
|
+
/** 结果上限:超过则显式截断并附标记(不能静默丢内容——模型会误以为看全了,
|
|
10
|
+
* 与 Grep 的 `truncateGrepOutput` 同一约定)。 */
|
|
11
|
+
const GLOB_MAX_RESULTS = 500
|
|
12
|
+
|
|
9
13
|
export function createGlobTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
|
|
10
14
|
return {
|
|
11
15
|
name: 'Glob',
|
|
@@ -25,12 +29,24 @@ export function createGlobTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
25
29
|
const basePath = resolveSafe(ctx.cwd, (params.path as string) || '.')
|
|
26
30
|
const glob = new Glob(pattern)
|
|
27
31
|
const results: string[] = []
|
|
32
|
+
let truncated = false
|
|
28
33
|
for await (const file of glob.scan({ cwd: basePath, absolute: true })) {
|
|
34
|
+
if (results.length >= GLOB_MAX_RESULTS) {
|
|
35
|
+
// 只有真的还有第 501 个匹配时才叫截断 —— 恰好 500 个匹配是完整结果。
|
|
36
|
+
truncated = true
|
|
37
|
+
break
|
|
38
|
+
}
|
|
29
39
|
results.push(file)
|
|
30
|
-
if (results.length >= 500) break
|
|
31
40
|
}
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
// 先掩码正文、再拼注解:`maskGlobOutput` 是**逐行当路径**去比对的
|
|
42
|
+
// (每行都过 `matchCredentialFile`),注解不是路径,不该喂给它。
|
|
43
|
+
const content = maskGlobOutput(results.join('\n') || '(no matches)', credentialConfig)
|
|
44
|
+
return {
|
|
45
|
+
success: true,
|
|
46
|
+
content: truncated
|
|
47
|
+
? `${content}\n\n... (truncated at ${GLOB_MAX_RESULTS} matches — narrow the pattern or "path")`
|
|
48
|
+
: content,
|
|
49
|
+
}
|
|
34
50
|
},
|
|
35
51
|
}
|
|
36
52
|
}
|
package/src/tools/file/grep.ts
CHANGED
|
@@ -16,17 +16,23 @@ export async function runSearch(
|
|
|
16
16
|
cmd: string[],
|
|
17
17
|
cwd: string,
|
|
18
18
|
timeoutMs: number,
|
|
19
|
-
): Promise<{ stdout: string; timedOut: boolean; exitCode: number | null }> {
|
|
19
|
+
): Promise<{ stdout: string; stderr: string; timedOut: boolean; exitCode: number | null }> {
|
|
20
20
|
const proc = Bun.spawn(cmd, { cwd, stdout: 'pipe', stderr: 'pipe' })
|
|
21
21
|
let timedOut = false
|
|
22
22
|
const timer = setTimeout(() => {
|
|
23
23
|
timedOut = true
|
|
24
24
|
proc.kill()
|
|
25
25
|
}, timeoutMs)
|
|
26
|
-
|
|
26
|
+
// 两条管道必须**并发**读。先读满 stdout 再读 stderr 会在子进程写满
|
|
27
|
+
// stderr(~64 KB 管道缓冲)时死锁:它阻塞在 write 上不退出,stdout 也就
|
|
28
|
+
// 永远读不到 EOF —— 只能等超时兜底,而超时会把「慢」和「错」说成同一件事。
|
|
29
|
+
const [stdout, stderr] = await Promise.all([
|
|
30
|
+
new Response(proc.stdout).text(),
|
|
31
|
+
new Response(proc.stderr).text(),
|
|
32
|
+
])
|
|
27
33
|
await proc.exited
|
|
28
34
|
clearTimeout(timer)
|
|
29
|
-
return { stdout, timedOut, exitCode: proc.exitCode }
|
|
35
|
+
return { stdout, stderr, timedOut, exitCode: proc.exitCode }
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
/** grep 输出上限:超过则显式截断并附标记(不能静默丢内容——模型会误以为看全了)。 */
|
|
@@ -48,6 +54,27 @@ export function isTopLevelScope(searchPath: string, home = homedir()): boolean {
|
|
|
48
54
|
return searchPath === home || searchPath === parse(searchPath).root
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/** 把未知异常变成一句可读的原因(`code` 才是可操作的部分:ENOENT / EAGAIN)。 */
|
|
58
|
+
function describeError(err: unknown): string {
|
|
59
|
+
if (err instanceof Error) {
|
|
60
|
+
const code = (err as { code?: unknown }).code
|
|
61
|
+
return typeof code === 'string' ? `${code}: ${err.message}` : err.message
|
|
62
|
+
}
|
|
63
|
+
return String(err)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 只有「rg 不在 PATH」才该回退到 `find`。别的异常(EAGAIN / EPERM / spawn
|
|
68
|
+
* 失败)说明 rg 装了却起不来 —— 那不是回退能解决的问题,再起一个 find 只会
|
|
69
|
+
* 换来第二个失败,而错误信息里的「去装 ripgrep」会把排查方向整条带偏。
|
|
70
|
+
*/
|
|
71
|
+
function isMissingBinary(err: unknown): boolean {
|
|
72
|
+
if (!(err instanceof Error)) return false
|
|
73
|
+
if ((err as { code?: unknown }).code === 'ENOENT') return true
|
|
74
|
+
// 有些抛出方只给句子不给 code。
|
|
75
|
+
return /ENOENT|not found|no such file or directory/i.test(err.message)
|
|
76
|
+
}
|
|
77
|
+
|
|
51
78
|
export function createGrepTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
|
|
52
79
|
return {
|
|
53
80
|
name: 'Grep',
|
|
@@ -107,7 +134,9 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
107
134
|
if (exitCode === 0) {
|
|
108
135
|
return {
|
|
109
136
|
success: true,
|
|
110
|
-
content:
|
|
137
|
+
content: truncateGrepOutput(
|
|
138
|
+
maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
|
|
139
|
+
),
|
|
111
140
|
}
|
|
112
141
|
}
|
|
113
142
|
// rg exit 2 (error, e.g. permission denied on protected dirs) — do NOT
|
|
@@ -117,12 +146,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
117
146
|
if (stdout && stdout.trim()) {
|
|
118
147
|
return {
|
|
119
148
|
success: true,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
credentialConfig,
|
|
124
|
-
'
|
|
125
|
-
),
|
|
149
|
+
// 先截断正文、再拼注解:注解拼在截断之内的话,它自己会被切掉,
|
|
150
|
+
// 模型拿到的就是一句没头没尾的提示。
|
|
151
|
+
content:
|
|
152
|
+
truncateGrepOutput(maskSearchOutput(stdout, credentialConfig, 'heading')) +
|
|
153
|
+
'\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
|
|
126
154
|
}
|
|
127
155
|
}
|
|
128
156
|
return {
|
|
@@ -132,7 +160,14 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
132
160
|
'rg error (exit 2) — likely permission denied on a large/protected tree. ' +
|
|
133
161
|
'Narrow scope with "path" (project directory) and "include".',
|
|
134
162
|
}
|
|
135
|
-
} catch {
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (!isMissingBinary(err)) {
|
|
165
|
+
return {
|
|
166
|
+
success: false,
|
|
167
|
+
content: '',
|
|
168
|
+
error: `ripgrep failed: ${describeError(err)}`,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
136
171
|
// rg not installed → fall through to grep (find + grep fallback)
|
|
137
172
|
}
|
|
138
173
|
|
|
@@ -153,7 +188,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
153
188
|
'+',
|
|
154
189
|
]
|
|
155
190
|
try {
|
|
156
|
-
const { stdout, timedOut, exitCode } = await runSearch(
|
|
191
|
+
const { stdout, stderr, timedOut, exitCode } = await runSearch(
|
|
192
|
+
grepArgs,
|
|
193
|
+
ctx.cwd,
|
|
194
|
+
GREP_TIMEOUT_MS,
|
|
195
|
+
)
|
|
157
196
|
if (timedOut) {
|
|
158
197
|
return {
|
|
159
198
|
success: false,
|
|
@@ -161,7 +200,17 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
161
200
|
error: `Grep timed out after ${GREP_TIMEOUT_MS / 1000}s — narrow scope with "path" and "include".`,
|
|
162
201
|
}
|
|
163
202
|
}
|
|
164
|
-
|
|
203
|
+
// 退出码 1 在 find 这里是**两件事**:真的没搜到,和「根本没搜成」
|
|
204
|
+
// —— 目录不可读、grep 正则非法、grep 不在 PATH,BSD find 全都退 1。
|
|
205
|
+
// 后者一律伴随 stderr,所以用 stderr 而非退出码分辨;不加这一刀,
|
|
206
|
+
// 模型会被告知「没有匹配」,而真相是这次搜索压根没跑起来。
|
|
207
|
+
if (exitCode === 1) {
|
|
208
|
+
const err = stderr.trim()
|
|
209
|
+
if (err) {
|
|
210
|
+
return { success: false, content: '', error: `Search failed: ${err.slice(0, 500)}` }
|
|
211
|
+
}
|
|
212
|
+
return { success: true, content: '(no matches)' }
|
|
213
|
+
}
|
|
165
214
|
if (exitCode === 0) {
|
|
166
215
|
return {
|
|
167
216
|
success: true,
|
|
@@ -171,13 +220,18 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
171
220
|
return {
|
|
172
221
|
success: false,
|
|
173
222
|
content: '',
|
|
174
|
-
error:
|
|
223
|
+
error:
|
|
224
|
+
`grep failed (exit ${exitCode})` +
|
|
225
|
+
(stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : '') +
|
|
226
|
+
'. Install ripgrep: brew install ripgrep',
|
|
175
227
|
}
|
|
176
|
-
} catch {
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// 这里回退自己起不来 —— 报回退的真实原因,别再断言「rg 没装」
|
|
230
|
+
// (走到这一步的路径本来就有「rg 没装」和「装了但起不来」两种)。
|
|
177
231
|
return {
|
|
178
232
|
success: false,
|
|
179
233
|
content: '',
|
|
180
|
-
error:
|
|
234
|
+
error: `Search failed to start: ${describeError(err)}`,
|
|
181
235
|
}
|
|
182
236
|
}
|
|
183
237
|
},
|
package/src/tools/file/read.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, fstatSync, closeSync, constants } from 'node:fs'
|
|
1
|
+
import { readFileSync, readSync, fstatSync, closeSync, constants } from 'node:fs'
|
|
2
2
|
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
3
3
|
import { resolveSafe } from '../../security/path'
|
|
4
4
|
import { openNoFollow, isSymlinkLoop } from '../../security/fd'
|
|
@@ -6,6 +6,115 @@ import type { Service } from '../../vajra'
|
|
|
6
6
|
import { toolKey } from '../seam'
|
|
7
7
|
import { withValidation } from '../validation'
|
|
8
8
|
|
|
9
|
+
/** Lines returned when the caller passes no `limit`. */
|
|
10
|
+
const DEFAULT_LIMIT = 2000
|
|
11
|
+
|
|
12
|
+
/** Bytes per syscall while scanning for newlines. */
|
|
13
|
+
const CHUNK_BYTES = 64 * 1024
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How far a single Read may scan to locate the requested lines. Lines are
|
|
17
|
+
* addressed by index, so finding line N means scanning everything before it;
|
|
18
|
+
* this is what keeps that scan from turning a request for two lines of a 2 GB
|
|
19
|
+
* file into a 2 GB read.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_SCAN_BYTES = 50_000_000
|
|
22
|
+
|
|
23
|
+
/** Render a line window the way the tool reports it: ` 123\ttext`. */
|
|
24
|
+
function formatLines(lines: string[], offset: number): string {
|
|
25
|
+
return lines.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`).join('\n')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Lines `[offset, offset + limit)` of an already-decoded string.
|
|
30
|
+
*
|
|
31
|
+
* Scans newlines instead of `content.split('\n')`, which materializes one JS
|
|
32
|
+
* string for *every* line of the file in order to return `limit` of them. On a
|
|
33
|
+
* 20 MB source, returning 2000 lines that way cost +34 MB of heap for 60-char
|
|
34
|
+
* lines and +100 MB for 3-char lines (333k vs 10M lines — it tracks line count,
|
|
35
|
+
* not bytes); scanning costs ~0 on top of the string it is handed.
|
|
36
|
+
*/
|
|
37
|
+
function windowFromString(content: string, offset: number, limit: number): string[] {
|
|
38
|
+
const out: string[] = []
|
|
39
|
+
let lineNo = 0
|
|
40
|
+
let start = 0
|
|
41
|
+
while (out.length < limit) {
|
|
42
|
+
const nl = content.indexOf('\n', start)
|
|
43
|
+
const end = nl === -1 ? content.length : nl
|
|
44
|
+
if (lineNo >= offset) out.push(content.slice(start, end))
|
|
45
|
+
lineNo++
|
|
46
|
+
if (nl === -1) break
|
|
47
|
+
start = nl + 1
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lines `[offset, offset + limit)` read from `fd` — only those lines are ever
|
|
54
|
+
* materialized, so cost tracks the window rather than the file: a 2000-line
|
|
55
|
+
* window of a 20 MB file leaves RSS at the process baseline (~38 MB), where
|
|
56
|
+
* reading the file whole and slicing it cost 142–255 MB.
|
|
57
|
+
*
|
|
58
|
+
* Two passes over the same bytes: the first only *looks* for newline bytes to
|
|
59
|
+
* learn where the window starts and ends, the second reads exactly that byte
|
|
60
|
+
* range and decodes it once. Scanning raw bytes is safe because `0x0a` never
|
|
61
|
+
* occurs inside a multi-byte UTF-8 sequence (continuation bytes are >= 0x80),
|
|
62
|
+
* and the decoded range starts and ends on a newline — so neither pass can split
|
|
63
|
+
* a character. Returns null when the window cannot be located within
|
|
64
|
+
* `MAX_SCAN_BYTES`.
|
|
65
|
+
*/
|
|
66
|
+
function windowFromFd(fd: number, offset: number, limit: number): string[] | null {
|
|
67
|
+
if (limit <= 0) return []
|
|
68
|
+
|
|
69
|
+
const buf = Buffer.allocUnsafe(CHUNK_BYTES)
|
|
70
|
+
let scanned = 0 // bytes consumed by the scan
|
|
71
|
+
let lineNo = 0
|
|
72
|
+
let windowStart = offset <= 0 ? 0 : -1
|
|
73
|
+
let windowEnd = -1
|
|
74
|
+
let eof = false
|
|
75
|
+
|
|
76
|
+
while (windowEnd === -1 && scanned < MAX_SCAN_BYTES) {
|
|
77
|
+
const n = readSync(fd, buf, 0, buf.length, scanned)
|
|
78
|
+
if (n <= 0) {
|
|
79
|
+
eof = true
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
const lastNl = buf.lastIndexOf(0x0a, n - 1)
|
|
83
|
+
if (lastNl !== -1) {
|
|
84
|
+
let from = 0
|
|
85
|
+
for (;;) {
|
|
86
|
+
const nl = buf.indexOf(0x0a, from)
|
|
87
|
+
if (nl === -1 || nl > lastNl) break
|
|
88
|
+
lineNo++ // line `lineNo - 1` ends at this newline
|
|
89
|
+
if (lineNo === offset) windowStart = scanned + nl + 1
|
|
90
|
+
if (lineNo === offset + limit) {
|
|
91
|
+
windowEnd = scanned + nl
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
from = nl + 1
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
scanned += n
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (windowEnd === -1) {
|
|
101
|
+
// Either the file ended (the window is everything that is left) or the scan
|
|
102
|
+
// budget ran out before the window's last line was reached.
|
|
103
|
+
if (!eof) return null
|
|
104
|
+
windowEnd = scanned
|
|
105
|
+
}
|
|
106
|
+
if (windowStart === -1) return [] // `offset` is past the end of the file
|
|
107
|
+
|
|
108
|
+
const out = Buffer.allocUnsafe(windowEnd - windowStart)
|
|
109
|
+
let got = 0
|
|
110
|
+
while (got < out.length) {
|
|
111
|
+
const n = readSync(fd, out, got, out.length - got, windowStart + got)
|
|
112
|
+
if (n <= 0) break
|
|
113
|
+
got += n
|
|
114
|
+
}
|
|
115
|
+
return out.toString('utf-8', 0, got).split('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
9
118
|
export function createReadTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
|
|
10
119
|
return {
|
|
11
120
|
name: 'Read',
|
|
@@ -40,59 +149,56 @@ export function createReadTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
40
149
|
throw err
|
|
41
150
|
}
|
|
42
151
|
|
|
43
|
-
let content: string
|
|
44
152
|
try {
|
|
45
|
-
|
|
46
|
-
if (stat.isDirectory()) {
|
|
153
|
+
if (fstatSync(fd).isDirectory()) {
|
|
47
154
|
return { success: false, content: '', error: `Path is a directory: ${filePath}` }
|
|
48
155
|
}
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
156
|
+
|
|
157
|
+
const offset = (params.offset as number) || 0
|
|
158
|
+
const limit = (params.limit as number) || DEFAULT_LIMIT
|
|
159
|
+
|
|
160
|
+
// ── Credential masking ──
|
|
161
|
+
// The rule matches on path alone, so this is decided before any read: a
|
|
162
|
+
// masked file needs its whole content (the mask is content-shaped), while
|
|
163
|
+
// every other file is served straight from the fd. Reading the file
|
|
164
|
+
// *first* is what used to make `offset`/`limit` useless on large files —
|
|
165
|
+
// the size check ran before the window was ever applied.
|
|
166
|
+
let result: string | null = null
|
|
167
|
+
if (credentialConfig) {
|
|
168
|
+
const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
|
|
169
|
+
await import('../../core/credential-masker')
|
|
170
|
+
const rule = matchCredentialFile(filePath, credentialConfig)
|
|
171
|
+
if (rule) {
|
|
172
|
+
const masked = maskContent(readFileSync(fd, 'utf-8'), rule)
|
|
173
|
+
// Full-file mask: return the sentinel immediately (offset/limit don't apply)
|
|
174
|
+
result =
|
|
175
|
+
masked === CREDENTIAL_SENTINEL
|
|
176
|
+
? masked
|
|
177
|
+
: formatLines(windowFromString(masked, offset, limit), offset)
|
|
56
178
|
}
|
|
57
179
|
}
|
|
58
|
-
content = readFileSync(fd, 'utf-8')
|
|
59
|
-
} finally {
|
|
60
|
-
closeSync(fd)
|
|
61
|
-
}
|
|
62
180
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (rule) {
|
|
75
|
-
const masked = maskContent(content, rule)
|
|
76
|
-
// Full-file mask: return sentinel immediately (offset/limit don't apply)
|
|
77
|
-
if (masked === CREDENTIAL_SENTINEL) {
|
|
78
|
-
return { success: true, content: masked }
|
|
181
|
+
if (result === null) {
|
|
182
|
+
const lines = windowFromFd(fd, offset, limit)
|
|
183
|
+
if (lines === null) {
|
|
184
|
+
return {
|
|
185
|
+
success: false,
|
|
186
|
+
content: '',
|
|
187
|
+
error:
|
|
188
|
+
`File too large: scanned ${MAX_SCAN_BYTES / 1e6} MB without reaching the end of lines ` +
|
|
189
|
+
`${offset}–${offset + limit - 1}. Narrow the range (smaller offset or limit), or use a ` +
|
|
190
|
+
`shell tool to slice the file.`,
|
|
191
|
+
}
|
|
79
192
|
}
|
|
80
|
-
|
|
81
|
-
const maskedLines = masked.split('\n')
|
|
82
|
-
const maskedSlice = maskedLines.slice(offset, offset + limit)
|
|
83
|
-
const maskedResult = maskedSlice
|
|
84
|
-
.map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
|
|
85
|
-
.join('\n')
|
|
86
|
-
return { success: true, content: maskedResult }
|
|
193
|
+
result = formatLines(lines, offset)
|
|
87
194
|
}
|
|
88
|
-
}
|
|
89
195
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
196
|
+
// ── Read tracking: mark file as read for Write tool safety ──
|
|
197
|
+
ctx.readFiles?.add(filePath)
|
|
198
|
+
return { success: true, content: result }
|
|
199
|
+
} finally {
|
|
200
|
+
closeSync(fd)
|
|
201
|
+
}
|
|
96
202
|
},
|
|
97
203
|
}
|
|
98
204
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -31,13 +31,21 @@ import { listAgentsTool } from './agent/list-agents'
|
|
|
31
31
|
import { computerUseTool } from './computer/computer-use'
|
|
32
32
|
import { scheduleWakeupTool } from './scheduling/schedule-wakeup.js'
|
|
33
33
|
import { cronCreateTool, cronDeleteTool, cronListTool } from './scheduling/cron.js'
|
|
34
|
-
import {
|
|
34
|
+
import { loadUserCredentialMaskingConfig } from '../config/loader'
|
|
35
35
|
|
|
36
36
|
function defaultVajraContext(): Context {
|
|
37
37
|
const ctx = new Context()
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
38
|
+
// Fail-closed 默认:无参调用(daemon / workflow)拿到的是**用户级**掩码策略,
|
|
39
|
+
// 且配置读不出来时留下的是默认值(掩码开),不是一块关掉的掩码。
|
|
40
|
+
//
|
|
41
|
+
// 原先给的是 DISABLED 配置,理由是「对齐 pre-seam 行为」—— 但掩码是安全控制,
|
|
42
|
+
// 「没配置就关掉」是把默认值的方向定反了:daemon 里 Read/Bash/Grep/Glob 的
|
|
43
|
+
// 掩码整套失效,子进程继承完整 process.env、输出不擦洗。
|
|
44
|
+
//
|
|
45
|
+
// 只取用户级、不取项目级:注册表是 daemon 进程级的一个实例而会话 cwd 各不相同
|
|
46
|
+
// (见 `loadUserCredentialMaskingConfig` 的注释),把某个项目的段套上去等于让它溢到
|
|
47
|
+
// 别的会话。交互式 CLI 走 index.tsx 自己的 ctx(含项目级),不经过这里。
|
|
48
|
+
ctx.provide('credentials', loadUserCredentialMaskingConfig())
|
|
41
49
|
return ctx
|
|
42
50
|
}
|
|
43
51
|
|