@miphamai/cli 0.85.1 → 0.85.2
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/package.json +1 -1
- package/src/agent/agent-experience.ts +3 -2
- package/src/agent/background-registry.ts +7 -3
- package/src/agent/cross-session/discovery.ts +5 -12
- package/src/agent/cross-session/file-inbox.ts +2 -2
- package/src/agent/effectiveness-tracker.ts +3 -2
- package/src/agent/sub-agent.ts +44 -4
- package/src/agent-view/dashboard.tsx +22 -0
- package/src/commands/autoloop-journal.ts +6 -5
- package/src/commands/environment.ts +11 -8
- package/src/config/credential-crypto.ts +13 -1
- package/src/config/keys-manager.ts +7 -4
- package/src/config/loader.ts +46 -35
- package/src/config/preferences.ts +6 -3
- package/src/core/constitution-loader.ts +3 -2
- package/src/core/crsi-producer.ts +4 -2
- package/src/core/crsi-sandbox.ts +2 -1
- package/src/core/dream-engine.ts +5 -11
- package/src/core/engine.ts +9 -4
- package/src/core/error-signature-db.ts +3 -2
- package/src/core/eval-harness.ts +3 -3
- package/src/core/hooks-executor.ts +60 -2
- package/src/core/memory/memory-manager.ts +10 -6
- package/src/core/permission-audit.ts +13 -6
- package/src/core/permission-rules.ts +99 -5
- package/src/core/permission.ts +6 -1
- package/src/core/rule-engine.ts +3 -2
- package/src/core/session-log.ts +48 -11
- package/src/core/session-store.ts +64 -44
- package/src/daemon/auth.ts +4 -3
- package/src/daemon/index.ts +4 -3
- package/src/i18n-core/locales/en-US.json +1 -0
- package/src/i18n-core/locales/zh-CN.json +1 -0
- package/src/index.tsx +6 -0
- package/src/mcp/oauth.ts +47 -5
- package/src/mcp/token-store.ts +10 -11
- package/src/providers/openai-compat.ts +11 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/regular-file.ts +63 -0
- package/src/shared/sanitize.ts +14 -2
- package/src/tools/agent/memory.ts +4 -2
- package/src/tools/agent/workflow.ts +6 -3
- package/src/tools/exec/task.ts +82 -30
- package/src/tools/scheduling/cron.ts +3 -9
- package/src/ui/app.tsx +48 -13
- package/src/ui/commands.ts +5 -2
- package/src/ui/ctrl-c-confirm.ts +63 -0
- package/src/workflow/journal.ts +80 -26
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
mkdirSync,
|
|
3
|
-
readdirSync,
|
|
4
|
-
readFileSync,
|
|
5
|
-
writeFileSync,
|
|
6
|
-
unlinkSync,
|
|
7
|
-
renameSync,
|
|
8
|
-
existsSync,
|
|
9
|
-
statSync,
|
|
10
|
-
} from 'node:fs'
|
|
1
|
+
import { mkdirSync, readdirSync, unlinkSync, existsSync, statSync } from 'node:fs'
|
|
11
2
|
import { join } from 'node:path'
|
|
12
3
|
import { createHash } from 'node:crypto'
|
|
13
4
|
import type { Message } from '../shared/types'
|
|
14
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
sanitizeSessionName,
|
|
7
|
+
SessionLog,
|
|
8
|
+
deriveMessages,
|
|
9
|
+
messageToEvents,
|
|
10
|
+
isValidMessage,
|
|
11
|
+
} from './session-log'
|
|
12
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
13
|
+
import { readRegularFileSync } from '../shared/regular-file'
|
|
15
14
|
import { miphamHome } from './paths.ts'
|
|
16
15
|
|
|
17
16
|
export interface SessionMetadata {
|
|
@@ -54,6 +53,25 @@ function filePath(name: string): string {
|
|
|
54
53
|
return join(SESSIONS_DIR, `${sanitizeSessionName(name)}.jsonl`)
|
|
55
54
|
}
|
|
56
55
|
|
|
56
|
+
/**
|
|
57
|
+
* 旧格式快照(单 JSON 对象 `{metadata, messages}`)的形状闸。
|
|
58
|
+
*
|
|
59
|
+
* `'metadata' in parsed` 拦不住 `{"metadata": null, …}` —— `in` 看的是**键在不在**,
|
|
60
|
+
* 而 `/resume <name>` 紧接着就读 `session.metadata.name`,那条链上没有 try(实测
|
|
61
|
+
* `TypeError`)。`messages` 的元素同理:`[null]` 会一路送到 UI 的
|
|
62
|
+
* `forwardedMessages.map((msg) => msg.role)`(`app.tsx:1041`)与 provider 的内容分支。
|
|
63
|
+
*
|
|
64
|
+
* 形状不对**整份返回 null**,而不是把坏元素过滤掉:调用方随即回落到事件日志那条路,
|
|
65
|
+
* 读不出来会如实报 load failed;过滤则会凭空造出一段残缺历史 —— 静默改写用户数据。
|
|
66
|
+
*/
|
|
67
|
+
function asStoredSnapshot(parsed: unknown): StoredSession | null {
|
|
68
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
69
|
+
const { metadata, messages } = parsed as { metadata?: unknown; messages?: unknown }
|
|
70
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
|
|
71
|
+
if (!Array.isArray(messages) || !messages.every(isValidMessage)) return null
|
|
72
|
+
return { metadata: metadata as SessionMetadata, messages: messages as Message[] }
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
export class SessionStore {
|
|
58
76
|
/**
|
|
59
77
|
* Save a session as JSONL (one JSON object per line).
|
|
@@ -79,10 +97,11 @@ export class SessionStore {
|
|
|
79
97
|
messages,
|
|
80
98
|
}
|
|
81
99
|
|
|
82
|
-
// Atomic write: write to temp file, then rename (same-fs rename is atomic)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
// Atomic write: write to temp file, then rename (same-fs rename is atomic).
|
|
101
|
+
// 临时名由 atomicWriteFileSync 生成(pid + 随机后缀)—— 从前这里是固定的
|
|
102
|
+
// `path + '.tmp'`,两个进程同时存同一个会话时,先 rename 的那个会把**后一个**写者的
|
|
103
|
+
// 内容搬进目标,而它自己的 rename 再抛 ENOENT:在一个「目标永不半截」的函数里丢一次写。
|
|
104
|
+
atomicWriteFileSync(path, JSON.stringify(session) + '\n', { mode: 0o600 })
|
|
86
105
|
|
|
87
106
|
// Incremental index update — only touch this session's entry
|
|
88
107
|
try {
|
|
@@ -123,18 +142,12 @@ export class SessionStore {
|
|
|
123
142
|
/** 从磁盘重开一个 SessionLog(不存在返回空 log)。旧格式快照自动迁移为事件日志。 */
|
|
124
143
|
static loadLog(name: string): SessionLog {
|
|
125
144
|
const path = filePath(name)
|
|
126
|
-
if (!existsSync(path)) return SessionLog.open(name)
|
|
127
145
|
try {
|
|
128
|
-
const raw =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
typeof parsed === 'object' &&
|
|
133
|
-
'metadata' in parsed &&
|
|
134
|
-
Array.isArray((parsed as { messages?: unknown }).messages)
|
|
135
|
-
) {
|
|
146
|
+
const raw = readRegularFileSync(path)
|
|
147
|
+
if (raw === null) return SessionLog.open(name)
|
|
148
|
+
const old = asStoredSnapshot(JSON.parse(raw))
|
|
149
|
+
if (old) {
|
|
136
150
|
// 旧格式快照 → 迁移为事件日志(重写文件,避免后续 append 混格式)
|
|
137
|
-
const old = parsed as StoredSession
|
|
138
151
|
const log = new SessionLog(name)
|
|
139
152
|
log.append({
|
|
140
153
|
type: 'session/start',
|
|
@@ -160,19 +173,12 @@ export class SessionStore {
|
|
|
160
173
|
*/
|
|
161
174
|
static load(name: string): StoredSession | null {
|
|
162
175
|
const path = filePath(name)
|
|
163
|
-
if (!existsSync(path)) return null
|
|
164
|
-
|
|
165
176
|
try {
|
|
166
|
-
const raw =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
typeof parsed === 'object' &&
|
|
172
|
-
'metadata' in parsed &&
|
|
173
|
-
Array.isArray((parsed as { messages?: unknown }).messages)
|
|
174
|
-
) {
|
|
175
|
-
return parsed as StoredSession
|
|
177
|
+
const raw = readRegularFileSync(path)
|
|
178
|
+
if (raw !== null) {
|
|
179
|
+
// 旧格式:单 JSON 对象 {metadata, messages}
|
|
180
|
+
const old = asStoredSnapshot(JSON.parse(raw))
|
|
181
|
+
if (old) return old
|
|
176
182
|
}
|
|
177
183
|
} catch {
|
|
178
184
|
// 多行 JSONL → 新格式,走事件解析
|
|
@@ -295,7 +301,7 @@ export class SessionStore {
|
|
|
295
301
|
}
|
|
296
302
|
}
|
|
297
303
|
|
|
298
|
-
|
|
304
|
+
atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
|
|
299
305
|
}
|
|
300
306
|
|
|
301
307
|
/**
|
|
@@ -323,7 +329,7 @@ export class SessionStore {
|
|
|
323
329
|
} else {
|
|
324
330
|
index.push(entry)
|
|
325
331
|
}
|
|
326
|
-
|
|
332
|
+
atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
|
|
327
333
|
}
|
|
328
334
|
|
|
329
335
|
/**
|
|
@@ -340,8 +346,7 @@ export class SessionStore {
|
|
|
340
346
|
? `${safeRaw.slice(0, 80)}-${createHash('sha256').update(safeRaw).digest('hex').slice(0, 16)}`
|
|
341
347
|
: safeRaw
|
|
342
348
|
const summaryPath = join(SUMMARIES_DIR, `${safe}.md`)
|
|
343
|
-
|
|
344
|
-
encoding: 'utf-8',
|
|
349
|
+
atomicWriteFileSync(summaryPath, `# ${name}\n\n${summary}\n\nTags: ${tags.join(', ')}\n`, {
|
|
345
350
|
mode: 0o600,
|
|
346
351
|
})
|
|
347
352
|
|
|
@@ -365,7 +370,7 @@ export class SessionStore {
|
|
|
365
370
|
tags,
|
|
366
371
|
})
|
|
367
372
|
}
|
|
368
|
-
|
|
373
|
+
atomicWriteFileSync(INDEX_FILE, JSON.stringify(index, null, 2), { mode: 0o600 })
|
|
369
374
|
} catch {
|
|
370
375
|
// Index write is best-effort
|
|
371
376
|
}
|
|
@@ -414,11 +419,26 @@ export class SessionStore {
|
|
|
414
419
|
|
|
415
420
|
/**
|
|
416
421
|
* Read the raw index file, returning empty array if missing or corrupt.
|
|
422
|
+
*
|
|
423
|
+
* 「corrupt」不只是语法错:`null`、`{"a":1}`、`[1,2]` 都过得了 `JSON.parse`。放它们
|
|
424
|
+
* 过去,`getLatest()` 会按数组用它 —— `null` 在启动路径(`index.tsx` 建系统提示时)
|
|
425
|
+
* 抛 `TypeError: index.length`;`[1,2]` 更安静:`getLatest()` 返回数字 `1`,调用方接着
|
|
426
|
+
* 读 `latest.name` 拿到 `undefined`,再喂给 `load()` ⇒ `name.replace` 抛。条目还必须
|
|
427
|
+
* 有**字符串 name**:索引条目的第一用途就是当文件名用。
|
|
417
428
|
*/
|
|
418
429
|
private static loadIndexRaw(): SessionIndexEntry[] {
|
|
419
|
-
|
|
430
|
+
const raw = readRegularFileSync(INDEX_FILE)
|
|
431
|
+
if (raw === null) return []
|
|
420
432
|
try {
|
|
421
|
-
|
|
433
|
+
const parsed: unknown = JSON.parse(raw)
|
|
434
|
+
if (!Array.isArray(parsed)) return []
|
|
435
|
+
return parsed.filter(
|
|
436
|
+
(e): e is SessionIndexEntry =>
|
|
437
|
+
!!e &&
|
|
438
|
+
typeof e === 'object' &&
|
|
439
|
+
typeof (e as { name?: unknown }).name === 'string' &&
|
|
440
|
+
(e as { name: string }).name.length > 0,
|
|
441
|
+
)
|
|
422
442
|
} catch {
|
|
423
443
|
return []
|
|
424
444
|
}
|
package/src/daemon/auth.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// apps/cli/src/daemon/auth.ts
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
3
|
-
import { readFileSync,
|
|
3
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
4
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
4
5
|
import { dirname } from 'node:path'
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -21,7 +22,7 @@ export function loadOrCreateToken(tokenPath: string): string {
|
|
|
21
22
|
|
|
22
23
|
const token = generateToken()
|
|
23
24
|
mkdirSync(dirname(tokenPath), { recursive: true, mode: 0o700 })
|
|
24
|
-
|
|
25
|
+
atomicWriteFileSync(tokenPath, token, { mode: 0o600 })
|
|
25
26
|
return token
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -51,7 +52,7 @@ export function verifyToken(expected: string, provided: string): boolean {
|
|
|
51
52
|
*/
|
|
52
53
|
export function rotateToken(tokenPath: string): string {
|
|
53
54
|
const token = generateToken()
|
|
54
|
-
|
|
55
|
+
atomicWriteFileSync(tokenPath, token, { mode: 0o600 })
|
|
55
56
|
return token
|
|
56
57
|
}
|
|
57
58
|
|
package/src/daemon/index.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// getPort() reads the last known port from disk
|
|
7
7
|
|
|
8
8
|
import { join } from 'node:path'
|
|
9
|
-
import { existsSync, readFileSync,
|
|
9
|
+
import { existsSync, readFileSync, unlinkSync, mkdirSync } from 'node:fs'
|
|
10
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
10
11
|
import { createServer as createNetServer } from 'node:net'
|
|
11
12
|
import type { Server } from 'bun'
|
|
12
13
|
import { DaemonDatabase } from './database'
|
|
@@ -247,8 +248,8 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
|
|
|
247
248
|
activeServer = server
|
|
248
249
|
|
|
249
250
|
// Write PID and port files
|
|
250
|
-
|
|
251
|
-
|
|
251
|
+
atomicWriteFileSync(PID_FILE, String(process.pid), { mode: 0o644 })
|
|
252
|
+
atomicWriteFileSync(PORT_FILE, String(port), { mode: 0o644 })
|
|
252
253
|
|
|
253
254
|
return { port, token }
|
|
254
255
|
}
|
package/src/index.tsx
CHANGED
|
@@ -346,6 +346,10 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
346
346
|
sessionId: options.remoteSession.sessionId,
|
|
347
347
|
}),
|
|
348
348
|
}),
|
|
349
|
+
// Ink 默认 exitOnCtrlC: true,它在**任何 handler 拿到按键之前**就退进程
|
|
350
|
+
// (App.js `input === '\x03'`,且 useInput 那条路还会跳过全部监听器)。
|
|
351
|
+
// 于是 Ctrl+C 的语义只能由我们自己定 —— 见 App 里的「再按一次才退」。
|
|
352
|
+
{ exitOnCtrlC: false },
|
|
349
353
|
)
|
|
350
354
|
await waitUntilExit()
|
|
351
355
|
|
|
@@ -364,6 +368,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
364
368
|
onAttach={() => {}}
|
|
365
369
|
onExit={() => process.exit(0)}
|
|
366
370
|
/>,
|
|
371
|
+
{ exitOnCtrlC: false },
|
|
367
372
|
)
|
|
368
373
|
await waitUntilExit()
|
|
369
374
|
process.exit(0)
|
|
@@ -868,6 +873,7 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
868
873
|
t,
|
|
869
874
|
}),
|
|
870
875
|
}),
|
|
876
|
+
{ exitOnCtrlC: false },
|
|
871
877
|
)
|
|
872
878
|
await waitUntilExit()
|
|
873
879
|
saveAndExit()
|
package/src/mcp/oauth.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { exec } from 'node:child_process'
|
|
|
3
3
|
import { createServer, Server } from 'node:http'
|
|
4
4
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
5
5
|
import type { McpServerConfig } from '../shared/types'
|
|
6
|
-
import { TokenStore } from './token-store'
|
|
6
|
+
import { TokenStore, type TokenData } from './token-store'
|
|
7
7
|
import { fetchWithRetry } from '../providers/fetch-utils'
|
|
8
8
|
import { createT } from '../i18n-core/t'
|
|
9
9
|
import enUS from '../i18n-core/locales/en-US.json'
|
|
@@ -23,6 +23,29 @@ interface TokenResponse {
|
|
|
23
23
|
scopes?: string[]
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* 这份凭证**绑给谁** —— 即它会流经哪几个端点、以哪个 client 身份取得。
|
|
28
|
+
*
|
|
29
|
+
* 为什么需要:`TokenStore` 只按**服务名**存(`<name>.enc`),而这个名来自
|
|
30
|
+
* `.mcp.json` —— 那是**从 cwd 读**的项目级配置。clone 一个仓库就等于让它给你的
|
|
31
|
+
* MCP 服务起名,于是「名字相同」推不出「签发方相同」:把某个常见名(如 `github`)
|
|
32
|
+
* 的 url / tokenUrl 指到别处,缓存里那份真凭证就会被送给新的主机 —— access token
|
|
33
|
+
* 被直接塞进 env 交给对面,refresh token 被 POST 给对面的令牌端点(后者更危险:
|
|
34
|
+
* 它能换出新的 access token)。
|
|
35
|
+
*
|
|
36
|
+
* 绑定的是**凭证会去的地方**,四者任一变化即视为另一份凭证:授权端点、令牌端点、
|
|
37
|
+
* client 身份、资源端点(access token 最终发给它)。
|
|
38
|
+
*/
|
|
39
|
+
export function credentialBinding(config: McpServerConfig): string {
|
|
40
|
+
const auth = config.auth
|
|
41
|
+
return JSON.stringify([
|
|
42
|
+
auth?.authorizationUrl ?? '',
|
|
43
|
+
auth?.tokenUrl ?? '',
|
|
44
|
+
auth?.clientId ?? '',
|
|
45
|
+
config.url ?? '',
|
|
46
|
+
])
|
|
47
|
+
}
|
|
48
|
+
|
|
26
49
|
function base64url(buf: Buffer): string {
|
|
27
50
|
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
28
51
|
}
|
|
@@ -127,12 +150,28 @@ export class OAuthClient {
|
|
|
127
150
|
scopes: data.scope?.split(' '),
|
|
128
151
|
}
|
|
129
152
|
|
|
130
|
-
this.store.save(config.name, result)
|
|
153
|
+
this.store.save(config.name, { ...result, boundTo: credentialBinding(config) })
|
|
131
154
|
return result
|
|
132
155
|
}
|
|
133
156
|
|
|
134
|
-
|
|
157
|
+
/**
|
|
158
|
+
* 取回该服务名下的凭证 —— **仅当它确实是当前这份配置签发的**。
|
|
159
|
+
*
|
|
160
|
+
* 绑定值不符时**不删**:把配置改回去,这份凭证仍然有效,而删除是不可逆的副作用。
|
|
161
|
+
* 但也**不用**它 —— 换过端点之后,「同名」不再说明任何事。
|
|
162
|
+
*
|
|
163
|
+
* 旧版本存下的凭证没有 `boundTo`(`undefined`)⇒ 一律不匹配 ⇒ 走一次 PKCE 重新
|
|
164
|
+
* 授权。这是认下的代价:无法核实签发方的凭证,就是不能拿出去用,而多授权一次
|
|
165
|
+
* 的代价远小于把一份 refresh token 交给别人的端点。
|
|
166
|
+
*/
|
|
167
|
+
private loadBoundToken(serverName: string, config: McpServerConfig): TokenData | null {
|
|
135
168
|
const saved = this.store.load(serverName)
|
|
169
|
+
if (!saved) return null
|
|
170
|
+
return saved.boundTo === credentialBinding(config) ? saved : null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async getValidAccessToken(serverName: string, config: McpServerConfig): Promise<string> {
|
|
174
|
+
const saved = this.loadBoundToken(serverName, config)
|
|
136
175
|
if (saved && new Date(saved.expiresAt).getTime() > Date.now() + 60000) {
|
|
137
176
|
return saved.accessToken
|
|
138
177
|
}
|
|
@@ -144,9 +183,11 @@ export class OAuthClient {
|
|
|
144
183
|
}
|
|
145
184
|
|
|
146
185
|
async refreshAccessToken(serverName: string, config: McpServerConfig): Promise<string> {
|
|
147
|
-
const saved = this.
|
|
186
|
+
const saved = this.loadBoundToken(serverName, config)
|
|
148
187
|
if (!saved?.refreshToken) {
|
|
149
|
-
throw new Error(
|
|
188
|
+
throw new Error(
|
|
189
|
+
`No refresh token available for "${serverName}" — none stored, or the stored one was issued for a different endpoint`,
|
|
190
|
+
)
|
|
150
191
|
}
|
|
151
192
|
const auth = config.auth!
|
|
152
193
|
// A single failed refresh is often a transient network/server error, not a
|
|
@@ -179,6 +220,7 @@ export class OAuthClient {
|
|
|
179
220
|
accessToken: data.access_token,
|
|
180
221
|
refreshToken: data.refresh_token || saved.refreshToken,
|
|
181
222
|
expiresAt: new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString(),
|
|
223
|
+
boundTo: credentialBinding(config),
|
|
182
224
|
})
|
|
183
225
|
return data.access_token
|
|
184
226
|
}
|
package/src/mcp/token-store.ts
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
readFileSync,
|
|
4
|
-
writeFileSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
unlinkSync,
|
|
7
|
-
readdirSync,
|
|
8
|
-
chmodSync,
|
|
9
|
-
} from 'node:fs'
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from 'node:fs'
|
|
2
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
10
3
|
import { join, dirname } from 'node:path'
|
|
11
4
|
import { encrypt, decrypt, getCredentialKey } from '../config/credential-crypto'
|
|
12
5
|
import { miphamHome } from '../core/paths.ts'
|
|
13
6
|
|
|
14
|
-
interface TokenData {
|
|
7
|
+
export interface TokenData {
|
|
15
8
|
accessToken: string
|
|
16
9
|
refreshToken?: string
|
|
17
10
|
expiresAt: string
|
|
18
11
|
createdAt?: string
|
|
19
12
|
scopes?: string[]
|
|
13
|
+
/**
|
|
14
|
+
* 这份凭证绑给哪个签发方(见 `credentialBinding`)。缺失 = 绑定未知,
|
|
15
|
+
* 调用方必须当作**不可用** —— 凭证只按服务名存,而服务名来自可从 cwd 读的
|
|
16
|
+
* 项目级配置,故「同名」推不出「同一签发方」。
|
|
17
|
+
*/
|
|
18
|
+
boundTo?: string
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
export class TokenStore {
|
|
@@ -36,7 +35,7 @@ export class TokenStore {
|
|
|
36
35
|
createdAt: data.createdAt || new Date().toISOString(),
|
|
37
36
|
})
|
|
38
37
|
const encrypted = encrypt(json, this.key)
|
|
39
|
-
|
|
38
|
+
atomicWriteFileSync(filePath, encrypted, { mode: 0o600 })
|
|
40
39
|
try {
|
|
41
40
|
chmodSync(filePath, 0o600)
|
|
42
41
|
} catch {
|
|
@@ -280,6 +280,17 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
280
280
|
const msg = messages[i]!
|
|
281
281
|
if (!msg) continue
|
|
282
282
|
|
|
283
|
+
// A `system` entry is a header, not a turn: it belongs in the first slot,
|
|
284
|
+
// and never alongside an already-emitted system prompt. A mid-array entry
|
|
285
|
+
// is a client-generated UI line — the engine stores a provider failure as
|
|
286
|
+
// `system` so a resumed session can render it as an ⚠ line — and passing it
|
|
287
|
+
// through as system role hands text that came *from a provider* the highest
|
|
288
|
+
// authority on the very next turn. anthropic.ts drops system entries
|
|
289
|
+
// outright (its protocol forbids them in the array at all); keeping the
|
|
290
|
+
// head is the part OpenAI-compatible endpoints genuinely accept.
|
|
291
|
+
const isHeader = msg.role === 'system' && i === 0 && result.length === 0
|
|
292
|
+
if (msg.role === 'system' && !isHeader) continue
|
|
293
|
+
|
|
283
294
|
// ── String content ──
|
|
284
295
|
if (typeof msg.content === 'string') {
|
|
285
296
|
// Combine: assistant text + next assistant message with tool_use blocks
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
export const PACKAGE_NAME = '@miphamai/cli' as const
|
|
10
10
|
|
|
11
11
|
/** 当前发布版本 */
|
|
12
|
-
export const PACKAGE_VERSION = '0.85.
|
|
12
|
+
export const PACKAGE_VERSION = '0.85.2' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 路径是不是**普通文件** —— 判据是文件**类型**,不是存在性。
|
|
5
|
+
*
|
|
6
|
+
* `existsSync` 对 FIFO、socket、设备节点、目录一律为真,而 `readFileSync` 读一个
|
|
7
|
+
* 没有写者的 FIFO 会**阻塞到有写者打开它为止**。于是 `mkfifo ~/.mipham/config.yml`
|
|
8
|
+
* 这一条命令(或某个崩掉的工具留在 `~/.mipham/` 下的 socket)就能让 CLI 启动**永久
|
|
9
|
+
* 挂住**:没有输出、没有报错,也没有任何东西能给它计时 —— 阻塞发生在同步调用里,
|
|
10
|
+
* 定时器与 signal handler 都排不上队。闸门只能架在文件类型上。
|
|
11
|
+
*
|
|
12
|
+
* 符号链接指向普通文件**算数**(`statSync` 跟随链接,`~/.mipham` 常被软链进
|
|
13
|
+
* dotfiles 仓库);指向 FIFO 的不算。
|
|
14
|
+
*
|
|
15
|
+
* 已知边界:stat 与随后的 read 之间不是原子的 —— 恰好在那道缝里把路径换成 FIFO
|
|
16
|
+
* 仍会阻塞。不设防:能改你配置路径的人本来就能做更坏的事。
|
|
17
|
+
*/
|
|
18
|
+
export function isRegularFile(path: string): boolean {
|
|
19
|
+
try {
|
|
20
|
+
return statSync(path).isFile()
|
|
21
|
+
} catch {
|
|
22
|
+
// 不存在(ENOENT)、路径中有一段不是目录(ENOTDIR)、权限不足(EACCES)、
|
|
23
|
+
// 链接成环(ELOOP)—— 对调用方都是同一件事:这里没有能读的内容。
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 读一个**普通文件**的文本内容;没有可读的普通文件时返回 null。
|
|
30
|
+
*
|
|
31
|
+
* null 的含义是「这里没有可读的普通文件」,三种来源一视同仁:缺失、不是普通文件
|
|
32
|
+
* (FIFO / socket / 目录 / 设备节点)、是普通文件但读不动(权限等)。
|
|
33
|
+
*
|
|
34
|
+
* 本函数**不往 stderr 写任何东西** —— 多数调用点读的是可选覆盖层,「文件不在」是
|
|
35
|
+
* 常态。需要提示的调用点自己判断(见 `config/loader.ts` 的 `safeParseYaml`)。
|
|
36
|
+
*/
|
|
37
|
+
export function readRegularFileSync(path: string): string | null {
|
|
38
|
+
if (!isRegularFile(path)) return null
|
|
39
|
+
try {
|
|
40
|
+
return readFileSync(path, 'utf-8')
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 追加写入,只在目标**是普通文件或还不存在**时才写;返回是否真的写了。
|
|
48
|
+
*
|
|
49
|
+
* `appendFileSync` 会**打开目标写** —— 目标若是 FIFO 就等到有读者为止,与读侧同一个
|
|
50
|
+
* 坑,只是撞在写这一侧(配置备份的 `copyFileSync` 当初也是这么挂住的)。「还不存在」
|
|
51
|
+
* 必须放行:追加写天然承担创建。
|
|
52
|
+
*
|
|
53
|
+
* 返回 `false` 的意思是**没写**,调用方得知道自己丢了什么 —— 别把「跳过」当成功。
|
|
54
|
+
*/
|
|
55
|
+
export function appendRegularFileSync(
|
|
56
|
+
path: string,
|
|
57
|
+
content: string,
|
|
58
|
+
options: { mode?: number } = {},
|
|
59
|
+
): boolean {
|
|
60
|
+
if (existsSync(path) && !isRegularFile(path)) return false
|
|
61
|
+
appendFileSync(path, content, { encoding: 'utf-8', mode: options.mode ?? 0o600 })
|
|
62
|
+
return true
|
|
63
|
+
}
|
package/src/shared/sanitize.ts
CHANGED
|
@@ -25,16 +25,28 @@
|
|
|
25
25
|
* applied to what tools *write to disk*, not only to what gets pattern-matched.
|
|
26
26
|
* Adding them here would silently rewrite file contents.
|
|
27
27
|
*
|
|
28
|
+
* Deliberately **not** included — the two joiners, ZWNJ (U+200C) and ZWJ (U+200D),
|
|
29
|
+
* for exactly the reason above. This is why the zero-width part of the set is spelled
|
|
30
|
+
* `\u{200B}\u{200E}-\u{200F}` and *not* the closed range `\u{200B}-\u{200F}` it used to be:
|
|
31
|
+
* - both are load-bearing in *visible* text. ZWNJ attaches a Persian/Arabic suffix to a
|
|
32
|
+
* Latin word or number (the plural of "PDF"), and ZWJ is what holds a family emoji
|
|
33
|
+
* together (U+1F468 U+200D U+1F469 U+200D U+1F467) rather than three separate people;
|
|
34
|
+
* - they buy nothing on the permission path either, since no shell ignores them —
|
|
35
|
+
* `bash -c 'ec<ZWJ>ho hi'` is `command not found` (same for ZWNJ and ZWSP). So removing
|
|
36
|
+
* them from the set costs no matching protection; it only stops the write path from
|
|
37
|
+
* rewriting what the user asked us to write.
|
|
38
|
+
*
|
|
28
39
|
* The tag block (U+E0000–E007F) *is* included: it is invisible in itself, and the
|
|
29
40
|
* only sequences that use it (subdivision flags, e.g. U+1F3F4 + a tag run) degrade
|
|
30
41
|
* to the bare black flag — which renders the same.
|
|
31
42
|
*/
|
|
32
43
|
const DANGEROUS_UNICODE =
|
|
33
|
-
/[\u{061C}\u{115F}-\u{1160}\u{180E}\u{200B}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{3164}\u{FEFF}\u{FFA0}\u{E0000}-\u{E007F}]/gu
|
|
44
|
+
/[\u{061C}\u{115F}-\u{1160}\u{180E}\u{200B}\u{200E}-\u{200F}\u{202A}-\u{202E}\u{2060}\u{2066}-\u{2069}\u{3164}\u{FEFF}\u{FFA0}\u{E0000}-\u{E007F}]/gu
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
47
|
* Strip dangerous invisible Unicode characters from a string.
|
|
37
|
-
* - Zero-width: U+200B (ZWSP), U+
|
|
48
|
+
* - Zero-width: U+200B (ZWSP), U+200E/F (LTR/RTL marks) — the joiners U+200C/U+200D are
|
|
49
|
+
* deliberately kept, see `DANGEROUS_UNICODE`
|
|
38
50
|
* - Bidi controls: U+202A-E, U+2066-9, U+061C (Arabic letter mark)
|
|
39
51
|
* - Word joiner: U+2060
|
|
40
52
|
* - BOM: U+FEFF
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { atomicWriteFileSync } from '../../shared/atomic-write'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
5
|
import { MemoryManager } from '../../core/memory/memory-manager'
|
|
@@ -83,7 +84,8 @@ export const memoryTool: ToolDefinition = {
|
|
|
83
84
|
|
|
84
85
|
if (action === 'write') {
|
|
85
86
|
const body = formatMemory(name, params.content as string)
|
|
86
|
-
|
|
87
|
+
// 与 MemoryManager 写的是同一批 .md:两侧都走原子写,权限语义保持一致。
|
|
88
|
+
atomicWriteFileSync(filePath, body, { mode: 0o644 })
|
|
87
89
|
return { success: true, content: `Memory "${name}" written` }
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -111,16 +111,19 @@ export const workflowTool: ToolDefinition = {
|
|
|
111
111
|
// Persist last-run state for /workflow save
|
|
112
112
|
let persistWarning = ''
|
|
113
113
|
try {
|
|
114
|
-
const { existsSync, mkdirSync
|
|
114
|
+
const { existsSync, mkdirSync } = await import('node:fs')
|
|
115
|
+
const { atomicWriteFileSync } = await import('../../shared/atomic-write')
|
|
115
116
|
const { join } = await import('node:path')
|
|
116
117
|
const workflowsDir = workflowScriptDir(process.cwd())
|
|
117
118
|
if (!existsSync(workflowsDir)) {
|
|
118
119
|
mkdirSync(workflowsDir, { recursive: true })
|
|
119
120
|
}
|
|
120
|
-
|
|
121
|
+
// 机器状态(每次 run 重写,`/workflow save` 会读回):非原子写留下的半截 JSON
|
|
122
|
+
// 会让那条读取抛解析错。
|
|
123
|
+
atomicWriteFileSync(
|
|
121
124
|
join(workflowsDir, '.last-run.json'),
|
|
122
125
|
JSON.stringify({ runId, script, timestamp: new Date().toISOString() }),
|
|
123
|
-
|
|
126
|
+
{ mode: 0o644 },
|
|
124
127
|
)
|
|
125
128
|
} catch (err) {
|
|
126
129
|
// Persistence stays best-effort — the workflow itself succeeded, so a
|