@miphamai/cli 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/mipham.ts +188 -0
- package/dist/mipham +0 -0
- package/package.json +9 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +9 -6
- package/skills/mipham/om-security.mipham-skill.md +11 -8
- package/skills/standard/doc-generator.SKILL.md +6 -1
- package/skills/standard/github-ops.SKILL.md +12 -7
- package/skills/standard/memory.SKILL.md +1 -0
- package/skills/standard/self-review.SKILL.md +1 -0
- package/skills/standard/superpower.SKILL.md +7 -7
- package/skills/standard/web-search.SKILL.md +3 -0
- package/src/agent/agent-context.ts +56 -0
- package/src/agent/agent-registry.ts +134 -0
- package/src/agent/sub-agent.ts +73 -89
- package/src/agent/types.ts +39 -0
- package/src/agent-view/agent-view-manager.ts +217 -0
- package/src/agent-view/dashboard.tsx +218 -0
- package/src/agent-view/session-peek.tsx +72 -0
- package/src/agent-view/session-row.tsx +70 -0
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +374 -0
- package/src/artifacts/versioning.ts +127 -0
- package/src/core/context-compact.ts +50 -0
- package/src/core/context-drain.ts +54 -0
- package/src/core/context-microcompact.ts +132 -0
- package/src/core/context-snip.ts +74 -0
- package/src/core/context-token.ts +108 -0
- package/src/core/context.ts +169 -0
- package/src/core/engine.ts +207 -58
- package/src/core/hooks-config.ts +52 -0
- package/src/core/hooks-executor.ts +113 -0
- package/src/core/hooks.ts +90 -30
- package/src/core/instructions.ts +12 -1
- package/src/core/memory/memory-loader.ts +23 -0
- package/src/core/memory/memory-manager.ts +192 -0
- package/src/core/memory/memory-writer.ts +72 -0
- package/src/core/permission-config.ts +34 -0
- package/src/core/permission-rules.ts +66 -0
- package/src/core/permission.ts +224 -34
- package/src/index.tsx +30 -2
- package/src/mcp/transport.ts +42 -5
- package/src/plugin/plugin-loader.ts +36 -0
- package/src/plugin/plugin-manager.ts +125 -0
- package/src/plugin/plugin-validator.ts +41 -0
- package/src/providers/fetch-utils.ts +1 -3
- package/src/providers/openai-compat.ts +2 -11
- package/src/shared/constants.ts +8 -1
- package/src/shared/types.ts +82 -2
- package/src/skills/fork-executor.ts +40 -0
- package/src/skills/loader.ts +48 -2
- package/src/tools/agent/agent.ts +20 -11
- package/src/tools/agent/skill.ts +32 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/computer/app-launcher.ts +39 -0
- package/src/tools/computer/browser.ts +48 -0
- package/src/tools/computer/computer-use.ts +88 -0
- package/src/tools/computer/playwright.d.ts +7 -0
- package/src/tools/computer/screenshot.ts +57 -0
- package/src/tools/index.ts +6 -0
- package/src/ui/app.tsx +20 -7
- package/src/ui/chat.tsx +9 -5
- package/src/ui/commands.ts +185 -40
- package/src/ui/input.tsx +203 -27
- package/src/ui/picker.tsx +2 -5
- package/src/ui/vim-motions.ts +96 -0
- package/src/workflow/budget.ts +33 -0
- package/src/workflow/journal.ts +105 -0
- package/src/workflow/primitives/agent.ts +51 -0
- package/src/workflow/primitives/parallel.ts +8 -0
- package/src/workflow/primitives/phase.ts +19 -0
- package/src/workflow/primitives/pipeline.ts +24 -0
- package/src/workflow/runtime.ts +87 -0
- package/src/workflow/sandbox.ts +75 -0
package/src/core/permission.ts
CHANGED
|
@@ -1,56 +1,127 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ToolDefinition,
|
|
3
|
+
PermissionMode,
|
|
4
|
+
PermissionLevel,
|
|
5
|
+
PermissionRule,
|
|
6
|
+
} from '../shared/index.ts'
|
|
7
|
+
import type { PermissionRuleEntry } from '../shared/index.ts'
|
|
8
|
+
import { matchBashRule, compileRule } from './permission-rules'
|
|
9
|
+
import { loadPermissionConfig, nextMode, MODE_CYCLE } from './permission-config'
|
|
10
|
+
|
|
11
|
+
const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
|
|
2
12
|
|
|
3
13
|
export class PermissionSystem {
|
|
4
|
-
private
|
|
5
|
-
private
|
|
14
|
+
private allowRules: PermissionRuleEntry[] = []
|
|
15
|
+
private denyRules: PermissionRuleEntry[] = []
|
|
16
|
+
private askRules: PermissionRuleEntry[] = []
|
|
17
|
+
/** Legacy exact-name rules for backward compat (set via setRule with 'auto' level). */
|
|
18
|
+
private legacyRules = new Map<string, PermissionLevel>()
|
|
19
|
+
/** Legacy default level from constructor when passed non-mode values like 'ask' or 'bypass'. */
|
|
20
|
+
private legacyDefaultFallback: PermissionLevel | null = null
|
|
21
|
+
private mode: PermissionMode = 'default'
|
|
6
22
|
|
|
7
|
-
constructor(
|
|
23
|
+
constructor(modeOrLevel: PermissionLevel = 'default') {
|
|
24
|
+
if (VALID_MODES.has(modeOrLevel)) {
|
|
25
|
+
this.mode = modeOrLevel as PermissionMode
|
|
26
|
+
} else {
|
|
27
|
+
// Legacy values ('ask', 'bypass') → store as fallback, use 'default' mode
|
|
28
|
+
this.mode = 'default'
|
|
29
|
+
this.legacyDefaultFallback = modeOrLevel
|
|
30
|
+
}
|
|
31
|
+
}
|
|
8
32
|
|
|
9
|
-
|
|
10
|
-
|
|
33
|
+
// ── Mode management ──
|
|
34
|
+
|
|
35
|
+
setMode(mode: PermissionMode): void {
|
|
36
|
+
this.mode = mode
|
|
11
37
|
}
|
|
12
38
|
|
|
13
|
-
|
|
14
|
-
return this.
|
|
39
|
+
getMode(): PermissionMode {
|
|
40
|
+
return this.mode
|
|
15
41
|
}
|
|
16
42
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} else {
|
|
21
|
-
const rule = toolNameOrRule
|
|
22
|
-
if (rule.pattern) {
|
|
23
|
-
this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
|
|
24
|
-
} else {
|
|
25
|
-
this.rules.set(rule.toolName, rule.level)
|
|
26
|
-
}
|
|
27
|
-
}
|
|
43
|
+
cycleMode(): PermissionMode {
|
|
44
|
+
this.mode = nextMode(this.mode)
|
|
45
|
+
return this.mode
|
|
28
46
|
}
|
|
29
47
|
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
// ── Rule management ──
|
|
49
|
+
|
|
50
|
+
allow(rule: string): void {
|
|
51
|
+
this.allowRules.push(compileRule(rule, 'allow'))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
deny(rule: string): void {
|
|
55
|
+
this.denyRules.push(compileRule(rule, 'deny'))
|
|
32
56
|
}
|
|
33
57
|
|
|
58
|
+
ask(rule: string): void {
|
|
59
|
+
this.askRules.push(compileRule(rule, 'ask'))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
loadConfig(raw: { mode?: string; allow?: string[]; deny?: string[] }): void {
|
|
63
|
+
const config = loadPermissionConfig(
|
|
64
|
+
raw as Partial<{ mode: PermissionMode; allow: string[]; deny: string[] }>,
|
|
65
|
+
)
|
|
66
|
+
this.mode = config.mode
|
|
67
|
+
|
|
68
|
+
this.allowRules = []
|
|
69
|
+
this.denyRules = []
|
|
70
|
+
this.askRules = []
|
|
71
|
+
|
|
72
|
+
for (const rule of config.allow) {
|
|
73
|
+
this.allowRules.push(compileRule(rule, 'allow'))
|
|
74
|
+
}
|
|
75
|
+
for (const rule of config.deny) {
|
|
76
|
+
this.denyRules.push(compileRule(rule, 'deny'))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Permission check ──
|
|
81
|
+
|
|
34
82
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
83
|
+
* Resolution chain (first match wins):
|
|
84
|
+
* 1. Deny rules → block
|
|
85
|
+
* 2. Ask rules → require approval
|
|
86
|
+
* 3. Allow rules → permit
|
|
87
|
+
* 4. Legacy exact-name rules (backward compat — e.g. setRule('tool', 'auto'))
|
|
88
|
+
* 5. Mode baseline → mode-specific default (overrides tool.permission for explicit modes)
|
|
89
|
+
* 6. Tool's own permission → tool-specific default (backward compat)
|
|
90
|
+
* 7. Legacy constructor fallback (when constructed with 'ask'/'bypass')
|
|
91
|
+
* 8. System default → 'ask'
|
|
38
92
|
*/
|
|
39
93
|
check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
|
|
40
|
-
// 1.
|
|
41
|
-
const
|
|
42
|
-
|
|
94
|
+
// 1. Check deny rules (always win)
|
|
95
|
+
for (const rule of this.denyRules) {
|
|
96
|
+
if (this.ruleMatches(rule, tool, input)) return 'ask'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2. Check ask rules
|
|
100
|
+
for (const rule of this.askRules) {
|
|
101
|
+
if (this.ruleMatches(rule, tool, input)) return 'ask'
|
|
102
|
+
}
|
|
43
103
|
|
|
44
|
-
//
|
|
45
|
-
for (const
|
|
46
|
-
if (
|
|
104
|
+
// 3. Check allow rules
|
|
105
|
+
for (const rule of this.allowRules) {
|
|
106
|
+
if (this.ruleMatches(rule, tool, input)) return 'bypass'
|
|
47
107
|
}
|
|
48
108
|
|
|
49
|
-
//
|
|
109
|
+
// 4. Legacy exact-name rules (backward compat)
|
|
110
|
+
const legacyLevel = this.legacyRules.get(tool.name)
|
|
111
|
+
if (legacyLevel !== undefined) return legacyLevel
|
|
112
|
+
|
|
113
|
+
// 5. Mode baseline
|
|
114
|
+
const baseline = this.modeBaseline(tool)
|
|
115
|
+
if (baseline !== 'mode-baseline') return baseline
|
|
116
|
+
|
|
117
|
+
// 6. Tool's own permission level (backward compat fallback)
|
|
50
118
|
if (tool.permission) return tool.permission
|
|
51
119
|
|
|
52
|
-
//
|
|
53
|
-
return this.
|
|
120
|
+
// 7. Legacy constructor fallback
|
|
121
|
+
if (this.legacyDefaultFallback) return this.legacyDefaultFallback
|
|
122
|
+
|
|
123
|
+
// 8. System default
|
|
124
|
+
return 'ask'
|
|
54
125
|
}
|
|
55
126
|
|
|
56
127
|
needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
@@ -61,8 +132,127 @@ export class PermissionSystem {
|
|
|
61
132
|
return this.check(tool, input) === 'bypass'
|
|
62
133
|
}
|
|
63
134
|
|
|
135
|
+
// ── Helpers ──
|
|
136
|
+
|
|
137
|
+
private ruleMatches(
|
|
138
|
+
rule: PermissionRuleEntry,
|
|
139
|
+
tool: ToolDefinition,
|
|
140
|
+
input: Record<string, unknown>,
|
|
141
|
+
): boolean {
|
|
142
|
+
// Try Bash-style matching first
|
|
143
|
+
if (rule.pattern.includes('(')) {
|
|
144
|
+
return matchBashRule(rule.pattern, tool.name, input)
|
|
145
|
+
}
|
|
146
|
+
// Simple tool name match
|
|
147
|
+
return rule.pattern === tool.name || rule.compiled.test(tool.name)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private modeBaseline(tool: ToolDefinition): PermissionLevel | 'mode-baseline' {
|
|
151
|
+
switch (this.mode) {
|
|
152
|
+
case 'default':
|
|
153
|
+
// Delegate to tool.permission (backward compat)
|
|
154
|
+
return 'mode-baseline'
|
|
155
|
+
|
|
156
|
+
case 'acceptEdits':
|
|
157
|
+
// Reads + file edits free; Bash requires approval
|
|
158
|
+
return tool.category === 'file'
|
|
159
|
+
? ['Bash'].includes(tool.name)
|
|
160
|
+
? 'ask'
|
|
161
|
+
: 'bypass'
|
|
162
|
+
: tool.name === 'Bash'
|
|
163
|
+
? 'ask'
|
|
164
|
+
: 'ask'
|
|
165
|
+
|
|
166
|
+
case 'plan':
|
|
167
|
+
// Only reads, no writes or executes
|
|
168
|
+
return tool.category === 'file' && ['Read', 'Grep', 'Glob'].includes(tool.name)
|
|
169
|
+
? 'bypass'
|
|
170
|
+
: 'ask'
|
|
171
|
+
|
|
172
|
+
case 'auto':
|
|
173
|
+
// Respect tool-level permissions; safety checks handled by hook layer
|
|
174
|
+
return 'mode-baseline'
|
|
175
|
+
|
|
176
|
+
case 'dontAsk':
|
|
177
|
+
// Only allowlisted tools free (already handled above); everything else requires approval
|
|
178
|
+
return 'ask'
|
|
179
|
+
|
|
180
|
+
case 'bypassPermissions':
|
|
181
|
+
return 'bypass'
|
|
182
|
+
|
|
183
|
+
default:
|
|
184
|
+
return 'mode-baseline'
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Legacy compatibility ──
|
|
189
|
+
|
|
190
|
+
setDefaultLevel(level: PermissionLevel): void {
|
|
191
|
+
// Map legacy 3-level to new mode
|
|
192
|
+
if (level === 'auto') this.mode = 'auto'
|
|
193
|
+
else if (level === 'bypass') this.mode = 'bypassPermissions'
|
|
194
|
+
else this.mode = 'default'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getDefaultLevel(): PermissionLevel {
|
|
198
|
+
// Legacy constructor fallback takes priority
|
|
199
|
+
if (this.legacyDefaultFallback) return this.legacyDefaultFallback
|
|
200
|
+
if (this.mode === 'auto' || this.mode === 'bypassPermissions' || this.mode === 'dontAsk')
|
|
201
|
+
return 'bypass'
|
|
202
|
+
if (this.mode === 'plan') return 'ask'
|
|
203
|
+
return 'auto'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
|
|
207
|
+
if (typeof toolNameOrRule === 'string') {
|
|
208
|
+
const toolName = toolNameOrRule
|
|
209
|
+
// Remove old entries for this tool
|
|
210
|
+
this.removeRuleFromArrays(toolName)
|
|
211
|
+
if (level !== undefined) {
|
|
212
|
+
this.legacyRules.set(toolName, level)
|
|
213
|
+
// Also sync to new-style arrays for listRules / new API consistency
|
|
214
|
+
if (level === 'bypass') this.allow(toolName)
|
|
215
|
+
else if (level === 'ask') this.ask(toolName)
|
|
216
|
+
// 'auto' is stored only in legacyRules (returns 'auto', not 'bypass')
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const rule = toolNameOrRule
|
|
220
|
+
if (rule.pattern) {
|
|
221
|
+
const entry = compileRule(rule.pattern, rule.level === 'bypass' ? 'allow' : 'ask')
|
|
222
|
+
if (rule.level === 'bypass') this.allowRules.push(entry)
|
|
223
|
+
else this.askRules.push(entry)
|
|
224
|
+
} else {
|
|
225
|
+
this.removeRuleFromArrays(rule.toolName)
|
|
226
|
+
this.legacyRules.set(rule.toolName, rule.level)
|
|
227
|
+
if (rule.level === 'bypass') this.allow(rule.toolName)
|
|
228
|
+
else if (rule.level === 'ask') this.ask(rule.toolName)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
removeRule(toolName: string): void {
|
|
234
|
+
this.legacyRules.delete(toolName)
|
|
235
|
+
this.removeRuleFromArrays(toolName)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private removeRuleFromArrays(toolName: string): void {
|
|
239
|
+
this.allowRules = this.allowRules.filter((r) => r.pattern !== toolName)
|
|
240
|
+
this.denyRules = this.denyRules.filter((r) => r.pattern !== toolName)
|
|
241
|
+
this.askRules = this.askRules.filter((r) => r.pattern !== toolName)
|
|
242
|
+
}
|
|
243
|
+
|
|
64
244
|
listRules(): Map<string, PermissionLevel> {
|
|
65
|
-
|
|
245
|
+
const map = new Map<string, PermissionLevel>(this.legacyRules)
|
|
246
|
+
for (const r of this.allowRules) {
|
|
247
|
+
if (!map.has(r.pattern)) map.set(r.pattern, 'bypass')
|
|
248
|
+
}
|
|
249
|
+
for (const r of this.denyRules) {
|
|
250
|
+
if (!map.has(r.pattern)) map.set(r.pattern, 'ask')
|
|
251
|
+
}
|
|
252
|
+
for (const r of this.askRules) {
|
|
253
|
+
if (!map.has(r.pattern)) map.set(r.pattern, 'ask')
|
|
254
|
+
}
|
|
255
|
+
return map
|
|
66
256
|
}
|
|
67
257
|
|
|
68
258
|
getByCategory(
|
package/src/index.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
1
2
|
import { render } from 'ink'
|
|
2
3
|
import { App } from './ui/app'
|
|
3
4
|
import { loadConfig } from './config/loader'
|
|
@@ -10,6 +11,10 @@ import { SkillsLoader } from './skills/loader'
|
|
|
10
11
|
import { createToolRegistry } from './tools'
|
|
11
12
|
import { McpClient } from './mcp/client'
|
|
12
13
|
import { HookEngine } from './core/hooks'
|
|
14
|
+
import { ArtifactServer } from './artifacts/server'
|
|
15
|
+
import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
|
|
16
|
+
import { AgentViewManager } from './agent-view/agent-view-manager'
|
|
17
|
+
import { AgentViewDashboard } from './agent-view/dashboard'
|
|
13
18
|
|
|
14
19
|
interface RunOptions {
|
|
15
20
|
model?: string
|
|
@@ -20,6 +25,23 @@ interface RunOptions {
|
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export async function runApp(options: RunOptions): Promise<void> {
|
|
28
|
+
// Handle `mipham agents` subcommand — launch standalone dashboard
|
|
29
|
+
const args = process.argv.slice(2)
|
|
30
|
+
if (args[0] === 'agents') {
|
|
31
|
+
const agentViewManager = new AgentViewManager()
|
|
32
|
+
const { waitUntilExit } = render(
|
|
33
|
+
<AgentViewDashboard
|
|
34
|
+
manager={agentViewManager}
|
|
35
|
+
onAttach={() => {}}
|
|
36
|
+
onExit={() => process.exit(0)}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
await waitUntilExit()
|
|
40
|
+
process.exit(0)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create AgentViewManager for the full app (shared across the session)
|
|
44
|
+
const agentViewManager = new AgentViewManager()
|
|
23
45
|
// Set terminal window title
|
|
24
46
|
process.stdout.write('\x1b]0;Mipham Code\x07')
|
|
25
47
|
|
|
@@ -83,16 +105,22 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
83
105
|
}
|
|
84
106
|
}
|
|
85
107
|
|
|
108
|
+
// Start artifact server (lazy — first artifact creation triggers listening)
|
|
109
|
+
const artifactsDir = join(process.cwd(), MIPHAM_DIR, ARTIFACTS_DIR)
|
|
110
|
+
const artifactServer = new ArtifactServer(artifactsDir, ARTIFACT_PORT)
|
|
111
|
+
|
|
86
112
|
// Create query engine
|
|
87
113
|
const engine = new QueryEngine(registry, context, tools)
|
|
88
114
|
engine.setHookEngine(hookEngine)
|
|
115
|
+
engine.setArtifactServer(artifactServer)
|
|
116
|
+
engine.setAgentViewManager(agentViewManager)
|
|
89
117
|
engine.setupContextSummarizer()
|
|
90
118
|
|
|
91
119
|
// Auto-save session on exit
|
|
92
|
-
let autoSaveName: string | undefined
|
|
93
120
|
const saveAndExit = () => {
|
|
121
|
+
artifactServer.stop()
|
|
94
122
|
if (context.getMessageCount() > 0) {
|
|
95
|
-
|
|
123
|
+
SessionStore.autoSave(context.getMessages(), {
|
|
96
124
|
provider: defaultProvider,
|
|
97
125
|
model: defaultModel,
|
|
98
126
|
})
|
package/src/mcp/transport.ts
CHANGED
|
@@ -1,11 +1,51 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
2
2
|
import type { JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from './types'
|
|
3
3
|
|
|
4
|
-
type ResponseHandler = (response: JsonRpcResponse) => void
|
|
5
4
|
type NotificationHandler = (notification: JsonRpcNotification) => void
|
|
6
5
|
|
|
7
6
|
const REQUEST_TIMEOUT_MS = 30_000
|
|
8
7
|
|
|
8
|
+
// ── Environment variable security: block sensitive vars from MCP subprocess ──
|
|
9
|
+
//
|
|
10
|
+
// By default, MCP servers receive a sanitised copy of the parent environment.
|
|
11
|
+
// Sensitive values (API keys, tokens, secrets, cloud credentials) are stripped
|
|
12
|
+
// to prevent data exfiltration by third-party MCP plugins.
|
|
13
|
+
//
|
|
14
|
+
// The `env` parameter on `start()` allows explicit overrides — use it to
|
|
15
|
+
// intentionally pass specific values to a trusted MCP server.
|
|
16
|
+
const SENSITIVE_ENV_PATTERNS = [
|
|
17
|
+
/_API_KEY$/i, // ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.
|
|
18
|
+
/_SECRET$/i, // STRIPE_SECRET, etc.
|
|
19
|
+
/_TOKEN$/i, // GITHUB_TOKEN, NPM_TOKEN, etc.
|
|
20
|
+
/_PASSWORD$/i, // DB_PASSWORD, etc.
|
|
21
|
+
/_CREDENTIALS?$/i, // GOOGLE_APPLICATION_CREDENTIALS, etc.
|
|
22
|
+
/^AWS_/, // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, etc.
|
|
23
|
+
/^GCLOUD_/, // GCP credentials
|
|
24
|
+
/^AZURE_/, // Azure credentials
|
|
25
|
+
/^DOCKER_/, // DOCKER_TOKEN, DOCKER_PASSWORD
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build a sanitised environment for MCP subprocesses.
|
|
30
|
+
* Strips sensitive vars; allows explicit overrides via `extra`.
|
|
31
|
+
*/
|
|
32
|
+
function buildProcEnv(extra?: Record<string, string>): Record<string, string> {
|
|
33
|
+
const sanitized: Record<string, string> = {}
|
|
34
|
+
|
|
35
|
+
for (const [key, value] of Object.entries(process.env as Record<string, string>)) {
|
|
36
|
+
if (value === undefined) continue
|
|
37
|
+
if (SENSITIVE_ENV_PATTERNS.some((p) => p.test(key))) continue
|
|
38
|
+
sanitized[key] = value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Explicit overrides take precedence (for intentionally shared secrets)
|
|
42
|
+
if (extra) {
|
|
43
|
+
Object.assign(sanitized, extra)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return sanitized
|
|
47
|
+
}
|
|
48
|
+
|
|
9
49
|
/**
|
|
10
50
|
* MCP stdio transport — spawns a subprocess and communicates via
|
|
11
51
|
* newline-delimited JSON-RPC 2.0 messages on stdin/stdout.
|
|
@@ -30,10 +70,7 @@ export class StdioTransport {
|
|
|
30
70
|
async start(command: string, args: string[], env?: Record<string, string>): Promise<void> {
|
|
31
71
|
this.closed = false
|
|
32
72
|
|
|
33
|
-
const procEnv
|
|
34
|
-
...(process.env as Record<string, string>),
|
|
35
|
-
...env,
|
|
36
|
-
}
|
|
73
|
+
const procEnv = buildProcEnv(env)
|
|
37
74
|
|
|
38
75
|
return new Promise((resolve, reject) => {
|
|
39
76
|
const child = spawn(command, args, {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PluginManager } from './plugin-manager'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
export function loadPlugins(
|
|
6
|
+
pluginManager: PluginManager,
|
|
7
|
+
engine: unknown,
|
|
8
|
+
skillsLoader: unknown,
|
|
9
|
+
): void {
|
|
10
|
+
for (const plugin of pluginManager.getEnabled()) {
|
|
11
|
+
// Load custom agents
|
|
12
|
+
const agentsDir = join(plugin.path, 'agents')
|
|
13
|
+
if (existsSync(agentsDir)) {
|
|
14
|
+
// → register with AgentRegistry
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Load custom skills
|
|
18
|
+
const skillsDir = join(plugin.path, 'skills')
|
|
19
|
+
if (existsSync(skillsDir)) {
|
|
20
|
+
// → register with SkillsLoader
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Load MCP servers
|
|
24
|
+
const mcpDir = join(plugin.path, 'mcp-servers')
|
|
25
|
+
if (existsSync(mcpDir)) {
|
|
26
|
+
// → register MCP configs
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Load hooks
|
|
30
|
+
// → parse plugin.json hooks → register with HookEngine
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Suppress unused parameter warnings — these are wiring stubs for future phases
|
|
34
|
+
void engine
|
|
35
|
+
void skillsLoader
|
|
36
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { validatePlugin } from './plugin-validator'
|
|
5
|
+
|
|
6
|
+
const PLUGIN_DIR = join(homedir(), '.mipham', 'plugins')
|
|
7
|
+
|
|
8
|
+
export interface InstalledPlugin {
|
|
9
|
+
name: string
|
|
10
|
+
version: string
|
|
11
|
+
path: string
|
|
12
|
+
enabled: boolean
|
|
13
|
+
installedAt: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class PluginManager {
|
|
17
|
+
private plugins: InstalledPlugin[] = []
|
|
18
|
+
private pluginDir: string
|
|
19
|
+
private statePath: string
|
|
20
|
+
|
|
21
|
+
constructor(pluginDir?: string) {
|
|
22
|
+
this.pluginDir = pluginDir ?? PLUGIN_DIR
|
|
23
|
+
mkdirSync(this.pluginDir, { recursive: true })
|
|
24
|
+
this.statePath = join(this.pluginDir, 'state.json')
|
|
25
|
+
this.loadState()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
install(sourcePath: string): { success: boolean; message: string } {
|
|
29
|
+
const validation = validatePlugin(sourcePath)
|
|
30
|
+
if (!validation.valid || !validation.manifest) {
|
|
31
|
+
return { success: false, message: validation.errors.join('; ') }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const destDir = join(this.pluginDir, validation.manifest.name)
|
|
35
|
+
if (existsSync(destDir)) {
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
message: `Plugin "${validation.manifest.name}" is already installed`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Copy plugin directory
|
|
43
|
+
mkdirSync(destDir, { recursive: true })
|
|
44
|
+
this.copyDir(sourcePath, destDir)
|
|
45
|
+
|
|
46
|
+
this.plugins.push({
|
|
47
|
+
name: validation.manifest.name,
|
|
48
|
+
version: validation.manifest.version,
|
|
49
|
+
path: destDir,
|
|
50
|
+
enabled: true,
|
|
51
|
+
installedAt: new Date().toISOString(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
this.saveState()
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
message: `Plugin "${validation.manifest.name}" v${validation.manifest.version} installed`,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
list(): InstalledPlugin[] {
|
|
62
|
+
return [...this.plugins]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
remove(name: string): boolean {
|
|
66
|
+
const plugin = this.plugins.find((p) => p.name === name)
|
|
67
|
+
if (!plugin) return false
|
|
68
|
+
try {
|
|
69
|
+
rmSync(plugin.path, { recursive: true, force: true })
|
|
70
|
+
} catch {
|
|
71
|
+
// Directory may already be gone — that's fine
|
|
72
|
+
}
|
|
73
|
+
this.plugins = this.plugins.filter((p) => p.name !== name)
|
|
74
|
+
this.saveState()
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
enable(name: string): boolean {
|
|
79
|
+
const p = this.plugins.find((p) => p.name === name)
|
|
80
|
+
if (!p) return false
|
|
81
|
+
p.enabled = true
|
|
82
|
+
this.saveState()
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
disable(name: string): boolean {
|
|
87
|
+
const p = this.plugins.find((p) => p.name === name)
|
|
88
|
+
if (!p) return false
|
|
89
|
+
p.enabled = false
|
|
90
|
+
this.saveState()
|
|
91
|
+
return true
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getEnabled(): InstalledPlugin[] {
|
|
95
|
+
return this.plugins.filter((p) => p.enabled)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private copyDir(src: string, dest: string): void {
|
|
99
|
+
mkdirSync(dest, { recursive: true })
|
|
100
|
+
for (const entry of readdirSync(src, { withFileTypes: true })) {
|
|
101
|
+
const srcPath = join(src, entry.name)
|
|
102
|
+
const destPath = join(dest, entry.name)
|
|
103
|
+
if (entry.isDirectory()) {
|
|
104
|
+
this.copyDir(srcPath, destPath)
|
|
105
|
+
} else {
|
|
106
|
+
const content = readFileSync(srcPath)
|
|
107
|
+
writeFileSync(destPath, content)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private loadState(): void {
|
|
113
|
+
try {
|
|
114
|
+
if (existsSync(this.statePath)) {
|
|
115
|
+
this.plugins = JSON.parse(readFileSync(this.statePath, 'utf-8'))
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
this.plugins = []
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private saveState(): void {
|
|
123
|
+
writeFileSync(this.statePath, JSON.stringify(this.plugins, null, 2), 'utf-8')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export interface PluginManifest {
|
|
5
|
+
name: string
|
|
6
|
+
version: string
|
|
7
|
+
miphamVersion?: string
|
|
8
|
+
hooks?: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validatePlugin(dir: string): {
|
|
12
|
+
valid: boolean
|
|
13
|
+
errors: string[]
|
|
14
|
+
manifest?: PluginManifest
|
|
15
|
+
} {
|
|
16
|
+
const errors: string[] = []
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const raw = readFileSync(join(dir, 'plugin.json'), 'utf-8')
|
|
20
|
+
const manifest = JSON.parse(raw) as PluginManifest
|
|
21
|
+
|
|
22
|
+
if (!manifest.name || !/^[a-z0-9-]+$/.test(manifest.name)) {
|
|
23
|
+
errors.push('Invalid plugin name: must be lowercase alphanumeric with hyphens')
|
|
24
|
+
}
|
|
25
|
+
if (!manifest.version) {
|
|
26
|
+
errors.push('Missing required field: version')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for suspicious hooks
|
|
30
|
+
if (manifest.hooks) {
|
|
31
|
+
const hooksStr = JSON.stringify(manifest.hooks)
|
|
32
|
+
if (hooksStr.includes('rm -rf') || hooksStr.includes('curl') || hooksStr.includes('eval')) {
|
|
33
|
+
errors.push('Suspicious hook commands detected — manual review required')
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { valid: errors.length === 0, errors, manifest }
|
|
38
|
+
} catch (err) {
|
|
39
|
+
return { valid: false, errors: [`Failed to read plugin.json: ${String(err)}`] }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -39,9 +39,7 @@ export async function fetchWithRetry(
|
|
|
39
39
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
40
|
const controller = new AbortController()
|
|
41
41
|
const timer = setTimeout(() => controller.abort(), timeout)
|
|
42
|
-
const signal = init.signal
|
|
43
|
-
? anySignal([init.signal, controller.signal])
|
|
44
|
-
: controller.signal
|
|
42
|
+
const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal
|
|
45
43
|
|
|
46
44
|
try {
|
|
47
45
|
const response = await fetch(url, { ...init, signal })
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ProviderConfig,
|
|
3
|
-
ModelInfo,
|
|
4
|
-
Message,
|
|
5
|
-
StreamChunk,
|
|
6
|
-
ContentBlock,
|
|
7
|
-
} from '../shared/index.ts'
|
|
1
|
+
import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
|
|
8
2
|
import type { ProviderInstance, ChatRequest } from './registry'
|
|
9
3
|
import { fetchWithRetry } from './fetch-utils'
|
|
10
4
|
|
|
@@ -49,10 +43,7 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
49
43
|
let buffer = ''
|
|
50
44
|
|
|
51
45
|
// Track incremental tool calls across streaming deltas
|
|
52
|
-
const pendingToolCalls = new Map<
|
|
53
|
-
number,
|
|
54
|
-
{ id: string; name: string; arguments: string }
|
|
55
|
-
>()
|
|
46
|
+
const pendingToolCalls = new Map<number, { id: string; name: string; arguments: string }>()
|
|
56
47
|
|
|
57
48
|
while (true) {
|
|
58
49
|
const { done, value } = await reader.read()
|
package/src/shared/constants.ts
CHANGED
|
@@ -361,8 +361,15 @@ export const PROTOCOL_LABELS: Record<string, string> = {
|
|
|
361
361
|
custom: 'Custom Protocol',
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system'] as const
|
|
364
|
+
export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system', 'artifact'] as const
|
|
365
365
|
export const CONFIG_FILE_NAME = 'config.yml'
|
|
366
366
|
export const MIPHAM_DIR = '.mipham'
|
|
367
367
|
export const USER_CONFIG_DIR = '.mipham'
|
|
368
368
|
export const MEMORY_DIR = 'memory'
|
|
369
|
+
|
|
370
|
+
// ── Artifact Constants ──
|
|
371
|
+
export const ARTIFACTS_DIR = 'artifacts'
|
|
372
|
+
export const ARTIFACT_PORT = 9876
|
|
373
|
+
export const ARTIFACT_MAX_PORT_TRIES = 10
|
|
374
|
+
export const ARTIFACT_MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
|
375
|
+
export const ARTIFACT_ALLOWED_EXTENSIONS = ['.html', '.svg']
|