loopat 0.1.0
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/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- package/web/dist/logo.png +0 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth 2.0 client — loopat owns the OAuth dance, not the sandboxed CC.
|
|
3
|
+
*
|
|
4
|
+
* Flow (high-level):
|
|
5
|
+
* 1. POST /api/mcp-auth/start { serverName, loopId }
|
|
6
|
+
* ↓
|
|
7
|
+
* read merged settings.json for the loop (loopClaudeDir/settings.json),
|
|
8
|
+
* look up mcpServers[serverName]; parse `Authorization: Bearer ${VAR}`
|
|
9
|
+
* from its headers — VAR is the env file we'll write the token to
|
|
10
|
+
* ↓
|
|
11
|
+
* discover OAuth metadata:
|
|
12
|
+
* - RFC 9728 (protected-resource metadata) → list of auth servers
|
|
13
|
+
* - RFC 8414 (auth-server metadata) → endpoints
|
|
14
|
+
* ↓
|
|
15
|
+
* RFC 7591 dynamic client registration (DCR) — register loopat as a
|
|
16
|
+
* client at the auth server, get client_id / optionally client_secret
|
|
17
|
+
* ↓
|
|
18
|
+
* generate PKCE verifier + challenge, generate state
|
|
19
|
+
* ↓
|
|
20
|
+
* stash flow context (user, server, envName, verifier, client creds, ...)
|
|
21
|
+
* keyed by state in an in-memory map with TTL
|
|
22
|
+
* ↓
|
|
23
|
+
* return { authorizationUrl } to the frontend; frontend navigates browser
|
|
24
|
+
*
|
|
25
|
+
* 2. browser → MCP server auth page → user authorizes → MCP server redirects
|
|
26
|
+
* back to GET /api/mcp-auth/callback?code=…&state=…
|
|
27
|
+
* ↓
|
|
28
|
+
* look up state in map (verify CSRF), exchange code+verifier for
|
|
29
|
+
* access_token at token_endpoint, write to the user's personal default
|
|
30
|
+
* vault as env `<envName>`
|
|
31
|
+
* ↓
|
|
32
|
+
* redirect browser back to /settings/mcp-auth?status=ok&server=…
|
|
33
|
+
*
|
|
34
|
+
* Discovery / DCR / token exchange are all standard OAuth 2.0 + RFCs that
|
|
35
|
+
* MCP spec mandates for servers offering OAuth. Servers that don't support
|
|
36
|
+
* DCR will need a future "operator pre-configures client_id" fallback.
|
|
37
|
+
*/
|
|
38
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
39
|
+
|
|
40
|
+
import { type McpServerConfig, writeVaultEnv } from "./config"
|
|
41
|
+
import { DEFAULT_VAULT } from "./vaults"
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extract the env var name from a server's `Authorization: Bearer ${VAR}`
|
|
45
|
+
* header. Returns null if the server has no parseable Bearer-template header
|
|
46
|
+
* (in which case loopat-managed OAuth doesn't apply — the server is either
|
|
47
|
+
* static-keyed, uses a non-Bearer auth scheme, or isn't HTTP).
|
|
48
|
+
*
|
|
49
|
+
* Strict matching by design:
|
|
50
|
+
* - header key matched case-insensitively
|
|
51
|
+
* - value must be exactly `Bearer ${VARNAME}` (case-insensitive `Bearer`,
|
|
52
|
+
* single env ref, no other characters)
|
|
53
|
+
* - `VARNAME` must be a valid env var identifier `[A-Z_][A-Z0-9_]*`
|
|
54
|
+
*
|
|
55
|
+
* Half-static templates like `Bearer ${PREFIX}_suffix` are rejected — we'd
|
|
56
|
+
* not know which env to write the OAuth result to.
|
|
57
|
+
*/
|
|
58
|
+
// Split into two parts so that `bearer` is case-insensitive (HTTP scheme
|
|
59
|
+
// matching) while the env name capture is strictly uppercase + underscore +
|
|
60
|
+
// digits — matches POSIX-style convention loopat enforces for vault env files.
|
|
61
|
+
const BEARER_PREFIX_RE = /^bearer\s+/i
|
|
62
|
+
const ENV_REF_RE = /^\$\{([A-Z_][A-Z0-9_]*)\}$/
|
|
63
|
+
|
|
64
|
+
export function parseBearerEnvName(server: McpServerConfig | undefined | null): string | null {
|
|
65
|
+
if (!server) return null
|
|
66
|
+
const headers = (server as any).headers as Record<string, string> | undefined
|
|
67
|
+
if (!headers || typeof headers !== "object") return null
|
|
68
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
69
|
+
if (k.toLowerCase() !== "authorization") continue
|
|
70
|
+
if (typeof v !== "string") return null
|
|
71
|
+
const trimmed = v.trim()
|
|
72
|
+
const prefix = trimmed.match(BEARER_PREFIX_RE)
|
|
73
|
+
if (!prefix) return null
|
|
74
|
+
const remainder = trimmed.slice(prefix[0].length)
|
|
75
|
+
const m = remainder.match(ENV_REF_RE)
|
|
76
|
+
return m ? m[1] : null
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes — generous; OAuth flows are slow
|
|
82
|
+
|
|
83
|
+
type FlowState = {
|
|
84
|
+
user: string
|
|
85
|
+
serverName: string
|
|
86
|
+
serverUrl: string
|
|
87
|
+
envName: string
|
|
88
|
+
redirectUri: string
|
|
89
|
+
codeVerifier: string
|
|
90
|
+
clientId: string
|
|
91
|
+
clientSecret?: string
|
|
92
|
+
authorizationEndpoint: string
|
|
93
|
+
tokenEndpoint: string
|
|
94
|
+
scope?: string
|
|
95
|
+
createdAt: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Pending OAuth flows keyed by `state` parameter. Each entry self-expires
|
|
100
|
+
* after STATE_TTL_MS. Lazy cleanup on insert: every time we add an entry, we
|
|
101
|
+
* sweep expired ones. No background timer needed.
|
|
102
|
+
*/
|
|
103
|
+
class FlowStateMap {
|
|
104
|
+
private map = new Map<string, FlowState>()
|
|
105
|
+
|
|
106
|
+
put(state: string, value: FlowState): void {
|
|
107
|
+
this.sweep()
|
|
108
|
+
this.map.set(state, value)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Returns and **removes** the entry (one-shot consumption). */
|
|
112
|
+
consume(state: string): FlowState | null {
|
|
113
|
+
const v = this.map.get(state)
|
|
114
|
+
if (!v) return null
|
|
115
|
+
this.map.delete(state)
|
|
116
|
+
if (Date.now() - v.createdAt > STATE_TTL_MS) return null
|
|
117
|
+
return v
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private sweep() {
|
|
121
|
+
const now = Date.now()
|
|
122
|
+
for (const [k, v] of this.map) {
|
|
123
|
+
if (now - v.createdAt > STATE_TTL_MS) this.map.delete(k)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export const flowStates = new FlowStateMap()
|
|
129
|
+
|
|
130
|
+
// ── helpers ────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
function b64url(buf: Buffer): string {
|
|
133
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function genState(): string {
|
|
137
|
+
return b64url(randomBytes(24))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function genPkce(): { verifier: string; challenge: string } {
|
|
141
|
+
const verifier = b64url(randomBytes(32))
|
|
142
|
+
const challenge = b64url(createHash("sha256").update(verifier).digest())
|
|
143
|
+
return { verifier, challenge }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Try a sequence of `.well-known` candidate URLs derived from a base URL, and
|
|
148
|
+
* return the first that responds with JSON. MCP spec says the protected-
|
|
149
|
+
* resource metadata sits at `<base>/.well-known/oauth-protected-resource` but
|
|
150
|
+
* servers vary on whether the MCP path segment is included.
|
|
151
|
+
*/
|
|
152
|
+
async function fetchJsonFirstOk(urls: string[], timeoutMs = 5000): Promise<{ url: string; json: any } | null> {
|
|
153
|
+
for (const url of urls) {
|
|
154
|
+
try {
|
|
155
|
+
const ctrl = new AbortController()
|
|
156
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
157
|
+
const r = await fetch(url, { signal: ctrl.signal, headers: { Accept: "application/json" } })
|
|
158
|
+
clearTimeout(t)
|
|
159
|
+
if (!r.ok) continue
|
|
160
|
+
const json = await r.json().catch(() => null)
|
|
161
|
+
if (json && typeof json === "object") return { url, json }
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── RFC 9728 protected-resource metadata ───────────────────────────────
|
|
168
|
+
|
|
169
|
+
type ProtectedResourceMetadata = {
|
|
170
|
+
resource: string
|
|
171
|
+
authorization_servers: string[]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function discoverProtectedResource(serverUrl: string): Promise<ProtectedResourceMetadata | null> {
|
|
175
|
+
// The MCP server URL may be `https://host/mcp` or just `https://host`.
|
|
176
|
+
// Try both `host/.well-known/oauth-protected-resource/mcp` (path-suffixed)
|
|
177
|
+
// and `host/.well-known/oauth-protected-resource` (root).
|
|
178
|
+
const u = new URL(serverUrl)
|
|
179
|
+
const candidates = [
|
|
180
|
+
`${u.origin}/.well-known/oauth-protected-resource${u.pathname}`.replace(/\/+$/, ""),
|
|
181
|
+
`${u.origin}/.well-known/oauth-protected-resource`,
|
|
182
|
+
]
|
|
183
|
+
const r = await fetchJsonFirstOk(candidates)
|
|
184
|
+
if (!r) return null
|
|
185
|
+
const json = r.json
|
|
186
|
+
if (
|
|
187
|
+
!json.authorization_servers ||
|
|
188
|
+
!Array.isArray(json.authorization_servers) ||
|
|
189
|
+
json.authorization_servers.length === 0
|
|
190
|
+
) {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
return json as ProtectedResourceMetadata
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── RFC 8414 authorization-server metadata ─────────────────────────────
|
|
197
|
+
|
|
198
|
+
type AuthServerMetadata = {
|
|
199
|
+
issuer?: string
|
|
200
|
+
authorization_endpoint: string
|
|
201
|
+
token_endpoint: string
|
|
202
|
+
registration_endpoint?: string
|
|
203
|
+
scopes_supported?: string[]
|
|
204
|
+
grant_types_supported?: string[]
|
|
205
|
+
code_challenge_methods_supported?: string[]
|
|
206
|
+
/** Optional. If supplied, controls token endpoint authentication. */
|
|
207
|
+
token_endpoint_auth_methods_supported?: string[]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function discoverAuthServer(authServerUrl: string): Promise<AuthServerMetadata | null> {
|
|
211
|
+
const u = new URL(authServerUrl)
|
|
212
|
+
const candidates = [
|
|
213
|
+
`${u.origin}/.well-known/oauth-authorization-server${u.pathname}`.replace(/\/+$/, ""),
|
|
214
|
+
`${u.origin}/.well-known/oauth-authorization-server`,
|
|
215
|
+
// Some MCP servers expose this even when also hosting protected resource.
|
|
216
|
+
// (RFC 8414 doesn't standardize a path, but this is conventional.)
|
|
217
|
+
]
|
|
218
|
+
const r = await fetchJsonFirstOk(candidates)
|
|
219
|
+
if (!r) return null
|
|
220
|
+
const json = r.json
|
|
221
|
+
if (!json.authorization_endpoint || !json.token_endpoint) return null
|
|
222
|
+
return json as AuthServerMetadata
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── OAuth capability probe ─────────────────────────────────────────────
|
|
226
|
+
//
|
|
227
|
+
// Tells the UI in advance whether loopat can OAuth into a given MCP server,
|
|
228
|
+
// so a "needs auth" button isn't shown for servers it can't actually handle
|
|
229
|
+
// (Slack / Google Drive / other consumer providers that don't expose DCR).
|
|
230
|
+
|
|
231
|
+
export type OAuthSupport =
|
|
232
|
+
/** Auth server exposes registration_endpoint — loopat can DCR + auto-auth. */
|
|
233
|
+
| "dcr"
|
|
234
|
+
/** OAuth flow exists but DCR isn't available — admin would need to manually
|
|
235
|
+
* register an app with the provider; loopat doesn't (yet) accept static
|
|
236
|
+
* client_id input, so this is effectively unsupported. */
|
|
237
|
+
| "manual"
|
|
238
|
+
/** Server doesn't advertise OAuth (no .well-known/oauth-protected-resource).
|
|
239
|
+
* Either public, API-key-based, or some other auth scheme — loopat has
|
|
240
|
+
* nothing to do; CC connects directly. */
|
|
241
|
+
| "none"
|
|
242
|
+
/** Probe failed — server unreachable, malformed metadata, etc. */
|
|
243
|
+
| "unreachable"
|
|
244
|
+
|
|
245
|
+
type ProbeResult = { support: OAuthSupport; probedAt: number }
|
|
246
|
+
|
|
247
|
+
// In-memory cache. Keyed by server URL. TTL differs by result class so a
|
|
248
|
+
// transient "unreachable" doesn't get stuck for a day.
|
|
249
|
+
const probeCache = new Map<string, ProbeResult>()
|
|
250
|
+
const TTL_OK_MS = 24 * 60 * 60 * 1000
|
|
251
|
+
const TTL_NEG_MS = 5 * 60 * 1000
|
|
252
|
+
|
|
253
|
+
export async function probeOAuthSupport(serverUrl: string, opts: { force?: boolean } = {}): Promise<OAuthSupport> {
|
|
254
|
+
const cached = probeCache.get(serverUrl)
|
|
255
|
+
if (!opts.force && cached) {
|
|
256
|
+
const ttl = cached.support === "dcr" || cached.support === "manual" ? TTL_OK_MS : TTL_NEG_MS
|
|
257
|
+
if (Date.now() - cached.probedAt < ttl) return cached.support
|
|
258
|
+
}
|
|
259
|
+
const support = await runProbe(serverUrl)
|
|
260
|
+
probeCache.set(serverUrl, { support, probedAt: Date.now() })
|
|
261
|
+
return support
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function runProbe(serverUrl: string): Promise<OAuthSupport> {
|
|
265
|
+
let prm
|
|
266
|
+
try {
|
|
267
|
+
prm = await discoverProtectedResource(serverUrl)
|
|
268
|
+
} catch {
|
|
269
|
+
return "unreachable"
|
|
270
|
+
}
|
|
271
|
+
if (!prm) return "none"
|
|
272
|
+
const authServerUrl = prm.authorization_servers[0]
|
|
273
|
+
let asm
|
|
274
|
+
try {
|
|
275
|
+
asm = await discoverAuthServer(authServerUrl)
|
|
276
|
+
} catch {
|
|
277
|
+
return "unreachable"
|
|
278
|
+
}
|
|
279
|
+
if (!asm) return "unreachable"
|
|
280
|
+
return asm.registration_endpoint ? "dcr" : "manual"
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Clear cached probe result(s). url omitted → clear everything. */
|
|
284
|
+
export function evictOAuthProbe(url?: string): void {
|
|
285
|
+
if (url) probeCache.delete(url)
|
|
286
|
+
else probeCache.clear()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── RFC 7591 dynamic client registration ───────────────────────────────
|
|
290
|
+
|
|
291
|
+
type DcrResponse = {
|
|
292
|
+
client_id: string
|
|
293
|
+
client_secret?: string
|
|
294
|
+
client_id_issued_at?: number
|
|
295
|
+
client_secret_expires_at?: number
|
|
296
|
+
redirect_uris?: string[]
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function dynamicRegister(
|
|
300
|
+
registrationEndpoint: string,
|
|
301
|
+
redirectUri: string,
|
|
302
|
+
): Promise<DcrResponse | null> {
|
|
303
|
+
const body = {
|
|
304
|
+
client_name: "loopat",
|
|
305
|
+
redirect_uris: [redirectUri],
|
|
306
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
307
|
+
response_types: ["code"],
|
|
308
|
+
token_endpoint_auth_method: "client_secret_basic",
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const r = await fetch(registrationEndpoint, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: { "content-type": "application/json", Accept: "application/json" },
|
|
314
|
+
body: JSON.stringify(body),
|
|
315
|
+
})
|
|
316
|
+
if (!r.ok) {
|
|
317
|
+
console.warn(`[loopat] DCR failed (${r.status}): ${await r.text().catch(() => "")}`)
|
|
318
|
+
return null
|
|
319
|
+
}
|
|
320
|
+
return (await r.json()) as DcrResponse
|
|
321
|
+
} catch (e: any) {
|
|
322
|
+
console.warn(`[loopat] DCR error: ${e?.message ?? e}`)
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── flow: start ────────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
export type StartResult =
|
|
330
|
+
| { ok: true; authorizationUrl: string; state: string }
|
|
331
|
+
| { ok: false; error: string }
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Look up a server in the loop's merged settings.json. Returns null when
|
|
335
|
+
* either the merged file is missing (loop never composed) or the server name
|
|
336
|
+
* isn't declared.
|
|
337
|
+
*/
|
|
338
|
+
async function lookupServerInMergedSettings(
|
|
339
|
+
loopId: string,
|
|
340
|
+
serverName: string,
|
|
341
|
+
): Promise<McpServerConfig | null> {
|
|
342
|
+
if (!loopId) return null
|
|
343
|
+
const { existsSync } = await import("node:fs")
|
|
344
|
+
const { readFile } = await import("node:fs/promises")
|
|
345
|
+
const { join } = await import("node:path")
|
|
346
|
+
const { loopClaudeDir } = await import("./paths")
|
|
347
|
+
const settingsPath = join(loopClaudeDir(loopId), "settings.json")
|
|
348
|
+
if (!existsSync(settingsPath)) return null
|
|
349
|
+
try {
|
|
350
|
+
const j = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
|
351
|
+
mcpServers?: Record<string, McpServerConfig>
|
|
352
|
+
}
|
|
353
|
+
return j.mcpServers?.[serverName] ?? null
|
|
354
|
+
} catch {
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Begin an OAuth flow for (user, serverName) in the context of `loopId`. The
|
|
361
|
+
* browser-side caller navigates to `authorizationUrl` next. The OAuth token,
|
|
362
|
+
* once obtained, lands in the user's personal default vault under the env
|
|
363
|
+
* name parsed from the server's `Authorization: Bearer ${VAR}` header.
|
|
364
|
+
*/
|
|
365
|
+
export async function startMcpAuth(opts: {
|
|
366
|
+
user: string
|
|
367
|
+
serverName: string
|
|
368
|
+
/** Loop the auth request originates from — used to resolve the server in
|
|
369
|
+
* the loop's merged settings.json. */
|
|
370
|
+
loopId: string
|
|
371
|
+
publicBaseUrl: string
|
|
372
|
+
}): Promise<StartResult> {
|
|
373
|
+
const { user, serverName, loopId, publicBaseUrl } = opts
|
|
374
|
+
|
|
375
|
+
const srv = await lookupServerInMergedSettings(loopId, serverName)
|
|
376
|
+
if (!srv) {
|
|
377
|
+
return { ok: false, error: `server "${serverName}" not found in loop's merged settings.json` }
|
|
378
|
+
}
|
|
379
|
+
if (srv.type !== "http" && srv.type !== "sse") {
|
|
380
|
+
return { ok: false, error: `server "${serverName}" is type "${srv.type}"; only http/sse support OAuth` }
|
|
381
|
+
}
|
|
382
|
+
const serverUrl = (srv as any).url as string
|
|
383
|
+
if (!serverUrl) return { ok: false, error: `server "${serverName}" missing url` }
|
|
384
|
+
|
|
385
|
+
const envName = parseBearerEnvName(srv)
|
|
386
|
+
if (!envName) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
error: `server "${serverName}" does not declare \`Authorization: Bearer \${VAR}\` in headers — loopat-managed OAuth requires that template`,
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// 1) discover protected-resource → list of authorization servers
|
|
394
|
+
const prm = await discoverProtectedResource(serverUrl)
|
|
395
|
+
if (!prm) {
|
|
396
|
+
return {
|
|
397
|
+
ok: false,
|
|
398
|
+
error: `failed to discover protected-resource metadata at ${serverUrl} — the server may not implement OAuth, or .well-known/oauth-protected-resource is unreachable`,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const authServerUrl = prm.authorization_servers[0]
|
|
402
|
+
|
|
403
|
+
// 2) discover authorization-server metadata
|
|
404
|
+
const asm = await discoverAuthServer(authServerUrl)
|
|
405
|
+
if (!asm) {
|
|
406
|
+
return { ok: false, error: `failed to discover auth-server metadata at ${authServerUrl}` }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 3) DCR (RFC 7591) — if the server doesn't expose registration_endpoint we
|
|
410
|
+
// refuse with an actionable error. (Future: operator-supplied client_id
|
|
411
|
+
// fallback.)
|
|
412
|
+
const redirectUri = `${publicBaseUrl.replace(/\/+$/, "")}/api/mcp-auth/callback`
|
|
413
|
+
if (!asm.registration_endpoint) {
|
|
414
|
+
return {
|
|
415
|
+
ok: false,
|
|
416
|
+
error: `auth server ${authServerUrl} does not advertise registration_endpoint (DCR); operator-static client_id fallback not yet implemented`,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
const reg = await dynamicRegister(asm.registration_endpoint, redirectUri)
|
|
420
|
+
if (!reg) return { ok: false, error: `dynamic client registration failed` }
|
|
421
|
+
|
|
422
|
+
// 4) PKCE + state, stash, build authorization URL
|
|
423
|
+
const { verifier, challenge } = genPkce()
|
|
424
|
+
const state = genState()
|
|
425
|
+
|
|
426
|
+
flowStates.put(state, {
|
|
427
|
+
user,
|
|
428
|
+
serverName,
|
|
429
|
+
serverUrl,
|
|
430
|
+
envName,
|
|
431
|
+
redirectUri,
|
|
432
|
+
codeVerifier: verifier,
|
|
433
|
+
clientId: reg.client_id,
|
|
434
|
+
clientSecret: reg.client_secret,
|
|
435
|
+
authorizationEndpoint: asm.authorization_endpoint,
|
|
436
|
+
tokenEndpoint: asm.token_endpoint,
|
|
437
|
+
scope: asm.scopes_supported?.join(" "),
|
|
438
|
+
createdAt: Date.now(),
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const authUrl = new URL(asm.authorization_endpoint)
|
|
442
|
+
authUrl.searchParams.set("response_type", "code")
|
|
443
|
+
authUrl.searchParams.set("client_id", reg.client_id)
|
|
444
|
+
authUrl.searchParams.set("redirect_uri", redirectUri)
|
|
445
|
+
authUrl.searchParams.set("state", state)
|
|
446
|
+
authUrl.searchParams.set("code_challenge", challenge)
|
|
447
|
+
authUrl.searchParams.set("code_challenge_method", "S256")
|
|
448
|
+
if (asm.scopes_supported?.length) {
|
|
449
|
+
authUrl.searchParams.set("scope", asm.scopes_supported.join(" "))
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { ok: true, authorizationUrl: authUrl.toString(), state }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ── flow: callback ──────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
export type CallbackResult =
|
|
458
|
+
| { ok: true; serverName: string }
|
|
459
|
+
| { ok: false; error: string }
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Process the OAuth redirect coming back from the MCP server. Exchanges
|
|
463
|
+
* code+verifier for an access_token and writes it to the user's personal
|
|
464
|
+
* default vault under the env name captured at flow start.
|
|
465
|
+
*/
|
|
466
|
+
export async function completeMcpAuth(opts: {
|
|
467
|
+
state: string
|
|
468
|
+
code: string
|
|
469
|
+
}): Promise<CallbackResult> {
|
|
470
|
+
const flow = flowStates.consume(opts.state)
|
|
471
|
+
if (!flow) return { ok: false, error: `unknown or expired state` }
|
|
472
|
+
|
|
473
|
+
// token endpoint exchange
|
|
474
|
+
const body = new URLSearchParams({
|
|
475
|
+
grant_type: "authorization_code",
|
|
476
|
+
code: opts.code,
|
|
477
|
+
redirect_uri: flow.redirectUri,
|
|
478
|
+
client_id: flow.clientId,
|
|
479
|
+
code_verifier: flow.codeVerifier,
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
const headers: Record<string, string> = {
|
|
483
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
484
|
+
Accept: "application/json",
|
|
485
|
+
}
|
|
486
|
+
if (flow.clientSecret) {
|
|
487
|
+
headers["Authorization"] =
|
|
488
|
+
"Basic " +
|
|
489
|
+
Buffer.from(`${encodeURIComponent(flow.clientId)}:${encodeURIComponent(flow.clientSecret)}`).toString("base64")
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
let resp: Response
|
|
493
|
+
try {
|
|
494
|
+
resp = await fetch(flow.tokenEndpoint, { method: "POST", headers, body })
|
|
495
|
+
} catch (e: any) {
|
|
496
|
+
return { ok: false, error: `token exchange network error: ${e?.message ?? e}` }
|
|
497
|
+
}
|
|
498
|
+
if (!resp.ok) {
|
|
499
|
+
return { ok: false, error: `token exchange ${resp.status}: ${await resp.text().catch(() => "")}` }
|
|
500
|
+
}
|
|
501
|
+
const tok = await resp.json().catch(() => null)
|
|
502
|
+
if (!tok?.access_token) return { ok: false, error: `token response missing access_token` }
|
|
503
|
+
|
|
504
|
+
// Persist as a plain vault env in the user's personal default vault.
|
|
505
|
+
// Refresh/revoke aren't implemented yet; refresh_token (if present) is
|
|
506
|
+
// dropped intentionally — re-running the flow ("Re-authorize" in /mcp)
|
|
507
|
+
// is the only refresh path today.
|
|
508
|
+
await writeVaultEnv(flow.user, DEFAULT_VAULT, flow.envName, tok.access_token)
|
|
509
|
+
|
|
510
|
+
// Token is persisted, but **already-running** LoopSessions still hold the
|
|
511
|
+
// old `query()` options. We intentionally do NOT auto-restart them here:
|
|
512
|
+
// that would interrupt long-running generations the user may have started
|
|
513
|
+
// in other loops. The /mcp popover exposes an explicit "Reload" button so
|
|
514
|
+
// the user reloads on their own terms, on the loop they're currently in.
|
|
515
|
+
return { ok: true, serverName: flow.serverName }
|
|
516
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding flow for new users.
|
|
3
|
+
*
|
|
4
|
+
* Surface:
|
|
5
|
+
* - Welcome card on Loops list page (frontend, see WelcomeCard.tsx)
|
|
6
|
+
* - One built-in skill `/loopat:onboarding` (server/templates/plugins/loopat/)
|
|
7
|
+
*
|
|
8
|
+
* State machine (per user):
|
|
9
|
+
*
|
|
10
|
+
* fresh ──"start"──→ started (with loopId) ──"done"──→ done
|
|
11
|
+
* │
|
|
12
|
+
* └──────── "skip" ─────────────────────────────────────→ done
|
|
13
|
+
*
|
|
14
|
+
* "Started" means we spawned an onboarding loop and the user is partway
|
|
15
|
+
* through. "Done" covers both completed and skipped — the Welcome card hides
|
|
16
|
+
* either way; we don't need to distinguish for UX.
|
|
17
|
+
*
|
|
18
|
+
* State lives in personal/<user>/.loopat/config.json under `onboarding`. Cache
|
|
19
|
+
* invalidation: writes go through readPersonalDisk → writeFile, so cached
|
|
20
|
+
* PersonalConfig stays correct via mtime check.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync } from "node:fs"
|
|
23
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
24
|
+
import { personalLoopatConfigPath, personalLoopatDir } from "./paths"
|
|
25
|
+
import type { OnboardingState, PersonalConfigDisk } from "./config"
|
|
26
|
+
import { createLoop } from "./loops"
|
|
27
|
+
|
|
28
|
+
/** Frontend-facing status. `fresh` = card shows "start"; `started` = card shows "continue". */
|
|
29
|
+
export type OnboardingStatus = {
|
|
30
|
+
state: "fresh" | "started" | "done"
|
|
31
|
+
loopId?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readDisk(user: string): Promise<PersonalConfigDisk> {
|
|
35
|
+
const path = personalLoopatConfigPath(user)
|
|
36
|
+
if (!existsSync(path)) {
|
|
37
|
+
return { providers: {} }
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readFile(path, "utf8")
|
|
41
|
+
const parsed = JSON.parse(raw) as PersonalConfigDisk
|
|
42
|
+
if (!parsed.providers || typeof parsed.providers !== "object") parsed.providers = {}
|
|
43
|
+
return parsed
|
|
44
|
+
} catch {
|
|
45
|
+
return { providers: {} }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function writeDisk(user: string, disk: PersonalConfigDisk): Promise<void> {
|
|
50
|
+
await mkdir(personalLoopatDir(user), { recursive: true })
|
|
51
|
+
await writeFile(personalLoopatConfigPath(user), JSON.stringify(disk, null, 2) + "\n")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function getOnboardingStatus(user: string): Promise<OnboardingStatus> {
|
|
55
|
+
const disk = await readDisk(user)
|
|
56
|
+
const o = disk.onboarding
|
|
57
|
+
if (!o) return { state: "fresh" }
|
|
58
|
+
// Treat anything that isn't explicitly "started" as completion. The agent
|
|
59
|
+
// writes this field via natural-language semantics (Edit on config.json),
|
|
60
|
+
// so it might say "done" / "completed" / "finished" / "complete" — they
|
|
61
|
+
// all mean the user has wrapped up the flow. Only "started" stays open.
|
|
62
|
+
if (o.status === "started") return { state: "started", loopId: o.loopId }
|
|
63
|
+
return { state: "done" }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function setOnboardingState(
|
|
67
|
+
user: string,
|
|
68
|
+
state: OnboardingState,
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const disk = await readDisk(user)
|
|
71
|
+
disk.onboarding = state
|
|
72
|
+
await writeDisk(user, disk)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create an onboarding loop and mark state as `started`. The loop itself is
|
|
77
|
+
* a regular loop — no special meta.kind — distinguished only by:
|
|
78
|
+
* - title "新手引导"
|
|
79
|
+
* - no repo (workdir is empty)
|
|
80
|
+
* - no sandbox (no toolchain needed)
|
|
81
|
+
*
|
|
82
|
+
* The kickoff message (`/loopat:onboarding`) is sent by the frontend after
|
|
83
|
+
* navigating to the loop, so we don't seed messages.jsonl from here.
|
|
84
|
+
*/
|
|
85
|
+
export async function startOnboardingLoop(user: string): Promise<{ loopId: string }> {
|
|
86
|
+
const loop = await createLoop({
|
|
87
|
+
title: "新手引导",
|
|
88
|
+
createdBy: user,
|
|
89
|
+
})
|
|
90
|
+
await setOnboardingState(user, {
|
|
91
|
+
status: "started",
|
|
92
|
+
loopId: loop.id,
|
|
93
|
+
at: new Date().toISOString(),
|
|
94
|
+
})
|
|
95
|
+
return { loopId: loop.id }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function markOnboardingDone(user: string): Promise<void> {
|
|
99
|
+
const existing = await getOnboardingStatus(user)
|
|
100
|
+
await setOnboardingState(user, {
|
|
101
|
+
status: "done",
|
|
102
|
+
loopId: existing.loopId,
|
|
103
|
+
at: new Date().toISOString(),
|
|
104
|
+
})
|
|
105
|
+
}
|