create-loopengine 0.1.49 → 0.1.50

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.50",
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,
@@ -1045,7 +1047,7 @@ function envSlotOf(req: IncomingMessage): 'shared' | 'agent' | undefined {
1045
1047
  // Admin UI" — LOOPENGINE_ADMIN_AUTH being optional today is an
1046
1048
  // acceptable gap for config, not for secrets).
1047
1049
  async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
1048
- if (!adminAuth) {
1050
+ if (!secretsProtected) {
1049
1051
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1050
1052
  JSON.stringify({ error: 'Refusing to set an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
1051
1053
  )
@@ -1087,8 +1089,8 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1087
1089
  return
1088
1090
  }
1089
1091
  try {
1090
- if (slot === 'agent') setAgentEnvVar(agentName, varName, body.value)
1091
- else setEnvVar(varName, body.value)
1092
+ if (slot === 'agent') await setAgentEnvVar(agentName, varName, body.value)
1093
+ else await setEnvVar(varName, body.value)
1092
1094
  } catch (err) {
1093
1095
  const status = err instanceof EnvVarNameError ? 400 : 500
1094
1096
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
@@ -1102,7 +1104,7 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1102
1104
  // not just config, so this refuses outright rather than proceeding
1103
1105
  // open when LOOPENGINE_ADMIN_AUTH isn't configured at all.
1104
1106
  async function handleEnvDelete(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
1105
- if (!adminAuth) {
1107
+ if (!secretsProtected) {
1106
1108
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1107
1109
  JSON.stringify({ error: 'Refusing to unset an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
1108
1110
  )
@@ -1118,8 +1120,8 @@ async function handleEnvDelete(req: IncomingMessage, res: ServerResponse, agentN
1118
1120
  return
1119
1121
  }
1120
1122
  try {
1121
- if (slot === 'agent') unsetAgentEnvVar(agentName, varName)
1122
- else unsetEnvVar(varName)
1123
+ if (slot === 'agent') await unsetAgentEnvVar(agentName, varName)
1124
+ else await unsetEnvVar(varName)
1123
1125
  } catch (err) {
1124
1126
  const status = err instanceof EnvVarNameError ? 400 : 500
1125
1127
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
@@ -1202,7 +1204,7 @@ async function handleAbilitiesSearchGet(res: ServerResponse, agentName: string,
1202
1204
  // LOOPENGINE_ADMIN_AUTH isn't configured at all, rather than proceeding
1203
1205
  // open like most routes here do.
1204
1206
  async function handleAbilityInstallPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1205
- if (!adminAuth) {
1207
+ if (!secretsProtected) {
1206
1208
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1207
1209
  JSON.stringify({ error: 'Refusing to install an ability without LOOPENGINE_ADMIN_AUTH configured — set it before installing abilities through this route.' }),
1208
1210
  )
@@ -1237,7 +1239,7 @@ async function handleAbilityInstallPost(req: IncomingMessage, res: ServerRespons
1237
1239
  // third-party code inside the agent's project the next time it's
1238
1240
  // loaded, same as install does.
1239
1241
  async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
1240
- if (!adminAuth) {
1242
+ if (!secretsProtected) {
1241
1243
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1242
1244
  JSON.stringify({ error: 'Refusing to upgrade an ability without LOOPENGINE_ADMIN_AUTH configured — set it before upgrading abilities through this route.' }),
1243
1245
  )
@@ -1272,7 +1274,7 @@ async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerRespons
1272
1274
  // behavior, is a normal partial outcome the Admin UI shows and offers
1273
1275
  // to retry with force, not a failure).
1274
1276
  async function handleAbilityUninstallDelete(res: ServerResponse, agentName: string, abilityName: string, force: boolean): Promise<void> {
1275
- if (!adminAuth) {
1277
+ if (!secretsProtected) {
1276
1278
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1277
1279
  JSON.stringify({ error: 'Refusing to uninstall an ability without LOOPENGINE_ADMIN_AUTH configured — set it before uninstalling abilities through this route.' }),
1278
1280
  )
@@ -1314,7 +1316,7 @@ const NPM_INSTALL_DEPS_TIMEOUT_MS = 180_000
1314
1316
  // package.json happened to declare, not something to trust for shell
1315
1317
  // interpolation.
1316
1318
  async function handleAbilityInstallDepsPost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
1317
- if (!adminAuth) {
1319
+ if (!secretsProtected) {
1318
1320
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1319
1321
  JSON.stringify({ error: 'Refusing to run npm install without LOOPENGINE_ADMIN_AUTH configured — set it before installing dependencies through this route.' }),
1320
1322
  )
@@ -1728,6 +1730,8 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1728
1730
  }
1729
1731
  const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
1730
1732
 
1733
+ openStreams++
1734
+ res.on('close', () => openStreams--)
1731
1735
  res.writeHead(200, {
1732
1736
  'content-type': 'text/event-stream',
1733
1737
  'cache-control': 'no-cache',
@@ -1831,7 +1835,15 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1831
1835
  // default. A real deployment opts in by setting it; either way, a
1832
1836
  // startup warning makes "I forgot to set this" loud instead of silent.
1833
1837
  const adminAuth = process.env.LOOPENGINE_ADMIN_AUTH
1834
- if (!adminAuth) {
1838
+ // Hosted mode (see loopengine's core/trusted-proxy.ts): every request
1839
+ // must carry an X-LoopEngine-Identity header signed with this secret by
1840
+ // the router in front of this server. Unset (the default, and every
1841
+ // self-hosted install) means this file behaves exactly as it always has.
1842
+ const trustedProxySecret = process.env.LOOPENGINE_TRUSTED_PROXY_SECRET
1843
+ // Routes that write secrets or install code refuse to run on a server
1844
+ // with no auth at all — either form of auth counts.
1845
+ const secretsProtected = Boolean(adminAuth || trustedProxySecret)
1846
+ if (!adminAuth && !trustedProxySecret) {
1835
1847
  console.warn(
1836
1848
  '[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
1849
  )
@@ -1844,7 +1856,12 @@ if (!adminAuth) {
1844
1856
  // first; a length mismatch isn't sensitive information worth spending a
1845
1857
  // constant-time comparison to protect.
1846
1858
  function isAuthorized(req: IncomingMessage): boolean {
1847
- if (!adminAuth) return true
1859
+ if (trustedProxySecret && verifyProxyIdentity(req.headers[TRUSTED_PROXY_IDENTITY_HEADER], trustedProxySecret)) return true
1860
+ // Basic Auth still works alongside trusted-proxy mode (direct operator
1861
+ // access to one workspace) — but with neither form of credentials, a
1862
+ // trusted-proxy server is closed, never open the way a server with no
1863
+ // auth configured at all is.
1864
+ if (!adminAuth) return !trustedProxySecret
1848
1865
  const header = req.headers.authorization
1849
1866
  if (!header || !header.startsWith('Basic ')) return false
1850
1867
  const provided = Buffer.from(header.slice('Basic '.length), 'base64')
@@ -2284,11 +2301,43 @@ async function handleLocalFile(req: IncomingMessage, res: ServerResponse): Promi
2284
2301
  createReadStream(resolved).pipe(res)
2285
2302
  }
2286
2303
 
2304
+ // Opt-in (LOOPENGINE_EXIT_ON_CODE_CHANGE=1): Node caches an imported
2305
+ // module, so a tool file an ability install/upgrade/uninstall just wrote
2306
+ // isn't picked up until the process restarts. Under a supervisor that
2307
+ // restarts it (a hosting platform, Docker's restart policy, systemd),
2308
+ // exiting cleanly right after the change is the simplest way to apply
2309
+ // it. Off by default — a plain `loopengine serve` has nothing to restart
2310
+ // it, so exiting there would just take the server down.
2311
+ const exitOnCodeChange = process.env.LOOPENGINE_EXIT_ON_CODE_CHANGE === '1'
2312
+ function exitIfCodeChanged(res: ServerResponse): void {
2313
+ if (!exitOnCodeChange || res.statusCode >= 300) return
2314
+ console.log('[loopengine] ability code changed — exiting so the supervisor restarts this server with it loaded')
2315
+ // shutdown() drains in-flight requests (this one's response included)
2316
+ // before exiting.
2317
+ setImmediate(() => void shutdown())
2318
+ }
2319
+
2320
+ // Backs GET /internal/activity (trusted-proxy mode only) — what a host
2321
+ // checks before stopping an idle server, so it never stops one mid-turn.
2322
+ let openStreams = 0
2323
+ let lastRequestAt = Date.now()
2324
+
2325
+ function handleActivityGet(res: ServerResponse): void {
2326
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
2327
+ JSON.stringify({
2328
+ inFlightTurns: sessionTurns.size,
2329
+ openStreams,
2330
+ lastRequestAt: new Date(lastRequestAt).toISOString(),
2331
+ idleSeconds: Math.floor((Date.now() - lastRequestAt) / 1000),
2332
+ }),
2333
+ )
2334
+ }
2335
+
2287
2336
  const server = createServer(async (req, res) => {
2288
2337
  if (!isAuthorized(req)) {
2289
- res
2290
- .writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Basic realm="loopengine"' })
2291
- .end(JSON.stringify({ error: 'authorization required' }))
2338
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
2339
+ if (adminAuth) headers['www-authenticate'] = 'Basic realm="loopengine"'
2340
+ res.writeHead(401, headers).end(JSON.stringify({ error: 'authorization required' }))
2292
2341
  return
2293
2342
  }
2294
2343
 
@@ -2308,6 +2357,14 @@ const server = createServer(async (req, res) => {
2308
2357
  // route individually.
2309
2358
  const pathname = (req.url ?? '/').split('?')[0]
2310
2359
 
2360
+ // Only exists in trusted-proxy mode, and polling it doesn't count as
2361
+ // activity itself.
2362
+ if (trustedProxySecret && req.method === 'GET' && pathname === '/internal/activity') {
2363
+ handleActivityGet(res)
2364
+ return
2365
+ }
2366
+ lastRequestAt = Date.now()
2367
+
2311
2368
  const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
2312
2369
  if (streamMatch) {
2313
2370
  await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
@@ -2685,17 +2742,20 @@ const server = createServer(async (req, res) => {
2685
2742
  const abilityUpgradeMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/upgrade$/)
2686
2743
  if (abilityUpgradeMatch && req.method === 'POST') {
2687
2744
  await handleAbilityUpgradePost(req, res, decodeURIComponent(abilityUpgradeMatch[1]), decodeURIComponent(abilityUpgradeMatch[2]))
2745
+ exitIfCodeChanged(res)
2688
2746
  return
2689
2747
  }
2690
2748
  const abilityInstallDepsMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/install-deps$/)
2691
2749
  if (abilityInstallDepsMatch && req.method === 'POST') {
2692
2750
  await handleAbilityInstallDepsPost(req, res, decodeURIComponent(abilityInstallDepsMatch[1]), decodeURIComponent(abilityInstallDepsMatch[2]))
2751
+ exitIfCodeChanged(res)
2693
2752
  return
2694
2753
  }
2695
2754
  const abilityMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)$/)
2696
2755
  if (abilityMatch && req.method === 'DELETE') {
2697
2756
  const force = new URL(req.url ?? '/', 'http://localhost').searchParams.get('force') === 'true'
2698
2757
  await handleAbilityUninstallDelete(res, decodeURIComponent(abilityMatch[1]), decodeURIComponent(abilityMatch[2]), force)
2758
+ exitIfCodeChanged(res)
2699
2759
  return
2700
2760
  }
2701
2761
  const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
@@ -2705,6 +2765,7 @@ const server = createServer(async (req, res) => {
2705
2765
  }
2706
2766
  if (abilitiesMatch && req.method === 'POST') {
2707
2767
  await handleAbilityInstallPost(req, res, decodeURIComponent(abilitiesMatch[1]))
2768
+ exitIfCodeChanged(res)
2708
2769
  return
2709
2770
  }
2710
2771
 
@@ -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": {