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.
Files changed (58) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +194 -0
  3. package/bin/loopat.mjs +65 -0
  4. package/package.json +52 -0
  5. package/server/package.json +22 -0
  6. package/server/src/api-tokens.ts +161 -0
  7. package/server/src/api-v1-openapi.ts +363 -0
  8. package/server/src/api-v1.ts +681 -0
  9. package/server/src/auth.ts +309 -0
  10. package/server/src/bootstrap.ts +113 -0
  11. package/server/src/chat.ts +390 -0
  12. package/server/src/claude-binary.ts +68 -0
  13. package/server/src/compose.ts +474 -0
  14. package/server/src/config.ts +783 -0
  15. package/server/src/files.ts +173 -0
  16. package/server/src/git-crypt-key.ts +36 -0
  17. package/server/src/git-host.ts +104 -0
  18. package/server/src/github.ts +161 -0
  19. package/server/src/index.ts +3204 -0
  20. package/server/src/kanban.ts +810 -0
  21. package/server/src/loop-stats.ts +225 -0
  22. package/server/src/loop-status.ts +67 -0
  23. package/server/src/loops.ts +1832 -0
  24. package/server/src/mcp-oauth.ts +516 -0
  25. package/server/src/onboarding.ts +105 -0
  26. package/server/src/paths.ts +190 -0
  27. package/server/src/personal-keys.ts +60 -0
  28. package/server/src/plugin-installer.ts +287 -0
  29. package/server/src/podman.ts +1216 -0
  30. package/server/src/presets.ts +30 -0
  31. package/server/src/profiles.ts +177 -0
  32. package/server/src/providers.ts +45 -0
  33. package/server/src/serve.ts +275 -0
  34. package/server/src/session.ts +1496 -0
  35. package/server/src/system-prompt.ts +90 -0
  36. package/server/src/term.ts +211 -0
  37. package/server/src/tiers.ts +762 -0
  38. package/server/src/vaults.ts +189 -0
  39. package/server/src/workspace.ts +501 -0
  40. package/server/templates/.claude-plugin/marketplace.json +13 -0
  41. package/server/templates/CLAUDE.md +78 -0
  42. package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
  43. package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
  44. package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
  45. package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
  46. package/server/templates/sandbox/Containerfile +113 -0
  47. package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
  48. package/web/dist/assets/Editor-DMS25Vve.js +1 -0
  49. package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
  50. package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
  51. package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
  52. package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
  53. package/web/dist/assets/index-DM5eO-Tv.js +163 -0
  54. package/web/dist/assets/index-DxIFezwv.css +1 -0
  55. package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
  56. package/web/dist/favicon.svg +1 -0
  57. package/web/dist/index.html +14 -0
  58. 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
+ }