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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopat",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
4
4
  "description": "Self-hosted AI workspace built around context management — works solo, scales to teams",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/simpx/loopat",
@@ -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
+ }
@@ -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
@@ -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
- const authed = authTokenEnv ? !!envs[authTokenEnv] : false
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.
@@ -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 = {
@@ -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
- mcpServers = { ...(merged.mcpServers ?? {}) }
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-DpqDiByg.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};
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};