@miphamai/cli 0.81.7 → 0.81.8
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/package.json +1 -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/keys-manager.ts +7 -8
- package/src/config/preferences.ts +5 -2
- package/src/core/cron-poller.ts +30 -6
- package/src/core/permission-rules.ts +140 -4
- package/src/core/session-log.ts +44 -1
- package/src/core/session-store.ts +11 -1
- package/src/daemon/session-worker.ts +15 -0
- package/src/index.tsx +3 -2
- package/src/plugin/plugin-manager.ts +17 -6
- package/src/providers/anthropic.ts +26 -2
- package/src/providers/openai-compat.ts +14 -1
- package/src/security/path.ts +6 -1
- package/src/shared/atomic-write.ts +28 -5
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +9 -0
- package/src/tools/artifact/artifact.ts +14 -4
- package/src/tools/exec/bash.ts +40 -18
- package/src/tools/exec/git.ts +7 -2
- package/src/tools/file/grep.ts +37 -13
- package/src/tools/file/read.ts +151 -45
- package/src/tools/scheduling/cron.ts +34 -5
- package/src/tools/system/config.ts +6 -2
- package/src/ui/commands.ts +27 -11
- package/src/artifacts/versioning.ts +0 -127
package/src/core/session-log.ts
CHANGED
|
@@ -163,7 +163,9 @@ export class SessionLog {
|
|
|
163
163
|
const trimmed = line.trim()
|
|
164
164
|
if (!trimmed) continue
|
|
165
165
|
try {
|
|
166
|
-
|
|
166
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
167
|
+
if (!isValidEvent(parsed)) continue
|
|
168
|
+
log.buf.push(parsed)
|
|
167
169
|
} catch {
|
|
168
170
|
// 跳过损坏行
|
|
169
171
|
}
|
|
@@ -173,6 +175,47 @@ export class SessionLog {
|
|
|
173
175
|
}
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
/**
|
|
179
|
+
* 事件结构校验 —— 磁盘→内存的**唯一**入口(`open()`)用它挡掉坏行。
|
|
180
|
+
*
|
|
181
|
+
* 只校验 `deriveMessages` 会解引用的字段:这不是 schema 校验,是「投影不许崩」的
|
|
182
|
+
* 最低门槛。**合法 JSON 不等于合法事件** —— `null`、`{"type":"user/message"}`(无
|
|
183
|
+
* message)、`{"type":"compaction/rewrite"}`(无 messages)都过得了 `JSON.parse`,
|
|
184
|
+
* 却让投影抛 TypeError(rewrite 那条更狠:`out` 直接变 `undefined`,下一条就炸),
|
|
185
|
+
* 而 `/resume` 那条链上没有 try 兜它。坏行来自手写/拼接/半截重排,不是本进程写的。
|
|
186
|
+
*
|
|
187
|
+
* 不校验 `session/start`、`assistant/chunk`、`checker/decision`:投影对它们无分支;
|
|
188
|
+
* 未知类型一律放行(前向兼容 —— 认不出来不等于要销毁它)。
|
|
189
|
+
*/
|
|
190
|
+
function isValidEvent(e: unknown): e is SessionEvent {
|
|
191
|
+
if (!e || typeof e !== 'object') return false
|
|
192
|
+
const ev = e as Record<string, unknown>
|
|
193
|
+
if (typeof ev.type !== 'string') return false
|
|
194
|
+
switch (ev.type) {
|
|
195
|
+
case 'user/message':
|
|
196
|
+
case 'assistant/message':
|
|
197
|
+
return !!ev.message && typeof ev.message === 'object'
|
|
198
|
+
case 'tool/call':
|
|
199
|
+
return (
|
|
200
|
+
typeof ev.id === 'string' &&
|
|
201
|
+
typeof ev.name === 'string' &&
|
|
202
|
+
!!ev.input &&
|
|
203
|
+
typeof ev.input === 'object'
|
|
204
|
+
)
|
|
205
|
+
case 'tool/result':
|
|
206
|
+
// 只要求 id:`result`(新格式)与 `content`(旧 JSONL)都可缺省,派生侧已兜底
|
|
207
|
+
return typeof ev.id === 'string'
|
|
208
|
+
case 'context/inject':
|
|
209
|
+
return typeof ev.text === 'string'
|
|
210
|
+
case 'compaction/summary':
|
|
211
|
+
return typeof ev.summary === 'string'
|
|
212
|
+
case 'compaction/rewrite':
|
|
213
|
+
return Array.isArray(ev.messages)
|
|
214
|
+
default:
|
|
215
|
+
return true
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
176
219
|
const SUMMARY_PREFIX = '[Earlier conversation summary]:'
|
|
177
220
|
|
|
178
221
|
export function isCompactionSummary(m: Message): boolean {
|
|
@@ -212,6 +212,11 @@ export class SessionStore {
|
|
|
212
212
|
|
|
213
213
|
/**
|
|
214
214
|
* List all saved sessions, most recent first.
|
|
215
|
+
*
|
|
216
|
+
* 单个文件读不出来只赔上它自己 —— 这里是为**逐文件**兜底,不是给整个列表兜底:
|
|
217
|
+
* 从前 try 包住整个 for 循环,第一个抛异常的文件就让 `/resume` 一条会话都不显示,
|
|
218
|
+
* 而其余文件全是好的。读不出来的(坏结构、半截写、I/O 错)直接跳过:连 metadata
|
|
219
|
+
* 都建不出来的会话没法在列表里表示。
|
|
215
220
|
*/
|
|
216
221
|
static list(): SessionMetadata[] {
|
|
217
222
|
ensureDir()
|
|
@@ -220,7 +225,12 @@ export class SessionStore {
|
|
|
220
225
|
const sessions: SessionMetadata[] = []
|
|
221
226
|
for (const file of files) {
|
|
222
227
|
const name = file.replace('.jsonl', '')
|
|
223
|
-
|
|
228
|
+
let session: StoredSession | null = null
|
|
229
|
+
try {
|
|
230
|
+
session = SessionStore.load(name)
|
|
231
|
+
} catch {
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
224
234
|
if (session?.metadata) {
|
|
225
235
|
sessions.push(session.metadata)
|
|
226
236
|
}
|
|
@@ -126,6 +126,9 @@ export class SessionWorker {
|
|
|
126
126
|
let totalInputTokens = 0
|
|
127
127
|
let totalOutputTokens = 0
|
|
128
128
|
let stopReason: string = 'end_turn'
|
|
129
|
+
// The provider hit its output ceiling at least once in this turn. Reported only
|
|
130
|
+
// when nothing worse happened — the three original values keep their precedence.
|
|
131
|
+
let truncated = false
|
|
129
132
|
|
|
130
133
|
try {
|
|
131
134
|
for await (const chunk of this.engine.process(prompt, signal)) {
|
|
@@ -152,6 +155,13 @@ export class SessionWorker {
|
|
|
152
155
|
if (chunk.outputTokens) totalOutputTokens += chunk.outputTokens
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
// A turn cut off at the provider's output ceiling must not be reported as a
|
|
159
|
+
// clean finish — downstream (the benchmark driver reads stopReason) would
|
|
160
|
+
// read a truncated trial as a legitimate ending.
|
|
161
|
+
if (chunk.type === 'stop' && chunk.truncated) {
|
|
162
|
+
truncated = true
|
|
163
|
+
}
|
|
164
|
+
|
|
155
165
|
// Deliberately NO break on 'stop' — the chunk type is overloaded here.
|
|
156
166
|
// Providers emit a provider-level 'stop' unconditionally at the end of
|
|
157
167
|
// EVERY LLM stream, including tool-call turns; the engine still has to
|
|
@@ -169,6 +179,11 @@ export class SessionWorker {
|
|
|
169
179
|
stopReason = 'interrupted'
|
|
170
180
|
}
|
|
171
181
|
|
|
182
|
+
// ── Truncation is the weakest signal: interrupted / error still win ──
|
|
183
|
+
if (truncated && stopReason === 'end_turn') {
|
|
184
|
+
stopReason = 'output_limit'
|
|
185
|
+
}
|
|
186
|
+
|
|
172
187
|
// Step 4: Persist assistant response and finalize
|
|
173
188
|
if (assistantContent) {
|
|
174
189
|
// Save the final (or partial) assistant message
|
package/src/index.tsx
CHANGED
|
@@ -53,7 +53,8 @@ import { initTelemetry, enableTelemetryNow } from './telemetry/index'
|
|
|
53
53
|
import { wasPrompted, markPrompted, isInteractive, setTelemetryEnabled } from './telemetry/consent'
|
|
54
54
|
import { officialEndpointHost } from './telemetry/endpoint'
|
|
55
55
|
import { getWorkspaceTrust } from './core/workspace-trust'
|
|
56
|
-
import {
|
|
56
|
+
import { ARTIFACT_PORT } from './shared/constants'
|
|
57
|
+
import { artifactsRoot } from './artifacts/paths'
|
|
57
58
|
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
58
59
|
import { AgentViewDashboard } from './agent-view/dashboard'
|
|
59
60
|
import { createT } from './i18n-core/t'
|
|
@@ -598,7 +599,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
598
599
|
}
|
|
599
600
|
|
|
600
601
|
// Start artifact server (lazy — first artifact creation triggers listening)
|
|
601
|
-
const artifactsDir =
|
|
602
|
+
const artifactsDir = artifactsRoot(process.cwd())
|
|
602
603
|
const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
|
|
603
604
|
|
|
604
605
|
// Create query engine
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
|
-
import {
|
|
12
|
+
import { execFileSync } from 'node:child_process'
|
|
13
13
|
import { validatePlugin } from './plugin-validator'
|
|
14
14
|
|
|
15
15
|
const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
|
|
@@ -110,11 +110,22 @@ export class PluginManager {
|
|
|
110
110
|
'utf-8',
|
|
111
111
|
)
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
113
|
+
// `--ignore-scripts`: without it, installing a plugin runs that package's
|
|
114
|
+
// preinstall/install/postinstall hooks as the current user — arbitrary
|
|
115
|
+
// code execution from any npm package, reachable via `/install-plugin`.
|
|
116
|
+
// A plugin only needs to *be* files on disk; it never needs a build step
|
|
117
|
+
// of its own to be loaded from here. `execFileSync` (argv array, no
|
|
118
|
+
// shell) keeps the command line independent of packageName, so package
|
|
119
|
+
// name validation is not the only thing standing between us and a shell.
|
|
120
|
+
execFileSync(
|
|
121
|
+
'npm',
|
|
122
|
+
['install', packageName, '--prefix', stagingDir, '--no-save', '--ignore-scripts'],
|
|
123
|
+
{
|
|
124
|
+
encoding: 'utf-8',
|
|
125
|
+
stdio: 'pipe',
|
|
126
|
+
timeout: 60_000,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
118
129
|
|
|
119
130
|
// npm installs the package into <stagingDir>/node_modules/<packageName>/ —
|
|
120
131
|
// validate there, then flatten it to the plugin dir root so its layout
|
|
@@ -33,6 +33,7 @@ interface AnthropicSSEEvent {
|
|
|
33
33
|
text?: string
|
|
34
34
|
thinking?: string
|
|
35
35
|
partial_json?: string
|
|
36
|
+
stop_reason?: string | null
|
|
36
37
|
}
|
|
37
38
|
error?: { type: string; message: string }
|
|
38
39
|
usage?: { input_tokens: number; output_tokens: number }
|
|
@@ -52,12 +53,21 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
52
53
|
let currentToolId = ''
|
|
53
54
|
let accumulatedToolInput = ''
|
|
54
55
|
|
|
56
|
+
// Set when the provider reports `stop_reason: 'max_tokens'` — the turn was cut
|
|
57
|
+
// off at the output ceiling rather than ended by the model.
|
|
58
|
+
let truncated = false
|
|
59
|
+
|
|
55
60
|
const messages = this.convertMessages(req.messages)
|
|
56
61
|
this.markPrefixCacheBreakpoint(messages)
|
|
57
62
|
|
|
63
|
+
// Priority: explicit request override (summarizer / sub-agent call sites) >
|
|
64
|
+
// the model's declared ceiling > 4096. The fallback stays: a model id that
|
|
65
|
+
// isn't in `config.models` has no known ceiling.
|
|
66
|
+
const declaredMaxOutput = this.config.models.find((m) => m.id === req.model)?.maxOutput
|
|
67
|
+
|
|
58
68
|
const body: Record<string, unknown> = {
|
|
59
69
|
model: req.model,
|
|
60
|
-
max_tokens: req.maxTokens || 4096,
|
|
70
|
+
max_tokens: req.maxTokens || declaredMaxOutput || 4096,
|
|
61
71
|
stream: true,
|
|
62
72
|
messages,
|
|
63
73
|
}
|
|
@@ -184,6 +194,13 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
case 'content_block_stop': {
|
|
197
|
+
// 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
|
|
198
|
+
// `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
|
|
199
|
+
// 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
|
|
200
|
+
// tool_call」的处置,这里结构上做不到(它的 finish_reason 与
|
|
201
|
+
// tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
|
|
202
|
+
// 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
|
|
203
|
+
// 那是一次行为变更,不属本次范围。
|
|
187
204
|
if (currentToolId && currentToolName && accumulatedToolInput) {
|
|
188
205
|
let parsedInput: Record<string, unknown> = {}
|
|
189
206
|
try {
|
|
@@ -223,11 +240,18 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
223
240
|
if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
|
|
224
241
|
accumulatedToolInput += event.delta.partial_json
|
|
225
242
|
}
|
|
243
|
+
// `max_tokens` means the turn hit the output ceiling. Without this the
|
|
244
|
+
// truncation is indistinguishable from `end_turn`: both arrive here and
|
|
245
|
+
// the terminal stop below looks the same either way.
|
|
246
|
+
const stopReason = event.delta?.stop_reason
|
|
247
|
+
if (stopReason === 'max_tokens') {
|
|
248
|
+
truncated = true
|
|
249
|
+
}
|
|
226
250
|
break
|
|
227
251
|
}
|
|
228
252
|
|
|
229
253
|
case 'message_stop': {
|
|
230
|
-
yield { type: 'stop' }
|
|
254
|
+
yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
|
|
231
255
|
return
|
|
232
256
|
}
|
|
233
257
|
|
|
@@ -17,11 +17,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
17
17
|
) || 'https://api.openai.com/v1'
|
|
18
18
|
const apiKey = this.resolveApiKey(this.config.apiKey)
|
|
19
19
|
|
|
20
|
+
// Priority: explicit request override (summarizer / sub-agent call sites) >
|
|
21
|
+
// the model's declared ceiling > 8192. The fallback stays: local `ollama`
|
|
22
|
+
// model ids are not in `config.models`, so their real ceiling is unknown.
|
|
23
|
+
const declaredMaxOutput = this.config.models.find((m) => m.id === req.model)?.maxOutput
|
|
24
|
+
|
|
20
25
|
const body = {
|
|
21
26
|
model: req.model,
|
|
22
27
|
messages: this.convertMessages(req.messages, req.systemPrompt),
|
|
23
28
|
stream: true,
|
|
24
|
-
max_tokens: req.maxTokens || 8192,
|
|
29
|
+
max_tokens: req.maxTokens || declaredMaxOutput || 8192,
|
|
25
30
|
temperature: req.temperature,
|
|
26
31
|
tools: req.tools?.map((t) => ({ type: 'function', function: t })),
|
|
27
32
|
}
|
|
@@ -173,6 +178,14 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
173
178
|
if (choice.finish_reason === 'stop') {
|
|
174
179
|
yield { type: 'stop', reasoning_content: reasoningContent }
|
|
175
180
|
}
|
|
181
|
+
|
|
182
|
+
if (choice.finish_reason === 'length') {
|
|
183
|
+
// Truncated: the accumulated tool calls were cut off mid-arguments, so
|
|
184
|
+
// their JSON is incomplete. Drop them rather than dispatching a broken
|
|
185
|
+
// call, and clear the map so the `[DONE]` handler can't emit them either.
|
|
186
|
+
pendingToolCalls.clear()
|
|
187
|
+
yield { type: 'stop', reasoning_content: reasoningContent, truncated: true }
|
|
188
|
+
}
|
|
176
189
|
} catch {
|
|
177
190
|
// skip unparseable chunks
|
|
178
191
|
}
|
package/src/security/path.ts
CHANGED
|
@@ -113,8 +113,13 @@ function findExistingParent(p: string): string | undefined {
|
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Check if `child` is within `parent` (or equal to it).
|
|
116
|
+
*
|
|
117
|
+
* Compares path segments, not string prefixes: `/a/b-evil` is NOT within
|
|
118
|
+
* `/a/b`. Both sides are expected to be resolved (no `..`, no trailing slash
|
|
119
|
+
* beyond the root). The trailing-slash normalization below is for callers that
|
|
120
|
+
* hand in an unresolved string such as `/proj/src/`.
|
|
116
121
|
*/
|
|
117
|
-
function isWithin(child: string, parent: string): boolean {
|
|
122
|
+
export function isWithin(child: string, parent: string): boolean {
|
|
118
123
|
// Normalize trailing slashes for comparison
|
|
119
124
|
const c = child.endsWith('/') ? child.slice(0, -1) : child
|
|
120
125
|
const p = parent.endsWith('/') ? parent.slice(0, -1) : parent
|
|
@@ -1,17 +1,40 @@
|
|
|
1
|
-
import { writeFileSync, renameSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { randomUUID } from 'node:crypto'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Write a file atomically: write to a same-directory
|
|
5
|
+
* Write a file atomically: write to a same-directory temp file, then rename
|
|
5
6
|
* over the target. Same-filesystem rename is atomic, so a crash or kill
|
|
6
7
|
* mid-write can never leave a truncated/corrupt file — readers see either the
|
|
7
8
|
* old or the new content, never a partial write.
|
|
9
|
+
*
|
|
10
|
+
* The temp name carries the pid and a random suffix rather than being a fixed
|
|
11
|
+
* `path + '.tmp'`. Several callers write to *shared* locations (the telemetry
|
|
12
|
+
* queue on every process exit, skill usage, the CRSI ledger), so two sessions or
|
|
13
|
+
* daemon workers on one machine can be inside this function for the same path at
|
|
14
|
+
* the same time. With one shared temp name that interleaving loses a write (the
|
|
15
|
+
* first rename moves the *second* writer's content into place) and then throws
|
|
16
|
+
* ENOENT on the other writer's rename — in a function whose whole point is that
|
|
17
|
+
* the target is never observed half-written. Same directory is still required:
|
|
18
|
+
* rename is only atomic within a filesystem.
|
|
8
19
|
*/
|
|
9
20
|
export function atomicWriteFileSync(
|
|
10
21
|
path: string,
|
|
11
22
|
content: string,
|
|
12
23
|
options: { mode?: number } = {},
|
|
13
24
|
): void {
|
|
14
|
-
const tmp = path
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
const tmp = `${path}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(tmp, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
|
|
28
|
+
renameSync(tmp, path)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
// Clean up on the failure path: a fixed temp name used to be overwritten by
|
|
31
|
+
// the next writer, but unique names mean every abandoned write would leave
|
|
32
|
+
// its own orphan forever — nothing else ever sweeps this directory.
|
|
33
|
+
try {
|
|
34
|
+
unlinkSync(tmp)
|
|
35
|
+
} catch {
|
|
36
|
+
// Already renamed away (or never created) — nothing to clean.
|
|
37
|
+
}
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
17
40
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.81.
|
|
12
|
+
export const PACKAGE_VERSION = '0.81.8' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
package/src/shared/types.ts
CHANGED
|
@@ -138,6 +138,15 @@ export interface StreamChunk {
|
|
|
138
138
|
inputTokens?: number
|
|
139
139
|
/** API-reported output token count (type: 'usage'). */
|
|
140
140
|
outputTokens?: number
|
|
141
|
+
/**
|
|
142
|
+
* The provider stopped because it hit the output token ceiling
|
|
143
|
+
* (OpenAI `finish_reason: 'length'` / Anthropic `stop_reason: 'max_tokens'`).
|
|
144
|
+
* Set **only when true** — absent on every normal stop, so the success path
|
|
145
|
+
* stays byte-identical. Without it a truncated turn is indistinguishable
|
|
146
|
+
* from a turn the model chose to end: the provider emits its terminal stop
|
|
147
|
+
* either way, and tool calls cut off mid-arguments are dropped silently.
|
|
148
|
+
*/
|
|
149
|
+
truncated?: boolean
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
// ── Config Types ──
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
-
import {
|
|
4
|
+
import { ARTIFACT_MAX_SIZE } from '../../shared/constants'
|
|
5
|
+
import { artifactsRoot } from '../../artifacts/paths'
|
|
5
6
|
import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
|
|
6
7
|
|
|
7
8
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
|
|
@@ -57,8 +58,10 @@ export const artifactTool: ToolDefinition = {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
// Determine output paths
|
|
61
|
-
|
|
61
|
+
// Determine output paths — the server's root, the manifest's home and this
|
|
62
|
+
// directory are one and the same; computing it here is what made the URL a
|
|
63
|
+
// guaranteed 404 before.
|
|
64
|
+
const baseDir = artifactsRoot(ctx.cwd)
|
|
62
65
|
const sessionDir = join(baseDir, ctx.sessionId)
|
|
63
66
|
mkdirSync(sessionDir, { recursive: true })
|
|
64
67
|
|
|
@@ -100,7 +103,7 @@ export const artifactTool: ToolDefinition = {
|
|
|
100
103
|
const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
|
|
101
104
|
const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
|
|
102
105
|
|
|
103
|
-
addToManifest(
|
|
106
|
+
const { quarantined } = addToManifest(
|
|
104
107
|
baseDir,
|
|
105
108
|
{
|
|
106
109
|
name,
|
|
@@ -124,6 +127,12 @@ export const artifactTool: ToolDefinition = {
|
|
|
124
127
|
const galleryUrl = port ? `http://localhost:${port}` : undefined
|
|
125
128
|
const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
|
|
126
129
|
const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
|
|
130
|
+
// The index was unreadable and got moved aside, so this publish started from
|
|
131
|
+
// nothing: say it here, or the user reads "saved" and never learns that the
|
|
132
|
+
// rest of the index is now a file next to it.
|
|
133
|
+
const warnLine = quarantined
|
|
134
|
+
? ` ⚠️ Index was unreadable; previous index kept at ${quarantined}`
|
|
135
|
+
: ''
|
|
127
136
|
|
|
128
137
|
return {
|
|
129
138
|
success: true,
|
|
@@ -132,6 +141,7 @@ export const artifactTool: ToolDefinition = {
|
|
|
132
141
|
` URL: ${url}`,
|
|
133
142
|
` Size: ${size.toLocaleString()} bytes`,
|
|
134
143
|
versionLine,
|
|
144
|
+
warnLine,
|
|
135
145
|
galleryLine,
|
|
136
146
|
'',
|
|
137
147
|
`Open in browser: /artifact open ${name}`,
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
1
2
|
import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
|
|
2
3
|
import { sanitizeCommand } from '../../shared/sanitize.ts'
|
|
3
4
|
import { DANGEROUS_GIT_PATTERNS } from './git.ts'
|
|
4
|
-
import { isUncOrDevicePath } from '../../security/path.ts'
|
|
5
|
+
import { isUncOrDevicePath, isWithin } from '../../security/path.ts'
|
|
5
6
|
import { findWorktreeMarker } from '../../core/paths.ts'
|
|
6
7
|
import type { Service } from '../../vajra'
|
|
7
8
|
import { toolKey } from '../seam'
|
|
@@ -308,6 +309,36 @@ function parseErrorLocations(stderr: string): ErrorLocation[] {
|
|
|
308
309
|
return unique.slice(0, 10)
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
/**
|
|
313
|
+
* 找出命令里第一个 `cd` 到工作区之外的**目标原样字符串**(供错误文案用);
|
|
314
|
+
* 无逃逸返回 null。判定边界是 `worktreeRoot`(项目根),不是 `cwd` ——
|
|
315
|
+
* 既有行为即如此:`cd <项目内其它目录>` 放行(见 test/tools/exec.test.ts
|
|
316
|
+
* 「allows cd inside the project from a .mipham worktree」)。
|
|
317
|
+
*
|
|
318
|
+
* 此前三个缺陷,其中两个是活的绕过:
|
|
319
|
+
* - 相对路径用字符串拼接而非 `resolve`:`cd ../../../..` 拼出来的串仍以
|
|
320
|
+
* cwd 开头,于是被当成「在区内」放行 —— **活绕过**;
|
|
321
|
+
* - `command.match(...)` 非全局,只看第一个 `cd`,`cd sub && cd /etc` 的
|
|
322
|
+
* 后半段完全不检查 —— **活绕过**;
|
|
323
|
+
* - 归属判定用 `resolved.startsWith(cwd)` 字符串前缀比较,`/proj/w1-evil`
|
|
324
|
+
* 会被判成「在 /proj/w1 里」;它只被 root 那个析取项兜住才没显形,故一并
|
|
325
|
+
* 改成按路径分段比较的 `isWithin`。
|
|
326
|
+
*/
|
|
327
|
+
export function resolveWorktreeEscape(
|
|
328
|
+
cwd: string,
|
|
329
|
+
worktreeRoot: string,
|
|
330
|
+
command: string,
|
|
331
|
+
): string | null {
|
|
332
|
+
const cdRe = /\bcd\s+(?:"([^"]+)"|'([^']+)'|([^\s;|&]+))/g
|
|
333
|
+
for (const m of command.matchAll(cdRe)) {
|
|
334
|
+
const target = m[1] ?? m[2] ?? m[3]
|
|
335
|
+
if (!target) continue
|
|
336
|
+
const resolved = resolve(cwd, target)
|
|
337
|
+
if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) return target
|
|
338
|
+
}
|
|
339
|
+
return null
|
|
340
|
+
}
|
|
341
|
+
|
|
311
342
|
export function createBashTool(credentialConfig?: CredentialMaskingConfig): ToolDefinition {
|
|
312
343
|
return {
|
|
313
344
|
name: 'Bash',
|
|
@@ -339,23 +370,14 @@ export function createBashTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
339
370
|
// 隔离度只增不减(只认新前缀会让旧工作树失去保护)。
|
|
340
371
|
const worktreeMarker = findWorktreeMarker(ctx.cwd)
|
|
341
372
|
if (worktreeMarker) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
: `${ctx.cwd}/${target}`.replace(/\/\.\//g, '/')
|
|
351
|
-
if (!resolved.startsWith(ctx.cwd) && !resolved.startsWith(worktreeMarker.root + '/')) {
|
|
352
|
-
return {
|
|
353
|
-
success: false,
|
|
354
|
-
content: '',
|
|
355
|
-
error:
|
|
356
|
-
`Worktree isolation: cannot cd outside worktree directory. ` +
|
|
357
|
-
`Attempted: ${target}. Use tools within the worktree only.`,
|
|
358
|
-
}
|
|
373
|
+
const escapeTarget = resolveWorktreeEscape(ctx.cwd, worktreeMarker.root, command)
|
|
374
|
+
if (escapeTarget !== null) {
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
content: '',
|
|
378
|
+
error:
|
|
379
|
+
`Worktree isolation: cannot cd outside worktree directory. ` +
|
|
380
|
+
`Attempted: ${escapeTarget}. Use tools within the worktree only.`,
|
|
359
381
|
}
|
|
360
382
|
}
|
|
361
383
|
}
|
package/src/tools/exec/git.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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'
|
|
3
5
|
|
|
4
6
|
// P0-4 (v2.1.222 alignment): Regex-based word-boundary patterns replace
|
|
5
7
|
// fragile substring matching. Each pattern describes what it blocks.
|
|
@@ -75,8 +77,11 @@ function isOutsideWorktree(command: string, cwd: string): string | null {
|
|
|
75
77
|
let match: RegExpExecArray | null
|
|
76
78
|
while ((match = pathPattern.exec(command)) !== null) {
|
|
77
79
|
const refPath = match[1]!
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
+
// 归一后按**路径分段**判归属,不用字符串前缀:此前 `refPath.startsWith(cwd)`
|
|
81
|
+
// 从不解析 `..`,`--work-tree=/proj/../etc` 因为「以 /proj/ 开头」被放行,
|
|
82
|
+
// 而 git 拿到的是 /etc。判据与 Bash 守卫(resolveWorktreeEscape)同一套。
|
|
83
|
+
const resolved = resolve(cwd, refPath)
|
|
84
|
+
if (!isWithin(resolved, cwd) && !isWithin(resolved, worktreeRoot)) {
|
|
80
85
|
return `Git command references path outside worktree: ${refPath}`
|
|
81
86
|
}
|
|
82
87
|
}
|
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 输出上限:超过则显式截断并附标记(不能静默丢内容——模型会误以为看全了)。 */
|
|
@@ -107,7 +113,9 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
107
113
|
if (exitCode === 0) {
|
|
108
114
|
return {
|
|
109
115
|
success: true,
|
|
110
|
-
content:
|
|
116
|
+
content: truncateGrepOutput(
|
|
117
|
+
maskSearchOutput(stdout || '(no matches)', credentialConfig, 'heading'),
|
|
118
|
+
),
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
// rg exit 2 (error, e.g. permission denied on protected dirs) — do NOT
|
|
@@ -117,12 +125,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
117
125
|
if (stdout && stdout.trim()) {
|
|
118
126
|
return {
|
|
119
127
|
success: true,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
credentialConfig,
|
|
124
|
-
'
|
|
125
|
-
),
|
|
128
|
+
// 先截断正文、再拼注解:注解拼在截断之内的话,它自己会被切掉,
|
|
129
|
+
// 模型拿到的就是一句没头没尾的提示。
|
|
130
|
+
content:
|
|
131
|
+
truncateGrepOutput(maskSearchOutput(stdout, credentialConfig, 'heading')) +
|
|
132
|
+
'\n\n(rg exited 2 — some paths unreadable; narrow scope for complete results)',
|
|
126
133
|
}
|
|
127
134
|
}
|
|
128
135
|
return {
|
|
@@ -153,7 +160,11 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
153
160
|
'+',
|
|
154
161
|
]
|
|
155
162
|
try {
|
|
156
|
-
const { stdout, timedOut, exitCode } = await runSearch(
|
|
163
|
+
const { stdout, stderr, timedOut, exitCode } = await runSearch(
|
|
164
|
+
grepArgs,
|
|
165
|
+
ctx.cwd,
|
|
166
|
+
GREP_TIMEOUT_MS,
|
|
167
|
+
)
|
|
157
168
|
if (timedOut) {
|
|
158
169
|
return {
|
|
159
170
|
success: false,
|
|
@@ -161,7 +172,17 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
161
172
|
error: `Grep timed out after ${GREP_TIMEOUT_MS / 1000}s — narrow scope with "path" and "include".`,
|
|
162
173
|
}
|
|
163
174
|
}
|
|
164
|
-
|
|
175
|
+
// 退出码 1 在 find 这里是**两件事**:真的没搜到,和「根本没搜成」
|
|
176
|
+
// —— 目录不可读、grep 正则非法、grep 不在 PATH,BSD find 全都退 1。
|
|
177
|
+
// 后者一律伴随 stderr,所以用 stderr 而非退出码分辨;不加这一刀,
|
|
178
|
+
// 模型会被告知「没有匹配」,而真相是这次搜索压根没跑起来。
|
|
179
|
+
if (exitCode === 1) {
|
|
180
|
+
const err = stderr.trim()
|
|
181
|
+
if (err) {
|
|
182
|
+
return { success: false, content: '', error: `Search failed: ${err.slice(0, 500)}` }
|
|
183
|
+
}
|
|
184
|
+
return { success: true, content: '(no matches)' }
|
|
185
|
+
}
|
|
165
186
|
if (exitCode === 0) {
|
|
166
187
|
return {
|
|
167
188
|
success: true,
|
|
@@ -171,7 +192,10 @@ export function createGrepTool(credentialConfig?: CredentialMaskingConfig): Tool
|
|
|
171
192
|
return {
|
|
172
193
|
success: false,
|
|
173
194
|
content: '',
|
|
174
|
-
error:
|
|
195
|
+
error:
|
|
196
|
+
`grep failed (exit ${exitCode})` +
|
|
197
|
+
(stderr.trim() ? `: ${stderr.trim().slice(0, 500)}` : '') +
|
|
198
|
+
'. Install ripgrep: brew install ripgrep',
|
|
175
199
|
}
|
|
176
200
|
} catch {
|
|
177
201
|
return {
|