loopat 0.1.37 → 0.1.39
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/server/src/a2a.ts +319 -0
- package/server/src/config.ts +32 -0
- package/server/src/index.ts +113 -4
- package/server/src/mcp-oauth.ts +45 -0
- package/server/src/session.ts +12 -1
- package/web/dist/assets/{Editor-COLw6QZY.js → Editor-D4hrkS4P.js} +1 -1
- package/web/dist/assets/{Markdown-Cd5ypa4q.js → Markdown-DBk-rC15.js} +1 -1
- package/web/dist/assets/{MilkdownEditor-CcuVQjWd.js → MilkdownEditor-i3ypZ0pY.js} +1 -1
- package/web/dist/assets/{index-DpqDiByg.js → index-CaZDV18H.js} +63 -63
- package/web/dist/assets/{index-cx-m2Ft5.css → index-DcxhfwnO.css} +1 -1
- package/web/dist/index.html +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A (Agent-to-Agent) adapter — in-process, standard A2A (Google's Agent2Agent).
|
|
3
|
+
*
|
|
4
|
+
* Each loopat account is its own A2A agent:
|
|
5
|
+
* - GET /a2a/<user>/agent-card.json → that user's Agent Card
|
|
6
|
+
* - POST /a2a/<user> → JSON-RPC (message/send | message/stream)
|
|
7
|
+
*
|
|
8
|
+
* Auth is per-user: the caller presents that user's loopat API token
|
|
9
|
+
* (`Authorization: Bearer la_…`). We validate the token resolves to <user> and
|
|
10
|
+
* forward it to loopat's own `/api/v1` over loopback — which already does
|
|
11
|
+
* per-token-user auth, loop creation, turn execution, and SSE — so this adapter
|
|
12
|
+
* is a thin protocol translator (A2A ↔ /api/v1 SSE) that can't break web or
|
|
13
|
+
* /api/v1. The A2A `contextId` (conversation) maps to a loopat loop.
|
|
14
|
+
*/
|
|
15
|
+
import { Hono, type Context } from "hono"
|
|
16
|
+
import { streamSSE } from "hono/streaming"
|
|
17
|
+
import { randomBytes } from "node:crypto"
|
|
18
|
+
import { resolveApiToken } from "./api-tokens"
|
|
19
|
+
import { loadA2AConfig, type A2AUserConfig } from "./config"
|
|
20
|
+
|
|
21
|
+
const A2A_PROTOCOL_VERSION = "0.3.0"
|
|
22
|
+
const SELF_BASE = `http://127.0.0.1:${process.env.PORT ?? 10001}`
|
|
23
|
+
|
|
24
|
+
// contextId (A2A conversation) → loopat loopId. In-memory: a restart starts a
|
|
25
|
+
// fresh conversation (acceptable for v1).
|
|
26
|
+
const ctxToLoop = new Map<string, string>()
|
|
27
|
+
|
|
28
|
+
function resolvePublicUrl(c: Context): string {
|
|
29
|
+
const configured = process.env.LOOPAT_A2A_PUBLIC_URL
|
|
30
|
+
if (configured) return configured.replace(/\/+$/, "")
|
|
31
|
+
const proto = c.req.header("x-forwarded-proto") ?? "http"
|
|
32
|
+
const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}`
|
|
33
|
+
return `${proto}://${host}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function agentCard(user: string, publicUrl: string, cfg: A2AUserConfig) {
|
|
37
|
+
const name = cfg.card?.name?.trim() || `Loopat Agent (${user})`
|
|
38
|
+
const description = cfg.card?.description?.trim() ||
|
|
39
|
+
"Self-hosted AI workspace (Claude Agent SDK). Runs development tasks in an isolated sandbox: writing code, reading repos, editing files, running commands, and researching."
|
|
40
|
+
return {
|
|
41
|
+
protocolVersion: A2A_PROTOCOL_VERSION,
|
|
42
|
+
name,
|
|
43
|
+
description,
|
|
44
|
+
url: `${publicUrl}/a2a/${encodeURIComponent(user)}`,
|
|
45
|
+
version: "1.0.0",
|
|
46
|
+
preferredTransport: "JSONRPC",
|
|
47
|
+
capabilities: { streaming: true, pushNotifications: false, stateTransitionHistory: false },
|
|
48
|
+
defaultInputModes: ["text/plain", "application/json"],
|
|
49
|
+
defaultOutputModes: ["text/plain"],
|
|
50
|
+
securitySchemes: {
|
|
51
|
+
bearer: { type: "http", scheme: "bearer", description: "A loopat API token belonging to this user." },
|
|
52
|
+
},
|
|
53
|
+
security: [{ bearer: [] }],
|
|
54
|
+
skills: [
|
|
55
|
+
{
|
|
56
|
+
id: "loopat_dev_turn",
|
|
57
|
+
name: "General development task",
|
|
58
|
+
description:
|
|
59
|
+
"Run one development task in a loopat sandbox: write/edit code, read the repo, run commands, research, and return the result. Multi-turn within a conversation (reuse contextId to continue).",
|
|
60
|
+
tags: ["coding", "agent", "dev", "sandbox"],
|
|
61
|
+
examples: ["Add a health-check endpoint to the repo and make the tests pass."],
|
|
62
|
+
inputModes: ["text/plain", "application/json"],
|
|
63
|
+
outputModes: ["text/plain"],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type ParsedMsg = { text: string; contextId?: string; taskId?: string }
|
|
70
|
+
|
|
71
|
+
function parseMessage(params: any): ParsedMsg {
|
|
72
|
+
const message = params?.message ?? {}
|
|
73
|
+
let text = ""
|
|
74
|
+
const parts = Array.isArray(message.parts) ? message.parts : []
|
|
75
|
+
for (const p of parts) {
|
|
76
|
+
if (p?.kind === "text" && typeof p.text === "string") { text += p.text }
|
|
77
|
+
else if (p?.kind === "data" && p.data) {
|
|
78
|
+
const d = p.data
|
|
79
|
+
text += typeof d.message === "string" ? d.message
|
|
80
|
+
: typeof d.text === "string" ? d.text
|
|
81
|
+
: typeof d === "string" ? d : ""
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
text: text.trim(),
|
|
86
|
+
contextId: typeof message.contextId === "string" && message.contextId ? message.contextId : undefined,
|
|
87
|
+
taskId: typeof message.taskId === "string" && message.taskId ? message.taskId : undefined,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function getOrCreateLoop(parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig): Promise<{ loopId: string; contextId: string }> {
|
|
92
|
+
let contextId = parsed.contextId
|
|
93
|
+
if (contextId && ctxToLoop.has(contextId)) return { loopId: ctxToLoop.get(contextId)!, contextId }
|
|
94
|
+
if (!contextId) contextId = `ctx_${randomBytes(8).toString("hex")}`
|
|
95
|
+
|
|
96
|
+
const title = parsed.text.slice(0, 60) || "a2a"
|
|
97
|
+
const res = await fetch(`${SELF_BASE}/api/v1/loops`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { authorization: callerAuth, "content-type": "application/json" },
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
title,
|
|
102
|
+
profiles: cfg.profiles && cfg.profiles.length ? cfg.profiles : undefined,
|
|
103
|
+
vault: cfg.vault || undefined,
|
|
104
|
+
metadata: { a2a_context_id: contextId },
|
|
105
|
+
}),
|
|
106
|
+
})
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const body = await res.text().catch(() => "")
|
|
109
|
+
throw new Error(`loop create failed: ${res.status} ${body}`)
|
|
110
|
+
}
|
|
111
|
+
const loop = await res.json()
|
|
112
|
+
const loopId = loop.id as string
|
|
113
|
+
ctxToLoop.set(contextId, loopId)
|
|
114
|
+
return { loopId, contextId }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type LoopatEvent = { event: string; data: any }
|
|
118
|
+
|
|
119
|
+
async function* loopbackTurn(loopId: string, content: string, taskId: string, callerAuth: string): AsyncGenerator<LoopatEvent> {
|
|
120
|
+
const res = await fetch(`${SELF_BASE}/api/v1/loops/${loopId}/messages`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
authorization: callerAuth,
|
|
124
|
+
"content-type": "application/json",
|
|
125
|
+
accept: "text/event-stream",
|
|
126
|
+
"idempotency-key": taskId,
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify({ content, permission_mode: "bypassPermissions" }),
|
|
129
|
+
})
|
|
130
|
+
if (!res.ok || !res.body) {
|
|
131
|
+
const body = await res.text().catch(() => "")
|
|
132
|
+
yield { event: "error", data: { code: "send_failed", message: `${res.status} ${body}` } }
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const reader = res.body.getReader()
|
|
136
|
+
const decoder = new TextDecoder()
|
|
137
|
+
let buf = ""
|
|
138
|
+
let curEvent = "message"
|
|
139
|
+
let curData = ""
|
|
140
|
+
const flush = (): LoopatEvent | null => {
|
|
141
|
+
if (!curData) { curEvent = "message"; return null }
|
|
142
|
+
let parsed: any = curData
|
|
143
|
+
try { parsed = JSON.parse(curData) } catch { /* keep string */ }
|
|
144
|
+
const ev = { event: curEvent, data: parsed }
|
|
145
|
+
curEvent = "message"; curData = ""
|
|
146
|
+
return ev
|
|
147
|
+
}
|
|
148
|
+
while (true) {
|
|
149
|
+
const { done, value } = await reader.read()
|
|
150
|
+
if (done) break
|
|
151
|
+
buf += decoder.decode(value, { stream: true })
|
|
152
|
+
const lines = buf.split("\n")
|
|
153
|
+
buf = lines.pop() ?? ""
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
if (line === "") { const ev = flush(); if (ev) yield ev; continue }
|
|
156
|
+
if (line.startsWith("event:")) curEvent = line.slice(6).trim()
|
|
157
|
+
else if (line.startsWith("data:")) curData += line.slice(5).trim()
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const ev = flush(); if (ev) yield ev
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function extractAssistantText(d: any): string | null {
|
|
164
|
+
if (!d || d.type !== "assistant") return null
|
|
165
|
+
const content = d.message?.content
|
|
166
|
+
if (!Array.isArray(content)) return null
|
|
167
|
+
const txt = content.filter((b: any) => b?.type === "text" && typeof b.text === "string").map((b: any) => b.text).join("")
|
|
168
|
+
return txt || null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resultError(d: any): string | null {
|
|
172
|
+
if (!d || d.type !== "result") return null
|
|
173
|
+
if (d.is_error === true) return typeof d.result === "string" && d.result ? d.result : "agent error"
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Standard A2A streaming event builders ─────────────────────────────────
|
|
178
|
+
function artifactUpdate(reqId: unknown, taskId: string, contextId: string, artifactId: string, text: string, append: boolean, lastChunk: boolean) {
|
|
179
|
+
return {
|
|
180
|
+
jsonrpc: "2.0", id: reqId,
|
|
181
|
+
result: {
|
|
182
|
+
kind: "artifact-update",
|
|
183
|
+
taskId, contextId, append, lastChunk,
|
|
184
|
+
artifact: { artifactId, name: "response", parts: [{ kind: "text", text }] },
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function statusUpdate(reqId: unknown, taskId: string, contextId: string, state: "working" | "completed" | "failed", final: boolean, errorText?: string) {
|
|
189
|
+
const status: Record<string, unknown> = { state }
|
|
190
|
+
if (errorText) {
|
|
191
|
+
status.message = {
|
|
192
|
+
kind: "message", role: "agent", messageId: `msg_${randomBytes(6).toString("hex")}`,
|
|
193
|
+
parts: [{ kind: "text", text: errorText }], taskId, contextId,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { jsonrpc: "2.0", id: reqId, result: { kind: "status-update", taskId, contextId, status, final } }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function handleStream(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) {
|
|
200
|
+
return streamSSE(c, async (stream) => {
|
|
201
|
+
const artifactId = `artifact_${randomBytes(8).toString("hex")}`
|
|
202
|
+
const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}`
|
|
203
|
+
try {
|
|
204
|
+
const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg)
|
|
205
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "working", false)) })
|
|
206
|
+
let gotDelta = false
|
|
207
|
+
let fallbackText: string | null = null
|
|
208
|
+
let errMsg: string | undefined
|
|
209
|
+
const finish = async () => {
|
|
210
|
+
if (errMsg) {
|
|
211
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "failed", true, errMsg)) })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
if (!gotDelta && fallbackText) {
|
|
215
|
+
await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, fallbackText, false, false)) })
|
|
216
|
+
}
|
|
217
|
+
await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, "", true, true)) })
|
|
218
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskId, contextId, "completed", true)) })
|
|
219
|
+
}
|
|
220
|
+
for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) {
|
|
221
|
+
if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") {
|
|
222
|
+
await stream.writeSSE({ data: JSON.stringify(artifactUpdate(reqId, taskId, contextId, artifactId, ev.data.text, gotDelta, false)) })
|
|
223
|
+
gotDelta = true
|
|
224
|
+
} else if (ev.event === "sdk_message") {
|
|
225
|
+
const t = extractAssistantText(ev.data); if (t) fallbackText = t
|
|
226
|
+
const e = resultError(ev.data); if (e) errMsg = e
|
|
227
|
+
} else if (ev.event === "done") {
|
|
228
|
+
await finish(); return
|
|
229
|
+
} else if (ev.event === "error" || ev.event === "interrupted") {
|
|
230
|
+
errMsg = ev.data?.message ?? ev.event
|
|
231
|
+
await finish(); return
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
await finish()
|
|
235
|
+
} catch (e: any) {
|
|
236
|
+
const taskIdSafe = taskId
|
|
237
|
+
await stream.writeSSE({ data: JSON.stringify(statusUpdate(reqId, taskIdSafe, parsed.contextId ?? "", "failed", true, e?.message ?? String(e))) })
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function handleSend(c: Context, reqId: unknown, parsed: ParsedMsg, callerAuth: string, cfg: A2AUserConfig) {
|
|
243
|
+
const artifactId = `artifact_${randomBytes(8).toString("hex")}`
|
|
244
|
+
const taskId = parsed.taskId || `task_${randomBytes(8).toString("hex")}`
|
|
245
|
+
try {
|
|
246
|
+
const { loopId, contextId } = await getOrCreateLoop(parsed, callerAuth, cfg)
|
|
247
|
+
let full = ""
|
|
248
|
+
let fallbackText: string | null = null
|
|
249
|
+
let errMsg: string | undefined
|
|
250
|
+
for await (const ev of loopbackTurn(loopId, parsed.text, taskId, callerAuth)) {
|
|
251
|
+
if (ev.event === "assistant_delta" && typeof ev.data?.text === "string") full += ev.data.text
|
|
252
|
+
else if (ev.event === "sdk_message") {
|
|
253
|
+
const t = extractAssistantText(ev.data); if (t) fallbackText = t
|
|
254
|
+
const e = resultError(ev.data); if (e) errMsg = e
|
|
255
|
+
}
|
|
256
|
+
else if (ev.event === "error" || ev.event === "interrupted") errMsg = ev.data?.message ?? ev.event
|
|
257
|
+
else if (ev.event === "done") break
|
|
258
|
+
}
|
|
259
|
+
if (!full && fallbackText) full = fallbackText
|
|
260
|
+
if (errMsg && !full) full = errMsg
|
|
261
|
+
const success = !errMsg
|
|
262
|
+
// A2A Task object.
|
|
263
|
+
return c.json({
|
|
264
|
+
jsonrpc: "2.0",
|
|
265
|
+
id: reqId,
|
|
266
|
+
result: {
|
|
267
|
+
kind: "task",
|
|
268
|
+
id: taskId,
|
|
269
|
+
contextId,
|
|
270
|
+
status: { state: success ? "completed" : "failed" },
|
|
271
|
+
artifacts: [{ artifactId, name: "response", parts: [{ kind: "text", text: full }] }],
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
} catch (e: any) {
|
|
275
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32603, message: e?.message ?? String(e) } })
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function buildA2A() {
|
|
280
|
+
const a = new Hono()
|
|
281
|
+
|
|
282
|
+
const cardHandler = async (c: Context) => {
|
|
283
|
+
const user = c.req.param("user")
|
|
284
|
+
if (!user) return c.json({ error: "not found" }, 404)
|
|
285
|
+
const cfg = await loadA2AConfig(user)
|
|
286
|
+
return c.json(agentCard(user, resolvePublicUrl(c), cfg))
|
|
287
|
+
}
|
|
288
|
+
a.get("/a2a/:user/agent-card.json", cardHandler)
|
|
289
|
+
a.get("/a2a/:user/.well-known/agent-card.json", cardHandler)
|
|
290
|
+
a.get("/a2a/:user/agent.json", cardHandler) // legacy filename alias
|
|
291
|
+
|
|
292
|
+
a.post("/a2a/:user", async (c) => {
|
|
293
|
+
const user = c.req.param("user")
|
|
294
|
+
const rpc = await c.req.json().catch(() => null)
|
|
295
|
+
const reqId = rpc?.id ?? null
|
|
296
|
+
if (!rpc || typeof rpc.method !== "string") {
|
|
297
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32600, message: "invalid request" } }, 400)
|
|
298
|
+
}
|
|
299
|
+
// Per-user auth: the caller must present THIS user's loopat API token.
|
|
300
|
+
const auth = c.req.header("authorization") ?? null
|
|
301
|
+
const tokenUser = await resolveApiToken(auth)
|
|
302
|
+
if (!tokenUser) {
|
|
303
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "unauthorized: present a loopat API token" } }, 401)
|
|
304
|
+
}
|
|
305
|
+
if (tokenUser !== user) {
|
|
306
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32000, message: "token does not belong to this agent" } }, 403)
|
|
307
|
+
}
|
|
308
|
+
const cfg = await loadA2AConfig(user)
|
|
309
|
+
const parsed = parseMessage(rpc.params)
|
|
310
|
+
if (!parsed.text) {
|
|
311
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32602, message: "empty message" } }, 400)
|
|
312
|
+
}
|
|
313
|
+
if (rpc.method === "message/stream") return handleStream(c, reqId, parsed, auth!, cfg)
|
|
314
|
+
if (rpc.method === "message/send") return handleSend(c, reqId, parsed, auth!, cfg)
|
|
315
|
+
return c.json({ jsonrpc: "2.0", id: reqId, error: { code: -32601, message: `method not found: ${rpc.method}` } }, 404)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
return a
|
|
319
|
+
}
|
package/server/src/config.ts
CHANGED
|
@@ -483,6 +483,38 @@ export async function loadPersonalConfig(
|
|
|
483
483
|
return cfg
|
|
484
484
|
}
|
|
485
485
|
|
|
486
|
+
// ── Per-user A2A config ───────────────────────────────────────────────────
|
|
487
|
+
// The user's A2A agent: editable card fields + which profiles/vault loops
|
|
488
|
+
// created by A2A use. No secrets (the credential is the user's API token), so
|
|
489
|
+
// it's plain JSON next to config.json. `$LOOPAT_HOME/personal/<user>/.loopat/a2a.json`.
|
|
490
|
+
export type A2AUserConfig = {
|
|
491
|
+
card?: { name?: string; description?: string }
|
|
492
|
+
profiles?: string[]
|
|
493
|
+
vault?: string
|
|
494
|
+
}
|
|
495
|
+
function a2aConfigPath(user: string): string {
|
|
496
|
+
return join(personalLoopatDir(user), "a2a.json")
|
|
497
|
+
}
|
|
498
|
+
export async function loadA2AConfig(user: string): Promise<A2AUserConfig> {
|
|
499
|
+
const p = a2aConfigPath(user)
|
|
500
|
+
if (!existsSync(p)) return {}
|
|
501
|
+
try {
|
|
502
|
+
const j = JSON.parse(await readFile(p, "utf8")) as A2AUserConfig
|
|
503
|
+
return j && typeof j === "object" ? j : {}
|
|
504
|
+
} catch { return {} }
|
|
505
|
+
}
|
|
506
|
+
export async function saveA2AConfig(user: string, patch: A2AUserConfig): Promise<void> {
|
|
507
|
+
const cur = await loadA2AConfig(user)
|
|
508
|
+
const next: A2AUserConfig = {
|
|
509
|
+
card: patch.card !== undefined ? patch.card : cur.card,
|
|
510
|
+
profiles: patch.profiles !== undefined ? patch.profiles : cur.profiles,
|
|
511
|
+
vault: patch.vault !== undefined ? patch.vault : cur.vault,
|
|
512
|
+
}
|
|
513
|
+
const p = a2aConfigPath(user)
|
|
514
|
+
await mkdir(dirname(p), { recursive: true })
|
|
515
|
+
await writeFile(p, JSON.stringify(next, null, 2) + "\n")
|
|
516
|
+
}
|
|
517
|
+
|
|
486
518
|
export function getActiveProvider(cfg: PersonalConfig): { name: string; provider: ProviderConfig } | null {
|
|
487
519
|
const raw = cfg.default
|
|
488
520
|
if (!raw) return null
|
package/server/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { execFile, execFileSync } from "node:child_process"
|
|
|
6
6
|
import { promisify } from "node:util"
|
|
7
7
|
import { listLoops, createLoop, getLoop, loopExists, patchLoopMeta, backfillAllMounts, ensureWorkspaceDirs, provisionUserPersonal, importPersonalFromRepo, setupPersonalViaProvider, listPersonalReposViaProvider, authenticateViaProvider, isPersonalFresh, ensureUiNotesWorktree, syncUiNotes, ffUpdateUiNotes, notesBehind, inspectPersonalDirty, syncPersonalToRemote, deletePersonalVault, pullPersonalFromRemote, pushPersonalToRemote, ensureContextMounts, effectiveDriver, isDriver, distillLoop, inspectRepoSync, pullRepoFromRemote, pushRepoToRemote, ensureUserContext, promoteKnowledgeConfig, listVaultPublicKeys, userOnboarding, submitOnboarding } from "./loops"
|
|
8
8
|
import { getEphemeralHostPort, probePodman, stopAllWorkspaceContainers, ensureServeContainer, ensurePortProxyContainer, ensureSandboxImage } from "./podman"
|
|
9
|
-
import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, type OAuthSupport } from "./mcp-oauth"
|
|
9
|
+
import { startMcpAuth, completeMcpAuth, probeOAuthSupport, evictOAuthProbe, parseBearerEnvName, mcpRequiredEnvs, parseTemplateVars, type OAuthSupport } from "./mcp-oauth"
|
|
10
10
|
import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
|
|
11
11
|
import {
|
|
12
12
|
initChat,
|
|
@@ -55,7 +55,8 @@ import {
|
|
|
55
55
|
personalReposDir,
|
|
56
56
|
loopsDir,
|
|
57
57
|
} from "./paths"
|
|
58
|
-
import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, type ProviderConfig, type ModelEntry } from "./config"
|
|
58
|
+
import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, loadKnowledgeConfig, saveKnowledgeConfig, loadA2AConfig, saveA2AConfig, type ProviderConfig, type ModelEntry } from "./config"
|
|
59
|
+
import { createApiToken, listApiTokens, revokeApiToken } from "./api-tokens"
|
|
59
60
|
import { listBoards, createBoard, renameBoard, listKanbanColumns, addCard, toggleCard, deleteCard, moveCard, updateCardMeta, updateCardBlock, reorderCards, createColumn, deleteColumn, readKanbanConfig, saveColumnOrder, setColumnColor, renameColumn, assignDriverForCard, createLoopFromCard, linkLoopToCard, kanbanUserCtx } from "./kanban"
|
|
60
61
|
import { printBootstrapBanner, printReadyLine } from "./bootstrap"
|
|
61
62
|
import { resolveProvider } from "./providers"
|
|
@@ -127,6 +128,11 @@ app.get("/api/version", (c) => {
|
|
|
127
128
|
import { buildApiV1 } from "./api-v1"
|
|
128
129
|
app.route("/api/v1", buildApiV1())
|
|
129
130
|
|
|
131
|
+
// A2A (Agent-to-Agent) adapter — per-user Agent Card + JSON-RPC under /a2a/<user>.
|
|
132
|
+
// Mounted before the SPA catch-all so those routes resolve.
|
|
133
|
+
import { buildA2A } from "./a2a"
|
|
134
|
+
app.route("/", buildA2A())
|
|
135
|
+
|
|
130
136
|
// ── workspace serve config ──
|
|
131
137
|
|
|
132
138
|
function getLocalIp(): string {
|
|
@@ -761,7 +767,16 @@ app.get("/api/mcp-servers", requireAuth, async (c) => {
|
|
|
761
767
|
const type = (srv?.type ?? "stdio") as string
|
|
762
768
|
const url = (srv as any)?.url as string | undefined
|
|
763
769
|
const authTokenEnv = parseBearerEnvName(srv)
|
|
764
|
-
|
|
770
|
+
// Generalized: a server is authed when EVERY ${VAR} it references (in url
|
|
771
|
+
// or headers) is set in the vault — not just a Bearer header token.
|
|
772
|
+
const requiredEnvs = mcpRequiredEnvs(srv)
|
|
773
|
+
const authed = requiredEnvs.length > 0
|
|
774
|
+
? requiredEnvs.every((e) => !!envs[e])
|
|
775
|
+
: (authTokenEnv ? !!envs[authTokenEnv] : false)
|
|
776
|
+
// Inline loopat metadata (CC never sees it — stripped before the SDK):
|
|
777
|
+
// a setup page where the user gets their credentials, for the auth flow.
|
|
778
|
+
const setupResource = typeof (srv as any)?.["x-loopat-resource"] === "string"
|
|
779
|
+
? (srv as any)["x-loopat-resource"] as string : undefined
|
|
765
780
|
let oauthSupport: OAuthSupport | undefined
|
|
766
781
|
if (url && (type === "http" || type === "sse")) {
|
|
767
782
|
try {
|
|
@@ -770,13 +785,55 @@ app.get("/api/mcp-servers", requireAuth, async (c) => {
|
|
|
770
785
|
oauthSupport = "unreachable"
|
|
771
786
|
}
|
|
772
787
|
}
|
|
773
|
-
return { name, type, url, authTokenEnv, authed, oauthSupport }
|
|
788
|
+
return { name, type, url, authTokenEnv, requiredEnvs, authed, setupResource, oauthSupport }
|
|
774
789
|
}),
|
|
775
790
|
)
|
|
776
791
|
|
|
777
792
|
return c.json({ servers })
|
|
778
793
|
})
|
|
779
794
|
|
|
795
|
+
// "I copied my MCP URL from the provider's page" setup flow: reverse the
|
|
796
|
+
// server's ${VAR} url template against the pasted concrete URL and store the
|
|
797
|
+
// extracted secrets in the vault. The template is read server-side (from the
|
|
798
|
+
// loop's merged settings, falling back to team settings) so only the vars the
|
|
799
|
+
// server actually declares can be written.
|
|
800
|
+
app.post("/api/mcp-setup/parse", requireAuth, async (c) => {
|
|
801
|
+
const userId = c.get("userId") as string
|
|
802
|
+
const body = await c.req.json().catch(() => ({}))
|
|
803
|
+
const serverName = typeof body.server === "string" ? body.server : ""
|
|
804
|
+
const pasted = typeof body.pastedUrl === "string" ? body.pastedUrl.trim() : ""
|
|
805
|
+
const loopId = typeof body.loopId === "string" && body.loopId ? body.loopId : undefined
|
|
806
|
+
if (!serverName || !pasted) return c.json({ error: "server and pastedUrl required" }, 400)
|
|
807
|
+
|
|
808
|
+
// Locate the server's url TEMPLATE (with ${VAR}s).
|
|
809
|
+
const { readFile: rf } = await import("node:fs/promises")
|
|
810
|
+
const readMcp = async (p: string): Promise<Record<string, any>> => {
|
|
811
|
+
if (!existsSync(p)) return {}
|
|
812
|
+
try { return (JSON.parse(await rf(p, "utf8"))?.mcpServers ?? {}) as Record<string, any> } catch { return {} }
|
|
813
|
+
}
|
|
814
|
+
let mcp: Record<string, any> = {}
|
|
815
|
+
if (loopId) {
|
|
816
|
+
const { loopClaudeDir } = await import("./paths")
|
|
817
|
+
mcp = await readMcp(pathJoin(loopClaudeDir(loopId), "settings.json"))
|
|
818
|
+
}
|
|
819
|
+
if (!mcp[serverName]) {
|
|
820
|
+
const { workspaceTeamSettingsPath } = await import("./paths")
|
|
821
|
+
mcp = { ...(await readMcp(workspaceTeamSettingsPath())), ...mcp }
|
|
822
|
+
}
|
|
823
|
+
const template = typeof mcp[serverName]?.url === "string" ? (mcp[serverName].url as string) : ""
|
|
824
|
+
if (!template) return c.json({ error: `no url template for server "${serverName}"` }, 404)
|
|
825
|
+
|
|
826
|
+
const vars = parseTemplateVars(template, pasted)
|
|
827
|
+
if (!vars || Object.keys(vars).length === 0) {
|
|
828
|
+
return c.json({ error: "pasted URL doesn't match this server's URL shape" }, 400)
|
|
829
|
+
}
|
|
830
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
831
|
+
const r = await writeVaultEnv(userId, DEFAULT_VAULT, name, value)
|
|
832
|
+
if (!r.ok) return c.json({ error: `couldn't save ${name}: ${r.error}` }, 400)
|
|
833
|
+
}
|
|
834
|
+
return c.json({ ok: true, set: Object.keys(vars) })
|
|
835
|
+
})
|
|
836
|
+
|
|
780
837
|
// Force re-probe of OAuth support. POST with no body clears entire cache;
|
|
781
838
|
// body {url: ...} evicts just that URL. Useful after admin fixes a server
|
|
782
839
|
// URL or adds a previously-unreachable server.
|
|
@@ -1500,6 +1557,58 @@ app.get("/api/vaults", requireAuth, async (c) => {
|
|
|
1500
1557
|
return c.json({ vaults: listVaults(userId) })
|
|
1501
1558
|
})
|
|
1502
1559
|
|
|
1560
|
+
// ── A2A settings (per-user agent card + default profiles/vault + key) ──
|
|
1561
|
+
const A2A_KEY_LABEL = "a2a"
|
|
1562
|
+
function a2aPublicBase(c: any): string {
|
|
1563
|
+
const configured = process.env.LOOPAT_A2A_PUBLIC_URL
|
|
1564
|
+
if (configured) return configured.replace(/\/+$/, "")
|
|
1565
|
+
const proto = c.req.header("x-forwarded-proto") ?? "http"
|
|
1566
|
+
const host = c.req.header("host") ?? `127.0.0.1:${process.env.PORT ?? 10001}`
|
|
1567
|
+
return `${proto}://${host}`
|
|
1568
|
+
}
|
|
1569
|
+
app.get("/api/a2a", requireAuth, async (c) => {
|
|
1570
|
+
const userId = c.get("userId") as string
|
|
1571
|
+
const cfg = await loadA2AConfig(userId)
|
|
1572
|
+
const { listVaults } = await import("./vaults")
|
|
1573
|
+
const { listProfiles } = await import("./profiles")
|
|
1574
|
+
const tokens = await listApiTokens(userId)
|
|
1575
|
+
const base = a2aPublicBase(c)
|
|
1576
|
+
return c.json({
|
|
1577
|
+
card: { name: cfg.card?.name ?? "", description: cfg.card?.description ?? "" },
|
|
1578
|
+
profiles: cfg.profiles ?? [],
|
|
1579
|
+
vault: cfg.vault ?? "",
|
|
1580
|
+
cardUrl: `${base}/a2a/${encodeURIComponent(userId)}/agent-card.json`,
|
|
1581
|
+
endpoint: `${base}/a2a/${encodeURIComponent(userId)}`,
|
|
1582
|
+
hasKey: tokens.some((t) => t.label === A2A_KEY_LABEL),
|
|
1583
|
+
availableProfiles: await listProfiles(userId),
|
|
1584
|
+
availableVaults: listVaults(userId),
|
|
1585
|
+
})
|
|
1586
|
+
})
|
|
1587
|
+
app.put("/api/a2a", requireAuth, async (c) => {
|
|
1588
|
+
const userId = c.get("userId") as string
|
|
1589
|
+
const body = await c.req.json().catch(() => ({}))
|
|
1590
|
+
const patch: { card?: { name?: string; description?: string }; profiles?: string[]; vault?: string } = {}
|
|
1591
|
+
if (body.card && typeof body.card === "object") {
|
|
1592
|
+
patch.card = {
|
|
1593
|
+
name: typeof body.card.name === "string" ? body.card.name.slice(0, 200) : undefined,
|
|
1594
|
+
description: typeof body.card.description === "string" ? body.card.description.slice(0, 2000) : undefined,
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
if (Array.isArray(body.profiles)) patch.profiles = body.profiles.filter((p: unknown): p is string => typeof p === "string")
|
|
1598
|
+
if (typeof body.vault === "string") patch.vault = body.vault
|
|
1599
|
+
await saveA2AConfig(userId, patch)
|
|
1600
|
+
return c.json({ ok: true })
|
|
1601
|
+
})
|
|
1602
|
+
// Rotate the A2A key: revoke any prior a2a-labelled token, mint a fresh one
|
|
1603
|
+
// (returned once — store it in the orchestrator).
|
|
1604
|
+
app.post("/api/a2a/key", requireAuth, async (c) => {
|
|
1605
|
+
const userId = c.get("userId") as string
|
|
1606
|
+
const existing = await listApiTokens(userId)
|
|
1607
|
+
for (const t of existing) if (t.label === A2A_KEY_LABEL) await revokeApiToken(userId, t.tokenId)
|
|
1608
|
+
const created = await createApiToken(userId, A2A_KEY_LABEL)
|
|
1609
|
+
return c.json({ token: created.token })
|
|
1610
|
+
})
|
|
1611
|
+
|
|
1503
1612
|
// Return the current user's `default_profiles` from
|
|
1504
1613
|
// personal/<u>/.loopat/config.json. Used by NewLoopDialog to pre-check the
|
|
1505
1614
|
// user's typical setup. Returns [] if config absent / field missing.
|
package/server/src/mcp-oauth.ts
CHANGED
|
@@ -78,6 +78,51 @@ export function parseBearerEnvName(server: McpServerConfig | undefined | null):
|
|
|
78
78
|
return null
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// ${VAR} or ${VAR:-default}, anywhere in a string.
|
|
82
|
+
const ENV_REF_G = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g
|
|
83
|
+
|
|
84
|
+
function escapeRegex(s: string): string {
|
|
85
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every env-var name referenced via ${VAR} in a server's url + header values.
|
|
89
|
+
* Generalizes parseBearerEnvName beyond `Authorization: Bearer ${VAR}` (e.g. a
|
|
90
|
+
* key embedded in the url). A server is "authed" when ALL of these are set. */
|
|
91
|
+
export function mcpRequiredEnvs(server: McpServerConfig | undefined | null): string[] {
|
|
92
|
+
if (!server) return []
|
|
93
|
+
const out = new Set<string>()
|
|
94
|
+
const scan = (s: unknown) => {
|
|
95
|
+
if (typeof s !== "string") return
|
|
96
|
+
for (const m of s.matchAll(ENV_REF_G)) out.add(m[1])
|
|
97
|
+
}
|
|
98
|
+
scan((server as any).url)
|
|
99
|
+
const headers = (server as any).headers
|
|
100
|
+
if (headers && typeof headers === "object") for (const v of Object.values(headers)) scan(v)
|
|
101
|
+
return [...out]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Reverse a `${VAR}`-templated string (e.g. the server url) against a concrete
|
|
105
|
+
* pasted value, extracting each VAR. Returns null if the paste doesn't match
|
|
106
|
+
* the template's shape. Powers the "paste your MCP URL → auto-fill the secrets"
|
|
107
|
+
* setup flow. */
|
|
108
|
+
export function parseTemplateVars(template: string, pasted: string): Record<string, string> | null {
|
|
109
|
+
const names: string[] = []
|
|
110
|
+
let re = "^"
|
|
111
|
+
let last = 0
|
|
112
|
+
for (const m of template.matchAll(ENV_REF_G)) {
|
|
113
|
+
const idx = m.index ?? 0
|
|
114
|
+
re += escapeRegex(template.slice(last, idx)) + "(.+?)"
|
|
115
|
+
names.push(m[1])
|
|
116
|
+
last = idx + m[0].length
|
|
117
|
+
}
|
|
118
|
+
re += escapeRegex(template.slice(last)) + "$"
|
|
119
|
+
const match = pasted.trim().match(new RegExp(re))
|
|
120
|
+
if (!match) return null
|
|
121
|
+
const out: Record<string, string> = {}
|
|
122
|
+
names.forEach((n, i) => { out[n] = match[i + 1] })
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
81
126
|
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes — generous; OAuth flows are slow
|
|
82
127
|
|
|
83
128
|
type FlowState = {
|
package/server/src/session.ts
CHANGED
|
@@ -429,7 +429,18 @@ class LoopSession {
|
|
|
429
429
|
if (existsSync(mergedSettingsPath)) {
|
|
430
430
|
try {
|
|
431
431
|
const merged = JSON.parse(await readFile(mergedSettingsPath, "utf8"))
|
|
432
|
-
|
|
432
|
+
// Strip loopat-only inline metadata (e.g. `x-loopat-resource`, used by
|
|
433
|
+
// the MCP setup UI) so the SDK / CC only sees standard MCP fields.
|
|
434
|
+
mcpServers = Object.fromEntries(
|
|
435
|
+
Object.entries((merged.mcpServers ?? {}) as Record<string, any>).map(([name, srv]) => {
|
|
436
|
+
if (srv && typeof srv === "object") {
|
|
437
|
+
const clean: Record<string, any> = {}
|
|
438
|
+
for (const [k, v] of Object.entries(srv)) if (!k.startsWith("x-loopat")) clean[k] = v
|
|
439
|
+
return [name, clean]
|
|
440
|
+
}
|
|
441
|
+
return [name, srv]
|
|
442
|
+
}),
|
|
443
|
+
)
|
|
433
444
|
} catch (e: any) {
|
|
434
445
|
console.warn(`[session ${loopId.slice(0,8)}] could not read merged mcpServers: ${e?.message ?? e}`)
|
|
435
446
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{n as e,o as t,t as n}from"./jsx-runtime-Bt-cYkS5.js";import{_ as r,v as i,y as a}from"./index-
|
|
1
|
+
import{n as e,o as t,t as n}from"./jsx-runtime-Bt-cYkS5.js";import{_ as r,v as i,y as a}from"./index-CaZDV18H.js";import{CodeEditor as o}from"./CodeEditor-DtHZtsPs.js";var s=a(`text-wrap`,[[`path`,{d:`m16 16-3 3 3 3`,key:`117b85`}],[`path`,{d:`M3 12h14.5a1 1 0 0 1 0 7H13`,key:`18xa6z`}],[`path`,{d:`M3 19h6`,key:`1ygdsz`}],[`path`,{d:`M3 5h18`,key:`1u36vt`}]]),c=t(e(),1),l=n();function u(){try{if(localStorage.getItem(`loopat:editor:wordWrap`)===`0`)return!1}catch{}return!0}function d({loopId:e,path:t,onSelectionChange:n}){let[a,d]=(0,c.useState)(``),[f,p]=(0,c.useState)(``),[m,h]=(0,c.useState)(!1),[g,_]=(0,c.useState)(!1),[v,y]=(0,c.useState)(u);(0,c.useEffect)(()=>{if(!t){d(``),p(``);return}h(!0),r(e,t).then(e=>{let t=e?.content??``;d(t),p(t)}).finally(()=>h(!1))},[e,t]);let b=t&&f!==a,x=async()=>{if(!(!t||g)){_(!0);try{await i(e,t,f)&&d(f)}finally{_(!1)}}};return t?(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`div`,{className:`flex-1 min-h-0 relative`,onKeyDown:e=>{(e.metaKey||e.ctrlKey)&&e.key===`s`&&(e.preventDefault(),x())},children:m?(0,l.jsx)(`div`,{className:`h-full w-full flex items-center justify-center text-[12px] text-gray-400`,children:`loading…`}):(0,l.jsx)(o,{path:t,value:f,onChange:p,wordWrap:v,onSelectionChange:n})}),(0,l.jsxs)(`div`,{className:`border-t border-gray-200 px-3 py-1.5 text-[11px] text-gray-500 flex items-center gap-3`,children:[(0,l.jsx)(`span`,{className:`truncate`,children:t}),b&&(0,l.jsx)(`button`,{onClick:x,className:`text-orange-600 hover:underline`,title:`ctrl/⌘+S`,children:g?`saving…`:`unsaved · save`}),(0,l.jsx)(`span`,{className:`flex-1`}),(0,l.jsx)(`button`,{onClick:()=>{let e=!v;y(e);try{localStorage.setItem(`loopat:editor:wordWrap`,e?`1`:`0`)}catch{}},className:`flex items-center gap-1 hover:text-gray-700 transition-colors ${v?`text-gray-500`:`text-gray-300`}`,title:v?`word wrap: on`:`word wrap: off`,children:(0,l.jsx)(s,{size:13})}),(0,l.jsx)(`span`,{children:`utf-8 · LF`})]})]}):(0,l.jsx)(`div`,{className:`flex-1 min-h-0 flex items-center justify-center text-[13px] text-gray-500 px-8 text-center`,children:`没打开文件 · 在 ▤ workdir 里点一个`})}export{d as Editor};
|