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 +1 -1
- package/template/adapters/http.ts +80 -15
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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
|
|