@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.
Files changed (69) hide show
  1. package/bin/mipham.ts +25 -0
  2. package/package.json +1 -1
  3. package/src/agent/cross-session/discovery.ts +45 -1
  4. package/src/agent/cross-session/file-inbox.ts +15 -0
  5. package/src/agent/message-router.ts +22 -2
  6. package/src/agent/sub-agent.ts +4 -3
  7. package/src/config/defaults.ts +19 -1
  8. package/src/config/keys-manager.ts +11 -1
  9. package/src/config/loader.ts +20 -10
  10. package/src/core/context.ts +86 -56
  11. package/src/core/credential-masker/output-scrub.ts +5 -0
  12. package/src/core/crsi-sandbox.ts +19 -7
  13. package/src/core/engine.ts +69 -35
  14. package/src/core/instructions.ts +4 -42
  15. package/src/core/permission.ts +7 -1
  16. package/src/core/red-team.ts +20 -5
  17. package/src/core/self-critique.ts +3 -1
  18. package/src/core/session-log.ts +225 -0
  19. package/src/core/session-store.ts +119 -34
  20. package/src/core/workspace-trust.ts +25 -2
  21. package/src/daemon/auth.ts +9 -6
  22. package/src/daemon/cors.ts +24 -16
  23. package/src/daemon/rate-limiter.ts +4 -0
  24. package/src/daemon/server.ts +24 -3
  25. package/src/i18n-core/locales/en-US.json +4 -0
  26. package/src/i18n-core/locales/zh-CN.json +4 -0
  27. package/src/index.tsx +62 -30
  28. package/src/mcp/client.ts +56 -17
  29. package/src/mcp/http-transport.ts +2 -0
  30. package/src/mcp/oauth.ts +1 -1
  31. package/src/providers/llm-replay.ts +36 -0
  32. package/src/providers/llm.ts +16 -0
  33. package/src/providers/registry.ts +2 -1
  34. package/src/security/gate.ts +10 -0
  35. package/src/security/url.ts +37 -14
  36. package/src/shared/constants.ts +4 -486
  37. package/src/shared/types.ts +6 -437
  38. package/src/skills/fork-executor.ts +3 -1
  39. package/src/skills/loader.ts +2 -1
  40. package/src/skills/seam.ts +18 -0
  41. package/src/tools/agent/agent.ts +1 -0
  42. package/src/tools/agent/skill.ts +1 -0
  43. package/src/tools/exec/bash.ts +141 -129
  44. package/src/tools/exec/git.ts +17 -0
  45. package/src/tools/file/read.ts +73 -66
  46. package/src/tools/file/write.ts +1 -1
  47. package/src/tools/index.ts +54 -132
  48. package/src/tools/network/web-fetch.ts +41 -26
  49. package/src/tools/seam.ts +25 -0
  50. package/src/tools/validation.ts +91 -0
  51. package/src/ui/app.tsx +36 -0
  52. package/src/ui/commands.ts +27 -6
  53. package/src/ui/input.tsx +21 -0
  54. package/src/vajra/compose/assemble.ts +19 -0
  55. package/src/vajra/compose/bundle.ts +39 -0
  56. package/src/vajra/compose/dump.ts +5 -0
  57. package/src/vajra/compose/index.ts +4 -0
  58. package/src/vajra/compose/mount.ts +30 -0
  59. package/src/vajra/context.ts +183 -0
  60. package/src/vajra/events.ts +13 -0
  61. package/src/vajra/index.ts +4 -0
  62. package/src/vajra/leaf/plan-runner.ts +94 -0
  63. package/src/vajra/service.ts +15 -0
  64. package/src/workflow/primitives/agent.ts +3 -1
  65. package/src/workflow/runtime.ts +23 -15
  66. package/src/workflow/sandbox.ts +51 -9
  67. package/src/core/compaction-progress.ts +0 -205
  68. package/src/core/context-compact.ts +0 -124
  69. 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
- // Start the transport (transport-specific), then perform the handshake.
171
- if (transport instanceof HttpTransport) {
172
- await transport.start(config.url ?? '', config.headers, config.env)
173
- } else {
174
- await transport.start(config.command ?? '', config.args ?? [], config.env)
175
- }
176
- const initResult: InitializeResult = await protocol.initialize()
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
- connection.status = 'connected'
179
- connection.serverInfo = initResult.serverInfo
188
+ connection.status = 'connected'
189
+ connection.serverInfo = initResult.serverInfo
180
190
 
181
- // Wire tools-changed notification
182
- protocol.on('tools-changed', async () => {
183
- await this.onToolsChanged(config.name)
184
- })
191
+ // Wire tools-changed notification
192
+ protocol.on('tools-changed', async () => {
193
+ await this.onToolsChanged(config.name)
194
+ })
185
195
 
186
- // Discover tools
187
- if (initResult.capabilities.tools) {
188
- connection.tools = await protocol.listTools()
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
@@ -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
  }
@@ -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
- // Use the bracket-less version for DNS lookups
53
- const dnsHostname = hostname.startsWith('[') ? hostname.slice(1, -1) : hostname
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 = lookupSync(dnsHostname, { all: true })
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 — that's OK for validation; the fetch will also fail
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
+ }