create-loopengine 0.1.35 → 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 +135 -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,
|
|
@@ -96,12 +100,15 @@ import {
|
|
|
96
100
|
ActauthRuleExistsError,
|
|
97
101
|
ActauthRuleNotFoundError,
|
|
98
102
|
installAbility,
|
|
103
|
+
upgradeAbility,
|
|
99
104
|
listInstalledAbilities,
|
|
100
105
|
AbilityAlreadyInstalledError,
|
|
101
106
|
AbilityCollisionError,
|
|
102
107
|
AbilityManifestError,
|
|
103
108
|
AbilityVersionError,
|
|
109
|
+
AbilityNotInstalledError,
|
|
104
110
|
searchPublicAbilities,
|
|
111
|
+
fetchLatestAbilityVersion,
|
|
105
112
|
describeModelProviders,
|
|
106
113
|
describeGateways,
|
|
107
114
|
scaffoldAgent,
|
|
@@ -996,15 +1003,42 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
|
|
|
996
1003
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
997
1004
|
}
|
|
998
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
|
+
|
|
999
1019
|
// Backs the Admin UI's Abilities tab list — everything currently
|
|
1000
|
-
// installed for this agent
|
|
1001
|
-
//
|
|
1002
|
-
|
|
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> {
|
|
1003
1029
|
if (!getEntry(agentName)) {
|
|
1004
1030
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1005
1031
|
return
|
|
1006
1032
|
}
|
|
1007
|
-
|
|
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 }))
|
|
1008
1042
|
}
|
|
1009
1043
|
|
|
1010
1044
|
// Backs the Abilities tab's search box — proxies to npm's own public
|
|
@@ -1065,6 +1099,92 @@ async function handleAbilityInstallPost(req: IncomingMessage, res: ServerRespons
|
|
|
1065
1099
|
}
|
|
1066
1100
|
}
|
|
1067
1101
|
|
|
1102
|
+
// Upgrades an already-installed ability to whatever it currently
|
|
1103
|
+
// resolves to (or, optionally, a specific spec) — same admin-auth
|
|
1104
|
+
// posture as handleAbilityInstallPost above, since this runs arbitrary
|
|
1105
|
+
// third-party code inside the agent's project the next time it's
|
|
1106
|
+
// loaded, same as install does.
|
|
1107
|
+
async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
|
|
1108
|
+
if (!adminAuth) {
|
|
1109
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1110
|
+
JSON.stringify({ error: 'Refusing to upgrade an ability without LOOPENGINE_ADMIN_AUTH configured — set it before upgrading abilities through this route.' }),
|
|
1111
|
+
)
|
|
1112
|
+
return
|
|
1113
|
+
}
|
|
1114
|
+
if (!getEntry(agentName)) {
|
|
1115
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
const body = await readJsonBody(req)
|
|
1119
|
+
const spec = typeof body.spec === 'string' && body.spec ? body.spec : undefined
|
|
1120
|
+
try {
|
|
1121
|
+
const result = await upgradeAbility(agentName, abilityName, spec ? { spec } : {})
|
|
1122
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(result))
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
const status =
|
|
1125
|
+
err instanceof AbilityNotInstalledError
|
|
1126
|
+
? 404
|
|
1127
|
+
: err instanceof AbilityManifestError || err instanceof AbilityVersionError
|
|
1128
|
+
? 422
|
|
1129
|
+
: 500
|
|
1130
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
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
|
+
|
|
1068
1188
|
// Backs the Actauth tab's rule editor — parses a rule body shared by both
|
|
1069
1189
|
// the add (POST) and update (PUT, minus `name`) routes below.
|
|
1070
1190
|
function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
|
|
@@ -2243,9 +2363,19 @@ const server = createServer(async (req, res) => {
|
|
|
2243
2363
|
await handleAbilitiesSearchGet(res, decodeURIComponent(abilitiesSearchMatch[1]), query)
|
|
2244
2364
|
return
|
|
2245
2365
|
}
|
|
2366
|
+
const abilityUpgradeMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/upgrade$/)
|
|
2367
|
+
if (abilityUpgradeMatch && req.method === 'POST') {
|
|
2368
|
+
await handleAbilityUpgradePost(req, res, decodeURIComponent(abilityUpgradeMatch[1]), decodeURIComponent(abilityUpgradeMatch[2]))
|
|
2369
|
+
return
|
|
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
|
+
}
|
|
2246
2376
|
const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
|
|
2247
2377
|
if (abilitiesMatch && req.method === 'GET') {
|
|
2248
|
-
handleAbilitiesGet(res, decodeURIComponent(abilitiesMatch[1]))
|
|
2378
|
+
await handleAbilitiesGet(res, decodeURIComponent(abilitiesMatch[1]))
|
|
2249
2379
|
return
|
|
2250
2380
|
}
|
|
2251
2381
|
if (abilitiesMatch && req.method === 'POST') {
|