create-loopengine 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/adapters/http.ts +96 -5
package/package.json
CHANGED
|
@@ -27,9 +27,13 @@
|
|
|
27
27
|
// *same* session don't race on read-modify-write of that session's
|
|
28
28
|
// history. What counts as "the same session" is deliberately not this
|
|
29
29
|
// file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
|
|
30
|
+
import { execFile } from 'node:child_process'
|
|
30
31
|
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
32
|
+
import { existsSync } from 'node:fs'
|
|
31
33
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
34
|
+
import { join } from 'node:path'
|
|
32
35
|
import { pathToFileURL } from 'node:url'
|
|
36
|
+
import { promisify } from 'node:util'
|
|
33
37
|
import { getEntry, listAgents, projectDir, registerAgent, updateAgent, type RegistryEntry } from '../agent-registry.js'
|
|
34
38
|
import {
|
|
35
39
|
loadAgentModule,
|
|
@@ -104,6 +108,7 @@ import {
|
|
|
104
108
|
AbilityVersionError,
|
|
105
109
|
AbilityNotInstalledError,
|
|
106
110
|
searchPublicAbilities,
|
|
111
|
+
fetchLatestAbilityVersion,
|
|
107
112
|
describeModelProviders,
|
|
108
113
|
describeGateways,
|
|
109
114
|
scaffoldAgent,
|
|
@@ -998,15 +1003,42 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
|
|
|
998
1003
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
999
1004
|
}
|
|
1000
1005
|
|
|
1006
|
+
// A cheap, good-enough presence check — existsSync(node_modules/<dep>)
|
|
1007
|
+
// — rather than an actual module resolution (require.resolve/
|
|
1008
|
+
// import.meta.resolve): this only ever decides whether to show an
|
|
1009
|
+
// "Install deps" button, not whether the ability will actually work, so
|
|
1010
|
+
// a false negative in an unusual node_modules layout (a workspace
|
|
1011
|
+
// hoisting a dep up to a parent directory this project doesn't own,
|
|
1012
|
+
// say) just means the button stays visible a little too long — an
|
|
1013
|
+
// extra no-op npm install away from being right again — not a wrong
|
|
1014
|
+
// answer the operator has to debug.
|
|
1015
|
+
function hasNodeModule(name: string): boolean {
|
|
1016
|
+
return existsSync(join(process.cwd(), 'node_modules', name))
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1001
1019
|
// Backs the Admin UI's Abilities tab list — everything currently
|
|
1002
|
-
// installed for this agent
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1020
|
+
// installed for this agent, each annotated with the latest version
|
|
1021
|
+
// currently published on the public npm registry (fetchLatestAbilityVersion,
|
|
1022
|
+
// best-effort — null for a private/file:/git-installed ability, or on any
|
|
1023
|
+
// registry hiccup) so the tab can skip offering an "Upgrade" button for
|
|
1024
|
+
// something already at the newest version, and with `missingDependencies`
|
|
1025
|
+
// (see hasNodeModule) so it can likewise skip offering "Install deps"
|
|
1026
|
+
// once they're all already present. Read-only, so no extra auth gate
|
|
1027
|
+
// beyond the normal Basic Auth middleware every admin route already has.
|
|
1028
|
+
async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1005
1029
|
if (!getEntry(agentName)) {
|
|
1006
1030
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1007
1031
|
return
|
|
1008
1032
|
}
|
|
1009
|
-
|
|
1033
|
+
const installed = listInstalledAbilities(agentName)
|
|
1034
|
+
const abilities = await Promise.all(
|
|
1035
|
+
installed.map(async (a) => {
|
|
1036
|
+
const latestVersion = await fetchLatestAbilityVersion(a.name)
|
|
1037
|
+
const missingDependencies = (a.dependencies ?? []).filter((dep) => !hasNodeModule(dep))
|
|
1038
|
+
return { ...a, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
|
|
1039
|
+
}),
|
|
1040
|
+
)
|
|
1041
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
|
|
1010
1042
|
}
|
|
1011
1043
|
|
|
1012
1044
|
// Backs the Abilities tab's search box — proxies to npm's own public
|
|
@@ -1099,6 +1131,60 @@ async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerRespons
|
|
|
1099
1131
|
}
|
|
1100
1132
|
}
|
|
1101
1133
|
|
|
1134
|
+
const execFileAsync = promisify(execFile)
|
|
1135
|
+
const NPM_INSTALL_DEPS_TIMEOUT_MS = 180_000
|
|
1136
|
+
|
|
1137
|
+
// Runs npm install <deps> in the project's own root (process.cwd(),
|
|
1138
|
+
// same convention loopengine's own env-admin.ts already establishes) —
|
|
1139
|
+
// the real npm packages an installed ability's tool code imports (e.g.
|
|
1140
|
+
// sharp, jszip) that installAbility itself never touches (it only ever
|
|
1141
|
+
// copies the ability's own files, deliberately never package.json/
|
|
1142
|
+
// node_modules — see installAbility's own doc comment). Meaningfully
|
|
1143
|
+
// more consequential than installing an ability's files: this mutates
|
|
1144
|
+
// the whole project's package.json/lockfile and runs arbitrary npm
|
|
1145
|
+
// lifecycle scripts, so same refuse-outright-without-
|
|
1146
|
+
// LOOPENGINE_ADMIN_AUTH posture as installing/upgrading an ability
|
|
1147
|
+
// itself. `deps` is read back off this ability's own persisted
|
|
1148
|
+
// provenance record (InstalledAbilityRecord.dependencies), never taken
|
|
1149
|
+
// from the request body — accepting an arbitrary client-supplied package
|
|
1150
|
+
// list here would let anyone who can reach this route install anything
|
|
1151
|
+
// on the server, not just what an already-installed ability actually
|
|
1152
|
+
// declared. execFile (array argv, no shell) rather than exec/a template
|
|
1153
|
+
// string — a dependency name is whatever text an ability's own
|
|
1154
|
+
// package.json happened to declare, not something to trust for shell
|
|
1155
|
+
// interpolation.
|
|
1156
|
+
async function handleAbilityInstallDepsPost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
|
|
1157
|
+
if (!adminAuth) {
|
|
1158
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1159
|
+
JSON.stringify({ error: 'Refusing to run npm install without LOOPENGINE_ADMIN_AUTH configured — set it before installing dependencies through this route.' }),
|
|
1160
|
+
)
|
|
1161
|
+
return
|
|
1162
|
+
}
|
|
1163
|
+
if (!getEntry(agentName)) {
|
|
1164
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1165
|
+
return
|
|
1166
|
+
}
|
|
1167
|
+
const record = listInstalledAbilities(agentName).find((a) => a.name === abilityName)
|
|
1168
|
+
if (!record) {
|
|
1169
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `Ability '${abilityName}' isn't installed for '${agentName}'.` }))
|
|
1170
|
+
return
|
|
1171
|
+
}
|
|
1172
|
+
const deps = record.dependencies ?? []
|
|
1173
|
+
if (!deps.length) {
|
|
1174
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ installed: [] }))
|
|
1175
|
+
return
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
await execFileAsync('npm', ['install', ...deps], { cwd: process.cwd(), timeout: NPM_INSTALL_DEPS_TIMEOUT_MS })
|
|
1179
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ installed: deps }))
|
|
1180
|
+
} catch (err) {
|
|
1181
|
+
const stderr = err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : undefined
|
|
1182
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(
|
|
1183
|
+
JSON.stringify({ error: stderr || (err instanceof Error ? err.message : String(err)) }),
|
|
1184
|
+
)
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1102
1188
|
// Backs the Actauth tab's rule editor — parses a rule body shared by both
|
|
1103
1189
|
// the add (POST) and update (PUT, minus `name`) routes below.
|
|
1104
1190
|
function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
|
|
@@ -2282,9 +2368,14 @@ const server = createServer(async (req, res) => {
|
|
|
2282
2368
|
await handleAbilityUpgradePost(req, res, decodeURIComponent(abilityUpgradeMatch[1]), decodeURIComponent(abilityUpgradeMatch[2]))
|
|
2283
2369
|
return
|
|
2284
2370
|
}
|
|
2371
|
+
const abilityInstallDepsMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/install-deps$/)
|
|
2372
|
+
if (abilityInstallDepsMatch && req.method === 'POST') {
|
|
2373
|
+
await handleAbilityInstallDepsPost(req, res, decodeURIComponent(abilityInstallDepsMatch[1]), decodeURIComponent(abilityInstallDepsMatch[2]))
|
|
2374
|
+
return
|
|
2375
|
+
}
|
|
2285
2376
|
const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
|
|
2286
2377
|
if (abilitiesMatch && req.method === 'GET') {
|
|
2287
|
-
handleAbilitiesGet(res, decodeURIComponent(abilitiesMatch[1]))
|
|
2378
|
+
await handleAbilitiesGet(res, decodeURIComponent(abilitiesMatch[1]))
|
|
2288
2379
|
return
|
|
2289
2380
|
}
|
|
2290
2381
|
if (abilitiesMatch && req.method === 'POST') {
|