create-loopengine 0.1.48 → 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.48",
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",
@@ -94,6 +94,12 @@ import {
94
94
  listDeclaredEnvVars,
95
95
  setEnvVar,
96
96
  unsetEnvVar,
97
+ setAgentEnvVar,
98
+ unsetAgentEnvVar,
99
+ createAgentEnv,
100
+ agentDirFor,
101
+ verifyProxyIdentity,
102
+ TRUSTED_PROXY_IDENTITY_HEADER,
97
103
  EnvVarNameError,
98
104
  readActauthConfig,
99
105
  addActauthRule,
@@ -1024,6 +1030,14 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
1024
1030
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(listDeclaredEnvVars(agentName)))
1025
1031
  }
1026
1032
 
1033
+ // `?slot=agent` targets this agent's own agents/<name>/.env (a per-agent
1034
+ // value, read by its tools through ToolContext.env) instead of the
1035
+ // project-wide .env — see loopengine's DeclaredEnvVar.slot.
1036
+ function envSlotOf(req: IncomingMessage): 'shared' | 'agent' | undefined {
1037
+ const slot = new URL(req.url ?? '/', 'http://localhost').searchParams.get('slot') ?? 'shared'
1038
+ return slot === 'shared' || slot === 'agent' ? slot : undefined
1039
+ }
1040
+
1027
1041
  // Writes a raw secret value into .env — meaningfully more sensitive than
1028
1042
  // the rest of the admin surface (config/business data vs. an actual
1029
1043
  // credential), so unlike every other route here, this one refuses
@@ -1033,7 +1047,7 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
1033
1047
  // Admin UI" — LOOPENGINE_ADMIN_AUTH being optional today is an
1034
1048
  // acceptable gap for config, not for secrets).
1035
1049
  async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
1036
- if (!adminAuth) {
1050
+ if (!secretsProtected) {
1037
1051
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1038
1052
  JSON.stringify({ error: 'Refusing to set an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
1039
1053
  )
@@ -1043,16 +1057,31 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1043
1057
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1044
1058
  return
1045
1059
  }
1060
+ const slot = envSlotOf(req)
1061
+ if (!slot) {
1062
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "slot must be 'shared' or 'agent'" }))
1063
+ return
1064
+ }
1046
1065
  const body = await readJsonBody(req)
1047
1066
  if (typeof body.value !== 'string') {
1048
1067
  res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'value is required' }))
1049
1068
  return
1050
1069
  }
1070
+ const declaredRows = listDeclaredEnvVars(agentName).filter((v) => v.name === varName)
1071
+ // A shared value for an agent-scoped var would never be read
1072
+ // (loopengine's createAgentEnv never falls back for one) — refuse it
1073
+ // rather than accept a write that silently does nothing.
1074
+ if (slot === 'shared' && declaredRows.some((v) => v.scope === 'agent')) {
1075
+ res.writeHead(400, { 'content-type': 'application/json' }).end(
1076
+ JSON.stringify({ error: `${varName} is declared scope 'agent' — set it per agent (slot=agent), not project-wide.` }),
1077
+ )
1078
+ return
1079
+ }
1051
1080
  // A dropdown-backed var (AbilityEnvDecl.options) is still writable
1052
1081
  // through this same raw route — worth rejecting an out-of-set value
1053
1082
  // here too, not just skipping validation because the Admin UI's own
1054
1083
  // <select> would never have produced one.
1055
- const declared = listDeclaredEnvVars(agentName).find((v) => v.name === varName)
1084
+ const declared = declaredRows.find((v) => v.slot === slot) ?? declaredRows[0]
1056
1085
  if (declared?.options && !declared.options.includes(body.value)) {
1057
1086
  res.writeHead(400, { 'content-type': 'application/json' }).end(
1058
1087
  JSON.stringify({ error: `"${body.value}" isn't one of this var's allowed values: ${declared.options.join(', ')}` }),
@@ -1060,7 +1089,8 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1060
1089
  return
1061
1090
  }
1062
1091
  try {
1063
- setEnvVar(varName, body.value)
1092
+ if (slot === 'agent') await setAgentEnvVar(agentName, varName, body.value)
1093
+ else await setEnvVar(varName, body.value)
1064
1094
  } catch (err) {
1065
1095
  const status = err instanceof EnvVarNameError ? 400 : 500
1066
1096
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
@@ -1073,8 +1103,8 @@ async function handleEnvPut(req: IncomingMessage, res: ServerResponse, agentName
1073
1103
  // removing a secret from .env is still a secrets-management action,
1074
1104
  // not just config, so this refuses outright rather than proceeding
1075
1105
  // open when LOOPENGINE_ADMIN_AUTH isn't configured at all.
1076
- async function handleEnvDelete(res: ServerResponse, agentName: string, varName: string): Promise<void> {
1077
- if (!adminAuth) {
1106
+ async function handleEnvDelete(req: IncomingMessage, res: ServerResponse, agentName: string, varName: string): Promise<void> {
1107
+ if (!secretsProtected) {
1078
1108
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1079
1109
  JSON.stringify({ error: 'Refusing to unset an env var without LOOPENGINE_ADMIN_AUTH configured — set it before managing secrets through this route.' }),
1080
1110
  )
@@ -1084,8 +1114,14 @@ async function handleEnvDelete(res: ServerResponse, agentName: string, varName:
1084
1114
  res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
1085
1115
  return
1086
1116
  }
1117
+ const slot = envSlotOf(req)
1118
+ if (!slot) {
1119
+ res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "slot must be 'shared' or 'agent'" }))
1120
+ return
1121
+ }
1087
1122
  try {
1088
- unsetEnvVar(varName)
1123
+ if (slot === 'agent') await unsetAgentEnvVar(agentName, varName)
1124
+ else await unsetEnvVar(varName)
1089
1125
  } catch (err) {
1090
1126
  const status = err instanceof EnvVarNameError ? 400 : 500
1091
1127
  res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
@@ -1168,7 +1204,7 @@ async function handleAbilitiesSearchGet(res: ServerResponse, agentName: string,
1168
1204
  // LOOPENGINE_ADMIN_AUTH isn't configured at all, rather than proceeding
1169
1205
  // open like most routes here do.
1170
1206
  async function handleAbilityInstallPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
1171
- if (!adminAuth) {
1207
+ if (!secretsProtected) {
1172
1208
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1173
1209
  JSON.stringify({ error: 'Refusing to install an ability without LOOPENGINE_ADMIN_AUTH configured — set it before installing abilities through this route.' }),
1174
1210
  )
@@ -1203,7 +1239,7 @@ async function handleAbilityInstallPost(req: IncomingMessage, res: ServerRespons
1203
1239
  // third-party code inside the agent's project the next time it's
1204
1240
  // loaded, same as install does.
1205
1241
  async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
1206
- if (!adminAuth) {
1242
+ if (!secretsProtected) {
1207
1243
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1208
1244
  JSON.stringify({ error: 'Refusing to upgrade an ability without LOOPENGINE_ADMIN_AUTH configured — set it before upgrading abilities through this route.' }),
1209
1245
  )
@@ -1238,7 +1274,7 @@ async function handleAbilityUpgradePost(req: IncomingMessage, res: ServerRespons
1238
1274
  // behavior, is a normal partial outcome the Admin UI shows and offers
1239
1275
  // to retry with force, not a failure).
1240
1276
  async function handleAbilityUninstallDelete(res: ServerResponse, agentName: string, abilityName: string, force: boolean): Promise<void> {
1241
- if (!adminAuth) {
1277
+ if (!secretsProtected) {
1242
1278
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1243
1279
  JSON.stringify({ error: 'Refusing to uninstall an ability without LOOPENGINE_ADMIN_AUTH configured — set it before uninstalling abilities through this route.' }),
1244
1280
  )
@@ -1280,7 +1316,7 @@ const NPM_INSTALL_DEPS_TIMEOUT_MS = 180_000
1280
1316
  // package.json happened to declare, not something to trust for shell
1281
1317
  // interpolation.
1282
1318
  async function handleAbilityInstallDepsPost(req: IncomingMessage, res: ServerResponse, agentName: string, abilityName: string): Promise<void> {
1283
- if (!adminAuth) {
1319
+ if (!secretsProtected) {
1284
1320
  res.writeHead(403, { 'content-type': 'application/json' }).end(
1285
1321
  JSON.stringify({ error: 'Refusing to run npm install without LOOPENGINE_ADMIN_AUTH configured — set it before installing dependencies through this route.' }),
1286
1322
  )
@@ -1694,6 +1730,8 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1694
1730
  }
1695
1731
  const { entry, message, rawSessionId, storageSessionId, tenant } = parsed.value
1696
1732
 
1733
+ openStreams++
1734
+ res.on('close', () => openStreams--)
1697
1735
  res.writeHead(200, {
1698
1736
  'content-type': 'text/event-stream',
1699
1737
  'cache-control': 'no-cache',
@@ -1797,7 +1835,15 @@ async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, a
1797
1835
  // default. A real deployment opts in by setting it; either way, a
1798
1836
  // startup warning makes "I forgot to set this" loud instead of silent.
1799
1837
  const adminAuth = process.env.LOOPENGINE_ADMIN_AUTH
1800
- 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) {
1801
1847
  console.warn(
1802
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.',
1803
1849
  )
@@ -1810,7 +1856,12 @@ if (!adminAuth) {
1810
1856
  // first; a length mismatch isn't sensitive information worth spending a
1811
1857
  // constant-time comparison to protect.
1812
1858
  function isAuthorized(req: IncomingMessage): boolean {
1813
- 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
1814
1865
  const header = req.headers.authorization
1815
1866
  if (!header || !header.startsWith('Basic ')) return false
1816
1867
  const provided = Buffer.from(header.slice('Basic '.length), 'base64')
@@ -1927,7 +1978,12 @@ async function handlePendingApprovalResolve(req: IncomingMessage, res: ServerRes
1927
1978
  return { checkpoint, result: { kind: 'validation-error', error: validationError } }
1928
1979
  }
1929
1980
  try {
1930
- const output = await tool.execute(editedArgs ?? item.args)
1981
+ const output = await tool.execute(editedArgs ?? item.args, {
1982
+ agentName: entry.config.name,
1983
+ tenant: checkpoint.tenant,
1984
+ sessionId: checkpoint.sessionId,
1985
+ env: createAgentEnv(entry.config.name, agentDirFor(entry.config)),
1986
+ })
1931
1987
  resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: JSON.stringify(output), is_error: false }
1932
1988
  } catch (err) {
1933
1989
  resultBlock = { type: 'tool_result', tool_use_id: item.toolUseId, content: `ERROR: ${err}`, is_error: true }
@@ -2245,11 +2301,43 @@ async function handleLocalFile(req: IncomingMessage, res: ServerResponse): Promi
2245
2301
  createReadStream(resolved).pipe(res)
2246
2302
  }
2247
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
+
2248
2336
  const server = createServer(async (req, res) => {
2249
2337
  if (!isAuthorized(req)) {
2250
- res
2251
- .writeHead(401, { 'content-type': 'application/json', 'www-authenticate': 'Basic realm="loopengine"' })
2252
- .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' }))
2253
2341
  return
2254
2342
  }
2255
2343
 
@@ -2269,6 +2357,14 @@ const server = createServer(async (req, res) => {
2269
2357
  // route individually.
2270
2358
  const pathname = (req.url ?? '/').split('?')[0]
2271
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
+
2272
2368
  const streamMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/messages\/stream$/)
2273
2369
  if (streamMatch) {
2274
2370
  await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
@@ -2626,7 +2722,7 @@ const server = createServer(async (req, res) => {
2626
2722
  return
2627
2723
  }
2628
2724
  if (envVarMatch && req.method === 'DELETE') {
2629
- await handleEnvDelete(res, decodeURIComponent(envVarMatch[1]), decodeURIComponent(envVarMatch[2]))
2725
+ await handleEnvDelete(req, res, decodeURIComponent(envVarMatch[1]), decodeURIComponent(envVarMatch[2]))
2630
2726
  return
2631
2727
  }
2632
2728
 
@@ -2646,17 +2742,20 @@ const server = createServer(async (req, res) => {
2646
2742
  const abilityUpgradeMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/upgrade$/)
2647
2743
  if (abilityUpgradeMatch && req.method === 'POST') {
2648
2744
  await handleAbilityUpgradePost(req, res, decodeURIComponent(abilityUpgradeMatch[1]), decodeURIComponent(abilityUpgradeMatch[2]))
2745
+ exitIfCodeChanged(res)
2649
2746
  return
2650
2747
  }
2651
2748
  const abilityInstallDepsMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)\/install-deps$/)
2652
2749
  if (abilityInstallDepsMatch && req.method === 'POST') {
2653
2750
  await handleAbilityInstallDepsPost(req, res, decodeURIComponent(abilityInstallDepsMatch[1]), decodeURIComponent(abilityInstallDepsMatch[2]))
2751
+ exitIfCodeChanged(res)
2654
2752
  return
2655
2753
  }
2656
2754
  const abilityMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/([^/]+)$/)
2657
2755
  if (abilityMatch && req.method === 'DELETE') {
2658
2756
  const force = new URL(req.url ?? '/', 'http://localhost').searchParams.get('force') === 'true'
2659
2757
  await handleAbilityUninstallDelete(res, decodeURIComponent(abilityMatch[1]), decodeURIComponent(abilityMatch[2]), force)
2758
+ exitIfCodeChanged(res)
2660
2759
  return
2661
2760
  }
2662
2761
  const abilitiesMatch = pathname.match(/^\/agents\/([^/]+)\/abilities$/)
@@ -2666,6 +2765,7 @@ const server = createServer(async (req, res) => {
2666
2765
  }
2667
2766
  if (abilitiesMatch && req.method === 'POST') {
2668
2767
  await handleAbilityInstallPost(req, res, decodeURIComponent(abilitiesMatch[1]))
2768
+ exitIfCodeChanged(res)
2669
2769
  return
2670
2770
  }
2671
2771
 
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.23",
12
+ "loopengine": "^0.1.63",
13
13
  "actauth": "^0.0.12"
14
14
  },
15
15
  "devDependencies": {