create-loopengine 0.1.75 → 0.1.77
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 +325 -20
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -148,6 +148,7 @@ import {
|
|
|
148
148
|
slackClickAllowed,
|
|
149
149
|
agentApprovers,
|
|
150
150
|
setAgentApprovers,
|
|
151
|
+
setAgentPrimaryNotifier,
|
|
151
152
|
listAgentSlackMembers,
|
|
152
153
|
replyPrivatelyInSlack,
|
|
153
154
|
slackArgs,
|
|
@@ -189,6 +190,22 @@ import {
|
|
|
189
190
|
AbilityNotInstalledError,
|
|
190
191
|
searchPublicAbilities,
|
|
191
192
|
fetchLatestAbilityVersion,
|
|
193
|
+
describeAbilitySources,
|
|
194
|
+
listPrivateAbilities,
|
|
195
|
+
removeAbilitySourceFromConsole,
|
|
196
|
+
saveAbilitySourceFromConsole,
|
|
197
|
+
abilityDetails,
|
|
198
|
+
MAX_UPLOAD_BYTES,
|
|
199
|
+
installUploadedAbility,
|
|
200
|
+
installWorkspaceAbility,
|
|
201
|
+
type AbilityUpload,
|
|
202
|
+
WorkspaceAbilityError,
|
|
203
|
+
listWorkspaceAbilities,
|
|
204
|
+
removeWorkspaceAbility,
|
|
205
|
+
AbilitySourceError,
|
|
206
|
+
abilityOrigin,
|
|
207
|
+
readAbilitySources,
|
|
208
|
+
type AbilitySource,
|
|
192
209
|
describeModelProviders,
|
|
193
210
|
loopengineVersion,
|
|
194
211
|
describeUpgrade,
|
|
@@ -196,7 +213,12 @@ import {
|
|
|
196
213
|
UpgradeError,
|
|
197
214
|
setModelProviderKey,
|
|
198
215
|
clearModelProviderKey,
|
|
216
|
+
listProviderModels,
|
|
199
217
|
ModelKeyError,
|
|
218
|
+
describeTimeZone,
|
|
219
|
+
setWorkspaceTimeZone,
|
|
220
|
+
TimeZoneError,
|
|
221
|
+
workspaceTimeZone,
|
|
200
222
|
describeGateways,
|
|
201
223
|
scaffoldAgent,
|
|
202
224
|
AgentNameError,
|
|
@@ -315,6 +337,32 @@ function readRawBody(req: IncomingMessage): Promise<string> {
|
|
|
315
337
|
})
|
|
316
338
|
}
|
|
317
339
|
|
|
340
|
+
/** readJsonBody with a size cap, for uploads: a body past `maxBytes` is
|
|
341
|
+
* refused before it's all read. */
|
|
342
|
+
function readLimitedJsonBody(req: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
|
|
343
|
+
return new Promise((resolve, reject) => {
|
|
344
|
+
const chunks: Buffer[] = []
|
|
345
|
+
let size = 0
|
|
346
|
+
req.on('data', (chunk: Buffer) => {
|
|
347
|
+
size += chunk.length
|
|
348
|
+
if (size > maxBytes) {
|
|
349
|
+
reject(new WorkspaceAbilityError('The upload is larger than 25 MB'))
|
|
350
|
+
req.destroy()
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
chunks.push(chunk)
|
|
354
|
+
})
|
|
355
|
+
req.on('end', () => {
|
|
356
|
+
try {
|
|
357
|
+
resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {})
|
|
358
|
+
} catch (err) {
|
|
359
|
+
reject(err)
|
|
360
|
+
}
|
|
361
|
+
})
|
|
362
|
+
req.on('error', reject)
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
318
366
|
function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
319
367
|
return new Promise((resolve, reject) => {
|
|
320
368
|
let data = ''
|
|
@@ -862,7 +910,9 @@ function parseAgentTemplateOptions(body: Record<string, unknown>): { ok: true; v
|
|
|
862
910
|
provider !== 'deepseek' &&
|
|
863
911
|
provider !== 'kimi' &&
|
|
864
912
|
provider !== 'glm' &&
|
|
865
|
-
provider !== 'gemini'
|
|
913
|
+
provider !== 'gemini' &&
|
|
914
|
+
provider !== 'xai' &&
|
|
915
|
+
provider !== 'meta'
|
|
866
916
|
) {
|
|
867
917
|
return { ok: false, error: `unsupported model provider '${String(provider)}'` }
|
|
868
918
|
}
|
|
@@ -1294,7 +1344,7 @@ function scheduleJson(res: ServerResponse, status: number, body: unknown): void
|
|
|
1294
1344
|
|
|
1295
1345
|
async function handleSchedulesGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1296
1346
|
if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
|
|
1297
|
-
scheduleJson(res, 200, { tasks: await listScheduledTasks({ agent: agentName }) })
|
|
1347
|
+
scheduleJson(res, 200, { tasks: await listScheduledTasks({ agent: agentName }), time_zone: workspaceTimeZone() })
|
|
1298
1348
|
}
|
|
1299
1349
|
|
|
1300
1350
|
async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -1304,7 +1354,7 @@ async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, ag
|
|
|
1304
1354
|
const task = await createScheduledTask({
|
|
1305
1355
|
agent: agentName,
|
|
1306
1356
|
message: String(body.message ?? ''),
|
|
1307
|
-
schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown; start_at?: unknown },
|
|
1357
|
+
schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown; start_at?: unknown; at?: unknown; weekday?: unknown },
|
|
1308
1358
|
task_name: typeof body.task_name === 'string' ? body.task_name : undefined,
|
|
1309
1359
|
notify: body.notify === true,
|
|
1310
1360
|
})
|
|
@@ -1363,7 +1413,8 @@ async function handleNotifierPut(req: IncomingMessage, res: ServerResponse, agen
|
|
|
1363
1413
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1364
1414
|
}
|
|
1365
1415
|
|
|
1366
|
-
|
|
1416
|
+
// `channel` removes just that app's connection; without it, all of them.
|
|
1417
|
+
function handleNotifierDelete(res: ServerResponse, agentName: string, channel?: string): void {
|
|
1367
1418
|
if (!secretsProtected) {
|
|
1368
1419
|
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
1369
1420
|
JSON.stringify({ error: 'Refusing to remove a notifier without LOOPENGINE_ADMIN_AUTH configured.' }),
|
|
@@ -1375,17 +1426,42 @@ function handleNotifierDelete(res: ServerResponse, agentName: string): void {
|
|
|
1375
1426
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1376
1427
|
return
|
|
1377
1428
|
}
|
|
1378
|
-
removeAgentNotifier(entry.config)
|
|
1429
|
+
removeAgentNotifier(entry.config, channel)
|
|
1379
1430
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1380
1431
|
}
|
|
1381
1432
|
|
|
1382
|
-
|
|
1433
|
+
// A body naming a `channel` tests that app's saved connection, primary
|
|
1434
|
+
// or not; without one, whichever notifier applies.
|
|
1435
|
+
async function handleNotifierTest(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1436
|
+
const entry = getEntry(agentName)
|
|
1437
|
+
if (!entry) {
|
|
1438
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1439
|
+
return
|
|
1440
|
+
}
|
|
1441
|
+
const body = await readJsonBody(req).catch(() => ({}) as Record<string, unknown>)
|
|
1442
|
+
const channel = typeof body.channel === 'string' ? body.channel : undefined
|
|
1443
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await testAgentNotifier(entry.config, defaultWebhook, channel)))
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// The Messaging tab's "Make primary": which saved connection is used.
|
|
1447
|
+
async function handleNotifierPrimaryPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1448
|
+
if (!secretsProtected) {
|
|
1449
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Refusing to change the notifier without LOOPENGINE_ADMIN_AUTH configured.' }))
|
|
1450
|
+
return
|
|
1451
|
+
}
|
|
1383
1452
|
const entry = getEntry(agentName)
|
|
1384
1453
|
if (!entry) {
|
|
1385
1454
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1386
1455
|
return
|
|
1387
1456
|
}
|
|
1388
|
-
|
|
1457
|
+
try {
|
|
1458
|
+
setAgentPrimaryNotifier(entry.config, (await readJsonBody(req)).channel)
|
|
1459
|
+
} catch (err) {
|
|
1460
|
+
const status = err instanceof NotifierFileError ? 400 : 500
|
|
1461
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1462
|
+
return
|
|
1463
|
+
}
|
|
1464
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAgentNotifier(entry.config, defaultWebhook), canSave: secretsProtected }))
|
|
1389
1465
|
}
|
|
1390
1466
|
|
|
1391
1467
|
// Backs the console's Slack channel picker. A bot token typed into the form
|
|
@@ -1581,6 +1657,10 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
|
|
|
1581
1657
|
return
|
|
1582
1658
|
}
|
|
1583
1659
|
const installed = listInstalledAbilities(agentName)
|
|
1660
|
+
let sources: AbilitySource[] = []
|
|
1661
|
+
try {
|
|
1662
|
+
sources = readAbilitySources()
|
|
1663
|
+
} catch {}
|
|
1584
1664
|
const abilities = await Promise.all(
|
|
1585
1665
|
installed.map(async (a) => {
|
|
1586
1666
|
// a.dependencies is only ever undefined for a record from before
|
|
@@ -1590,9 +1670,13 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
|
|
|
1590
1670
|
// otherwise permanently hide "Install deps" for an ability that
|
|
1591
1671
|
// genuinely still needs something.
|
|
1592
1672
|
const dependencies = a.dependencies !== undefined ? a.dependencies : ((await backfillDependencies(agentName, a.name)) ?? [])
|
|
1593
|
-
|
|
1673
|
+
// Public or private, for the console's badge. Only a public one is
|
|
1674
|
+
// checked against npm for a newer version: a private ability could
|
|
1675
|
+
// share its name with an unrelated public package.
|
|
1676
|
+
const origin = abilityOrigin(a.spec, a.name, sources)
|
|
1677
|
+
const latestVersion = origin.kind === 'public' ? await fetchLatestAbilityVersion(a.name) : null
|
|
1594
1678
|
const missingDependencies = dependencies.filter((dep) => !hasNodeModule(dep))
|
|
1595
|
-
return { ...a, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
|
|
1679
|
+
return { ...a, origin, dependencies, latestVersion, upToDate: latestVersion !== null && latestVersion === a.version, missingDependencies }
|
|
1596
1680
|
}),
|
|
1597
1681
|
)
|
|
1598
1682
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
|
|
@@ -1782,12 +1866,86 @@ function parseActauthRuleBody(body: Record<string, unknown>, requireName: boolea
|
|
|
1782
1866
|
return { ok: true, value: { name: typeof body.name === 'string' ? body.name : '', scope: body.scope, tool: body.tool, decision: body.decision } }
|
|
1783
1867
|
}
|
|
1784
1868
|
|
|
1785
|
-
|
|
1869
|
+
// The Permissions data. `rules`: every rule, each with the ability it
|
|
1870
|
+
// belongs to (empty for none): the one that added it when installed, else
|
|
1871
|
+
// the one whose tool it's for. Edited in full from the Advanced tab.
|
|
1872
|
+
// `tools`: every tool the agent has (its own, subagents, Composio), each
|
|
1873
|
+
// with its ability and decision: its rule for everyone (*/*), else its
|
|
1874
|
+
// first rule, else the default; `scopeNote` says when that isn't the whole
|
|
1875
|
+
// story, so the Permissions tab doesn't look like it has duplicates.
|
|
1876
|
+
async function actauthConfigWithAbilities(agentName: string) {
|
|
1877
|
+
const config = readActauthConfig(agentName)
|
|
1878
|
+
let installed: Array<{ name: string; tools: string[]; actauthRules: string[] }> = []
|
|
1879
|
+
try {
|
|
1880
|
+
installed = listInstalledAbilities(agentName)
|
|
1881
|
+
} catch {}
|
|
1882
|
+
const abilityFor = (tool: string, ruleName?: string) =>
|
|
1883
|
+
(installed.find((a) => ruleName && a.actauthRules.includes(ruleName)) ?? installed.find((a) => a.tools.includes(tool)))?.name ?? null
|
|
1884
|
+
const rules = config.rules.map((rule) => ({ ...rule, ability: abilityFor(rule.tool, rule.name) }))
|
|
1885
|
+
|
|
1886
|
+
const entry = getEntry(agentName)
|
|
1887
|
+
let toolNames: string[] = []
|
|
1888
|
+
if (entry) {
|
|
1889
|
+
try {
|
|
1890
|
+
const own = entry.config.tools ?? (await loadDefaultTools(entry.config))
|
|
1891
|
+
const all = [...own, ...(await loadSubagentAsTools(entry.config)), ...(await loadGatewayToolsFromDir(agentDir(agentName)))]
|
|
1892
|
+
toolNames = [...new Set(all.map((t) => t.name))]
|
|
1893
|
+
} catch {}
|
|
1894
|
+
}
|
|
1895
|
+
const tools = toolNames.map((tool) => {
|
|
1896
|
+
const forTool = rules.filter((r) => r.tool === tool)
|
|
1897
|
+
const rule = forTool.find((r) => r.scope === '*/*') ?? forTool[0]
|
|
1898
|
+
const others = forTool.length - (rule ? 1 : 0)
|
|
1899
|
+
const scopeNote = rule && rule.scope !== '*/*' ? `${rule.scope} only` : others ? `+${others} scoped ${others === 1 ? 'rule' : 'rules'} in Advanced` : null
|
|
1900
|
+
return {
|
|
1901
|
+
tool,
|
|
1902
|
+
ability: rule?.ability ?? abilityFor(tool),
|
|
1903
|
+
decision: rule?.decision ?? config.defaultDecision,
|
|
1904
|
+
isDefault: !rule,
|
|
1905
|
+
// A rule with no name can't be changed here (rules are changed by name).
|
|
1906
|
+
editable: !rule || Boolean(rule.name),
|
|
1907
|
+
scopeNote,
|
|
1908
|
+
}
|
|
1909
|
+
})
|
|
1910
|
+
return { ...config, rules, tools }
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// The Permissions tab's switch for one tool: updates its rule for everyone
|
|
1914
|
+
// (*/*), or adds one (tool-<name>) when it has none.
|
|
1915
|
+
async function handleActauthToolPut(req: IncomingMessage, res: ServerResponse, agentName: string, tool: string): Promise<void> {
|
|
1786
1916
|
if (!getEntry(agentName)) {
|
|
1787
1917
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1788
1918
|
return
|
|
1789
1919
|
}
|
|
1790
|
-
|
|
1920
|
+
const body = await readJsonBody(req)
|
|
1921
|
+
if (!isDecision(body.decision)) {
|
|
1922
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: "decision must be 'allow', 'ask', or 'deny'" }))
|
|
1923
|
+
return
|
|
1924
|
+
}
|
|
1925
|
+
const { rules } = readActauthConfig(agentName)
|
|
1926
|
+
const existing = rules.find((r) => r.tool === tool && r.scope === '*/*' && r.name)
|
|
1927
|
+
try {
|
|
1928
|
+
if (existing) {
|
|
1929
|
+
updateActauthRule(agentName, existing.name!, { scope: existing.scope, tool, decision: body.decision })
|
|
1930
|
+
} else {
|
|
1931
|
+
const base = `tool-${tool}`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').slice(0, 80)
|
|
1932
|
+
let name = base
|
|
1933
|
+
for (let i = 2; rules.some((r) => r.name === name); i++) name = `${base}-${i}`
|
|
1934
|
+
addActauthRule(agentName, { name, scope: '*/*', tool, decision: body.decision })
|
|
1935
|
+
}
|
|
1936
|
+
} catch (err) {
|
|
1937
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1938
|
+
return
|
|
1939
|
+
}
|
|
1940
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
async function handleActauthGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1944
|
+
if (!getEntry(agentName)) {
|
|
1945
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1946
|
+
return
|
|
1947
|
+
}
|
|
1948
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
|
|
1791
1949
|
}
|
|
1792
1950
|
|
|
1793
1951
|
async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -1808,7 +1966,7 @@ async function handleActauthRulePost(req: IncomingMessage, res: ServerResponse,
|
|
|
1808
1966
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1809
1967
|
return
|
|
1810
1968
|
}
|
|
1811
|
-
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(
|
|
1969
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
|
|
1812
1970
|
}
|
|
1813
1971
|
|
|
1814
1972
|
async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
|
|
@@ -1829,10 +1987,10 @@ async function handleActauthRulePut(req: IncomingMessage, res: ServerResponse, a
|
|
|
1829
1987
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1830
1988
|
return
|
|
1831
1989
|
}
|
|
1832
|
-
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(
|
|
1990
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
|
|
1833
1991
|
}
|
|
1834
1992
|
|
|
1835
|
-
function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): void {
|
|
1993
|
+
async function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleName: string): Promise<void> {
|
|
1836
1994
|
if (!getEntry(agentName)) {
|
|
1837
1995
|
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: `unknown agent '${agentName}'` }))
|
|
1838
1996
|
return
|
|
@@ -1844,7 +2002,7 @@ function handleActauthRuleDelete(res: ServerResponse, agentName: string, ruleNam
|
|
|
1844
2002
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
1845
2003
|
return
|
|
1846
2004
|
}
|
|
1847
|
-
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(
|
|
2005
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
|
|
1848
2006
|
}
|
|
1849
2007
|
|
|
1850
2008
|
async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
@@ -1858,7 +2016,7 @@ async function handleActauthDefaultDecisionPut(req: IncomingMessage, res: Server
|
|
|
1858
2016
|
return
|
|
1859
2017
|
}
|
|
1860
2018
|
setDefaultDecision(agentName, body.decision)
|
|
1861
|
-
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(
|
|
2019
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await actauthConfigWithAbilities(agentName)))
|
|
1862
2020
|
}
|
|
1863
2021
|
|
|
1864
2022
|
// GET /agents content-negotiates on this: a browser navigating there sends
|
|
@@ -3374,6 +3532,7 @@ const server = createServer(async (req, res) => {
|
|
|
3374
3532
|
environment: process.env.LOOPENGINE_ENV ?? 'production',
|
|
3375
3533
|
publicUrl: process.env.LOOPENGINE_PUBLIC_URL ?? null,
|
|
3376
3534
|
scheduler: process.env.LOOPENGINE_SCHEDULER === 'off' ? 'off' : 'on',
|
|
3535
|
+
timeZone: workspaceTimeZone(),
|
|
3377
3536
|
}),
|
|
3378
3537
|
)
|
|
3379
3538
|
return
|
|
@@ -3411,6 +3570,19 @@ const server = createServer(async (req, res) => {
|
|
|
3411
3570
|
)
|
|
3412
3571
|
return
|
|
3413
3572
|
}
|
|
3573
|
+
// The model dropdown in the New agent and Model forms: what the
|
|
3574
|
+
// provider lists for the saved key. Never includes the key itself.
|
|
3575
|
+
const providerModelsMatch = req.method === 'GET' && pathname.match(/^\/config\/models\/([a-z]+)\/models$/)
|
|
3576
|
+
if (providerModelsMatch) {
|
|
3577
|
+
try {
|
|
3578
|
+
const view = await listProviderModels(providerModelsMatch[1])
|
|
3579
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(view))
|
|
3580
|
+
} catch (err) {
|
|
3581
|
+
const status = err instanceof ModelKeyError ? 400 : 500
|
|
3582
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
3583
|
+
}
|
|
3584
|
+
return
|
|
3585
|
+
}
|
|
3414
3586
|
// A provider's API key, from the Models page: write-only, never read
|
|
3415
3587
|
// back. Same rule as the Environment tab: refused on a server with no
|
|
3416
3588
|
// auth, since anyone who could reach it could replace the key.
|
|
@@ -3433,6 +3605,29 @@ const server = createServer(async (req, res) => {
|
|
|
3433
3605
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true }))
|
|
3434
3606
|
return
|
|
3435
3607
|
}
|
|
3608
|
+
// Settings → Time zone (web/global-config.ts): what "9:00" means in
|
|
3609
|
+
// schedules. Saved like a model key, so refused without auth too.
|
|
3610
|
+
if (req.method === 'GET' && pathname === '/config/time-zone') {
|
|
3611
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ ...describeTimeZone(), canSave: secretsProtected }))
|
|
3612
|
+
return
|
|
3613
|
+
}
|
|
3614
|
+
if (req.method === 'PUT' && pathname === '/config/time-zone') {
|
|
3615
|
+
if (!secretsProtected) {
|
|
3616
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(
|
|
3617
|
+
JSON.stringify({ error: 'Refusing to change settings without LOOPENGINE_ADMIN_AUTH configured: set it first, or put LOOPENGINE_TIME_ZONE in .env.' }),
|
|
3618
|
+
)
|
|
3619
|
+
return
|
|
3620
|
+
}
|
|
3621
|
+
try {
|
|
3622
|
+
const body = await readJsonBody(req)
|
|
3623
|
+
const outcome = await setWorkspaceTimeZone(body.timeZone, { moveSchedules: body.moveSchedules === true })
|
|
3624
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, ...outcome }))
|
|
3625
|
+
} catch (err) {
|
|
3626
|
+
const status = err instanceof TimeZoneError ? 400 : 500
|
|
3627
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
3628
|
+
}
|
|
3629
|
+
return
|
|
3630
|
+
}
|
|
3436
3631
|
if (req.method === 'GET' && pathname === '/config/gateways') {
|
|
3437
3632
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await describeGateways()))
|
|
3438
3633
|
return
|
|
@@ -3580,12 +3775,17 @@ const server = createServer(async (req, res) => {
|
|
|
3580
3775
|
return
|
|
3581
3776
|
}
|
|
3582
3777
|
if (notifierMatch && req.method === 'DELETE') {
|
|
3583
|
-
handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]))
|
|
3778
|
+
handleNotifierDelete(res, decodeURIComponent(notifierMatch[1]), new URL(req.url ?? '/', 'http://localhost').searchParams.get('channel') ?? undefined)
|
|
3584
3779
|
return
|
|
3585
3780
|
}
|
|
3586
3781
|
const notifierTestMatch = req.method === 'POST' && pathname.match(/^\/agents\/([^/]+)\/notifier\/test$/)
|
|
3587
3782
|
if (notifierTestMatch) {
|
|
3588
|
-
await handleNotifierTest(res, decodeURIComponent(notifierTestMatch[1]))
|
|
3783
|
+
await handleNotifierTest(req, res, decodeURIComponent(notifierTestMatch[1]))
|
|
3784
|
+
return
|
|
3785
|
+
}
|
|
3786
|
+
const notifierPrimaryMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/notifier\/primary$/)
|
|
3787
|
+
if (notifierPrimaryMatch) {
|
|
3788
|
+
await handleNotifierPrimaryPut(req, res, decodeURIComponent(notifierPrimaryMatch[1]))
|
|
3589
3789
|
return
|
|
3590
3790
|
}
|
|
3591
3791
|
// Backs the console's Schedules tab — see handleSchedulesGet above.
|
|
@@ -3655,6 +3855,105 @@ const server = createServer(async (req, res) => {
|
|
|
3655
3855
|
// itself need to be excluded from matching as an agent name (it
|
|
3656
3856
|
// never would be one in practice, but this ordering means never
|
|
3657
3857
|
// having to think about it).
|
|
3858
|
+
// Private ability sources (Settings) and what they offer (the
|
|
3859
|
+
// Abilities page's Private section): web/ability-sources-admin.ts.
|
|
3860
|
+
// Saving or removing one stores a token, so it's refused without auth,
|
|
3861
|
+
// like a model key.
|
|
3862
|
+
if (pathname === '/config/ability-sources' && req.method === 'GET') {
|
|
3863
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ ...describeAbilitySources(), canSave: secretsProtected }))
|
|
3864
|
+
return
|
|
3865
|
+
}
|
|
3866
|
+
const abilitySourceMatch = pathname.match(/^\/config\/ability-sources(?:\/([a-z0-9-]+))?$/)
|
|
3867
|
+
if (abilitySourceMatch && (req.method === 'PUT' || req.method === 'DELETE')) {
|
|
3868
|
+
if (!secretsProtected) {
|
|
3869
|
+
res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Refusing to change ability sources without LOOPENGINE_ADMIN_AUTH configured: set it first, or edit ability-sources.yml and .env.' }))
|
|
3870
|
+
return
|
|
3871
|
+
}
|
|
3872
|
+
try {
|
|
3873
|
+
if (req.method === 'PUT') await saveAbilitySourceFromConsole(await readJsonBody(req))
|
|
3874
|
+
else if (abilitySourceMatch[1]) await removeAbilitySourceFromConsole(abilitySourceMatch[1])
|
|
3875
|
+
else throw new AbilitySourceError('Name the source to remove')
|
|
3876
|
+
} catch (err) {
|
|
3877
|
+
const status = err instanceof AbilitySourceError ? 400 : 500
|
|
3878
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
3879
|
+
return
|
|
3880
|
+
}
|
|
3881
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ...describeAbilitySources(), canSave: secretsProtected }))
|
|
3882
|
+
return
|
|
3883
|
+
}
|
|
3884
|
+
// What an ability can do: its tools (what each does, its permission)
|
|
3885
|
+
// and skills. `agent` adds that agent's current decisions.
|
|
3886
|
+
if (pathname === '/abilities/details' && req.method === 'GET') {
|
|
3887
|
+
const params = new URL(req.url ?? '/', 'http://localhost').searchParams
|
|
3888
|
+
const spec = params.get('spec')?.trim()
|
|
3889
|
+
const agent = params.get('agent') ?? undefined
|
|
3890
|
+
if (!spec || (agent && !getEntry(agent))) {
|
|
3891
|
+
res.writeHead(400, { 'content-type': 'application/json' }).end(JSON.stringify({ error: spec ? `unknown agent '${agent}'` : 'spec is required' }))
|
|
3892
|
+
return
|
|
3893
|
+
}
|
|
3894
|
+
try {
|
|
3895
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(await abilityDetails(spec, { agent })))
|
|
3896
|
+
} catch (err) {
|
|
3897
|
+
res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
3898
|
+
}
|
|
3899
|
+
return
|
|
3900
|
+
}
|
|
3901
|
+
// The workspace's private abilities (core/workspace-abilities.ts):
|
|
3902
|
+
// installed from the Abilities page, added to agents from their tab.
|
|
3903
|
+
if (pathname === '/abilities/workspace' && req.method === 'GET') {
|
|
3904
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
|
|
3905
|
+
return
|
|
3906
|
+
}
|
|
3907
|
+
if (pathname === '/abilities/workspace' && (req.method === 'POST' || req.method === 'DELETE')) {
|
|
3908
|
+
try {
|
|
3909
|
+
if (req.method === 'POST') {
|
|
3910
|
+
const spec = (await readJsonBody(req)).spec
|
|
3911
|
+
if (typeof spec !== 'string' || !spec.trim()) throw new WorkspaceAbilityError('spec is required')
|
|
3912
|
+
await installWorkspaceAbility(spec.trim())
|
|
3913
|
+
} else {
|
|
3914
|
+
removeWorkspaceAbility(new URL(req.url ?? '/', 'http://localhost').searchParams.get('name') ?? '')
|
|
3915
|
+
}
|
|
3916
|
+
} catch (err) {
|
|
3917
|
+
const status = err instanceof WorkspaceAbilityError ? 400 : 502
|
|
3918
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
3919
|
+
return
|
|
3920
|
+
}
|
|
3921
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
|
|
3922
|
+
return
|
|
3923
|
+
}
|
|
3924
|
+
// An ability uploaded from the browser (a .zip/.tgz, or a folder's
|
|
3925
|
+
// files, base64), stored in the workspace like one from a source.
|
|
3926
|
+
if (pathname === '/abilities/workspace/upload' && req.method === 'POST') {
|
|
3927
|
+
try {
|
|
3928
|
+
// Base64 adds a third, plus the JSON around it.
|
|
3929
|
+
const body = await readLimitedJsonBody(req, Math.ceil(MAX_UPLOAD_BYTES * 1.4) + 64 * 1024)
|
|
3930
|
+
const upload: AbilityUpload =
|
|
3931
|
+
body.kind === 'files' && Array.isArray(body.files)
|
|
3932
|
+
? { kind: 'files', folderName: typeof body.folderName === 'string' ? body.folderName : undefined, files: (body.files as Array<{ path?: unknown; data?: unknown }>).map((f) => ({ path: String(f?.path ?? ''), data: String(f?.data ?? '') })) }
|
|
3933
|
+
: { kind: 'archive', fileName: String(body.fileName ?? ''), data: String(body.data ?? '') }
|
|
3934
|
+
await installUploadedAbility(upload)
|
|
3935
|
+
} catch (err) {
|
|
3936
|
+
const status = err instanceof WorkspaceAbilityError ? 400 : 500
|
|
3937
|
+
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
3938
|
+
return
|
|
3939
|
+
}
|
|
3940
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
|
|
3941
|
+
return
|
|
3942
|
+
}
|
|
3943
|
+
// Public abilities on npm, for the Abilities page (no agent involved).
|
|
3944
|
+
if (pathname === '/abilities/search' && req.method === 'GET') {
|
|
3945
|
+
try {
|
|
3946
|
+
const results = await searchPublicAbilities(new URL(req.url ?? '/', 'http://localhost').searchParams.get('q') ?? undefined)
|
|
3947
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ results }))
|
|
3948
|
+
} catch (err) {
|
|
3949
|
+
res.writeHead(502, { 'content-type': 'application/json' }).end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }))
|
|
3950
|
+
}
|
|
3951
|
+
return
|
|
3952
|
+
}
|
|
3953
|
+
if (pathname === '/abilities/private' && req.method === 'GET') {
|
|
3954
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(await listPrivateAbilities()))
|
|
3955
|
+
return
|
|
3956
|
+
}
|
|
3658
3957
|
const abilitiesSearchMatch = pathname.match(/^\/agents\/([^/]+)\/abilities\/search$/)
|
|
3659
3958
|
if (abilitiesSearchMatch && req.method === 'GET') {
|
|
3660
3959
|
const query = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')
|
|
@@ -3701,13 +4000,19 @@ const server = createServer(async (req, res) => {
|
|
|
3701
4000
|
return
|
|
3702
4001
|
}
|
|
3703
4002
|
|
|
4003
|
+
const actauthToolMatch = req.method === 'PUT' && pathname.match(/^\/agents\/([^/]+)\/actauth\/tools\/([^/]+)$/)
|
|
4004
|
+
if (actauthToolMatch) {
|
|
4005
|
+
await handleActauthToolPut(req, res, decodeURIComponent(actauthToolMatch[1]), decodeURIComponent(actauthToolMatch[2]))
|
|
4006
|
+
return
|
|
4007
|
+
}
|
|
4008
|
+
|
|
3704
4009
|
const actauthRuleMatch = pathname.match(/^\/agents\/([^/]+)\/actauth\/rules\/([^/]+)$/)
|
|
3705
4010
|
if (actauthRuleMatch && req.method === 'PUT') {
|
|
3706
4011
|
await handleActauthRulePut(req, res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
|
|
3707
4012
|
return
|
|
3708
4013
|
}
|
|
3709
4014
|
if (actauthRuleMatch && req.method === 'DELETE') {
|
|
3710
|
-
handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
|
|
4015
|
+
await handleActauthRuleDelete(res, decodeURIComponent(actauthRuleMatch[1]), decodeURIComponent(actauthRuleMatch[2]))
|
|
3711
4016
|
return
|
|
3712
4017
|
}
|
|
3713
4018
|
|
|
@@ -3719,7 +4024,7 @@ const server = createServer(async (req, res) => {
|
|
|
3719
4024
|
|
|
3720
4025
|
const actauthMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/actauth$/)
|
|
3721
4026
|
if (actauthMatch) {
|
|
3722
|
-
handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
|
|
4027
|
+
await handleActauthGet(res, decodeURIComponent(actauthMatch[1]))
|
|
3723
4028
|
return
|
|
3724
4029
|
}
|
|
3725
4030
|
|