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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.36",
3
+ "version": "0.1.39",
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,
@@ -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. Read-only, so no extra auth gate beyond the
1003
- // normal Basic Auth middleware every admin route already has.
1004
- function handleAbilitiesGet(res: ServerResponse, agentName: string): void {
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
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listInstalledAbilities(agentName) }))
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') {