create-loopengine 0.1.49 → 0.1.51

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.49",
3
+ "version": "0.1.51",
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",
@@ -98,6 +98,8 @@ import {
98
98
  unsetAgentEnvVar,
99
99
  createAgentEnv,
100
100
  agentDirFor,
101
+ verifyProxyIdentity,
102
+ TRUSTED_PROXY_IDENTITY_HEADER,
101
103
  EnvVarNameError,
102
104
  readActauthConfig,
103
105
  addActauthRule,
@@ -380,6 +382,10 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
380
382
  contextBudgetTokens: config.contextBudgetTokens ?? 100000,
381
383
  skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
382
384
  skillsDirs: skillsDirs,
385
+ // How many abilities are installed — just the count, for the console's
386
+ // tab badge; the Abilities tab's own GET .../abilities has the detail
387
+ // (and its slower npm version lookups).
388
+ abilityCount: listInstalledAbilities(config.name).length,
383
389
  skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
384
390
  systemSkills: systemSkillIndex.map((s) => ({ name: s.name, description: s.description })),
385
391
  tools: tools.map(describeTool),
@@ -1045,7 +1051,7 @@ function envSlotOf(req: IncomingMessage): 'shared' | 'agent' | undefined {
1045
1051
  // Admin UI" — LOOPENGINE_ADMIN_AUTH being optional today is an
1046
1052
  // acceptable gap for config, not for secrets).
1047
1053
  async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
1048
- if (!adminAuth) {
1054
+ if (!secretsProtected) {
1049
1055
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1050
1056
  JSON.stringify({ error: 'Refusing to set an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
1051
1057
  )
@@ -1087,8 +1093,8 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1087
1093
  return
1088
1094
  }
1089
1095
  try {
1090
- if (slot === 'agent') setAgentEnvVar(agentName, varName, body.value)
1091
- else setEnvVar(varName, body.value)
1096
+ if (slot === 'agent') await setAgentEnvVar(agentName, varName, body.value)
1097
+ else await setEnvVar(varName, body.value)
1092
1098
  } catch (err) {
1093
1099
  const status = err instanceof EnvVarNameError ? 400 : 500
1094
1100
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
@@ -1102,7 +1108,7 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1102
1108
  // not just config, so this refuses outright rather than proceeding
1103
1109
  // open when LOOPENGINE_ADMIN_AUTH isn't configured at all.
1104
1110
  async function handleEnvDelete(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
1105
- if (!adminAuth) {
1111
+ if (!secretsProtected) {
1106
1112
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1107
1113
  JSON.stringify({ error: 'Refusing to unset an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
1108
1114
  )
@@ -1118,8 +1124,8 @@ async function handleEnvDelete(req: IncomingMessage, res: ServerResponse, agentN
1118
1124
  return
1119
1125
  }
1120
1126
  try {
1121
- if (slot === 'agent') unsetAgentEnvVar(agentName, varName)
1122
- else unsetEnvVar(varName)
1127
+ if (slot === 'agent') await unsetAgentEnvVar(agentName, varName)
1128
+ else await unsetEnvVar(varName)
1123
1129
  } catch (err) {
1124
1130
  const status = err instanceof EnvVarNameError ? 400 : 500
1125
1131
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
@@ -1202,7 +1208,7 @@ async function handleAbilitiesSearchGet(res: ServerResponse, agentName: string,
1202
1208
  // LOOPENGINE_ADMIN_AUTH isn't configured at all, rather than proceeding
1203
1209
  // open like most routes here do.
1204
1210
  async function handleAbilityInstallPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1205
- if (!adminAuth) {
1211
+ if (!secretsProtected) {
1206
1212
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1207
1213
  JSON.stringify({ error: 'Refusing to install an ability without LOOPENGINE_ADMIN_AUTH configured — set it before installing abilities through this route.' }),
1208
1214
  )
@@ -1237,7 +1243,7 @@ async function handleAbilityInstallPost(req: IncomingMessage, res: ServerRespons
1237
1243
  // third-party code inside the agent's project the next time it's
1238
1244
  // loaded, same as install does.
1239
1245
  async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
1240
- if (!adminAuth) {
1246
+ if (!secretsProtected) {
1241
1247
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1242
1248
  JSON.stringify({ error: 'Refusing to upgrade an ability without LOOPENGINE_ADMIN_AUTH configured — set it before upgrading abilities through this route.' }),
1243
1249
  )
@@ -1272,7 +1278,7 @@ async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerRespons
1272
1278
  // behavior, is a normal partial outcome the Admin UI shows and offers
1273
1279
  // to retry with force, not a failure).
1274
1280
  async function handleAbilityUninstallDelete(res: ServerResponse, agentName: string, abilityName: string, force: boolean): Promise<void> {
1275
- if (!adminAuth) {
1281
+ if (!secretsProtected) {
1276
1282
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1277
1283
  JSON.stringify({ error: 'Refusing to uninstall an ability without LOOPENGINE_ADMIN_AUTH configured — set it before uninstalling abilities through this route.' }),
1278
1284
  )
@@ -1314,7 +1320,7 @@ const NPM_INSTALL_DEPS_TIMEOUT_MS = 180_000
1314
1320
  // package.json happened to declare, not something to trust for shell
1315
1321
  // interpolation.
1316
1322
  async function handleAbilityInstallDepsPost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
1317
- if (!adminAuth) {
1323
+ if (!secretsProtected) {
1318
1324
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1319
1325
  JSON.stringify({ error: 'Refusing to run npm install without LOOPENGINE_ADMIN_AUTH configured — set it before installing dependencies through this route.' }),
1320
1326
  )
@@ -1728,6 +1734,8 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1728
1734
  }
1729
1735
  const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
1730
1736
 
1737
+ openStreams++
1738
+ res.on('close', () => openStreams--)
1731
1739
  res.writeHead(200, {
1732
1740
  'content-type': 'text/event-stream',
1733
1741
  'cache-control': 'no-cache',
@@ -1831,7 +1839,15 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1831
1839
  // default. A real deployment opts in by setting it; either way, a
1832
1840
  // startup warning makes "I forgot to set this" loud instead of silent.
1833
1841
  const adminAuth = process.env.LOOPENGINE_ADMIN_AUTH
1834
- if (!adminAuth) {
1842
+ // Hosted mode (see loopengine's core/trusted-proxy.ts): every request
1843
+ // must carry an X-LoopEngine-Identity header signed with this secret by
1844
+ // the router in front of this server. Unset (the default, and every
1845
+ // self-hosted install) means this file behaves exactly as it always has.
1846
+ const trustedProxySecret = process.env.LOOPENGINE_TRUSTED_PROXY_SECRET
1847
+ // Routes that write secrets or install code refuse to run on a server
1848
+ // with no auth at all — either form of auth counts.
1849
+ const secretsProtected = Boolean(adminAuth || trustedProxySecret)
1850
+ if (!adminAuth && !trustedProxySecret) {
1835
1851
  console.warn(
1836
1852
  '[loopengine] LOOPENGINE_ADMIN_AUTH is not set — every route on this server (including tool-call approvals, conversation history, and permission rules) is open to anyone who can reach it. Set LOOPENGINE_ADMIN_AUTH="user:pass" to require HTTP Basic Auth.',
1837
1853
  )
@@ -1844,7 +1860,12 @@ if (!adminAuth) {
1844
1860
  // first; a length mismatch isn't sensitive information worth spending a
1845
1861
  // constant-time comparison to protect.
1846
1862
  function isAuthorized(req: IncomingMessage): boolean {
1847
- if (!adminAuth) return true
1863
+ if (trustedProxySecret && verifyProxyIdentity(req.headers[TRUSTED_PROXY_IDENTITY_HEADER], trustedProxySecret)) return true
1864
+ // Basic Auth still works alongside trusted-proxy mode (direct operator
1865
+ // access to one workspace) — but with neither form of credentials, a
1866
+ // trusted-proxy server is closed, never open the way a server with no
1867
+ // auth configured at all is.
1868
+ if (!adminAuth) return !trustedProxySecret
1848
1869
  const header = req.headers.authorization
1849
1870
  if (!header || !header.startsWith('Basic ')) return false
1850
1871
  const provided = Buffer.from(header.slice('Basic '.length), 'base64')
@@ -2284,11 +2305,43 @@ async function handleLocalFile(req: IncomingMessage, res: ServerResponse): Promi
2284
2305
  createReadStream(resolved).pipe(res)
2285
2306
  }
2286
2307
 
2308
+ // Opt-in (LOOPENGINE_EXIT_ON_CODE_CHANGE=1): Node caches an imported
2309
+ // module, so a tool file an ability install/upgrade/uninstall just wrote
2310
+ // isn't picked up until the process restarts. Under a supervisor that
2311
+ // restarts it (a hosting platform, Docker's restart policy, systemd),
2312
+ // exiting cleanly right after the change is the simplest way to apply
2313
+ // it. Off by default — a plain `loopengine serve` has nothing to restart
2314
+ // it, so exiting there would just take the server down.
2315
+ const exitOnCodeChange = process.env.LOOPENGINE_EXIT_ON_CODE_CHANGE === '1'
2316
+ function exitIfCodeChanged(res: ServerResponse): void {
2317
+ if (!exitOnCodeChange || res.statusCode >= 300) return
2318
+ console.log('[loopengine] ability code changed — exiting so the supervisor restarts this server with it loaded')
2319
+ // shutdown() drains in-flight requests (this one's response included)
2320
+ // before exiting.
2321
+ setImmediate(() => void shutdown())
2322
+ }
2323
+
2324
+ // Backs GET /internal/activity (trusted-proxy mode only) — what a host
2325
+ // checks before stopping an idle server, so it never stops one mid-turn.
2326
+ let openStreams = 0
2327
+ let lastRequestAt = Date.now()
2328
+
2329
+ function handleActivityGet(res: ServerResponse): void {
2330
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
2331
+ JSON.stringify({
2332
+ inFlightTurns: sessionTurns.size,
2333
+ openStreams,
2334
+ lastRequestAt: new Date(lastRequestAt).toISOString(),
2335
+ idleSeconds: Math.floor((Date.now() - lastRequestAt) / 1000),
2336
+ }),
2337
+ )
2338
+ }
2339
+
2287
2340
  const server = createServer(async (req, res) => {
2288
2341
  if (!isAuthorized(req)) {
2289
- res
2290
- .writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Basic realm="loopengine"' })
2291
- .end(JSON.stringify({ error: 'authorization required' }))
2342
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
2343
+ if (adminAuth) headers['www-authenticate'] = 'Basic realm="loopengine"'
2344
+ res.writeHead(401, headers).end(JSON.stringify({ error: 'authorization required' }))
2292
2345
  return
2293
2346
  }
2294
2347
 
@@ -2308,6 +2361,14 @@ const server = createServer(async (req, res) => {
2308
2361
  // route individually.
2309
2362
  const pathname = (req.url ?? '/').split('?')[0]
2310
2363
 
2364
+ // Only exists in trusted-proxy mode, and polling it doesn't count as
2365
+ // activity itself.
2366
+ if (trustedProxySecret && req.method === 'GET' && pathname === '/internal/activity') {
2367
+ handleActivityGet(res)
2368
+ return
2369
+ }
2370
+ lastRequestAt = Date.now()
2371
+
2311
2372
  const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
2312
2373
  if (streamMatch) {
2313
2374
  await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
@@ -2685,17 +2746,20 @@ const server = createServer(async (req, res) => {
2685
2746
  const abilityUpgradeMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/upgrade$/)
2686
2747
  if (abilityUpgradeMatch && req.method === 'POST') {
2687
2748
  await handleAbilityUpgradePost(req, res, decodeURIComponent(abilityUpgradeMatch[1]), decodeURIComponent(abilityUpgradeMatch[2]))
2749
+ exitIfCodeChanged(res)
2688
2750
  return
2689
2751
  }
2690
2752
  const abilityInstallDepsMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/install-deps$/)
2691
2753
  if (abilityInstallDepsMatch && req.method === 'POST') {
2692
2754
  await handleAbilityInstallDepsPost(req, res, decodeURIComponent(abilityInstallDepsMatch[1]), decodeURIComponent(abilityInstallDepsMatch[2]))
2755
+ exitIfCodeChanged(res)
2693
2756
  return
2694
2757
  }
2695
2758
  const abilityMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)$/)
2696
2759
  if (abilityMatch && req.method === 'DELETE') {
2697
2760
  const force = new URL(req.url ?? '/', 'http://localhost').searchParams.get('force') === 'true'
2698
2761
  await handleAbilityUninstallDelete(res, decodeURIComponent(abilityMatch[1]), decodeURIComponent(abilityMatch[2]), force)
2762
+ exitIfCodeChanged(res)
2699
2763
  return
2700
2764
  }
2701
2765
  const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
@@ -2705,6 +2769,7 @@ const server = createServer(async (req, res) => {
2705
2769
  }
2706
2770
  if (abilitiesMatch && req.method === 'POST') {
2707
2771
  await handleAbilityInstallPost(req, res, decodeURIComponent(abilitiesMatch[1]))
2772
+ exitIfCodeChanged(res)
2708
2773
  return
2709
2774
  }
2710
2775
 
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.62",
12
+ "loopengine": "^0.1.63",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {