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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.35",
3
+ "version": "0.1.38",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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. Read-only, so no extra auth gate beyond the
1001
- // normal Basic Auth middleware every admin route already has.
1002
- function handleAbilitiesGet(res: ServerResponse, agentName: string): void {
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
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listInstalledAbilities(agentName) }))
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') {