@miphamai/cli 0.37.0 → 0.39.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/bin/mipham.ts +25 -0
- package/package.json +1 -1
- package/src/agent/cross-session/discovery.ts +45 -1
- package/src/agent/cross-session/file-inbox.ts +15 -0
- package/src/agent/message-router.ts +22 -2
- package/src/agent/sub-agent.ts +4 -3
- package/src/config/defaults.ts +19 -1
- package/src/config/keys-manager.ts +11 -1
- package/src/config/loader.ts +20 -10
- package/src/core/context.ts +86 -56
- package/src/core/credential-masker/output-scrub.ts +5 -0
- package/src/core/crsi-sandbox.ts +19 -7
- package/src/core/engine.ts +69 -35
- package/src/core/instructions.ts +4 -42
- package/src/core/permission.ts +7 -1
- package/src/core/red-team.ts +20 -5
- package/src/core/self-critique.ts +3 -1
- package/src/core/session-log.ts +225 -0
- package/src/core/session-store.ts +119 -34
- package/src/core/workspace-trust.ts +25 -2
- package/src/daemon/auth.ts +9 -6
- package/src/daemon/cors.ts +24 -16
- package/src/daemon/rate-limiter.ts +4 -0
- package/src/daemon/server.ts +24 -3
- package/src/i18n-core/locales/en-US.json +4 -0
- package/src/i18n-core/locales/zh-CN.json +4 -0
- package/src/index.tsx +62 -30
- package/src/mcp/client.ts +56 -17
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/oauth.ts +1 -1
- package/src/providers/llm-replay.ts +36 -0
- package/src/providers/llm.ts +16 -0
- package/src/providers/registry.ts +2 -1
- package/src/security/gate.ts +10 -0
- package/src/security/url.ts +37 -14
- package/src/shared/constants.ts +4 -486
- package/src/shared/types.ts +6 -437
- package/src/skills/fork-executor.ts +3 -1
- package/src/skills/loader.ts +2 -1
- package/src/skills/seam.ts +18 -0
- package/src/tools/agent/agent.ts +1 -0
- package/src/tools/agent/skill.ts +1 -0
- package/src/tools/exec/bash.ts +141 -129
- package/src/tools/exec/git.ts +17 -0
- package/src/tools/file/read.ts +73 -66
- package/src/tools/file/write.ts +1 -1
- package/src/tools/index.ts +54 -132
- package/src/tools/network/web-fetch.ts +41 -26
- package/src/tools/seam.ts +25 -0
- package/src/tools/validation.ts +91 -0
- package/src/ui/app.tsx +36 -0
- package/src/ui/commands.ts +27 -6
- package/src/ui/input.tsx +21 -0
- package/src/vajra/compose/assemble.ts +19 -0
- package/src/vajra/compose/bundle.ts +39 -0
- package/src/vajra/compose/dump.ts +5 -0
- package/src/vajra/compose/index.ts +4 -0
- package/src/vajra/compose/mount.ts +30 -0
- package/src/vajra/context.ts +183 -0
- package/src/vajra/events.ts +13 -0
- package/src/vajra/index.ts +4 -0
- package/src/vajra/leaf/plan-runner.ts +94 -0
- package/src/vajra/service.ts +15 -0
- package/src/workflow/primitives/agent.ts +3 -1
- package/src/workflow/runtime.ts +23 -15
- package/src/workflow/sandbox.ts +51 -9
- package/src/core/compaction-progress.ts +0 -205
- package/src/core/context-compact.ts +0 -124
- package/src/core/context-drain.ts +0 -113
package/src/mcp/client.ts
CHANGED
|
@@ -22,6 +22,15 @@ const bundles: Record<string, TranslationMap> = {
|
|
|
22
22
|
}
|
|
23
23
|
const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
|
|
24
24
|
|
|
25
|
+
// Connect-handshake timeout: a hung MCP server should fail startup fast rather
|
|
26
|
+
// than block on the per-request 60s timeout. Overridable via env for tests.
|
|
27
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
|
28
|
+
|
|
29
|
+
function connectTimeoutMs(): number {
|
|
30
|
+
const env = Number(process.env.MIPHAM_MCP_CONNECT_TIMEOUT_MS)
|
|
31
|
+
return Number.isFinite(env) && env > 0 ? env : DEFAULT_CONNECT_TIMEOUT_MS
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
interface ActiveConnection {
|
|
26
35
|
config: McpServerConfig
|
|
27
36
|
transport: Transport
|
|
@@ -167,33 +176,63 @@ export class McpClient {
|
|
|
167
176
|
this.connections.set(config.name, connection)
|
|
168
177
|
|
|
169
178
|
try {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
await this.withConnectTimeout(config.name, async () => {
|
|
180
|
+
// Start the transport (transport-specific), then perform the handshake.
|
|
181
|
+
if (transport instanceof HttpTransport) {
|
|
182
|
+
await transport.start(config.url ?? '', config.headers, config.env)
|
|
183
|
+
} else {
|
|
184
|
+
await transport.start(config.command ?? '', config.args ?? [], config.env)
|
|
185
|
+
}
|
|
186
|
+
const initResult: InitializeResult = await protocol.initialize()
|
|
177
187
|
|
|
178
|
-
|
|
179
|
-
|
|
188
|
+
connection.status = 'connected'
|
|
189
|
+
connection.serverInfo = initResult.serverInfo
|
|
180
190
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
191
|
+
// Wire tools-changed notification
|
|
192
|
+
protocol.on('tools-changed', async () => {
|
|
193
|
+
await this.onToolsChanged(config.name)
|
|
194
|
+
})
|
|
185
195
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
196
|
+
// Discover tools
|
|
197
|
+
if (initResult.capabilities.tools) {
|
|
198
|
+
connection.tools = await protocol.listTools()
|
|
199
|
+
}
|
|
200
|
+
})
|
|
190
201
|
} catch (err) {
|
|
191
202
|
connection.status = 'error'
|
|
192
203
|
connection.error = String(err)
|
|
204
|
+
try {
|
|
205
|
+
await transport.close()
|
|
206
|
+
} catch {
|
|
207
|
+
// Best-effort cleanup on a failed connect
|
|
208
|
+
}
|
|
193
209
|
throw err
|
|
194
210
|
}
|
|
195
211
|
}
|
|
196
212
|
|
|
213
|
+
/** Race the connect handshake against a timeout so a hung server fails fast. */
|
|
214
|
+
private async withConnectTimeout<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
215
|
+
const timeoutMs = connectTimeoutMs()
|
|
216
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
217
|
+
// Swallow a late rejection: if the timeout wins, transport.close() in the
|
|
218
|
+
// caller's catch makes fn()'s pending request reject after we've moved on.
|
|
219
|
+
const work = fn()
|
|
220
|
+
work.catch(() => {})
|
|
221
|
+
try {
|
|
222
|
+
return await Promise.race([
|
|
223
|
+
work,
|
|
224
|
+
new Promise<never>((_, reject) => {
|
|
225
|
+
timer = setTimeout(
|
|
226
|
+
() => reject(new Error(`MCP connect timed out for "${name}" after ${timeoutMs}ms`)),
|
|
227
|
+
timeoutMs,
|
|
228
|
+
)
|
|
229
|
+
}),
|
|
230
|
+
])
|
|
231
|
+
} finally {
|
|
232
|
+
if (timer) clearTimeout(timer)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
197
236
|
/**
|
|
198
237
|
* Disconnect an MCP server and return the names of its registered tools
|
|
199
238
|
* so the caller can unregister them from the central tool registry.
|
|
@@ -119,6 +119,7 @@ export class HttpTransport implements Transport {
|
|
|
119
119
|
},
|
|
120
120
|
body: JSON.stringify(request),
|
|
121
121
|
signal: controller.signal,
|
|
122
|
+
redirect: 'manual',
|
|
122
123
|
})
|
|
123
124
|
|
|
124
125
|
if (!response.ok) {
|
|
@@ -164,6 +165,7 @@ export class HttpTransport implements Transport {
|
|
|
164
165
|
...this.headers,
|
|
165
166
|
},
|
|
166
167
|
body: JSON.stringify(notification),
|
|
168
|
+
redirect: 'manual',
|
|
167
169
|
}).catch(() => {
|
|
168
170
|
/* notifications are best-effort */
|
|
169
171
|
})
|
package/src/mcp/oauth.ts
CHANGED
|
@@ -68,7 +68,7 @@ export class OAuthClient {
|
|
|
68
68
|
resolve(receivedCode)
|
|
69
69
|
}
|
|
70
70
|
})
|
|
71
|
-
server.listen(port, () => {
|
|
71
|
+
server.listen(port, '127.0.0.1', () => {
|
|
72
72
|
const authUrl = new URL(auth.authorizationUrl)
|
|
73
73
|
authUrl.searchParams.set('response_type', 'code')
|
|
74
74
|
authUrl.searchParams.set('client_id', auth.clientId)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Llm } from './llm'
|
|
2
|
+
import type { ChatRequest } from './registry'
|
|
3
|
+
import type { StreamChunk } from '../shared'
|
|
4
|
+
|
|
5
|
+
export interface RecordedTurn {
|
|
6
|
+
req: ChatRequest
|
|
7
|
+
chunks: StreamChunk[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 包装一个 Llm:委托的同时记录每轮 (req, chunks)。 */
|
|
11
|
+
export function recordLlm(inner: Llm): { llm: Llm; turns: RecordedTurn[] } {
|
|
12
|
+
const turns: RecordedTurn[] = []
|
|
13
|
+
const llm: Llm = {
|
|
14
|
+
async *chat(req) {
|
|
15
|
+
const chunks: StreamChunk[] = []
|
|
16
|
+
for await (const chunk of inner.chat(req)) {
|
|
17
|
+
chunks.push(chunk)
|
|
18
|
+
yield chunk
|
|
19
|
+
}
|
|
20
|
+
turns.push({ req, chunks })
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
return { llm, turns }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 回放已记录的 turns(确定性、无网络)。按顺序消费,超出则静默结束。 */
|
|
27
|
+
export function replayLlm(turns: RecordedTurn[]): Llm {
|
|
28
|
+
let cursor = 0
|
|
29
|
+
return {
|
|
30
|
+
async *chat() {
|
|
31
|
+
const turn = turns[cursor++]
|
|
32
|
+
if (!turn) return
|
|
33
|
+
for (const chunk of turn.chunks) yield chunk
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context, Disposer } from '../vajra'
|
|
2
|
+
import type { ChatRequest } from './registry'
|
|
3
|
+
import type { StreamChunk } from '../shared'
|
|
4
|
+
|
|
5
|
+
/** LLM 适配缝的 capability —— chat 能力。换 provider = 换 chat 实现,engine 零 fork 跟随。 */
|
|
6
|
+
export interface Llm {
|
|
7
|
+
chat(req: ChatRequest): AsyncGenerator<StreamChunk>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 缝键:ctx.llm。 */
|
|
11
|
+
export const LLM_KEY = 'llm'
|
|
12
|
+
|
|
13
|
+
/** 把一个 Llm(如 ProviderRegistry 或 llm-replay)挂载为 ctx.llm。 */
|
|
14
|
+
export function mountLlm(ctx: Context, llm: Llm): Disposer {
|
|
15
|
+
return ctx.provide(LLM_KEY, llm)
|
|
16
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
|
|
2
|
+
import type { Llm } from './llm'
|
|
2
3
|
|
|
3
4
|
export interface ProviderInstance {
|
|
4
5
|
config: ProviderConfig
|
|
@@ -17,7 +18,7 @@ export interface ChatRequest {
|
|
|
17
18
|
signal?: AbortSignal
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
export class ProviderRegistry {
|
|
21
|
+
export class ProviderRegistry implements Llm {
|
|
21
22
|
private providers = new Map<string, ProviderInstance>()
|
|
22
23
|
private activeProviderId: string
|
|
23
24
|
private activeModelId: string
|
package/src/security/gate.ts
CHANGED
|
@@ -90,4 +90,14 @@ export class SecurityGate {
|
|
|
90
90
|
}
|
|
91
91
|
return { blocked: false }
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
/** Redact known API-key-shaped tokens from output (defense-in-depth beyond config patterns). */
|
|
95
|
+
static redactCredentialLeak(output: string): string {
|
|
96
|
+
if (!this.checkCredentialLeak(output).blocked) return output
|
|
97
|
+
let out = output
|
|
98
|
+
for (const { regex } of API_KEY_PATTERNS) {
|
|
99
|
+
out = out.replace(new RegExp(regex.source, 'gi'), '[REDACTED]')
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
93
103
|
}
|
package/src/security/url.ts
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import dns from 'node:dns'
|
|
2
|
-
const { lookupSync } = dns as unknown as {
|
|
3
|
-
lookupSync: (
|
|
4
|
-
hostname: string,
|
|
5
|
-
options?: { all?: boolean },
|
|
6
|
-
) => Array<{ address: string; family: number }>
|
|
7
|
-
}
|
|
8
2
|
|
|
9
3
|
// ── Protocol allow-list ──
|
|
10
4
|
const ALLOWED_PROTOCOLS = ['http:', 'https:']
|
|
@@ -20,7 +14,7 @@ const IPV4_BLOCKED = [
|
|
|
20
14
|
]
|
|
21
15
|
|
|
22
16
|
/** Validate a URL for SSRF safety. Returns null if safe, or an error string. */
|
|
23
|
-
export function validateUrl(urlStr: string): string | null {
|
|
17
|
+
export async function validateUrl(urlStr: string): Promise<string | null> {
|
|
24
18
|
let parsed: URL
|
|
25
19
|
try {
|
|
26
20
|
parsed = new URL(urlStr)
|
|
@@ -41,18 +35,17 @@ export function validateUrl(urlStr: string): string | null {
|
|
|
41
35
|
return `URL hostname "${hostname}" resolves to a blocked IP range (private / loopback / link-local).`
|
|
42
36
|
}
|
|
43
37
|
|
|
44
|
-
// 3. Check for raw IPv6 loopback / private
|
|
45
|
-
// Strip brackets from IPv6 hostnames (Bun's URL parser keeps them)
|
|
38
|
+
// 3. Check for raw IPv6 loopback / private / IPv4-mapped
|
|
46
39
|
const ipv6Host = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
|
|
47
40
|
if (isBlockedIPv6(ipv6Host)) {
|
|
48
41
|
return `URL hostname "${hostname}" resolves to a blocked IPv6 address.`
|
|
49
42
|
}
|
|
50
43
|
|
|
51
|
-
// 4. DNS rebinding protection: resolve hostname and check resolved IPs
|
|
52
|
-
//
|
|
53
|
-
|
|
44
|
+
// 4. DNS rebinding protection: resolve hostname and check resolved IPs.
|
|
45
|
+
// Uses async dns.promises.lookup — the old sync `dns.lookupSync` never existed,
|
|
46
|
+
// so this check was previously dead code (always threw → swallowed → "safe").
|
|
54
47
|
try {
|
|
55
|
-
const addresses =
|
|
48
|
+
const addresses = await dns.promises.lookup(ipv6Host, { all: true })
|
|
56
49
|
for (const addr of addresses) {
|
|
57
50
|
if (isBlockedIPv4(addr.address)) {
|
|
58
51
|
return `URL hostname "${hostname}" resolved to ${addr.address} (blocked IP range).`
|
|
@@ -62,7 +55,7 @@ export function validateUrl(urlStr: string): string | null {
|
|
|
62
55
|
}
|
|
63
56
|
}
|
|
64
57
|
} catch {
|
|
65
|
-
// DNS resolution failed —
|
|
58
|
+
// DNS resolution failed — the subsequent fetch will fail too; treat as safe.
|
|
66
59
|
}
|
|
67
60
|
|
|
68
61
|
return null // safe
|
|
@@ -92,5 +85,35 @@ function isBlockedIPv6(ip: string): boolean {
|
|
|
92
85
|
if (ip.startsWith('fc') || ip.startsWith('fd')) return true
|
|
93
86
|
// IPv6 unspecified
|
|
94
87
|
if (ip === '::' || ip === '0:0:0:0:0:0:0:0') return true
|
|
88
|
+
|
|
89
|
+
// IPv4-mapped (::ffff:a.b.c.d / ::ffff:xxxx:xxxx) and IPv4-compatible (::a.b.c.d / ::xxxx:xxxx)
|
|
90
|
+
// — these can smuggle a private IPv4 past the literal-IPv6 checks above.
|
|
91
|
+
const embedded = extractEmbeddedIPv4(ip)
|
|
92
|
+
if (embedded && isBlockedIPv4(embedded)) return true
|
|
93
|
+
|
|
95
94
|
return false
|
|
96
95
|
}
|
|
96
|
+
|
|
97
|
+
/** Extract the IPv4 address embedded in an IPv4-mapped / IPv4-compatible IPv6 address. */
|
|
98
|
+
function extractEmbeddedIPv4(ip: string): string | null {
|
|
99
|
+
let rest: string | null = null
|
|
100
|
+
const mapped = ip.match(/^::ffff:(.+)$/i)
|
|
101
|
+
const compat = ip.match(/^::([0-9a-fA-F]{1,4}):([0-9a-fA-F]{1,4})$/)
|
|
102
|
+
if (mapped) {
|
|
103
|
+
rest = mapped[1]!
|
|
104
|
+
} else if (compat) {
|
|
105
|
+
rest = `${compat[1]}:${compat[2]}`
|
|
106
|
+
} else {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Dotted-quad form (::ffff:127.0.0.1)
|
|
111
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(rest)) return rest
|
|
112
|
+
|
|
113
|
+
// Hex form (::ffff:7f00:1)
|
|
114
|
+
const hex = rest.match(/^([0-9a-fA-F]{1,4}):([0-9a-fA-F]{1,4})$/)
|
|
115
|
+
if (!hex) return null
|
|
116
|
+
const hi = parseInt(hex[1]!, 16)
|
|
117
|
+
const lo = parseInt(hex[2]!, 16)
|
|
118
|
+
return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`
|
|
119
|
+
}
|