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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.36",
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,
@@ -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. 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 {
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
- 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 }))
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') {