@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/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Mipham Code v0.81.
|
|
1
|
+
# Mipham Code v0.81.7
|
|
2
2
|
|
|
3
3
|
**Multi-model open-core intelligent coding terminal** — 12 AI providers, 137 commands, 31 tools, 28 skills + marketplace, self-update. AI-assisted code generation, security auditing, MCP protocol, and extensible skills — in a single CLI.
|
|
4
4
|
|
package/package.json
CHANGED
|
@@ -1,47 +1,95 @@
|
|
|
1
|
-
import {
|
|
2
|
-
readFileSync,
|
|
3
|
-
writeFileSync,
|
|
4
|
-
existsSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
renameSync,
|
|
7
|
-
copyFileSync,
|
|
8
|
-
unlinkSync,
|
|
9
|
-
} from 'node:fs'
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, renameSync, copyFileSync, unlinkSync } from 'node:fs'
|
|
10
2
|
import { join } from 'node:path'
|
|
3
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
11
4
|
import type { ArtifactManifest, ArtifactEntry } from '../shared/types'
|
|
12
5
|
|
|
6
|
+
function emptyManifest(): ArtifactManifest {
|
|
7
|
+
return { version: 1, artifacts: [] }
|
|
8
|
+
}
|
|
9
|
+
|
|
13
10
|
/**
|
|
14
11
|
* Read the artifact manifest from disk, or return an empty one if it doesn't exist.
|
|
12
|
+
*
|
|
13
|
+
* An unreadable index also yields an empty manifest — the callers here only *show*
|
|
14
|
+
* artifacts (gallery, `/artifact list`), and showing none beats throwing at them.
|
|
15
|
+
* Writers must not go through this path: see `readManifestForUpdate`.
|
|
15
16
|
*/
|
|
16
17
|
export function readManifest(dir: string): ArtifactManifest {
|
|
17
18
|
const path = join(dir, 'index.json')
|
|
18
19
|
if (!existsSync(path)) {
|
|
19
|
-
return
|
|
20
|
+
return emptyManifest()
|
|
20
21
|
}
|
|
21
22
|
try {
|
|
22
23
|
return JSON.parse(readFileSync(path, 'utf-8'))
|
|
23
24
|
} catch {
|
|
24
|
-
return
|
|
25
|
+
return emptyManifest()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Read the manifest for a caller that is about to write it back.
|
|
31
|
+
*
|
|
32
|
+
* The difference from `readManifest` is what happens when the file exists but does
|
|
33
|
+
* not parse: a writer must not treat that as "empty", because writing back an
|
|
34
|
+
* empty manifest replaces every other artifact's entry with nothing — the files
|
|
35
|
+
* stay on disk, but the gallery and `/artifact list` lose them. So the unreadable
|
|
36
|
+
* file is renamed aside (bytes kept, recoverable by hand) and reported, and the
|
|
37
|
+
* caller decides what to tell the user.
|
|
38
|
+
*/
|
|
39
|
+
function readManifestForUpdate(dir: string): {
|
|
40
|
+
manifest: ArtifactManifest
|
|
41
|
+
quarantined?: string
|
|
42
|
+
} {
|
|
43
|
+
const path = join(dir, 'index.json')
|
|
44
|
+
if (!existsSync(path)) return { manifest: emptyManifest() }
|
|
45
|
+
try {
|
|
46
|
+
return { manifest: JSON.parse(readFileSync(path, 'utf-8')) }
|
|
47
|
+
} catch {
|
|
48
|
+
const quarantined = `${path}.corrupt-${new Date().toISOString().replace(/[:.]/g, '-')}`
|
|
49
|
+
// If this rename fails we let it throw: the alternative is overwriting the
|
|
50
|
+
// only copy of the index with a manifest built from nothing.
|
|
51
|
+
renameSync(path, quarantined)
|
|
52
|
+
return { manifest: emptyManifest(), quarantined }
|
|
25
53
|
}
|
|
26
54
|
}
|
|
27
55
|
|
|
28
56
|
/**
|
|
29
57
|
* Write the manifest to disk, creating parent directories as needed.
|
|
58
|
+
*
|
|
59
|
+
* Atomic: a crash mid-write used to leave a truncated `index.json`, which is
|
|
60
|
+
* exactly the corruption `readManifestForUpdate` then has to quarantine.
|
|
30
61
|
*/
|
|
31
62
|
export function writeManifest(dir: string, manifest: ArtifactManifest): void {
|
|
32
63
|
mkdirSync(dir, { recursive: true })
|
|
33
|
-
|
|
64
|
+
atomicWriteFileSync(join(dir, 'index.json'), JSON.stringify(manifest, null, 2), {
|
|
65
|
+
mode: 0o644,
|
|
66
|
+
})
|
|
34
67
|
}
|
|
35
68
|
|
|
36
69
|
/**
|
|
37
70
|
* Add an entry to the manifest and persist it.
|
|
38
|
-
*
|
|
71
|
+
*
|
|
72
|
+
* An entry is identified by `name` **and** `sessionId`, matching how the tool
|
|
73
|
+
* looks one up: the manifest is a single global `index.json` holding every
|
|
74
|
+
* session's artifacts, so keying on the name alone made two sessions publishing
|
|
75
|
+
* the same name overwrite each other's entry — the loser's file stayed on disk
|
|
76
|
+
* but vanished from the index.
|
|
77
|
+
*
|
|
78
|
+
* Returns the written manifest plus, when the previous index was unreadable, the
|
|
79
|
+
* path its bytes were moved to — the caller is expected to say so rather than
|
|
80
|
+
* let an artifact appear to publish cleanly over a lost index.
|
|
39
81
|
*/
|
|
40
|
-
export function addToManifest(
|
|
41
|
-
|
|
82
|
+
export function addToManifest(
|
|
83
|
+
dir: string,
|
|
84
|
+
entry: ArtifactEntry,
|
|
85
|
+
port?: number,
|
|
86
|
+
): { manifest: ArtifactManifest; quarantined?: string } {
|
|
87
|
+
const { manifest, quarantined } = readManifestForUpdate(dir)
|
|
42
88
|
if (port !== undefined) manifest.port = port
|
|
43
89
|
|
|
44
|
-
const idx = manifest.artifacts.findIndex(
|
|
90
|
+
const idx = manifest.artifacts.findIndex(
|
|
91
|
+
(a) => a.name === entry.name && a.sessionId === entry.sessionId,
|
|
92
|
+
)
|
|
45
93
|
if (idx >= 0) {
|
|
46
94
|
manifest.artifacts[idx] = entry
|
|
47
95
|
} else {
|
|
@@ -49,7 +97,7 @@ export function addToManifest(dir: string, entry: ArtifactEntry, port?: number):
|
|
|
49
97
|
}
|
|
50
98
|
|
|
51
99
|
writeManifest(dir, manifest)
|
|
52
|
-
return manifest
|
|
100
|
+
return { manifest, quarantined }
|
|
53
101
|
}
|
|
54
102
|
|
|
55
103
|
/**
|
|
@@ -64,31 +112,39 @@ export function getSessionArtifacts(dir: string, sessionId: string): ArtifactEnt
|
|
|
64
112
|
* Archive an existing artifact file by renaming it with a version tag.
|
|
65
113
|
* e.g. dashboard.html → dashboard.v1.html, dashboard.v1.html → dashboard.v2.html.
|
|
66
114
|
*
|
|
67
|
-
* Returns the version tag assigned to the archived file
|
|
115
|
+
* Returns the version tag assigned to the archived file, or `undefined` when
|
|
116
|
+
* there was nothing to archive — in which case the manifest is left untouched.
|
|
117
|
+
*
|
|
118
|
+
* 「没归档就什么都不记」是刻意的:源文件找不到时照样推进版本号、往 `versions` 里
|
|
119
|
+
* 塞一个标签,等于在 manifest 里留一版磁盘上并不存在的版本。走到那条路并不难
|
|
120
|
+
* —— 条目按 name 找、文件按 `<session>/<name><ext>` 找,把同一个名字从 html 改
|
|
121
|
+
* 成 svg 就错开了。宁可少记一版,也不能记一版假的。
|
|
68
122
|
*/
|
|
69
|
-
export function archiveVersion(dir: string, entry: ArtifactEntry): string {
|
|
70
|
-
const manifest = readManifest(dir)
|
|
71
|
-
const versionCount = (entry.versionCount || 1) + 1
|
|
123
|
+
export function archiveVersion(dir: string, entry: ArtifactEntry): string | undefined {
|
|
72
124
|
const ext = entry.type === 'svg' ? '.svg' : '.html'
|
|
73
|
-
const versionTag = `v${versionCount}`
|
|
74
|
-
|
|
75
|
-
// Rename the current file to a versioned copy
|
|
76
125
|
const baseName = entry.name
|
|
77
126
|
const currentPath = join(dir, entry.sessionId, `${baseName}${ext}`)
|
|
127
|
+
|
|
128
|
+
if (!existsSync(currentPath)) return undefined
|
|
129
|
+
|
|
130
|
+
const versionCount = (entry.versionCount || 1) + 1
|
|
131
|
+
const versionTag = `v${versionCount}`
|
|
78
132
|
const archivedPath = join(dir, entry.sessionId, `${baseName}.${versionTag}${ext}`)
|
|
79
133
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
unlinkSync(currentPath)
|
|
87
|
-
}
|
|
134
|
+
try {
|
|
135
|
+
renameSync(currentPath, archivedPath)
|
|
136
|
+
} catch {
|
|
137
|
+
// If rename fails (e.g. cross-device), copy instead
|
|
138
|
+
copyFileSync(currentPath, archivedPath)
|
|
139
|
+
unlinkSync(currentPath)
|
|
88
140
|
}
|
|
89
141
|
|
|
90
|
-
// Update manifest entry
|
|
91
|
-
|
|
142
|
+
// Update manifest entry — keyed on name *and* session, like every other
|
|
143
|
+
// lookup here: a same-named artifact in another session is a different one.
|
|
144
|
+
const manifest = readManifest(dir)
|
|
145
|
+
const artifact = manifest.artifacts.find(
|
|
146
|
+
(a) => a.name === entry.name && a.sessionId === entry.sessionId,
|
|
147
|
+
)
|
|
92
148
|
if (artifact) {
|
|
93
149
|
const versions = artifact.versions || ['v1']
|
|
94
150
|
versions.push(versionTag)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { ARTIFACTS_DIR, MIPHAM_DIR } from '../shared/constants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The one place that decides *where on disk an artifact lives*.
|
|
6
|
+
*
|
|
7
|
+
* Three sites need to agree on this: the Artifact tool (writes the file and the
|
|
8
|
+
* manifest), the ArtifactServer (serves files and builds the gallery from the
|
|
9
|
+
* manifest rooted here), and `/artifact list` (reads the manifest). Each of them
|
|
10
|
+
* computing the path itself is what produced the original bug: the tool wrote
|
|
11
|
+
* `<cwd>/artifacts/...` while the server served `<cwd>/.mipham/artifacts/...`,
|
|
12
|
+
* so the URL the tool reported was a guaranteed 404 — the two halves never met.
|
|
13
|
+
*
|
|
14
|
+
* Keeping them joined here means a change moves all three together; the guard
|
|
15
|
+
* test asserts the *behaviour* (the reported URL resolves), not the literal.
|
|
16
|
+
*/
|
|
17
|
+
export function artifactsRoot(cwd: string): string {
|
|
18
|
+
return join(cwd, MIPHAM_DIR, ARTIFACTS_DIR)
|
|
19
|
+
}
|
package/src/artifacts/server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createServer, type Server } from 'node:http'
|
|
2
|
-
import {
|
|
2
|
+
import type { Socket } from 'node:net'
|
|
3
|
+
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
|
|
3
4
|
import { join, normalize, extname } from 'node:path'
|
|
4
5
|
import { ARTIFACT_ALLOWED_EXTENSIONS } from '../shared/constants'
|
|
5
6
|
import { readManifest } from './manifest'
|
|
6
|
-
import { ArtifactVersioning } from './versioning'
|
|
7
7
|
import type { ArtifactEntry } from '../shared/types'
|
|
8
8
|
import { getMetrics } from '../core/metrics'
|
|
9
9
|
|
|
@@ -29,12 +29,11 @@ export class ArtifactServer {
|
|
|
29
29
|
private started = false
|
|
30
30
|
private sseClients: SseClient[] = []
|
|
31
31
|
private sseIdCounter = 0
|
|
32
|
-
private
|
|
32
|
+
private sockets = new Set<Socket>()
|
|
33
33
|
|
|
34
34
|
constructor(artifactsDir: string, preferredPort: number) {
|
|
35
35
|
this.artifactsDir = artifactsDir
|
|
36
36
|
this.port = preferredPort
|
|
37
|
-
this.versioning = new ArtifactVersioning(artifactsDir)
|
|
38
37
|
}
|
|
39
38
|
|
|
40
39
|
/** Notify all connected SSE clients to reload. Called after artifact changes. */
|
|
@@ -82,6 +81,12 @@ export class ArtifactServer {
|
|
|
82
81
|
this.server = null
|
|
83
82
|
this.started = false
|
|
84
83
|
}
|
|
84
|
+
|
|
85
|
+
// `close()` only stops *accepting* — sockets already established (a browser's
|
|
86
|
+
// keep-alive, an SSE stream) stay open and keep being served, so a "stopped"
|
|
87
|
+
// server answers on the old port until the client hangs up. Destroy them.
|
|
88
|
+
for (const socket of this.sockets) socket.destroy()
|
|
89
|
+
this.sockets.clear()
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
getPort(): number {
|
|
@@ -114,6 +119,10 @@ export class ArtifactServer {
|
|
|
114
119
|
const srv = createServer((req, res) => {
|
|
115
120
|
this.handleRequest(req, res)
|
|
116
121
|
})
|
|
122
|
+
srv.on('connection', (socket) => {
|
|
123
|
+
this.sockets.add(socket)
|
|
124
|
+
socket.on('close', () => this.sockets.delete(socket))
|
|
125
|
+
})
|
|
117
126
|
srv.on('error', reject)
|
|
118
127
|
srv.listen(port, () => {
|
|
119
128
|
this.server = srv
|
|
@@ -216,8 +225,31 @@ export class ArtifactServer {
|
|
|
216
225
|
})
|
|
217
226
|
}
|
|
218
227
|
|
|
219
|
-
/**
|
|
228
|
+
/**
|
|
229
|
+
* Per-artifact SSE stream: pushes the artifact's content to connected browsers
|
|
230
|
+
* every 500ms, so a page can follow an artifact the AI rewrites in place.
|
|
231
|
+
*
|
|
232
|
+
* Resolves the file through `resolveFile` — the same coordinate the gallery
|
|
233
|
+
* links to and the same traversal guard the static path uses — and 404s when no
|
|
234
|
+
* artifact carries that name, instead of holding a stream open on a file that
|
|
235
|
+
* does not exist.
|
|
236
|
+
*/
|
|
220
237
|
private handleNameSse(name: string, res: any): void {
|
|
238
|
+
const entry = readManifest(this.artifactsDir).artifacts.find((a) => a.name === name)
|
|
239
|
+
if (!entry) {
|
|
240
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
241
|
+
res.end(`No artifact named "${name}"`)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const ext = entry.type === 'svg' ? '.svg' : '.html'
|
|
246
|
+
const { filePath, error, status } = this.resolveFile(`/${entry.sessionId}/${entry.name}${ext}`)
|
|
247
|
+
if (error) {
|
|
248
|
+
res.writeHead(status || 404, { 'Content-Type': 'text/plain' })
|
|
249
|
+
res.end(error)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
|
|
221
253
|
res.writeHead(200, {
|
|
222
254
|
'Content-Type': 'text/event-stream',
|
|
223
255
|
'Cache-Control': 'no-cache',
|
|
@@ -225,10 +257,18 @@ export class ArtifactServer {
|
|
|
225
257
|
'Access-Control-Allow-Origin': '*',
|
|
226
258
|
})
|
|
227
259
|
|
|
260
|
+
let last: string | null = null
|
|
228
261
|
const interval = setInterval(() => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
262
|
+
try {
|
|
263
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
264
|
+
// Only on change: without this the stream re-sends the whole file every
|
|
265
|
+
// 500ms per client for as long as the tab stays open.
|
|
266
|
+
if (content !== last) {
|
|
267
|
+
last = content
|
|
268
|
+
res.write(`data: ${JSON.stringify({ type: 'update', name, content })}\n\n`)
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// Momentarily absent (archiving renames it) — skip this tick, try the next.
|
|
232
272
|
}
|
|
233
273
|
}, 500)
|
|
234
274
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
|
|
2
2
|
import { join, dirname } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
4
5
|
import { saveProviderApiKey } from './loader'
|
|
5
6
|
|
|
6
7
|
const MIPHAM_HOME = join(homedir(), '.mipham')
|
|
@@ -39,14 +40,12 @@ function loadKeys(): KeysData {
|
|
|
39
40
|
|
|
40
41
|
function saveKeys(data: KeysData): void {
|
|
41
42
|
mkdirSync(dirname(KEYS_FILE), { recursive: true })
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// chmod on Windows is a no-op
|
|
49
|
-
}
|
|
43
|
+
// 从前这里「转了一半」:先写一个固定名的 `.tmp`,**然后不 rename、直接再写一遍
|
|
44
|
+
// 目标**。留在磁盘上的 `.tmp` 是废物,目标仍然非原子 —— 并发 `/keys rotate` 撞进
|
|
45
|
+
// 同一个临时名,或写到一半被打断,`loadKeys` 把不可解析的 JSON 吞成 `{}`
|
|
46
|
+
// (见上面的 catch)⇒ 全部轮换元数据静默消失。atomicWriteFileSync 自己写唯一名
|
|
47
|
+
// 临时文件再 rename,两者一起解决。
|
|
48
|
+
atomicWriteFileSync(KEYS_FILE, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
function daysSince(iso: string): number {
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* NOT for config.yml settings — those belong in the YAML config system.
|
|
6
6
|
* NOT for secrets — this file is plain JSON, not encrypted.
|
|
7
7
|
*/
|
|
8
|
-
import { readFileSync,
|
|
8
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
11
12
|
|
|
12
13
|
const PREFS_PATH = join(homedir(), '.mipham', 'preferences.json')
|
|
13
14
|
|
|
@@ -27,7 +28,9 @@ function writePrefs(prefs: Record<string, string>): void {
|
|
|
27
28
|
try {
|
|
28
29
|
const dir = join(homedir(), '.mipham')
|
|
29
30
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
30
|
-
|
|
31
|
+
// 原子写:裸 writeFileSync 原地截断,崩在写中途就留下一份不可解析的文件,
|
|
32
|
+
// 而 readPrefs 把不可解析吞成「空」⇒ **全部**偏好静默消失(不是丢一项)。
|
|
33
|
+
atomicWriteFileSync(PREFS_PATH, JSON.stringify(prefs, null, 2), { mode: 0o600 })
|
|
31
34
|
} catch {
|
|
32
35
|
// best-effort; never crash because preferences failed to save
|
|
33
36
|
}
|
package/src/core/cron-poller.ts
CHANGED
|
@@ -9,9 +9,22 @@ import { computeNextFire } from './cron'
|
|
|
9
9
|
import type { CronJob } from '../tools/scheduling/cron'
|
|
10
10
|
import { readAllJobs, writeJob, deleteJobFile } from '../tools/scheduling/cron'
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Whether a job belongs to `cwd`.
|
|
14
|
+
*
|
|
15
|
+
* A job with no `cwd` is from a file written before jobs carried one; it matches
|
|
16
|
+
* anywhere so an existing user's schedule keeps firing instead of going silent.
|
|
17
|
+
* `cwd === undefined` means the caller did not ask for scoping at all (the pure
|
|
18
|
+
* helpers' existing callers), so nothing is filtered.
|
|
19
|
+
*/
|
|
20
|
+
function matchesCwd(job: CronJob, cwd?: string): boolean {
|
|
21
|
+
if (job.cwd === undefined || cwd === undefined) return true
|
|
22
|
+
return job.cwd === cwd
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Jobs whose nextFire is at or before `now` — and which belong to `cwd`. */
|
|
26
|
+
export function findDueJobs(jobs: CronJob[], now: Date, cwd?: string): CronJob[] {
|
|
27
|
+
return jobs.filter((j) => new Date(j.nextFire).getTime() <= now.getTime() && matchesCwd(j, cwd))
|
|
15
28
|
}
|
|
16
29
|
|
|
17
30
|
/** Next state after firing a due job: recurring advances; one-shot → null (delete). */
|
|
@@ -24,9 +37,20 @@ export function advanceJob(job: CronJob, now: Date): CronJob | null {
|
|
|
24
37
|
}
|
|
25
38
|
}
|
|
26
39
|
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Read due jobs, enqueue their prompts, and advance/delete. Returns fired count.
|
|
42
|
+
*
|
|
43
|
+
* `cwd` defaults to the process's working directory — the same source
|
|
44
|
+
* `ToolContext.cwd` comes from — because the enqueued prompt lands in *this*
|
|
45
|
+
* session and is executed here. Without the filter, a schedule created in one
|
|
46
|
+
* project would be run by whatever session happened to be open in another.
|
|
47
|
+
*/
|
|
48
|
+
export function checkCronJobs(
|
|
49
|
+
enqueue: (prompt: string) => void,
|
|
50
|
+
now = new Date(),
|
|
51
|
+
cwd = process.cwd(),
|
|
52
|
+
): number {
|
|
53
|
+
const due = findDueJobs(readAllJobs(), now, cwd)
|
|
30
54
|
for (const job of due) {
|
|
31
55
|
enqueue(job.prompt)
|
|
32
56
|
const next = advanceJob(job, now)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
1
3
|
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
2
4
|
import { matchPath } from './credential-masker/matcher'
|
|
3
5
|
|
|
@@ -106,6 +108,9 @@ const PREFIX_COMMANDS = new Set([
|
|
|
106
108
|
'stdbuf',
|
|
107
109
|
])
|
|
108
110
|
|
|
111
|
+
/** `timeout` 的 duration 形态:`5` / `0.5` / `30s` / `2m` / `1h` / `1d`。 */
|
|
112
|
+
const TIMEOUT_DURATION_RE = /^\d+(\.\d+)?[smhd]?$/
|
|
113
|
+
|
|
109
114
|
/** Value-taking options of wrapper commands (consume the following token). */
|
|
110
115
|
const PREFIX_VALUE_OPTIONS = new Set([
|
|
111
116
|
'-u',
|
|
@@ -193,6 +198,76 @@ function stripQuotes(s: string): string {
|
|
|
193
198
|
return s
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
/** Shell 结构符号:分组与取反 —— 它们自身不是命令,紧跟其后的是。 */
|
|
202
|
+
const LEADING_SHELL_PUNCT = new Set(['(', '{', '!'])
|
|
203
|
+
|
|
204
|
+
/** Shell 关键字:其后才是真正的命令(`do rm -rf x` 执行的命令是 `rm`)。 */
|
|
205
|
+
const LEADING_SHELL_KEYWORDS = new Set([
|
|
206
|
+
'do',
|
|
207
|
+
'then',
|
|
208
|
+
'else',
|
|
209
|
+
'elif',
|
|
210
|
+
'if',
|
|
211
|
+
'while',
|
|
212
|
+
'until',
|
|
213
|
+
'for',
|
|
214
|
+
'case',
|
|
215
|
+
'time',
|
|
216
|
+
'coproc',
|
|
217
|
+
])
|
|
218
|
+
|
|
219
|
+
/** 一条前导赋值:`NAME=value`(name 是合法标识符,`=` 前无引号)。 */
|
|
220
|
+
const LEADING_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 剥掉一段 shell 片段**前导**的噪声 token,露出真正的基命令。
|
|
224
|
+
*
|
|
225
|
+
* 为什么需要它:`Bash(rm *)` / `Read(secret)` 这类规则要匹配的是**命令本身**,
|
|
226
|
+
* 而 shell 允许在命令前放赋值(`IFS=x rm -rf x`)、分组符号(`( rm -rf x )`)、
|
|
227
|
+
* 关键字(`for …; do rm -rf x; done`)。这些都不改变「执行了什么命令」,却足以
|
|
228
|
+
* 让匹配器看不到 `rm`,于是 deny 规则被一个空格级的改写绕过。
|
|
229
|
+
*
|
|
230
|
+
* 只剥前导、可反复剥(`FOO=1 ! rm …`)。剥多了一律是过匹配,而 deny 规则的过
|
|
231
|
+
* 匹配是安全方向。关键字自己的裸 flag 也一并剥掉(`time -p rm …` 里的 `-p`),
|
|
232
|
+
* 否则关键字被剥走后会剩下 `-p rm …`,仍然看不见 `rm`。
|
|
233
|
+
*
|
|
234
|
+
* 不剥尾随符号(`rm -rf x )` 里的 `)`):`wildcardMatch` 的 `*` 已经吃掉它。
|
|
235
|
+
*/
|
|
236
|
+
export function stripLeadingShellNoise(segment: string): string {
|
|
237
|
+
let tokens = segment.trim().split(/\s+/).filter(Boolean)
|
|
238
|
+
let stripped = false
|
|
239
|
+
let skipFlags = false
|
|
240
|
+
for (;;) {
|
|
241
|
+
const head = tokens[0]
|
|
242
|
+
if (!head) break
|
|
243
|
+
if (skipFlags && /^--?[A-Za-z]/.test(head)) {
|
|
244
|
+
tokens = tokens.slice(1)
|
|
245
|
+
stripped = true
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
skipFlags = false
|
|
249
|
+
const bare = head.replace(/^[({!]+/, '') // `(!` 这类连写
|
|
250
|
+
if (bare !== head) {
|
|
251
|
+
tokens = bare ? [bare, ...tokens.slice(1)] : tokens.slice(1)
|
|
252
|
+
stripped = true
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
if (LEADING_SHELL_PUNCT.has(head) || LEADING_ASSIGNMENT_RE.test(head)) {
|
|
256
|
+
tokens = tokens.slice(1)
|
|
257
|
+
stripped = true
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (LEADING_SHELL_KEYWORDS.has(head)) {
|
|
261
|
+
tokens = tokens.slice(1)
|
|
262
|
+
stripped = true
|
|
263
|
+
skipFlags = true // `time -p rm …` —— 关键字自己的裸 flag 不是命令
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
return stripped ? tokens.join(' ') : segment
|
|
269
|
+
}
|
|
270
|
+
|
|
196
271
|
function uniq(items: string[]): string[] {
|
|
197
272
|
return [...new Set(items)]
|
|
198
273
|
}
|
|
@@ -203,6 +278,29 @@ export interface BashFileAccess {
|
|
|
203
278
|
write: string[]
|
|
204
279
|
}
|
|
205
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 展开候选路径里**已知**的变量:前导 `~`、`$HOME`/`${HOME}`。
|
|
283
|
+
*
|
|
284
|
+
* 为什么需要:规则里写的是绝对路径(`Read(/Users/me/.ssh/id_rsa)`),而用户敲的
|
|
285
|
+
* 是同一个文件的另一种拼法(`cat ~/.ssh/id_rsa`)—— 不展开即等于放行。展开后
|
|
286
|
+
* 两种拼法落到同一个字符串上,绝对路径形与路径通配形(如「.ssh 下任意文件」)
|
|
287
|
+
* 规则都能命中。
|
|
288
|
+
*
|
|
289
|
+
* **只展开 HOME**。`$FOO` 这类未知变量原样保留:展开它需要求值环境,静默展开成
|
|
290
|
+
* 空串会让 `/x/$FOO/y` 变成 `/x//y` —— 那是**新增**一个漏判方向,比不展开更坏。
|
|
291
|
+
* `$PWD` 同理需要 cwd,而 `matchBashRule` 的调用点拿不到 cwd,故未覆盖。这两条
|
|
292
|
+
* 都是本函数已知的边界,不是遗漏。
|
|
293
|
+
*
|
|
294
|
+
* `~` 只在**开头**展开(`a/~/b` 里的 `~` 是普通字符,shell 也不展开它)。
|
|
295
|
+
*/
|
|
296
|
+
export function expandKnownPathVars(p: string): string {
|
|
297
|
+
const home = homedir() // 每次取,不在模块加载时绑定(HOME 可能被测试隔离改写)
|
|
298
|
+
let out = p
|
|
299
|
+
if (out === '~') out = home
|
|
300
|
+
else if (out.startsWith('~/')) out = join(home, out.slice(2))
|
|
301
|
+
return out.replace(/\$\{?HOME\}?/g, home)
|
|
302
|
+
}
|
|
303
|
+
|
|
206
304
|
/**
|
|
207
305
|
* Extract the file paths a Bash command reads or writes, so Read()/Write()/
|
|
208
306
|
* Edit() deny rules also apply to Bash (not just the Read/Write/Edit tools).
|
|
@@ -231,7 +329,9 @@ export function extractBashFileAccess(command: string): BashFileAccess {
|
|
|
231
329
|
// recursing into `$(...)` / backtick substitutions.
|
|
232
330
|
scanReaderWriterCommands(command, read, write)
|
|
233
331
|
|
|
234
|
-
|
|
332
|
+
// 展开放在出口这一处,而不是每个 push 点 —— 重定向与读/写命令、以及它们的
|
|
333
|
+
// 递归内层都汇进这两个数组,一处展开即全覆盖。
|
|
334
|
+
return { read: uniq(read.map(expandKnownPathVars)), write: uniq(write.map(expandKnownPathVars)) }
|
|
235
335
|
}
|
|
236
336
|
|
|
237
337
|
/** Extract the inner commands of `$(...)` and backtick substitutions. */
|
|
@@ -244,6 +344,12 @@ function extractSubstitutions(command: string): string[] {
|
|
|
244
344
|
while ((m = dollarParen.exec(command)) !== null) inners.push(m[1]!)
|
|
245
345
|
const backtick = /`([^`]*)`/g
|
|
246
346
|
while ((m = backtick.exec(command)) !== null) inners.push(m[1]!)
|
|
347
|
+
// 进程替换 `<(cmd)` / `>(cmd)`:`cat <(cat secret)` 里读 secret 的是里层的
|
|
348
|
+
// `cat`,外层只是把它的 stdout 当成一个文件名。参数排除 `<>` 是因为
|
|
349
|
+
// `<(cat secret)` 的捕获若允许 `>`,遇到 `>(...)` 形态会被提前截断;非嵌套组
|
|
350
|
+
// 由调用方的递归处理。
|
|
351
|
+
const procSub = /[<>]\(([^()<>]*)\)/g
|
|
352
|
+
while ((m = procSub.exec(command)) !== null) inners.push(m[1]!)
|
|
247
353
|
return inners
|
|
248
354
|
}
|
|
249
355
|
|
|
@@ -261,6 +367,12 @@ function flattenCommand(command: string, depth = 0): string[] {
|
|
|
261
367
|
out.push(seg)
|
|
262
368
|
const stripped = stripPrefixCommand(seg)
|
|
263
369
|
if (stripped !== seg) out.push(stripped)
|
|
370
|
+
// 剥掉前导噪声后的形态也要参与匹配:`IFS=x rm -rf x` / `( rm -rf x )` /
|
|
371
|
+
// `for …; do rm -rf x; done` 执行的仍是 `rm`,规则必须看得见它。同一个函数
|
|
372
|
+
// 也被 scanReaderWriterCommands 用(Read/Write/Edit 桥接那条路径)—— 两条
|
|
373
|
+
// 路径共用一份归一化,只接一条就是只修一半。
|
|
374
|
+
const denoised = stripLeadingShellNoise(seg)
|
|
375
|
+
if (denoised !== seg) out.push(denoised)
|
|
264
376
|
for (const inner of extractSubstitutions(seg)) {
|
|
265
377
|
out.push(...flattenCommand(inner, depth + 1))
|
|
266
378
|
}
|
|
@@ -303,7 +415,10 @@ function effectiveCommand(tokens: string[]): {
|
|
|
303
415
|
if (name === 'env') {
|
|
304
416
|
while (i < tokens.length && tokens[i]!.includes('=')) i++ // `VAR=value` assignments
|
|
305
417
|
}
|
|
306
|
-
|
|
418
|
+
// `timeout` 的位置参数是 duration,**不是**必然存在:`timeout 5 cmd` 有,
|
|
419
|
+
// `timeout --preserve-status cmd` 没有。无条件 `i++` 会把后者真正的命令
|
|
420
|
+
// (`cat`)当成 duration 吃掉,基命令退化成它的第一个参数。
|
|
421
|
+
if (name === 'timeout' && i < tokens.length && TIMEOUT_DURATION_RE.test(tokens[i]!)) i++
|
|
307
422
|
// `eval`'s remaining arguments are themselves a command line, and must be
|
|
308
423
|
// captured here rather than via the SHELL_COMMANDS branch below: `eval "cat
|
|
309
424
|
// secret"` tokenizes to ['eval', '"cat', 'secret"'], so the base degrades to
|
|
@@ -388,7 +503,10 @@ function scanReaderWriterCommands(
|
|
|
388
503
|
depth = 0,
|
|
389
504
|
): void {
|
|
390
505
|
for (const seg of splitShellSegments(command)) {
|
|
391
|
-
|
|
506
|
+
// 先剥前导噪声再 tokenize:`IFS=x cat secret` / `! cat secret` /
|
|
507
|
+
// `time -p cat secret` 读的是同一个文件,Read() 规则必须看得见 `cat`。
|
|
508
|
+
// 与 flattenCommand 共用 stripLeadingShellNoise —— 两条路径一份归一化。
|
|
509
|
+
const tokens = stripLeadingShellNoise(seg).split(/\s+/).filter(Boolean)
|
|
392
510
|
if (tokens.length > 0) {
|
|
393
511
|
const { base, args, payload } = effectiveCommand(tokens)
|
|
394
512
|
if (READER_COMMANDS.has(base)) {
|
|
@@ -418,6 +536,13 @@ function scanReaderWriterCommands(
|
|
|
418
536
|
}
|
|
419
537
|
}
|
|
420
538
|
|
|
539
|
+
/**
|
|
540
|
+
* `matchBashRule` 真正会按参数匹配工具名的集合。**与 matchBashRule 的分支一一
|
|
541
|
+
* 对应** —— 那里 `return false` 的工具,参数化规则在 validateRulePattern 里必须
|
|
542
|
+
* 被拒(否则规则永远不命中,是静默空防护)。改一边必须改另一边。
|
|
543
|
+
*/
|
|
544
|
+
const PARAMETERISED_TOOLS = new Set(['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob'])
|
|
545
|
+
|
|
421
546
|
// Match a tool(parameter) rule against an actual tool call.
|
|
422
547
|
//
|
|
423
548
|
// Pattern formats:
|
|
@@ -500,6 +625,10 @@ export function wildcardMatch(pattern: string, input: string): boolean {
|
|
|
500
625
|
* `Bash()`) is silently dead today — this returns a human-readable reason so
|
|
501
626
|
* the caller can report it as an invalid setting instead of ignoring it.
|
|
502
627
|
*
|
|
628
|
+
* 校验的是**结构 + 工具名**(后者见 PARAMETERISED_TOOLS)。**不**校验子模式本身
|
|
629
|
+
* 能否匹配任何东西 —— `Bash(zzz *)` 合法、装得上、只是不会命中,那是用户自己的
|
|
630
|
+
* 选择,不是配置错误。
|
|
631
|
+
*
|
|
503
632
|
* Returns null when the pattern is valid, or a reason string when malformed.
|
|
504
633
|
*/
|
|
505
634
|
export function validateRulePattern(pattern: string): string | null {
|
|
@@ -510,9 +639,16 @@ export function validateRulePattern(pattern: string): string | null {
|
|
|
510
639
|
// Empty parameter: `Bash()` or `Bash( )`
|
|
511
640
|
if (/\(\s*\)$/.test(pattern)) return 'empty parameter'
|
|
512
641
|
// Must be exactly `ToolName(param)` with nothing before or after.
|
|
513
|
-
|
|
642
|
+
const shape = pattern.match(/^(\w+)\((.+)\)$/)
|
|
643
|
+
if (!shape) {
|
|
514
644
|
return 'unexpected text after the closing parenthesis'
|
|
515
645
|
}
|
|
646
|
+
// 工具名必须落在 matchBashRule 真正会按参数匹配的那一集合里,否则这条规则是
|
|
647
|
+
// **静默空防护**:语法合法、装得上、永远不命中,而用户以为配了保护。
|
|
648
|
+
const tool = shape[1]!
|
|
649
|
+
if (!PARAMETERISED_TOOLS.has(tool)) {
|
|
650
|
+
return `parameterised rules are not supported for tool "${tool}" (it would never match)`
|
|
651
|
+
}
|
|
516
652
|
return null
|
|
517
653
|
}
|
|
518
654
|
|