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 +1 -1
- package/template/adapters/http.ts +117 -17
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -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 (!
|
|
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 =
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
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
|
|