create-loopengine 0.1.32 → 0.1.34

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.32",
3
+ "version": "0.1.34",
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",
@@ -95,6 +95,13 @@ import {
95
95
  setDefaultDecision,
96
96
  ActauthRuleExistsError,
97
97
  ActauthRuleNotFoundError,
98
+ installAbility,
99
+ listInstalledAbilities,
100
+ AbilityAlreadyInstalledError,
101
+ AbilityCollisionError,
102
+ AbilityManifestError,
103
+ AbilityVersionError,
104
+ searchPublicAbilities,
98
105
  describeModelProviders,
99
106
  describeGateways,
100
107
  scaffoldAgent,
@@ -861,7 +868,18 @@ async function handleEditAgent(req: IncomingMessage, res: ServerResponse, agentN
861
868
  }
862
869
  updateAgent(agentName, { config: configPatch, createModelCall })
863
870
 
864
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeAgent(getEntry(agentName)!)))
871
+ // Same "compute before writeHead" reasoning as the GET .../config
872
+ // route above — describeAgent can throw (a broken tools/index.ts
873
+ // import), and chaining it straight into .end()'s argument would send
874
+ // a 200 status before finding that out.
875
+ let described: Record<string, unknown>
876
+ try {
877
+ described = await describeAgent(getEntry(agentName)!)
878
+ } catch (err) {
879
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
880
+ return
881
+ }
882
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(described))
865
883
  }
866
884
 
867
885
  // Backs the Skills tab's edit form (GET .../skills/:skillId to populate
@@ -967,6 +985,75 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
967
985
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
968
986
  }
969
987
 
988
+ // Backs the Admin UI's Abilities tab list — everything currently
989
+ // installed for this agent. Read-only, so no extra auth gate beyond the
990
+ // normal Basic Auth middleware every admin route already has.
991
+ function handleAbilitiesGet(res: ServerResponse, agentName: string): void {
992
+ if (!getEntry(agentName)) {
993
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
994
+ return
995
+ }
996
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listInstalledAbilities(agentName) }))
997
+ }
998
+
999
+ // Backs the Abilities tab's search box — proxies to npm's own public
1000
+ // registry search (loopengine's own searchPublicAbilities) so the
1001
+ // browser never needs direct access to registry.npmjs.org, and the
1002
+ // actual query text (the loopengine-ability keyword filter) stays a
1003
+ // server-side detail rather than something the client has to know to
1004
+ // construct.
1005
+ async function handleAbilitiesSearchGet(res: ServerResponse, agentName: string, query: string | null): Promise<void> {
1006
+ if (!getEntry(agentName)) {
1007
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1008
+ return
1009
+ }
1010
+ try {
1011
+ const results = await searchPublicAbilities(query ?? undefined)
1012
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ results }))
1013
+ } catch (err) {
1014
+ res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1015
+ }
1016
+ }
1017
+
1018
+ // Installs an ability — any spec `npm pack` accepts (a bare/scoped
1019
+ // registry name, a pinned version, a git URL, or a local `file:../path`
1020
+ // for a private ability) — into this agent's own tree. Meaningfully
1021
+ // more sensitive than the rest of this admin surface: this runs
1022
+ // arbitrary third-party code inside the agent's project the next time
1023
+ // it's loaded, not just a config value or a credential. Same posture as
1024
+ // handleEnvPut's own secret-writing gate above — refuses outright when
1025
+ // LOOPENGINE_ADMIN_AUTH isn't configured at all, rather than proceeding
1026
+ // open like most routes here do.
1027
+ async function handleAbilityInstallPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1028
+ if (!adminAuth) {
1029
+ res.writeHead(403, { 'content-type': 'application/json' }).end(
1030
+ JSON.stringify({ error: 'Refusing to install an ability without LOOPENGINE_ADMIN_AUTH configured — set it before installing abilities through this route.' }),
1031
+ )
1032
+ return
1033
+ }
1034
+ if (!getEntry(agentName)) {
1035
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1036
+ return
1037
+ }
1038
+ const body = await readJsonBody(req)
1039
+ if (typeof body.spec !== 'string' || !body.spec) {
1040
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'spec is required' }))
1041
+ return
1042
+ }
1043
+ try {
1044
+ const result = await installAbility(agentName, body.spec)
1045
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(result))
1046
+ } catch (err) {
1047
+ const status =
1048
+ err instanceof AbilityAlreadyInstalledError || err instanceof AbilityCollisionError
1049
+ ? 409
1050
+ : err instanceof AbilityManifestError || err instanceof AbilityVersionError
1051
+ ? 422
1052
+ : 500
1053
+ res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
1054
+ }
1055
+ }
1056
+
970
1057
  // Backs the Actauth tab's rule editor — parses a rule body shared by both
971
1058
  // the add (POST) and update (PUT, minus `name`) routes below.
972
1059
  function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolean): { ok: true; value: { name: string; scope: string; tool: string; decision: Decision } } | { ok: false; error: string } {
@@ -2011,7 +2098,23 @@ const server = createServer(async (req, res) => {
2011
2098
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
2012
2099
  return
2013
2100
  }
2014
- res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeAgent(entry)))
2101
+ // describeAgent dynamically imports this agent's own tools/index.ts
2102
+ // (loadDefaultTools) — a broken import there (a bad tool file, a
2103
+ // dependency the agent's own code needs but doesn't have) throws.
2104
+ // Computed before writeHead, not chained directly into .end() as an
2105
+ // argument — chaining would send the 200 status line first, then
2106
+ // throw while still computing the body, leaving the connection
2107
+ // open with headers sent but no body ever written (a client-side
2108
+ // "Unexpected end of JSON input", not a clear error) instead of a
2109
+ // real 500 with an actual message.
2110
+ let described: Record<string, unknown>
2111
+ try {
2112
+ described = await describeAgent(entry)
2113
+ } catch (err) {
2114
+ res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
2115
+ return
2116
+ }
2117
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(described))
2015
2118
  return
2016
2119
  }
2017
2120
 
@@ -2116,6 +2219,29 @@ const server = createServer(async (req, res) => {
2116
2219
  return
2117
2220
  }
2118
2221
 
2222
+ // Backs the Admin UI's Abilities tab — see handleAbilitiesGet/
2223
+ // handleAbilitiesSearchGet/handleAbilityInstallPost's own doc
2224
+ // comments above. The /search route is checked before the plain
2225
+ // :agentName routes right below since 'search' would otherwise
2226
+ // itself need to be excluded from matching as an agent name (it
2227
+ // never would be one in practice, but this ordering means never
2228
+ // having to think about it).
2229
+ const abilitiesSearchMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/search$/)
2230
+ if (abilitiesSearchMatch && req.method === 'GET') {
2231
+ const query = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')
2232
+ await handleAbilitiesSearchGet(res, decodeURIComponent(abilitiesSearchMatch[1]), query)
2233
+ return
2234
+ }
2235
+ const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
2236
+ if (abilitiesMatch && req.method === 'GET') {
2237
+ handleAbilitiesGet(res, decodeURIComponent(abilitiesMatch[1]))
2238
+ return
2239
+ }
2240
+ if (abilitiesMatch && req.method === 'POST') {
2241
+ await handleAbilityInstallPost(req, res, decodeURIComponent(abilitiesMatch[1]))
2242
+ return
2243
+ }
2244
+
2119
2245
  // Backs the Actauth tab's rule editor and default_decision control.
2120
2246
  // The default-decision route is checked before the :ruleName routes
2121
2247
  // right below since 'default-decision' would otherwise itself match