create-loopengine 0.1.36 → 0.1.39
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 +104 -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,
|
|
@@ -98,12 +102,14 @@ import {
|
|
|
98
102
|
installAbility,
|
|
99
103
|
upgradeAbility,
|
|
100
104
|
listInstalledAbilities,
|
|
105
|
+
backfillDependencies,
|
|
101
106
|
AbilityAlreadyInstalledError,
|
|
102
107
|
AbilityCollisionError,
|
|
103
108
|
AbilityManifestError,
|
|
104
109
|
AbilityVersionError,
|
|
105
110
|
AbilityNotInstalledError,
|
|
106
111
|
searchPublicAbilities,
|
|
112
|
+
fetchLatestAbilityVersion,
|
|
107
113
|
describeModelProviders,
|
|
108
114
|
describeGateways,
|
|
109
115
|
scaffoldAgent,
|
|
@@ -998,15 +1004,49 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
|
|
|
998
1004
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
999
1005
|
}
|
|
1000
1006
|
|
|
1007
|
+
// A cheap, good-enough presence check — existsSync(node_modules/<dep>)
|
|
1008
|
+
// — rather than an actual module resolution (require.resolve/
|
|
1009
|
+
// import.meta.resolve): this only ever decides whether to show an
|
|
1010
|
+
// "Install deps" button, not whether the ability will actually work, so
|
|
1011
|
+
// a false negative in an unusual node_modules layout (a workspace
|
|
1012
|
+
// hoisting a dep up to a parent directory this project doesn't own,
|
|
1013
|
+
// say) just means the button stays visible a little too long — an
|
|
1014
|
+
// extra no-op npm install away from being right again — not a wrong
|
|
1015
|
+
// answer the operator has to debug.
|
|
1016
|
+
function hasNodeModule(name: string): boolean {
|
|
1017
|
+
return existsSync(join(process.cwd(), 'node_modules', name))
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1001
1020
|
// Backs the Admin UI's Abilities tab list — everything currently
|
|
1002
|
-
// installed for this agent
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1021
|
+
// installed for this agent, each annotated with the latest version
|
|
1022
|
+
// currently published on the public npm registry (fetchLatestAbilityVersion,
|
|
1023
|
+
// best-effort — null for a private/file:/git-installed ability, or on any
|
|
1024
|
+
// registry hiccup) so the tab can skip offering an "Upgrade" button for
|
|
1025
|
+
// something already at the newest version, and with `missingDependencies`
|
|
1026
|
+
// (see hasNodeModule) so it can likewise skip offering "Install deps"
|
|
1027
|
+
// once they're all already present. Read-only, so no extra auth gate
|
|
1028
|
+
// beyond the normal Basic Auth middleware every admin route already has.
|
|
1029
|
+
async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1005
1030
|
if (!getEntry(agentName)) {
|
|
1006
1031
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1007
1032
|
return
|
|
1008
1033
|
}
|
|
1009
|
-
|
|
1034
|
+
const installed = listInstalledAbilities(agentName)
|
|
1035
|
+
const abilities = await Promise.all(
|
|
1036
|
+
installed.map(async (a) => {
|
|
1037
|
+
// a.dependencies is only ever undefined for a record from before
|
|
1038
|
+
// that field existed — backfillDependencies re-derives and
|
|
1039
|
+
// persists it once rather than this route silently treating
|
|
1040
|
+
// "never recorded" the same as "confirmed zero", which would
|
|
1041
|
+
// otherwise permanently hide "Install deps" for an ability that
|
|
1042
|
+
// genuinely still needs something.
|
|
1043
|
+
const dependencies = a.dependencies !== undefined ? a.dependencies : ((await backfillDependencies(agentName, a.name)) ?? [])
|
|
1044
|
+
const latestVersion = await fetchLatestAbilityVersion(a.name)
|
|
1045
|
+
const missingDependencies = dependencies.filter((dep) => !hasNodeModule(dep))
|
|
1046
|
+
return { ...a, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
|
|
1047
|
+
}),
|
|
1048
|
+
)
|
|
1049
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
|
|
1010
1050
|
}
|
|
1011
1051
|
|
|
1012
1052
|
// Backs the Abilities tab's search box — proxies to npm's own public
|
|
@@ -1099,6 +1139,60 @@ async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerRespons
|
|
|
1099
1139
|
}
|
|
1100
1140
|
}
|
|
1101
1141
|
|
|
1142
|
+
const execFileAsync = promisify(execFile)
|
|
1143
|
+
const NPM_INSTALL_DEPS_TIMEOUT_MS = 180_000
|
|
1144
|
+
|
|
1145
|
+
// Runs npm install <deps> in the project's own root (process.cwd(),
|
|
1146
|
+
// same convention loopengine's own env-admin.ts already establishes) —
|
|
1147
|
+
// the real npm packages an installed ability's tool code imports (e.g.
|
|
1148
|
+
// sharp, jszip) that installAbility itself never touches (it only ever
|
|
1149
|
+
// copies the ability's own files, deliberately never package.json/
|
|
1150
|
+
// node_modules — see installAbility's own doc comment). Meaningfully
|
|
1151
|
+
// more consequential than installing an ability's files: this mutates
|
|
1152
|
+
// the whole project's package.json/lockfile and runs arbitrary npm
|
|
1153
|
+
// lifecycle scripts, so same refuse-outright-without-
|
|
1154
|
+
// LOOPENGINE_ADMIN_AUTH posture as installing/upgrading an ability
|
|
1155
|
+
// itself. `deps` is read back off this ability's own persisted
|
|
1156
|
+
// provenance record (InstalledAbilityRecord.dependencies), never taken
|
|
1157
|
+
// from the request body — accepting an arbitrary client-supplied package
|
|
1158
|
+
// list here would let anyone who can reach this route install anything
|
|
1159
|
+
// on the server, not just what an already-installed ability actually
|
|
1160
|
+
// declared. execFile (array argv, no shell) rather than exec/a template
|
|
1161
|
+
// string — a dependency name is whatever text an ability's own
|
|
1162
|
+
// package.json happened to declare, not something to trust for shell
|
|
1163
|
+
// interpolation.
|
|
1164
|
+
async function handleAbilityInstallDepsPost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
|
|
1165
|
+
if (!adminAuth) {
|
|
1166
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1167
|
+
JSON.stringify({ error: 'Refusing to run npm install without LOOPENGINE_ADMIN_AUTH configured — set it before installing dependencies through this route.' }),
|
|
1168
|
+
)
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
if (!getEntry(agentName)) {
|
|
1172
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1173
|
+
return
|
|
1174
|
+
}
|
|
1175
|
+
const record = listInstalledAbilities(agentName).find((a) => a.name === abilityName)
|
|
1176
|
+
if (!record) {
|
|
1177
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `Ability '${abilityName}' isn't installed for '${agentName}'.` }))
|
|
1178
|
+
return
|
|
1179
|
+
}
|
|
1180
|
+
const deps = record.dependencies ?? []
|
|
1181
|
+
if (!deps.length) {
|
|
1182
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ installed: [] }))
|
|
1183
|
+
return
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
await execFileAsync('npm', ['install', ...deps], { cwd: process.cwd(), timeout: NPM_INSTALL_DEPS_TIMEOUT_MS })
|
|
1187
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ installed: deps }))
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
const stderr = err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr: unknown }).stderr) : undefined
|
|
1190
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(
|
|
1191
|
+
JSON.stringify({ error: stderr || (err instanceof Error ? err.message : String(err)) }),
|
|
1192
|
+
)
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1102
1196
|
// Backs the Actauth tab's rule editor — parses a rule body shared by both
|
|
1103
1197
|
// the add (POST) and update (PUT, minus `name`) routes below.
|
|
1104
1198
|
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 +2376,14 @@ const server = createServer(async (req, res) => {
|
|
|
2282
2376
|
await handleAbilityUpgradePost(req, res, decodeURIComponent(abilityUpgradeMatch[1]), decodeURIComponent(abilityUpgradeMatch[2]))
|
|
2283
2377
|
return
|
|
2284
2378
|
}
|
|
2379
|
+
const abilityInstallDepsMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/install-deps$/)
|
|
2380
|
+
if (abilityInstallDepsMatch && req.method === 'POST') {
|
|
2381
|
+
await handleAbilityInstallDepsPost(req, res, decodeURIComponent(abilityInstallDepsMatch[1]), decodeURIComponent(abilityInstallDepsMatch[2]))
|
|
2382
|
+
return
|
|
2383
|
+
}
|
|
2285
2384
|
const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
|
|
2286
2385
|
if (abilitiesMatch && req.method === 'GET') {
|
|
2287
|
-
handleAbilitiesGet(res, decodeURIComponent(abilitiesMatch[1]))
|
|
2386
|
+
await handleAbilitiesGet(res, decodeURIComponent(abilitiesMatch[1]))
|
|
2288
2387
|
return
|
|
2289
2388
|
}
|
|
2290
2389
|
if (abilitiesMatch && req.method === 'POST') {
|