@mohou/host 1.0.20
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 +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { appEntries } from '@mohou/contract'
|
|
2
|
+
|
|
3
|
+
/** Loopback paths. Spell them here. Call sites use these helpers. */
|
|
4
|
+
|
|
5
|
+
export const httpLayout = {
|
|
6
|
+
apps: '/api/apps',
|
|
7
|
+
app: '/api/app',
|
|
8
|
+
events: '/api/events',
|
|
9
|
+
call: '/api/call',
|
|
10
|
+
hostConfig: '/api/host-config',
|
|
11
|
+
palettes: '/api/palettes',
|
|
12
|
+
about: '/api/about',
|
|
13
|
+
updates: '/api/updates',
|
|
14
|
+
updateInstall: '/api/updates/install',
|
|
15
|
+
updateAck: '/api/updates/ack',
|
|
16
|
+
providers: '/api/runtime-providers',
|
|
17
|
+
activate: '/api/runtime-providers/activate',
|
|
18
|
+
probe: '/api/runtime-providers/probe',
|
|
19
|
+
restart: '/api/restart',
|
|
20
|
+
authorSkill: '/api/author-skill',
|
|
21
|
+
authorSkillReveal: '/api/author-skill/reveal',
|
|
22
|
+
authorMcp: '/api/author-mcp',
|
|
23
|
+
authorMcpReveal: '/api/author-mcp/reveal',
|
|
24
|
+
mcpServers: '/api/mcp-servers',
|
|
25
|
+
mcpCheck: '/api/mcp-servers/check',
|
|
26
|
+
mcpAdmit: '/api/mcp-servers/admit',
|
|
27
|
+
mcpImport: '/api/mcp-servers/import',
|
|
28
|
+
credentials: '/api/credentials',
|
|
29
|
+
credentialRemove: '/api/credentials/remove',
|
|
30
|
+
tools: '/api/tools',
|
|
31
|
+
invoke: '/api/tools/invoke',
|
|
32
|
+
mcp: '/mcp',
|
|
33
|
+
runner: '/app',
|
|
34
|
+
trash: '/api/trash',
|
|
35
|
+
panel: '/',
|
|
36
|
+
panelScript: '/panel.js',
|
|
37
|
+
open: 'open',
|
|
38
|
+
reload: 'reload',
|
|
39
|
+
restore: 'restore',
|
|
40
|
+
restoreStorage: 'restore',
|
|
41
|
+
history: 'history',
|
|
42
|
+
storage: 'storage',
|
|
43
|
+
theme: 'theme',
|
|
44
|
+
ui: 'ui',
|
|
45
|
+
entry: 'entry.js',
|
|
46
|
+
sheet: appEntries.stylesheet,
|
|
47
|
+
assets: 'assets',
|
|
48
|
+
} as const
|
|
49
|
+
|
|
50
|
+
/** History list bound. Host policy, not a locked number. */
|
|
51
|
+
export const historyListBound = { default: 50, max: 200 } as const
|
|
52
|
+
|
|
53
|
+
export function runnerPath(appId: string): string {
|
|
54
|
+
return `${httpLayout.runner}/${encodeURIComponent(appId)}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function appResource(appId: string, ...rest: string[]): string {
|
|
58
|
+
return [httpLayout.app, encodeURIComponent(appId), ...rest.map(encodeURIComponent)].join('/')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function appsResource(appId: string, ...rest: string[]): string {
|
|
62
|
+
return [httpLayout.apps, encodeURIComponent(appId), ...rest.map(encodeURIComponent)].join('/')
|
|
63
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import type { Context, Hono } from 'hono'
|
|
2
|
+
|
|
3
|
+
import type { HostEnv } from './env.ts'
|
|
4
|
+
|
|
5
|
+
import type { McpEditorServer } from '../host/mcp-editor.ts'
|
|
6
|
+
import { authorMcpToolList } from '../tools/schemas.ts'
|
|
7
|
+
import { historyListBound, httpLayout } from './layout.ts'
|
|
8
|
+
import { aboutInfo, type LoopbackPorts } from './ports.ts'
|
|
9
|
+
import { authoringDenied, isPin, isRecord, mergePolicy, ok } from './reply.ts'
|
|
10
|
+
|
|
11
|
+
/** Panel and owner routes. No authoring token. */
|
|
12
|
+
export function mountOwner(app: Hono<HostEnv>, ports: LoopbackPorts): void {
|
|
13
|
+
if (ports.panel !== undefined) {
|
|
14
|
+
const panel = ports.panel
|
|
15
|
+
app.get(httpLayout.panel, async (c) => {
|
|
16
|
+
if (panel.paint === undefined) return c.html(panel.html)
|
|
17
|
+
const painted = await panel.paint()
|
|
18
|
+
return c.html(injectPanelPaint(panel.html, painted))
|
|
19
|
+
})
|
|
20
|
+
app.get(httpLayout.panelScript, c => c.body(panel.script, 200, {
|
|
21
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
22
|
+
}))
|
|
23
|
+
}
|
|
24
|
+
app.get(httpLayout.apps, async (c) => {
|
|
25
|
+
return c.json({ apps: await ports.list() })
|
|
26
|
+
})
|
|
27
|
+
app.get(httpLayout.trash, async (c) => {
|
|
28
|
+
return c.json({ apps: await ports.listTrash() })
|
|
29
|
+
})
|
|
30
|
+
app.get(httpLayout.palettes, async (c) => {
|
|
31
|
+
return c.json(await ports.listPalettes())
|
|
32
|
+
})
|
|
33
|
+
app.get(httpLayout.hostConfig, c => c.json({ ok: true, policy: ports.readPolicy() }))
|
|
34
|
+
app.post(httpLayout.hostConfig, (c) => {
|
|
35
|
+
const denied = rejectWithoutToken(c, ports)
|
|
36
|
+
if (denied !== undefined) return denied
|
|
37
|
+
return writeConfig(c, ports)
|
|
38
|
+
})
|
|
39
|
+
app.get(httpLayout.about, c => c.json({
|
|
40
|
+
ok: true,
|
|
41
|
+
...aboutInfo(),
|
|
42
|
+
authoring: {
|
|
43
|
+
url: `http://127.0.0.1:${ports.readPolicy().hostPort}${httpLayout.mcp}`,
|
|
44
|
+
token: ports.authoringToken,
|
|
45
|
+
tools: authorMcpToolList().map(tool => ({ name: tool.name, description: tool.description })),
|
|
46
|
+
},
|
|
47
|
+
}))
|
|
48
|
+
app.get(httpLayout.updates, async (c) => {
|
|
49
|
+
return c.json({ ok: true, ...await ports.checkUpdate() })
|
|
50
|
+
})
|
|
51
|
+
app.post(httpLayout.updateInstall, async (c) => {
|
|
52
|
+
const denied = rejectWithoutToken(c, ports)
|
|
53
|
+
if (denied !== undefined) return denied
|
|
54
|
+
if (ports.installUpdate === undefined) {
|
|
55
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'update install is not available' } }, 400)
|
|
56
|
+
}
|
|
57
|
+
const body = await readBody(c)
|
|
58
|
+
const version = isRecord(body) && typeof body.version === 'string' ? body.version : ''
|
|
59
|
+
return ok(c, async () => {
|
|
60
|
+
if (ports.installUpdate === undefined) throw new Error('update install is not available')
|
|
61
|
+
if (version.length === 0) throw new Error('update version is missing')
|
|
62
|
+
await ports.installUpdate(version)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
app.post(httpLayout.updateAck, async (c) => {
|
|
66
|
+
if (ports.ackUpdate === undefined) {
|
|
67
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'update acknowledgement is not available' } }, 400)
|
|
68
|
+
}
|
|
69
|
+
const body = await readBody(c)
|
|
70
|
+
const at = isRecord(body) && typeof body.at === 'number' ? body.at : undefined
|
|
71
|
+
if (at === undefined) {
|
|
72
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'attempt time is required' } }, 400)
|
|
73
|
+
}
|
|
74
|
+
return ok(c, () => {
|
|
75
|
+
if (ports.ackUpdate === undefined) throw new Error('update acknowledgement is not available')
|
|
76
|
+
return ports.ackUpdate(at)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
app.get(httpLayout.providers, async c => c.json({ providers: await providerList(ports) }))
|
|
80
|
+
app.post(httpLayout.activate, (c) => {
|
|
81
|
+
const denied = rejectWithoutToken(c, ports)
|
|
82
|
+
if (denied !== undefined) return denied
|
|
83
|
+
return activate(c, ports)
|
|
84
|
+
})
|
|
85
|
+
app.post(httpLayout.probe, (c) => {
|
|
86
|
+
const denied = rejectWithoutToken(c, ports)
|
|
87
|
+
if (denied !== undefined) return denied
|
|
88
|
+
return probe(c, ports)
|
|
89
|
+
})
|
|
90
|
+
app.get(httpLayout.authorSkill, (c) => {
|
|
91
|
+
if (ports.readAuthorSkill === undefined) {
|
|
92
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'author skill is not available' } }, 400)
|
|
93
|
+
}
|
|
94
|
+
const custom = c.req.query('custom') ?? ''
|
|
95
|
+
const customDirs = custom.length === 0 ? [] : custom.split('\n')
|
|
96
|
+
return ok(c, async () => {
|
|
97
|
+
if (ports.readAuthorSkill === undefined) throw new Error('author skill is not available')
|
|
98
|
+
return ports.readAuthorSkill(customDirs)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
app.post(httpLayout.authorSkill, async (c) => {
|
|
102
|
+
const denied = rejectWithoutToken(c, ports)
|
|
103
|
+
if (denied !== undefined) return denied
|
|
104
|
+
if (ports.writeAuthorSkill === undefined) {
|
|
105
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'author skill is not available' } }, 400)
|
|
106
|
+
}
|
|
107
|
+
const body = await readBody(c)
|
|
108
|
+
const agentIds = isRecord(body) && Array.isArray(body.agentIds) ? body.agentIds.filter((item): item is string => typeof item === 'string') : []
|
|
109
|
+
const customDirs = isRecord(body) && Array.isArray(body.customDirs) ? body.customDirs.filter((item): item is string => typeof item === 'string') : []
|
|
110
|
+
return ok(c, async () => {
|
|
111
|
+
if (ports.writeAuthorSkill === undefined) throw new Error('author skill is not available')
|
|
112
|
+
return ports.writeAuthorSkill(agentIds, customDirs)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
app.post(httpLayout.authorSkillReveal, async (c) => {
|
|
116
|
+
if (ports.revealAuthorSkill === undefined) {
|
|
117
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'author skill is not available' } }, 400)
|
|
118
|
+
}
|
|
119
|
+
const body = await readBody(c)
|
|
120
|
+
const dest = isRecord(body) && typeof body.dest === 'string' ? body.dest : ''
|
|
121
|
+
return ok(c, async () => {
|
|
122
|
+
if (ports.revealAuthorSkill === undefined) throw new Error('author skill is not available')
|
|
123
|
+
await ports.revealAuthorSkill(dest)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
app.get(httpLayout.authorMcp, (c) => {
|
|
127
|
+
if (ports.readAuthorMcp === undefined) {
|
|
128
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'author mcp is not available' } }, 400)
|
|
129
|
+
}
|
|
130
|
+
return ok(c, async () => {
|
|
131
|
+
if (ports.readAuthorMcp === undefined) throw new Error('author mcp is not available')
|
|
132
|
+
return ports.readAuthorMcp()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
app.post(httpLayout.authorMcp, async (c) => {
|
|
136
|
+
const denied = rejectWithoutToken(c, ports)
|
|
137
|
+
if (denied !== undefined) return denied
|
|
138
|
+
if (ports.writeAuthorMcp === undefined) {
|
|
139
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'author mcp is not available' } }, 400)
|
|
140
|
+
}
|
|
141
|
+
const body = await readBody(c)
|
|
142
|
+
const agentIds = isRecord(body) && Array.isArray(body.agentIds) ? body.agentIds.filter((item): item is string => typeof item === 'string') : []
|
|
143
|
+
const description = isRecord(body) && typeof body.description === 'string' ? body.description : ''
|
|
144
|
+
return ok(c, async () => {
|
|
145
|
+
if (ports.writeAuthorMcp === undefined) throw new Error('author mcp is not available')
|
|
146
|
+
return ports.writeAuthorMcp(agentIds, description)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
app.post(httpLayout.authorMcpReveal, async (c) => {
|
|
150
|
+
if (ports.revealAuthorMcp === undefined) {
|
|
151
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'author mcp is not available' } }, 400)
|
|
152
|
+
}
|
|
153
|
+
const body = await readBody(c)
|
|
154
|
+
const dest = isRecord(body) && typeof body.dest === 'string' ? body.dest : ''
|
|
155
|
+
return ok(c, async () => {
|
|
156
|
+
if (ports.revealAuthorMcp === undefined) throw new Error('author mcp is not available')
|
|
157
|
+
await ports.revealAuthorMcp(dest)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
app.post(httpLayout.restart, (c) => {
|
|
161
|
+
const denied = rejectWithoutToken(c, ports)
|
|
162
|
+
if (denied !== undefined) return denied
|
|
163
|
+
if (ports.restart === undefined) {
|
|
164
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'host restart is not available' } }, 400)
|
|
165
|
+
}
|
|
166
|
+
// Delay past response flush. Disposing too soon drops the TCP body and the panel hangs on "Restarting…".
|
|
167
|
+
setTimeout(() => {
|
|
168
|
+
void ports.restart?.().catch((error: unknown) => {
|
|
169
|
+
console.error('host restart failed', error)
|
|
170
|
+
})
|
|
171
|
+
}, 400)
|
|
172
|
+
return c.json({ ok: true, result: { restarting: true } })
|
|
173
|
+
})
|
|
174
|
+
app.get(httpLayout.mcpServers, c => ok(c, () => ports.readMcp()))
|
|
175
|
+
app.post(httpLayout.mcpServers, async (c) => {
|
|
176
|
+
const denied = rejectWithoutToken(c, ports)
|
|
177
|
+
if (denied !== undefined) return denied
|
|
178
|
+
const body = await readBody(c)
|
|
179
|
+
const servers = isRecord(body) ? mcpServers(body.servers) : undefined
|
|
180
|
+
if (servers === undefined) return c.json({ ok: false, error: { code: 'config-invalid', message: 'servers are invalid' } }, 400)
|
|
181
|
+
return ok(c, () => ports.writeMcp(servers))
|
|
182
|
+
})
|
|
183
|
+
app.post(httpLayout.mcpCheck, async (c) => {
|
|
184
|
+
const denied = rejectWithoutToken(c, ports)
|
|
185
|
+
if (denied !== undefined) return denied
|
|
186
|
+
const body = await readBody(c)
|
|
187
|
+
const server = mcpServer(body)
|
|
188
|
+
if (server === undefined) return c.json({ ok: false, error: { code: 'config-invalid', message: 'server is invalid' } }, 400)
|
|
189
|
+
return ok(c, () => ports.checkMcp(server))
|
|
190
|
+
})
|
|
191
|
+
app.post(httpLayout.mcpAdmit, async (c) => {
|
|
192
|
+
const body = await readBody(c)
|
|
193
|
+
const text = isRecord(body) && typeof body.text === 'string' ? body.text : undefined
|
|
194
|
+
if (text === undefined) return c.json({ ok: false, error: { code: 'config-invalid', message: 'mcp import is empty' } }, 400)
|
|
195
|
+
return ok(c, () => Promise.resolve({ servers: ports.admitMcp(text) }))
|
|
196
|
+
})
|
|
197
|
+
app.get(`${httpLayout.mcpImport}/:source`, c => ok(c, () => ports.importMcp(c.req.param('source')).then(servers => ({ servers }))))
|
|
198
|
+
app.get(httpLayout.credentials, (c) => {
|
|
199
|
+
if (!ports.authorized(c.req.header('authorization'))) return authoringDenied(c)
|
|
200
|
+
return ok(c, () => ports.readCredentials())
|
|
201
|
+
})
|
|
202
|
+
app.post(httpLayout.credentials, async (c) => {
|
|
203
|
+
if (!ports.authorized(c.req.header('authorization'))) return authoringDenied(c)
|
|
204
|
+
const body = await readBody(c)
|
|
205
|
+
const name = isRecord(body) && typeof body.name === 'string' ? body.name : undefined
|
|
206
|
+
const secret = isRecord(body) && typeof body.secret === 'string' ? body.secret : undefined
|
|
207
|
+
const description = isRecord(body) && typeof body.description === 'string' ? body.description : ''
|
|
208
|
+
if (name === undefined) {
|
|
209
|
+
return c.json({ ok: false, error: { code: 'credential-invalid', message: 'a credential needs a name' } }, 400)
|
|
210
|
+
}
|
|
211
|
+
return ok(c, () => ports.putCredential(name, description, secret))
|
|
212
|
+
})
|
|
213
|
+
app.post(httpLayout.credentialRemove, async (c) => {
|
|
214
|
+
if (!ports.authorized(c.req.header('authorization'))) return authoringDenied(c)
|
|
215
|
+
const body = await readBody(c)
|
|
216
|
+
const name = isRecord(body) && typeof body.name === 'string' ? body.name : undefined
|
|
217
|
+
if (name === undefined) return c.json({ ok: false, error: { code: 'credential-invalid', message: 'credential name is missing' } }, 400)
|
|
218
|
+
return ok(c, () => ports.removeCredential(name))
|
|
219
|
+
})
|
|
220
|
+
app.post(`${httpLayout.apps}/:appId/${httpLayout.open}`, async (c) => {
|
|
221
|
+
const body = await readBody(c)
|
|
222
|
+
const title = isRecord(body) && typeof body.title === 'string' ? body.title : undefined
|
|
223
|
+
return ok(c, () => ports.open(c.req.param('appId'), title))
|
|
224
|
+
})
|
|
225
|
+
app.post(`${httpLayout.apps}/:appId/${httpLayout.reload}`, c => ok(c, () => ports.reload(c.req.param('appId'))))
|
|
226
|
+
app.get(`${httpLayout.apps}/:appId/${httpLayout.history}`, async (c) => {
|
|
227
|
+
const raw = Number(c.req.query('limit') ?? '')
|
|
228
|
+
const limit = Math.min(Number.isFinite(raw) && raw > 0 ? raw : historyListBound.default, historyListBound.max)
|
|
229
|
+
const nodes = await ports.readHistory(c.req.param('appId'))
|
|
230
|
+
return c.json({ commits: nodes.slice(0, limit) })
|
|
231
|
+
})
|
|
232
|
+
app.get(`${httpLayout.apps}/:appId/${httpLayout.history}/:commitId`, (c) => {
|
|
233
|
+
return ok(c, () => ports.readCommit(c.req.param('appId'), c.req.param('commitId')))
|
|
234
|
+
})
|
|
235
|
+
app.post(`${httpLayout.apps}/:appId/${httpLayout.storage}/${httpLayout.restoreStorage}`, (c) => {
|
|
236
|
+
return ok(c, () => ports.restoreStorage(c.req.param('appId')))
|
|
237
|
+
})
|
|
238
|
+
app.get(`${httpLayout.apps}/:appId/${httpLayout.storage}`, c => ok(c, () => ports.readStorage(c.req.param('appId'))))
|
|
239
|
+
app.get(`${httpLayout.apps}/:appId/${httpLayout.storage}/:table`, (c) => {
|
|
240
|
+
return ok(c, () => ports.readTable(c.req.param('appId'), c.req.param('table')))
|
|
241
|
+
})
|
|
242
|
+
app.get(`${httpLayout.apps}/:appId/${httpLayout.theme}`, c => ok(c, async () => ({
|
|
243
|
+
pin: await ports.readPin(c.req.param('appId')),
|
|
244
|
+
appFile: await ports.appFile(c.req.param('appId')),
|
|
245
|
+
appTheme: await ports.readAppTheme(c.req.param('appId')),
|
|
246
|
+
})))
|
|
247
|
+
app.post(`${httpLayout.apps}/:appId/${httpLayout.theme}`, async (c) => {
|
|
248
|
+
const body = await readBody(c)
|
|
249
|
+
if (!isPin(body)) {
|
|
250
|
+
return c.json({ ok: false, error: { code: 'theme-invalid', message: 'pin is invalid' } }, 400)
|
|
251
|
+
}
|
|
252
|
+
return ok(c, () => ports.setPin(c.req.param('appId'), body))
|
|
253
|
+
})
|
|
254
|
+
app.delete(`${httpLayout.app}/:appId`, c => ok(c, () => ports.deleteApp(c.req.param('appId'))))
|
|
255
|
+
app.post(`${httpLayout.trash}/:appId/${httpLayout.restore}`, (c) => {
|
|
256
|
+
return ok(c, () => ports.undeleteApp(c.req.param('appId')))
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function providerList(ports: LoopbackPorts): Promise<unknown[]> {
|
|
261
|
+
return Promise.all(ports.providers.map(async (provider) => {
|
|
262
|
+
const describe = provider.describe?.bind(provider)
|
|
263
|
+
return {
|
|
264
|
+
id: provider.id,
|
|
265
|
+
...provider.label === undefined ? {} : { label: provider.label },
|
|
266
|
+
fields: describe?.() ?? [],
|
|
267
|
+
models: provider.models === undefined ? [] : await provider.models(),
|
|
268
|
+
}
|
|
269
|
+
}))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function writeConfig(c: Context, ports: LoopbackPorts): Promise<Response> {
|
|
273
|
+
const body = await readBody(c)
|
|
274
|
+
if (!isRecord(body)) {
|
|
275
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'invalid json' } }, 400)
|
|
276
|
+
}
|
|
277
|
+
return ok(c, () => ports.writePolicy(mergePolicy(ports.readPolicy(), body)))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function activate(c: Context, ports: LoopbackPorts): Promise<Response> {
|
|
281
|
+
const body = await readBody(c)
|
|
282
|
+
if (!isRecord(body) || typeof body.id !== 'string') {
|
|
283
|
+
return c.json({ ok: false, error: { code: 'config-invalid', message: 'provider id is required' } }, 400)
|
|
284
|
+
}
|
|
285
|
+
const current = ports.readPolicy()
|
|
286
|
+
if (!isRecord(current)) {
|
|
287
|
+
return c.json({ ok: false, error: { code: 'config-missing', message: 'host config is missing' } }, 400)
|
|
288
|
+
}
|
|
289
|
+
return ok(c, () => ports.writePolicy({
|
|
290
|
+
...current,
|
|
291
|
+
runtimeProvider: {
|
|
292
|
+
id: body.id,
|
|
293
|
+
...isRecord(body.config) ? { config: body.config } : {},
|
|
294
|
+
},
|
|
295
|
+
}))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function probe(c: Context, ports: LoopbackPorts): Promise<Response> {
|
|
299
|
+
const body = await readBody(c)
|
|
300
|
+
const id = isRecord(body) ? body.id : undefined
|
|
301
|
+
if (typeof id !== 'string') {
|
|
302
|
+
return c.json({ ok: false, error: { code: 'provider-missing', message: 'provider id is required' } }, 400)
|
|
303
|
+
}
|
|
304
|
+
return ok(c, () => ports.probe(id))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function mcpServers(value: unknown): readonly McpEditorServer[] | undefined {
|
|
308
|
+
if (!Array.isArray(value)) return undefined
|
|
309
|
+
const servers: McpEditorServer[] = []
|
|
310
|
+
for (const item of value) {
|
|
311
|
+
const server = mcpServer(item)
|
|
312
|
+
if (server === undefined) return undefined
|
|
313
|
+
servers.push(server)
|
|
314
|
+
}
|
|
315
|
+
return servers
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function mcpServer(value: unknown): McpEditorServer | undefined {
|
|
319
|
+
if (!isRecord(value) || typeof value.id !== 'string' || value.id.trim().length === 0) return undefined
|
|
320
|
+
const args = stringList(value.args)
|
|
321
|
+
const env = stringRecord(value.env)
|
|
322
|
+
const headers = stringRecord(value.headers)
|
|
323
|
+
const transport = value.transport === 'sse' || value.transport === 'streamable-http' ? value.transport : undefined
|
|
324
|
+
let server: McpEditorServer = { id: value.id }
|
|
325
|
+
if (typeof value.description === 'string' && value.description.trim().length > 0) server = { ...server, description: value.description.trim() }
|
|
326
|
+
if (value.enabled === false || value.disabled === true) server = { ...server, enabled: false }
|
|
327
|
+
if (typeof value.command === 'string') server = { ...server, command: value.command }
|
|
328
|
+
if (args !== undefined) server = { ...server, args }
|
|
329
|
+
if (env !== undefined) server = { ...server, env }
|
|
330
|
+
if (typeof value.url === 'string') server = { ...server, url: value.url }
|
|
331
|
+
if (transport !== undefined) server = { ...server, transport }
|
|
332
|
+
if (headers !== undefined) server = { ...server, headers }
|
|
333
|
+
return server
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function stringList(value: unknown): string[] | undefined {
|
|
337
|
+
if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) return undefined
|
|
338
|
+
return value
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function stringRecord(value: unknown): Record<string, string> | undefined {
|
|
342
|
+
if (!isRecord(value)) return undefined
|
|
343
|
+
const record: Record<string, string> = {}
|
|
344
|
+
for (const [key, item] of Object.entries(value)) {
|
|
345
|
+
if (typeof item !== 'string') return undefined
|
|
346
|
+
record[key] = item
|
|
347
|
+
}
|
|
348
|
+
return record
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A route that changes host configuration or spawns a configured process requires the token. */
|
|
352
|
+
function rejectWithoutToken(c: Context, ports: LoopbackPorts): Response | undefined {
|
|
353
|
+
return ports.authorized(c.req.header('authorization')) ? undefined : authoringDenied(c)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function readBody(c: Context): Promise<unknown> {
|
|
357
|
+
try {
|
|
358
|
+
return await c.req.json()
|
|
359
|
+
} catch {
|
|
360
|
+
return undefined
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Bake the host palette into the panel document. Tailwind utilities read these variables. */
|
|
365
|
+
export function injectPanelPaint(
|
|
366
|
+
html: string,
|
|
367
|
+
paint: { readonly style: string; readonly appearance: 'system' | 'light' | 'dark' },
|
|
368
|
+
): string {
|
|
369
|
+
const mode = paint.appearance === 'system' ? '' : ` data-mode="${paint.appearance}"`
|
|
370
|
+
const script = paint.appearance === 'system'
|
|
371
|
+
? `<script>
|
|
372
|
+
const applyMode = () => { document.documentElement.dataset.mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' }
|
|
373
|
+
applyMode()
|
|
374
|
+
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyMode)
|
|
375
|
+
</script>`
|
|
376
|
+
: ''
|
|
377
|
+
return html
|
|
378
|
+
.replace('<html>', `<html${mode}>`)
|
|
379
|
+
.replace('</head>', `<style id="mma-theme">${paint.style.replaceAll('</', '<\\/')}</style>${script}</head>`)
|
|
380
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
|
|
5
|
+
import type { RuntimeProvider } from '@mohou/runtime-provider'
|
|
6
|
+
|
|
7
|
+
import type { AuthorMcpLayout, AuthorMcpStatus } from '../host/author-mcp.ts'
|
|
8
|
+
import type { AuthorSkillLayout, AuthorSkillStatus } from '../host/author-skill.ts'
|
|
9
|
+
import { probeBrain, type HostPolicy } from '../host/config.ts'
|
|
10
|
+
import type { CredentialListing } from '../credentials/provider.ts'
|
|
11
|
+
import type { McpCheck, McpEditorServer } from '../host/mcp-editor.ts'
|
|
12
|
+
import type { McpLoadFailure } from '../host/mcp.ts'
|
|
13
|
+
import type { AppPin } from '../theme/pin.ts'
|
|
14
|
+
import type { UpdateCheck } from './updates.ts'
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url)
|
|
17
|
+
|
|
18
|
+
export interface LoopbackApp {
|
|
19
|
+
readonly id: string
|
|
20
|
+
readonly name: string
|
|
21
|
+
readonly description: string
|
|
22
|
+
readonly version: string
|
|
23
|
+
readonly acronym: string
|
|
24
|
+
readonly tags?: readonly string[]
|
|
25
|
+
readonly createdAt?: string
|
|
26
|
+
readonly updatedAt?: string
|
|
27
|
+
readonly activity?: { readonly openCount: number; readonly lastOpenedAt: string }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Owner and iframe calls. Author routes do not use this bag. */
|
|
31
|
+
export interface LoopbackPorts {
|
|
32
|
+
list(): Promise<ReadonlyArray<LoopbackApp & { pin: AppPin; appFile: boolean }>>
|
|
33
|
+
listTrash(): Promise<readonly LoopbackApp[]>
|
|
34
|
+
deleteApp(appId: string): Promise<void>
|
|
35
|
+
undeleteApp(appId: string): Promise<unknown>
|
|
36
|
+
open(appId: string, title?: string): Promise<unknown>
|
|
37
|
+
reload(appId: string): Promise<void>
|
|
38
|
+
call(appId: string, method: string, args: unknown, publish?: (value: unknown) => Promise<void> | void): Promise<unknown>
|
|
39
|
+
readPolicy(): HostPolicy
|
|
40
|
+
writePolicy(raw: unknown): Promise<{ policy: HostPolicy; restartRequired: boolean }>
|
|
41
|
+
probe(id: string): ReturnType<typeof probeBrain>
|
|
42
|
+
providers: readonly RuntimeProvider[]
|
|
43
|
+
listPalettes(): Promise<unknown>
|
|
44
|
+
readPin(appId: string): Promise<AppPin>
|
|
45
|
+
setPin(appId: string, pin: AppPin): Promise<AppPin>
|
|
46
|
+
appFile(appId: string): Promise<boolean>
|
|
47
|
+
readAppTheme(appId: string): Promise<{ name?: string; nameZh?: string; swatch: string; style: string } | null>
|
|
48
|
+
readHistory(appId: string): Promise<readonly unknown[]>
|
|
49
|
+
readCommit(appId: string, commitId: string): Promise<unknown>
|
|
50
|
+
readStorage(appId: string): Promise<unknown>
|
|
51
|
+
readTable(appId: string, table: string): Promise<unknown>
|
|
52
|
+
runnerDocument(appId: string): Promise<string>
|
|
53
|
+
entryScript(appId: string): Promise<string>
|
|
54
|
+
stylesheet(appId: string): Promise<string>
|
|
55
|
+
assetFile(appId: string, rest: string): Promise<{ bytes: Uint8Array<ArrayBuffer>; type: string }>
|
|
56
|
+
restoreStorage(appId: string): Promise<void>
|
|
57
|
+
readErrors(appId: string): unknown
|
|
58
|
+
vendorFile(file: string): Promise<string>
|
|
59
|
+
panel?: {
|
|
60
|
+
readonly html: string
|
|
61
|
+
readonly script: string
|
|
62
|
+
readonly paint?: () => Promise<{ readonly style: string; readonly appearance: 'system' | 'light' | 'dark' }>
|
|
63
|
+
}
|
|
64
|
+
authoringToken: string
|
|
65
|
+
authorized(header: string | undefined): boolean
|
|
66
|
+
subscribeHost(listener: (event: unknown) => void): () => void
|
|
67
|
+
subscribeFrames(listener: (event: unknown) => void): () => void
|
|
68
|
+
subscribeApp(appId: string, since: number, listener: (event: unknown) => void): () => void
|
|
69
|
+
checkUpdate(): Promise<UpdateCheck>
|
|
70
|
+
installUpdate?(version: string): Promise<void>
|
|
71
|
+
/** The panel showed the launcher's last attempt. The record is dropped, not shown twice. */
|
|
72
|
+
ackUpdate?(at: number): Promise<void>
|
|
73
|
+
restart?(): Promise<void>
|
|
74
|
+
authorSkill?: AuthorSkillLayout
|
|
75
|
+
readAuthorSkill?(customDirs?: readonly string[]): Promise<AuthorSkillStatus>
|
|
76
|
+
writeAuthorSkill?(agentIds: readonly string[], customDirs: readonly string[]): Promise<AuthorSkillStatus>
|
|
77
|
+
revealAuthorSkill?(dest: string): Promise<void>
|
|
78
|
+
authorMcp?: AuthorMcpLayout
|
|
79
|
+
readAuthorMcp?(): Promise<AuthorMcpStatus>
|
|
80
|
+
writeAuthorMcp?(agentIds: readonly string[], description: string): Promise<AuthorMcpStatus>
|
|
81
|
+
revealAuthorMcp?(dest: string): Promise<void>
|
|
82
|
+
readMcp(): Promise<{ servers: readonly McpEditorServer[]; unresolved: readonly McpLoadFailure[] }>
|
|
83
|
+
writeMcp(servers: readonly McpEditorServer[]): Promise<{ unresolved: readonly McpLoadFailure[] }>
|
|
84
|
+
checkMcp(server: McpEditorServer): Promise<McpCheck>
|
|
85
|
+
admitMcp(text: string): readonly McpEditorServer[]
|
|
86
|
+
importMcp(source: string): Promise<readonly McpEditorServer[]>
|
|
87
|
+
/** Names, descriptions, and whether the store accepts a write. A secret is never on this surface. */
|
|
88
|
+
readCredentials(): Promise<{ credentials: readonly CredentialListing[]; writable: boolean }>
|
|
89
|
+
/**
|
|
90
|
+
* Write one account. An absent or empty secret keeps the stored one, so an owner can fix a
|
|
91
|
+
* description without pasting a secret they may no longer hold. A name with no account and no
|
|
92
|
+
* secret has nothing to keep: `credential-invalid`.
|
|
93
|
+
*/
|
|
94
|
+
putCredential(name: string, description: string, secret: string | undefined): Promise<void>
|
|
95
|
+
removeCredential(name: string): Promise<void>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readVendorFile(dir: string, name: string): Promise<string> {
|
|
99
|
+
return readFile(`${dir}/${name}`, 'utf8')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function aboutInfo(): { name: string; current: string; platform: string; private: boolean } {
|
|
103
|
+
const parsed = JSON.parse(readFileSync(require.resolve('@mohou/host/package.json'), 'utf8')) as {
|
|
104
|
+
name?: unknown
|
|
105
|
+
version?: unknown
|
|
106
|
+
private?: unknown
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
name: typeof parsed.name === 'string' ? parsed.name : '',
|
|
110
|
+
current: typeof parsed.version === 'string' ? parsed.version : '',
|
|
111
|
+
platform: process.platform,
|
|
112
|
+
private: parsed.private === true,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
import type { AppPin } from '../theme/pin.ts'
|
|
4
|
+
|
|
5
|
+
export async function ok(c: Context, work: () => Promise<unknown>): Promise<Response> {
|
|
6
|
+
try {
|
|
7
|
+
return c.json({ ok: true, result: await work() })
|
|
8
|
+
} catch (error) {
|
|
9
|
+
return c.json({ ok: false, error: errorBody(error) }, 400)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The one refusal for a route that requires the authoring token. */
|
|
14
|
+
export function authoringDenied(c: Context): Response {
|
|
15
|
+
return c.json({ ok: false, error: { code: 'authoring-token', message: 'authoring token is missing or wrong' } }, 401)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function errorBody(error: unknown): { code: string; message: string } {
|
|
19
|
+
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
|
|
20
|
+
? error.code
|
|
21
|
+
: 'failed'
|
|
22
|
+
const message = error instanceof Error ? error.message : 'request failed'
|
|
23
|
+
return { code, message }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function mergePolicy(current: unknown, body: Record<string, unknown>): Record<string, unknown> {
|
|
31
|
+
const base = isRecord(current) ? current : {}
|
|
32
|
+
const runtimeRoot = typeof base.runtimeRoot === 'string' ? base.runtimeRoot : undefined
|
|
33
|
+
const provider = isRecord(body.runtimeProvider) ? body.runtimeProvider : undefined
|
|
34
|
+
const previous = isRecord(base.runtimeProvider) ? base.runtimeProvider : undefined
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
...body,
|
|
38
|
+
...runtimeRoot === undefined ? {} : { runtimeRoot },
|
|
39
|
+
...provider === undefined || previous === undefined ? {} : {
|
|
40
|
+
runtimeProvider: {
|
|
41
|
+
...previous,
|
|
42
|
+
...provider,
|
|
43
|
+
...provider.config === undefined && previous.config !== undefined ? { config: previous.config } : {},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isPin(value: unknown): value is AppPin {
|
|
50
|
+
if (!isRecord(value) || typeof value.kind !== 'string') return false
|
|
51
|
+
if (value.kind === 'default' || value.kind === 'follow-host' || value.kind === 'app-file') return true
|
|
52
|
+
return value.kind === 'palette' && typeof value.id === 'string' && value.id.length > 0
|
|
53
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Codes the loopback surface emits that are not a tool code. Callers match `code`. */
|
|
2
|
+
|
|
3
|
+
export const routeCodes = ['not-found', 'unknown-vendor'] as const
|
|
4
|
+
|
|
5
|
+
export type RouteCode = (typeof routeCodes)[number]
|
|
6
|
+
|
|
7
|
+
export class RouteError extends Error {
|
|
8
|
+
readonly code: RouteCode
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param code - one of {@link routeCodes}
|
|
12
|
+
* @param message - human text; not the match key
|
|
13
|
+
*/
|
|
14
|
+
constructor(code: RouteCode, message: string, options?: { cause?: unknown }) {
|
|
15
|
+
super(message, options)
|
|
16
|
+
this.name = 'RouteError'
|
|
17
|
+
this.code = code
|
|
18
|
+
}
|
|
19
|
+
}
|