@miphamai/cli 0.38.0 → 0.39.1

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 (62) hide show
  1. package/bin/mipham.ts +25 -0
  2. package/package.json +1 -1
  3. package/src/agent/sub-agent.ts +4 -3
  4. package/src/config/defaults.ts +19 -1
  5. package/src/config/keys-manager.ts +11 -1
  6. package/src/config/loader.ts +20 -10
  7. package/src/core/context.ts +86 -56
  8. package/src/core/credential-masker/output-scrub.ts +5 -0
  9. package/src/core/crsi-sandbox.ts +19 -7
  10. package/src/core/engine.ts +50 -33
  11. package/src/core/instructions.ts +4 -42
  12. package/src/core/permission.ts +7 -1
  13. package/src/core/red-team.ts +4 -4
  14. package/src/core/self-critique.ts +3 -1
  15. package/src/core/session-log.ts +225 -0
  16. package/src/core/session-store.ts +119 -34
  17. package/src/daemon/auth.ts +9 -6
  18. package/src/daemon/cors.ts +24 -16
  19. package/src/daemon/rate-limiter.ts +4 -0
  20. package/src/daemon/server.ts +24 -3
  21. package/src/index.tsx +54 -28
  22. package/src/mcp/http-transport.ts +2 -0
  23. package/src/mcp/oauth.ts +1 -1
  24. package/src/providers/llm-replay.ts +36 -0
  25. package/src/providers/llm.ts +16 -0
  26. package/src/providers/registry.ts +2 -1
  27. package/src/security/gate.ts +10 -0
  28. package/src/security/url.ts +37 -14
  29. package/src/shared/constants.ts +40 -54
  30. package/src/shared/mipham-models.json +50 -0
  31. package/src/shared/types.ts +53 -49
  32. package/src/skills/fork-executor.ts +3 -1
  33. package/src/skills/loader.ts +2 -1
  34. package/src/skills/seam.ts +18 -0
  35. package/src/tools/agent/agent.ts +1 -0
  36. package/src/tools/agent/skill.ts +1 -0
  37. package/src/tools/exec/bash.ts +140 -128
  38. package/src/tools/exec/git.ts +17 -0
  39. package/src/tools/file/read.ts +73 -66
  40. package/src/tools/file/write.ts +1 -1
  41. package/src/tools/index.ts +54 -132
  42. package/src/tools/network/web-fetch.ts +41 -26
  43. package/src/tools/seam.ts +25 -0
  44. package/src/tools/validation.ts +91 -0
  45. package/src/ui/commands.ts +17 -5
  46. package/src/ui/input.tsx +8 -1
  47. package/src/vajra/compose/assemble.ts +19 -0
  48. package/src/vajra/compose/bundle.ts +39 -0
  49. package/src/vajra/compose/dump.ts +5 -0
  50. package/src/vajra/compose/index.ts +4 -0
  51. package/src/vajra/compose/mount.ts +30 -0
  52. package/src/vajra/context.ts +183 -0
  53. package/src/vajra/events.ts +13 -0
  54. package/src/vajra/index.ts +4 -0
  55. package/src/vajra/leaf/plan-runner.ts +94 -0
  56. package/src/vajra/service.ts +15 -0
  57. package/src/workflow/primitives/agent.ts +3 -1
  58. package/src/workflow/runtime.ts +23 -15
  59. package/src/workflow/sandbox.ts +51 -9
  60. package/src/core/compaction-progress.ts +0 -205
  61. package/src/core/context-compact.ts +0 -124
  62. package/src/core/context-drain.ts +0 -113
@@ -11,6 +11,7 @@ import {
11
11
  import { join } from 'node:path'
12
12
  import { createHash } from 'node:crypto'
13
13
  import type { Message } from '../shared/types'
14
+ import { sanitizeSessionName, SessionLog, deriveMessages, messageToEvents } from './session-log'
14
15
 
15
16
  export interface SessionMetadata {
16
17
  name: string
@@ -46,21 +47,11 @@ export interface SessionIndexEntry {
46
47
  }
47
48
 
48
49
  function ensureDir(): void {
49
- mkdirSync(SESSIONS_DIR, { recursive: true })
50
+ mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 })
50
51
  }
51
52
 
52
53
  function filePath(name: string): string {
53
- // Sanitize name for filesystem
54
- const safe = name.replace(/[^a-zA-Z0-9_-]/g, '_')
55
-
56
- // For very long names (>100 chars), use SHA-256 hash suffix to prevent collision
57
- if (safe.length > 100) {
58
- const hash = createHash('sha256').update(safe).digest('hex').slice(0, 16)
59
- const truncated = safe.slice(0, 80)
60
- return join(SESSIONS_DIR, `${truncated}-${hash}.jsonl`)
61
- }
62
-
63
- return join(SESSIONS_DIR, `${safe}.jsonl`)
54
+ return join(SESSIONS_DIR, `${sanitizeSessionName(name)}.jsonl`)
64
55
  }
65
56
 
66
57
  export class SessionStore {
@@ -90,7 +81,7 @@ export class SessionStore {
90
81
 
91
82
  // Atomic write: write to temp file, then rename (same-fs rename is atomic)
92
83
  const tmp = path + '.tmp'
93
- writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
84
+ writeFileSync(tmp, JSON.stringify(session) + '\n', { encoding: 'utf-8', mode: 0o600 })
94
85
  renameSync(tmp, path)
95
86
 
96
87
  // Incremental index update — only touch this session's entry
@@ -101,6 +92,69 @@ export class SessionStore {
101
92
  }
102
93
  }
103
94
 
95
+ /** 追加持久化一个 SessionLog(幂等:只写新事件)。缺失 session/start 时补一个。 */
96
+ static saveLog(
97
+ name: string,
98
+ log: SessionLog,
99
+ meta?: { provider?: string; model?: string; cwd?: string },
100
+ ): void {
101
+ ensureDir()
102
+ const events = log.events()
103
+ if (!events.some((e) => e.type === 'session/start')) {
104
+ log.append({ type: 'session/start', at: Date.now(), sessionId: name, ...meta })
105
+ }
106
+ log.save()
107
+ try {
108
+ const messages = deriveMessages(log.events())
109
+ SessionStore.updateIndexEntry(name, {
110
+ name,
111
+ createdAt: new Date().toISOString(),
112
+ updatedAt: new Date().toISOString(),
113
+ provider: meta?.provider || 'unknown',
114
+ model: meta?.model || 'unknown',
115
+ messageCount: messages.length,
116
+ cwd: meta?.cwd,
117
+ })
118
+ } catch {
119
+ // index 更新 best-effort
120
+ }
121
+ }
122
+
123
+ /** 从磁盘重开一个 SessionLog(不存在返回空 log)。旧格式快照自动迁移为事件日志。 */
124
+ static loadLog(name: string): SessionLog {
125
+ const path = filePath(name)
126
+ if (!existsSync(path)) return SessionLog.open(name)
127
+ try {
128
+ const raw = readFileSync(path, 'utf-8')
129
+ const parsed = JSON.parse(raw) as unknown
130
+ if (
131
+ parsed &&
132
+ typeof parsed === 'object' &&
133
+ 'metadata' in parsed &&
134
+ Array.isArray((parsed as { messages?: unknown }).messages)
135
+ ) {
136
+ // 旧格式快照 → 迁移为事件日志(重写文件,避免后续 append 混格式)
137
+ const old = parsed as StoredSession
138
+ const log = new SessionLog(name)
139
+ log.append({
140
+ type: 'session/start',
141
+ at: Date.parse(old.metadata.createdAt) || Date.now(),
142
+ sessionId: name,
143
+ provider: old.metadata.provider,
144
+ model: old.metadata.model,
145
+ cwd: old.metadata.cwd,
146
+ })
147
+ for (const m of old.messages) for (const e of messageToEvents(m)) log.append(e)
148
+ unlinkSync(path)
149
+ log.save()
150
+ return log
151
+ }
152
+ } catch {
153
+ // 非旧格式(多行 JSONL 或单事件)→ 走 open
154
+ }
155
+ return SessionLog.open(name)
156
+ }
157
+
104
158
  /**
105
159
  * Load a saved session. Returns null if not found or unparseable.
106
160
  */
@@ -110,14 +164,48 @@ export class SessionStore {
110
164
 
111
165
  try {
112
166
  const raw = readFileSync(path, 'utf-8')
113
- const session = JSON.parse(raw) as StoredSession
114
- // Validate structure
115
- if (!session.metadata || !Array.isArray(session.messages)) {
116
- return null
167
+ // 旧格式:单 JSON 对象 {metadata, messages}
168
+ const parsed = JSON.parse(raw) as unknown
169
+ if (
170
+ parsed &&
171
+ typeof parsed === 'object' &&
172
+ 'metadata' in parsed &&
173
+ Array.isArray((parsed as { messages?: unknown }).messages)
174
+ ) {
175
+ return parsed as StoredSession
117
176
  }
118
- return session
119
177
  } catch {
120
- return null
178
+ // 多行 JSONL → 新格式,走事件解析
179
+ }
180
+ return SessionStore.logToStoredSession(name, SessionLog.open(name))
181
+ }
182
+
183
+ private static logToStoredSession(name: string, log: SessionLog): StoredSession | null {
184
+ const events = log.events()
185
+ if (events.length === 0) return null
186
+ const start = events.find((e) => e.type === 'session/start') as
187
+ | {
188
+ type: 'session/start'
189
+ at: number
190
+ sessionId: string
191
+ provider?: string
192
+ model?: string
193
+ cwd?: string
194
+ }
195
+ | undefined
196
+ const messages = deriveMessages(events)
197
+ const stat = statSync(filePath(name))
198
+ return {
199
+ metadata: {
200
+ name,
201
+ createdAt: start?.at ? new Date(start.at).toISOString() : stat.mtime.toISOString(),
202
+ updatedAt: stat.mtime.toISOString(),
203
+ provider: start?.provider || 'unknown',
204
+ model: start?.model || 'unknown',
205
+ messageCount: messages.length,
206
+ cwd: start?.cwd,
207
+ },
208
+ messages,
121
209
  }
122
210
  }
123
211
 
@@ -128,20 +216,14 @@ export class SessionStore {
128
216
  ensureDir()
129
217
  try {
130
218
  const files = readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.jsonl'))
131
-
132
219
  const sessions: SessionMetadata[] = []
133
220
  for (const file of files) {
134
- try {
135
- const raw = readFileSync(join(SESSIONS_DIR, file), 'utf-8')
136
- const session = JSON.parse(raw) as StoredSession
137
- if (session.metadata) {
138
- sessions.push(session.metadata)
139
- }
140
- } catch {
141
- // Skip corrupt files
221
+ const name = file.replace('.jsonl', '')
222
+ const session = SessionStore.load(name)
223
+ if (session?.metadata) {
224
+ sessions.push(session.metadata)
142
225
  }
143
226
  }
144
-
145
227
  sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
146
228
  return sessions
147
229
  } catch {
@@ -203,7 +285,7 @@ export class SessionStore {
203
285
  }
204
286
  }
205
287
 
206
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
288
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
207
289
  }
208
290
 
209
291
  /**
@@ -231,7 +313,7 @@ export class SessionStore {
231
313
  } else {
232
314
  index.push(entry)
233
315
  }
234
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
316
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
235
317
  }
236
318
 
237
319
  /**
@@ -240,7 +322,7 @@ export class SessionStore {
240
322
  */
241
323
  static saveSummary(name: string, summary: string, tags: string[]): void {
242
324
  ensureDir()
243
- mkdirSync(SUMMARIES_DIR, { recursive: true })
325
+ mkdirSync(SUMMARIES_DIR, { recursive: true, mode: 0o700 })
244
326
 
245
327
  const safeRaw = name.replace(/[^a-zA-Z0-9_-]/g, '_')
246
328
  const safe =
@@ -248,7 +330,10 @@ export class SessionStore {
248
330
  ? `${safeRaw.slice(0, 80)}-${createHash('sha256').update(safeRaw).digest('hex').slice(0, 16)}`
249
331
  : safeRaw
250
332
  const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
251
- writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, 'utf-8')
333
+ writeFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, {
334
+ encoding: 'utf-8',
335
+ mode: 0o600,
336
+ })
252
337
 
253
338
  // Update index entry — create minimal one if not present
254
339
  try {
@@ -270,7 +355,7 @@ export class SessionStore {
270
355
  tags,
271
356
  })
272
357
  }
273
- writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), 'utf-8')
358
+ writeFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { encoding: 'utf-8', mode: 0o600 })
274
359
  } catch {
275
360
  // Index write is best-effort
276
361
  }
@@ -73,16 +73,19 @@ export function listTokens(tokenPath: string): string[] {
73
73
  * trusted and auth is bypassed. Auth enforcement activates when
74
74
  * MIPHAM_BIND=0.0.0.0 for remote access.
75
75
  */
76
- export function authMiddleware(request: Request, validToken: string): Response | null {
76
+ export function authMiddleware(
77
+ request: Request,
78
+ validToken: string,
79
+ peerAddress: string | undefined,
80
+ ): Response | null {
77
81
  // Allow health endpoint without auth
78
82
  const url = new URL(request.url)
79
83
  if (url.pathname === '/api/v1/health') return null
80
84
 
81
- // localhost requests skip auth
82
- const host = request.headers.get('host') || ''
83
- if (host.startsWith('127.0.0.1') || host.startsWith('localhost') || host.startsWith('[::1]')) {
84
- return null
85
- }
85
+ // Trust only actual loopback connections (socket IP), never the spoofable Host header.
86
+ const isLoopback =
87
+ peerAddress === '127.0.0.1' || peerAddress === '::1' || peerAddress === '::ffff:127.0.0.1'
88
+ if (isLoopback) return null
86
89
 
87
90
  const auth = request.headers.get('authorization')
88
91
  if (!auth || !auth.startsWith('Bearer ')) {
@@ -1,10 +1,11 @@
1
1
  // apps/cli/src/daemon/cors.ts
2
2
  //
3
3
  // CORS middleware for the daemon HTTP API.
4
- // Handles preflight OPTIONS requests and provides helpers for
5
- // adding CORS headers to responses from external origins.
4
+ // Only origins explicitly listed in MIPHAM_CORS_ORIGINS (comma-separated) are
5
+ // allowed cross-origin access. By default (empty) no external origin is allowed,
6
+ // so a malicious web page cannot read daemon responses cross-origin.
6
7
 
7
- const ALLOWED_HEADERS = 'Authorization, Content-Type'
8
+ const ALLOWED_HEADERS = 'Content-Type'
8
9
  const ALLOWED_METHODS = 'GET, POST, PATCH, DELETE, OPTIONS'
9
10
 
10
11
  /**
@@ -14,24 +15,31 @@ export function isLocalhostOrigin(origin: string): boolean {
14
15
  return origin.includes('localhost') || origin.includes('127.0.0.1') || origin.includes('[::1]')
15
16
  }
16
17
 
18
+ function getAllowedOrigins(): string[] {
19
+ const raw = process.env.MIPHAM_CORS_ORIGINS || ''
20
+ return raw
21
+ .split(',')
22
+ .map((s) => s.trim())
23
+ .filter(Boolean)
24
+ }
25
+
26
+ function isAllowedOrigin(origin: string): boolean {
27
+ return getAllowedOrigins().includes(origin)
28
+ }
29
+
17
30
  /**
18
31
  * Handle CORS preflight (OPTIONS) requests.
19
32
  *
20
- * Returns a Response with appropriate CORS headers for preflight,
21
- * or null if the request does not need CORS handling (not a preflight,
22
- * or origin is localhost).
23
- *
24
- * For non-preflight requests with an external Origin, the caller should
25
- * use `addCorsHeaders` to attach CORS headers to the response.
33
+ * Returns a Response with CORS headers only for explicitly allow-listed external
34
+ * origins. Localhost origins need no CORS; unlisted external origins get no CORS
35
+ * headers (the browser blocks the cross-origin read).
26
36
  */
27
37
  export function corsMiddleware(request: Request): Response | null {
28
38
  const origin = request.headers.get('origin')
29
39
  if (!origin) return null
30
40
 
31
- // Skip localhost origins — no CORS needed
32
- if (isLocalhostOrigin(origin)) return null
41
+ if (isLocalhostOrigin(origin) || !isAllowedOrigin(origin)) return null
33
42
 
34
- // Handle preflight
35
43
  if (request.method === 'OPTIONS') {
36
44
  return new Response(null, {
37
45
  status: 204,
@@ -39,7 +47,7 @@ export function corsMiddleware(request: Request): Response | null {
39
47
  'Access-Control-Allow-Origin': origin,
40
48
  'Access-Control-Allow-Methods': ALLOWED_METHODS,
41
49
  'Access-Control-Allow-Headers': ALLOWED_HEADERS,
42
- 'Access-Control-Max-Age': '86400',
50
+ 'Access-Control-Max-Age': '3600',
43
51
  },
44
52
  })
45
53
  }
@@ -48,12 +56,12 @@ export function corsMiddleware(request: Request): Response | null {
48
56
  }
49
57
 
50
58
  /**
51
- * Add CORS headers to a response when the request has an external origin.
52
- * If the origin is localhost or absent, the response is returned unchanged.
59
+ * Add CORS headers to a response only for explicitly allow-listed external origins.
60
+ * Localhost / absent / unlisted origins return the response unchanged.
53
61
  */
54
62
  export function addCorsHeaders(response: Response, request: Request): Response {
55
63
  const origin = request.headers.get('origin')
56
- if (!origin || isLocalhostOrigin(origin)) return response
64
+ if (!origin || isLocalhostOrigin(origin) || !isAllowedOrigin(origin)) return response
57
65
 
58
66
  const headers = new Headers(response.headers)
59
67
  headers.set('Access-Control-Allow-Origin', origin)
@@ -30,6 +30,10 @@ export class RateLimiter {
30
30
  */
31
31
  check(key: string): { allowed: boolean; remaining: number; resetAt: number } {
32
32
  const now = Date.now()
33
+ // Bound the map size to prevent unbounded growth under a flood of distinct keys.
34
+ if (this.windows.size >= 100_000 && !this.windows.has(key)) {
35
+ this.cleanup()
36
+ }
33
37
  let entry = this.windows.get(key)
34
38
 
35
39
  if (!entry || now >= entry.resetAt) {
@@ -21,7 +21,7 @@ import { bootstrapProviders } from '../providers/bootstrap'
21
21
  import { createToolRegistry } from '../tools'
22
22
  import { PermissionSystem } from '../core/permission'
23
23
  import type { ProviderRegistry } from '../providers/registry'
24
- import type { ToolDefinition } from '../shared/types'
24
+ import type { ToolDefinition, PermissionMode } from '../shared/types'
25
25
 
26
26
  interface ServerConfig {
27
27
  db: DaemonDatabase
@@ -42,6 +42,27 @@ interface WsData {
42
42
  sessionId: string
43
43
  }
44
44
 
45
+ const DAEMON_PERMISSION_MODES: ReadonlySet<PermissionMode> = new Set<PermissionMode>([
46
+ 'default',
47
+ 'acceptEdits',
48
+ 'plan',
49
+ 'auto',
50
+ 'dontAsk',
51
+ 'bypassPermissions',
52
+ ])
53
+
54
+ /**
55
+ * Resolve the daemon's permission mode from env, defaulting to least-privilege
56
+ * 'default'. Headless daemon sessions can never prompt, so 'ask'-level tools
57
+ * (Bash/Write/Edit) are blocked rather than auto-approved.
58
+ */
59
+ function resolveDaemonPermission(): PermissionMode {
60
+ const fromEnv = process.env.MIPHAM_DAEMON_PERMISSION
61
+ return fromEnv && DAEMON_PERMISSION_MODES.has(fromEnv as PermissionMode)
62
+ ? (fromEnv as PermissionMode)
63
+ : 'default'
64
+ }
65
+
45
66
  export function createServer(config: ServerConfig): Server<WsData> {
46
67
  const {
47
68
  db,
@@ -142,7 +163,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
142
163
  }
143
164
  }
144
165
 
145
- const permission = new PermissionSystem('bypassPermissions')
166
+ const permission = new PermissionSystem(resolveDaemonPermission())
146
167
  const engine = new QueryEngine(sharedRegistry, context, sharedTools, permission)
147
168
  engine.setSessionId(sessionId)
148
169
  engineCache.set(sessionId, engine)
@@ -201,7 +222,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
201
222
  }
202
223
 
203
224
  // ── Auth check ──────────────────────────────────
204
- const authError = authMiddleware(req, token)
225
+ const authError = authMiddleware(req, token, server.requestIP(req)?.address)
205
226
  if (authError) return addCorsHeaders(authError, req)
206
227
 
207
228
  // Helper: create JSON response with CORS headers for external origins
package/src/index.tsx CHANGED
@@ -26,12 +26,16 @@ import { ContextManager } from './core/context'
26
26
  import { PrefixCacheTracker } from './core/context-token'
27
27
  import { QueryEngine } from './core/engine'
28
28
  import { ExperienceRuleEngine } from './core/rule-engine.js'
29
+ import { SessionLog } from './core/session-log'
29
30
  import { SessionStore } from './core/session-store'
30
31
  import type { PermissionLevel, MiphamConfig } from './shared/types'
31
32
  import { SkillsLoader } from './skills/loader'
32
33
  import { PluginManager } from './plugin/plugin-manager'
33
34
  import { loadPlugins } from './plugin/plugin-loader'
34
35
  import { createToolRegistry } from './tools'
36
+ import { Context } from './vajra'
37
+ import { mountSkills, SKILLS_KEY } from './skills/seam'
38
+ import { mountLlm, LLM_KEY } from './providers/llm'
35
39
  import { McpClient } from './mcp/client'
36
40
  import { registerMcpServerTools } from './mcp/registry'
37
41
  import { AgentRegistry } from './agent/agent-registry'
@@ -328,6 +332,16 @@ export async function runApp(options: RunOptions): Promise<void> {
328
332
  compactionThreshold: 0.9,
329
333
  contextWindow: adaptiveThresholds ? modelContextWindow : undefined,
330
334
  })
335
+ const sessionLog = new SessionLog(sessionName)
336
+ sessionLog.append({
337
+ type: 'session/start',
338
+ at: Date.now(),
339
+ sessionId: sessionName,
340
+ provider: defaultProvider,
341
+ model: defaultModel,
342
+ cwd: process.cwd(),
343
+ })
344
+ context.setLog(sessionLog)
331
345
  // Cache-aware microcompaction: track the provider's prompt-cache prefix.
332
346
  context.setCacheTracker(new PrefixCacheTracker())
333
347
 
@@ -335,19 +349,19 @@ export async function runApp(options: RunOptions): Promise<void> {
335
349
  getMemoryManager().setContextWindow(modelContextWindow)
336
350
 
337
351
  if (options.resume) {
338
- const saved = SessionStore.load(options.resume)
339
- if (saved) {
340
- // Restore working directory if saved
341
- if (saved.metadata.cwd && existsSync(saved.metadata.cwd)) {
352
+ const log = SessionStore.loadLog(options.resume)
353
+ const events = log.events()
354
+ if (events.length > 0) {
355
+ const start = events.find((e) => e.type === 'session/start') as
356
+ { type: 'session/start'; cwd?: string } | undefined
357
+ if (start?.cwd && existsSync(start.cwd)) {
342
358
  try {
343
- process.chdir(saved.metadata.cwd)
359
+ process.chdir(start.cwd)
344
360
  } catch {
345
- // cwd may no longer exist; silently continue
361
+ // cwd 可能已不存在
346
362
  }
347
363
  }
348
- for (const msg of saved.messages) {
349
- context.addMessage(msg)
350
- }
364
+ context.restoreLog(log)
351
365
  context.setSystemPrompt(instructions.buildSystemPrompt(config.permission as string))
352
366
  }
353
367
  }
@@ -423,8 +437,21 @@ export async function runApp(options: RunOptions): Promise<void> {
423
437
  context.setSystemPrompt(prompt)
424
438
  }
425
439
 
426
- // Create tool registry with all built-in tools
427
- const tools = createToolRegistry()
440
+ // Initialize credential masking pipeline (strategies: Full, Extract, JWT)
441
+ const { initializePipeline } = await import('./core/credential-masker/index')
442
+ initializePipeline()
443
+
444
+ // Load credential masking config and inject it into the tool seam
445
+ const credentialMaskingConfig = loadCredentialMaskingConfig()
446
+ const vajraContext = new Context()
447
+ vajraContext.provide('credentials', credentialMaskingConfig)
448
+
449
+ // Create tool registry with all built-in tools (mounted as Vajra services)
450
+ const tools = createToolRegistry(vajraContext)
451
+
452
+ // 绞杀收官:skills + llm 也挂载到同一 Vajra Context(tools 已接)
453
+ mountSkills(vajraContext, skillsLoader)
454
+ mountLlm(vajraContext, registry)
428
455
 
429
456
  // Connect MCP servers and register their tools into the tool registry.
430
457
  // Uses Promise.allSettled for parallel connection — failures are non-fatal.
@@ -471,23 +498,13 @@ export async function runApp(options: RunOptions): Promise<void> {
471
498
  engine.setHookEngine(hookEngine)
472
499
  engine.setArtifactServer(artifactServer)
473
500
  engine.setAgentViewManager(agentViewManager)
474
- engine.setSkillsLoader(skillsLoader)
501
+ engine.setSkills(vajraContext.get(SKILLS_KEY)!)
502
+ engine.setLlm(vajraContext.get(LLM_KEY)!)
475
503
 
476
504
  // Wire inference hooks (DLP) configuration
477
505
  const inferenceHookConfig = loadInferenceHookConfig()
478
506
  engine.setInferenceHookConfig(inferenceHookConfig)
479
507
 
480
- // Initialize credential masking pipeline (strategies: Full, Extract, JWT)
481
- const { initializePipeline } = await import('./core/credential-masker/index')
482
- initializePipeline()
483
-
484
- // Wire credential masking configuration into tools
485
- const credentialMaskingConfig = loadCredentialMaskingConfig()
486
- const { setCredentialMaskingConfigForRead } = await import('./tools/file/read')
487
- const { setCredentialMaskingConfigForBash } = await import('./tools/exec/bash')
488
- setCredentialMaskingConfigForRead(credentialMaskingConfig)
489
- setCredentialMaskingConfigForBash(credentialMaskingConfig)
490
-
491
508
  // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
492
509
  if (config.permission) {
493
510
  engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
@@ -559,11 +576,20 @@ export async function runApp(options: RunOptions): Promise<void> {
559
576
  hookEngine.executeSessionEnd(sessionName).catch(() => {})
560
577
  artifactServer.stop()
561
578
  if (context.getMessageCount() > 0) {
562
- SessionStore.save(sessionName, context.getMessages(), {
563
- provider: defaultProvider,
564
- model: defaultModel,
565
- cwd: process.cwd(),
566
- })
579
+ const log = context.getLog()
580
+ if (log) {
581
+ SessionStore.saveLog(sessionName, log, {
582
+ provider: defaultProvider,
583
+ model: defaultModel,
584
+ cwd: process.cwd(),
585
+ })
586
+ } else {
587
+ SessionStore.save(sessionName, context.getMessages(), {
588
+ provider: defaultProvider,
589
+ model: defaultModel,
590
+ cwd: process.cwd(),
591
+ })
592
+ }
567
593
  }
568
594
  process.exit(0)
569
595
  }
@@ -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
  }