@llamaventures/cli 1.2.3 → 1.3.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/AGENT_BRIEFING.md CHANGED
@@ -76,8 +76,8 @@ Default to action. Ask only for genuine judgment.
76
76
 
77
77
  | Error | What to do |
78
78
  |---|---|
79
- | `Error[NO_AUTH]` | Tell user: mint a token at `command.llamaventures.vc/settings/tokens`, then `llama token set <llc_...>`. |
80
- | `Error[UNAUTHORIZED]` | Credentials rejected (revoked / expired / wrong account). Same recovery re-mint. |
79
+ | `Error[NO_AUTH]` | Tell user: run `llama auth login` (browser sign-in via Google, OAuth tokens stored in OS Keychain). For unattended/CI: mint a long-lived PAT at `command.llamaventures.vc/settings/tokens` and `llama token set <llc_...>`. |
80
+ | `Error[UNAUTHORIZED]` | Credentials rejected (revoked / expired / wrong account). If using OAuth: `llama auth login` again. If using PAT: re-mint. |
81
81
  | HTTP 5xx | Wait 5s, retry once. Two failures → tell the user "Command unavailable, will retry later." |
82
82
  | `Too many failed authentication attempts` (HTTP 429) | IP rate-limit. Wait until next UTC hour, OR switch network (e.g. tether to phone). |
83
83
 
@@ -87,7 +87,9 @@ Default to action. Ask only for genuine judgment.
87
87
 
88
88
  ```bash
89
89
  # Auth
90
- llama auth status
90
+ llama auth login # browser PKCE flow → OAuth tokens in OS Keychain (recommended)
91
+ llama auth logout # revoke + clear local
92
+ llama auth status # show identity + active method
91
93
 
92
94
  # Pipeline — read
93
95
  llama deal search "<name>"
package/README.md CHANGED
@@ -103,16 +103,37 @@ The client tries credentials **in this order**, on every call:
103
103
 
104
104
  | # | Source | Header sent | Best for |
105
105
  |---|--------|-------------|----------|
106
- | 1 | `gcloud auth print-identity-token` | `Authorization: Bearer …` | Team members on a workstation (zero config) |
107
- | 2 | `$LLAMA_TOKEN` env var | `X-Llama-Token` | CI runners, sandboxed cloud agents |
108
- | 3 | `~/.llama/token` (mode `0600`) | `X-Llama-Token` | Persistent local install |
109
- | 4 | `~/.llama-command/config.json` | `X-Llama-Token` | Legacy CLI v0.1 auto-migrates to `~/.llama/token` on first read |
106
+ | 1 | `llama auth login` (OAuth 2.1, OS Keychain) | `Authorization: Bearer …` | **Recommended for everyone.** One-shot browser login; tokens auto-refresh and survive reboots. |
107
+ | 2 | `gcloud auth print-identity-token` | `Authorization: Bearer …` | Workstations with gcloud already wired (zero config) |
108
+ | 3 | `$LLAMA_TOKEN` env var | `X-Llama-Token` | CI runners, sandboxed cloud agents |
109
+ | 4 | `~/.llama/token` (mode `0600`) | `X-Llama-Token` | Persistent local install (legacy PATs) |
110
+ | 5 | `~/.llama-command/config.json` | `X-Llama-Token` | CLI v0.1 — auto-migrates to `~/.llama/token` |
110
111
 
111
- Both Bearer and X-Llama-Token are sent if both exist. The server tries Bearer
112
- first; on verification failure it falls through to X-Llama-Token. Inspect the
113
- resolved identity any time with `llama auth status`.
112
+ If both Bearer and X-Llama-Token are present, both are sent — the server tries
113
+ Bearer first and falls through to X-Llama-Token on verification failure.
114
+ Inspect the resolved identity any time with `llama auth status`.
114
115
 
115
- ### Zero-config — recommended for team members
116
+ ### Browser sign-in — recommended
117
+
118
+ ```bash
119
+ llama auth login # opens browser → Google sign-in → consent → done
120
+ llama auth status # → activeMethod=oauth, scope, identity
121
+ llama deal search acme-ai # ready
122
+ ```
123
+
124
+ `llama auth login` runs an OAuth 2.1 PKCE + RFC 8252 loopback flow against
125
+ `https://command.llamaventures.vc`, exchanges the code for an access + refresh
126
+ token pair, and stores them in the OS Keychain (macOS Keychain / Windows
127
+ Credential Manager / Linux Secret Service via [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring)).
128
+ Linux containers without libsecret use a 0600-mode file at `~/.llama/oauth.json`
129
+ — same posture `gcloud` / `gh` / `aws` ship with on Linux servers. Refresh
130
+ tokens rotate transparently when the access token nears expiry; a cross-process
131
+ file lock prevents two shells from burning each other's refresh during
132
+ concurrent calls.
133
+
134
+ `llama auth logout` revokes server-side via RFC 7009 and clears local storage.
135
+
136
+ ### gcloud — for machines already wired with `gcloud auth login`
116
137
 
117
138
  ```bash
118
139
  gcloud auth login # one-time; pick your @llamaventures.vc account
@@ -120,7 +141,7 @@ llama auth status # → role + email
120
141
  llama deal search acme-ai # ready
121
142
  ```
122
143
 
123
- ### Manual token — for machines without `gcloud`, or stable CI
144
+ ### Long-lived PAT — for CI / unattended environments
124
145
 
125
146
  1. Sign in to https://command.llamaventures.vc.
126
147
  2. Open `/settings/tokens` → **Mint Token**.
package/bin/llama.mjs CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  startExternalSession,
29
29
  uploadExternalFile,
30
30
  } from "../lib/external.mjs";
31
+ import { LLAMA_CLI_CLIENT_ID, pkceLoopbackFlow, revokeToken as revokeOAuthToken } from "../lib/oauth-flow.mjs";
32
+ import { deleteBundle, detectBackend, readBundle, writeBundle } from "../lib/oauth-storage.mjs";
31
33
 
32
34
  function parseFlags(args) {
33
35
  const flags = {};
@@ -309,13 +311,19 @@ Upload a file (deck / pitch / one-pager):
309
311
  Interactive REPL (requires existing session):
310
312
  llama pitch
311
313
 
314
+ Wrap up the pitch (asks the agent to call finalize_intake immediately):
315
+ llama pitch finalize # use when you're done — agent stops asking
316
+
312
317
  Inspect / clean up:
313
318
  llama pitch status # session id, idle minutes, finalized?
314
319
  llama pitch end # clear local session state
315
320
 
316
321
  Caps (server-enforced):
317
- 5 sessions per IP per day, 3 per email per day, 30min idle timeout,
322
+ 5 sessions per IP per day, 3 per email per day, 60min idle timeout,
318
323
  100 messages per session, 1M tokens per session.
324
+
325
+ Environment:
326
+ LLAMA_API_URL override base URL (dev: http://localhost:3000)
319
327
  `);
320
328
  return;
321
329
  }
@@ -403,14 +411,49 @@ Caps (server-enforced):
403
411
  cleared: !!had,
404
412
  session_file: EXTERNAL_SESSION_FILE,
405
413
  note: had
406
- ? "Local session state cleared. Server-side session may still be active until idle timeout (30min)."
414
+ ? "Local session state cleared. Server-side session may still be active until idle timeout (60min)."
407
415
  : "No local session was active.",
408
416
  });
409
417
  return;
410
418
  }
411
419
 
420
+ if (action === "finalize") {
421
+ // Founder-initiated finalize: send a sentinel token in the chat
422
+ // stream that the system prompt recognizes as "wrap up now." The
423
+ // intake agent calls finalize_intake on this turn with whatever
424
+ // fields are recorded — no extra questions, no confirmation prompt.
425
+ // Local session is left as-is; on next read its `finalized=true`
426
+ // reflects the server's status.
427
+ const session = readExternalSession();
428
+ if (!session) {
429
+ throw new Error(
430
+ "No active pitch session. Run `llama pitch start --name \"...\" --email \"...\"` first."
431
+ );
432
+ }
433
+ if (session.finalized) {
434
+ throw new Error(
435
+ "This pitch session is already finalized. Run `llama pitch end` to clear local state."
436
+ );
437
+ }
438
+ process.stderr.write("Asking the agent to wrap up...\n");
439
+ const result = await sendExternalMessage("[FOUNDER_FINALIZE_REQUEST]");
440
+ process.stdout.write(result.text + "\n");
441
+ if (result.finalized) {
442
+ process.stderr.write("\n--- Pitch session finalized ---\n");
443
+ if (result.finalize_payload) {
444
+ process.stderr.write(JSON.stringify(result.finalize_payload, null, 2) + "\n");
445
+ }
446
+ } else {
447
+ process.stderr.write(
448
+ "\n⚠ Agent did not call finalize_intake on this turn. " +
449
+ "Try `llama pitch finalize` once more, or `llama pitch end` to abandon.\n"
450
+ );
451
+ }
452
+ return;
453
+ }
454
+
412
455
  // No action → REPL mode (requires existing session)
413
- if (action === undefined || (rest.length === 0 && !["start", "say", "upload", "status", "end"].includes(action))) {
456
+ if (action === undefined || (rest.length === 0 && !["start", "say", "upload", "status", "end", "finalize"].includes(action))) {
414
457
  // Treat any unknown bare action as "join existing session in REPL mode"
415
458
  const session = readExternalSession();
416
459
  if (!session) {
@@ -656,8 +699,11 @@ https://command.llamaventures.vc/settings/tokens, run
656
699
  ? "~/.llama-command/config.json (legacy)"
657
700
  : null;
658
701
 
702
+ const oauthBundle = await readBundle();
703
+ const oauthBackend = oauthBundle ? await detectBackend() : null;
704
+
659
705
  let serverCheck = "skipped (no credentials)";
660
- if (bearer || token) {
706
+ if (oauthBundle?.access_token || bearer || token) {
661
707
  try {
662
708
  const me = await request("GET", "/api/me");
663
709
  serverCheck = `ok — authenticated as ${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
@@ -666,12 +712,101 @@ https://command.llamaventures.vc/settings/tokens, run
666
712
  }
667
713
  }
668
714
 
669
- print({
715
+ const out = {
670
716
  baseUrl: getBaseUrl(),
717
+ activeMethod: oauthBundle?.access_token
718
+ ? "oauth"
719
+ : bearer
720
+ ? "gcloud-bearer"
721
+ : token
722
+ ? "llama-token"
723
+ : "none",
724
+ oauth: oauthBundle
725
+ ? {
726
+ storage: oauthBackend,
727
+ client_id: oauthBundle.client_id,
728
+ scope: oauthBundle.scope,
729
+ issuer: oauthBundle.issuer,
730
+ expires_in_seconds: Math.max(0, Math.round((oauthBundle.expires_at - Date.now()) / 1000)),
731
+ }
732
+ : "absent (run `llama auth login`)",
671
733
  gcloudIdentityToken: bearer ? "present" : "absent",
672
734
  llamaToken: token ? `${token.slice(0, 8)}...${token.slice(-4)}` : "absent",
673
735
  llamaTokenSource: tokenSrc,
674
736
  serverCheck,
737
+ };
738
+ print(out);
739
+ return;
740
+ }
741
+
742
+ // ============================================================
743
+ // auth login — PKCE + loopback browser flow
744
+ // ============================================================
745
+ if (area === "auth" && action === "login") {
746
+ const { flags } = parseFlags(rest);
747
+ const requestedScope = typeof flags.scope === "string" && flags.scope.trim()
748
+ ? flags.scope.trim()
749
+ : "read write";
750
+ const baseUrl = getBaseUrl();
751
+ const resource = baseUrl; // general API audience (oauthApiResource on the server)
752
+
753
+ console.error(`Signing in to ${baseUrl} as Llama CLI (client_id=${LLAMA_CLI_CLIENT_ID})...`);
754
+ const bundle = await pkceLoopbackFlow({ baseUrl, scope: requestedScope, resource });
755
+ const stored = await writeBundle({
756
+ access_token: bundle.access_token,
757
+ refresh_token: bundle.refresh_token,
758
+ expires_at: Date.now() + (bundle.expires_in ?? 3600) * 1000,
759
+ scope: bundle.scope,
760
+ client_id: bundle.client_id,
761
+ issuer: bundle.issuer,
762
+ resource: bundle.resource,
763
+ created_at: Date.now(),
764
+ });
765
+
766
+ // Verify by hitting /api/me with the new token.
767
+ let identity = "(unable to verify — /api/me did not respond)";
768
+ try {
769
+ const me = await request("GET", "/api/me");
770
+ identity = `${me?.email ?? "unknown"} (role: ${me?.role ?? "unknown"})`;
771
+ } catch (e) {
772
+ identity = `verification failed: ${e.message.split("\n")[0]}`;
773
+ }
774
+
775
+ print({
776
+ ok: true,
777
+ message: "Signed in",
778
+ identity,
779
+ storage: stored.backend,
780
+ scope: bundle.scope,
781
+ expires_in_seconds: bundle.expires_in,
782
+ });
783
+ return;
784
+ }
785
+
786
+ // ============================================================
787
+ // auth logout — revoke + clear local
788
+ // ============================================================
789
+ if (area === "auth" && action === "logout") {
790
+ const bundle = await readBundle();
791
+ if (!bundle) {
792
+ print({ ok: true, message: "No OAuth credentials to clear" });
793
+ return;
794
+ }
795
+ let revoked = false;
796
+ try {
797
+ revoked = await revokeOAuthToken({
798
+ baseUrl: bundle.issuer ?? getBaseUrl(),
799
+ token: bundle.refresh_token,
800
+ tokenTypeHint: "refresh_token",
801
+ });
802
+ } catch {
803
+ revoked = false;
804
+ }
805
+ await deleteBundle();
806
+ print({
807
+ ok: true,
808
+ message: "Signed out — local credentials cleared",
809
+ serverRevoke: revoked ? "succeeded" : "failed (server unreachable or token already invalid; local state cleared anyway)",
675
810
  });
676
811
  return;
677
812
  }
package/lib/client.mjs CHANGED
@@ -139,18 +139,54 @@ export async function tryGcloudIdentityToken() {
139
139
  }
140
140
  }
141
141
 
142
- // Build the auth header set. If both Bearer and X-Llama-Token are available,
143
- // send both — the server tries Bearer first and falls through to
144
- // X-Llama-Token on verification failure.
142
+ // Build the auth header set. Priority order (server tries them in this
143
+ // order too and falls through on failure):
144
+ //
145
+ // 1. OAuth access token from Keychain (`llama auth login`) — Bearer
146
+ // header. Auto-refreshes if near expiry. Highest priority because
147
+ // it's scope-aware + revocable.
148
+ // 2. gcloud identity token — Bearer header. Falls back if no OAuth.
149
+ // 3. X-Llama-Token PAT — sent alongside whatever Bearer was set, so
150
+ // server's authenticate() can fall through on Bearer-verify failure.
145
151
  export async function getAuthHeaders() {
146
152
  const headers = {};
147
- const bearer = await tryGcloudIdentityToken();
148
- if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
153
+ // Lazy import keeps zero-OAuth call paths fast and avoids loading
154
+ // @napi-rs/keyring's native binding when the user isn't using OAuth.
155
+ let oauthAccess = null;
156
+ try {
157
+ const { getValidAccessToken } = await import("./oauth-refresh.mjs");
158
+ oauthAccess = await getValidAccessToken();
159
+ } catch {
160
+ // OAuth modules failed to load (e.g. keyring native binding missing
161
+ // on this platform) — fall through to gcloud / PAT silently.
162
+ }
163
+ if (oauthAccess) {
164
+ headers["Authorization"] = `Bearer ${oauthAccess}`;
165
+ } else {
166
+ const bearer = await tryGcloudIdentityToken();
167
+ if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
168
+ }
149
169
  const token = getToken();
150
170
  if (token) headers["X-Llama-Token"] = token;
151
171
  return headers;
152
172
  }
153
173
 
174
+ /**
175
+ * Was the Bearer header on this request set from an OAuth access token?
176
+ * `request()` uses this to decide whether a 401 should trigger a
177
+ * refresh-and-retry-once path (only meaningful when we sent an OAuth
178
+ * token; gcloud / PAT 401s should NOT retry blindly).
179
+ */
180
+ async function bearerCameFromOAuth() {
181
+ try {
182
+ const { readBundle } = await import("./oauth-storage.mjs");
183
+ const bundle = await readBundle();
184
+ return Boolean(bundle?.access_token);
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
154
190
  // Structured no-credential error. Format is stable so agents can pattern-match
155
191
  // `Error[NO_AUTH]` and trigger a recovery flow.
156
192
  function noAuthError() {
@@ -181,6 +217,10 @@ function unauthorizedError() {
181
217
  }
182
218
 
183
219
  export async function request(method, endpoint, body) {
220
+ return requestWithRetry(method, endpoint, body, /* allowRetry */ true);
221
+ }
222
+
223
+ async function requestWithRetry(method, endpoint, body, allowRetry) {
184
224
  const authHeaders = await getAuthHeaders();
185
225
  if (Object.keys(authHeaders).length === 0) throw noAuthError();
186
226
  const res = await fetch(`${getBaseUrl()}${endpoint}`, {
@@ -191,7 +231,29 @@ export async function request(method, endpoint, body) {
191
231
  },
192
232
  body: body === undefined ? undefined : JSON.stringify(body),
193
233
  });
234
+
235
+ // 401 + we sent an OAuth Bearer + this is the first attempt → try a
236
+ // forced refresh once. Covers two cases: (a) clock skew between client
237
+ // and server pushed us past expiry mid-request, (b) server-side
238
+ // revocation occurred between the client cache and now. Either way,
239
+ // the refresh either succeeds (we retry once with the new access
240
+ // token) or fails (refresh token also dead — bubble UNAUTHORIZED).
241
+ if (res.status === 401 && allowRetry && (await bearerCameFromOAuth())) {
242
+ let refreshed = null;
243
+ try {
244
+ const { forceRefresh } = await import("./oauth-refresh.mjs");
245
+ refreshed = await forceRefresh();
246
+ } catch {
247
+ refreshed = null;
248
+ }
249
+ if (refreshed) {
250
+ return requestWithRetry(method, endpoint, body, /* allowRetry */ false);
251
+ }
252
+ throw unauthorizedError();
253
+ }
254
+
194
255
  if (res.status === 401) throw unauthorizedError();
256
+
195
257
  const text = await res.text();
196
258
  let data;
197
259
  try {
package/lib/external.mjs CHANGED
@@ -100,6 +100,10 @@ export async function startExternalSession({ name, email }) {
100
100
  pow_nonce: powNonce,
101
101
  user_agent: "@llamaventures/cli",
102
102
  }),
103
+ // Cap at 60s — start-session is PoW + DB insert, never legitimate
104
+ // beyond a few seconds. Without this, a network hang freezes the CLI
105
+ // indefinitely.
106
+ signal: AbortSignal.timeout(60_000),
103
107
  });
104
108
 
105
109
  if (!res.ok) {
@@ -218,6 +222,11 @@ export async function sendExternalMessage(message, { attachments, onChunk } = {}
218
222
  message,
219
223
  ...(attachments ? { attachments } : {}),
220
224
  }),
225
+ // 180s ceiling — covers a legitimate slow agent turn (multi-tool
226
+ // call + deck read + Sonnet ~2k token reply ≈ 90-120s in practice)
227
+ // while still detecting a dead connection. Without this, a hung
228
+ // SSE stream freezes the CLI indefinitely.
229
+ signal: AbortSignal.timeout(180_000),
221
230
  });
222
231
 
223
232
  if (!res.ok) {
@@ -343,6 +352,10 @@ export async function uploadExternalFile(filePath) {
343
352
  method: "POST",
344
353
  headers: { Cookie: `external_session=${session.session_id}` },
345
354
  body: formData,
355
+ // 180s ceiling — covers a 50MB upload over a slow tether (~280KB/s).
356
+ // Faster networks return in seconds; this only kicks in on a dead
357
+ // connection so the CLI doesn't hang forever.
358
+ signal: AbortSignal.timeout(180_000),
346
359
  });
347
360
 
348
361
  if (!res.ok) {
@@ -0,0 +1,245 @@
1
+ // OAuth 2.1 PKCE + loopback flow for the Llama CLI.
2
+ //
3
+ // Mirrors `gh auth login` / `gcloud auth login`: the CLI binds an
4
+ // ephemeral HTTP server on 127.0.0.1, opens the browser to the
5
+ // authorization endpoint with a PKCE challenge + state, and waits for
6
+ // the user to approve. The browser redirects to the loopback URL
7
+ // carrying the auth code; the local server captures it and shuts down.
8
+ // The CLI then exchanges the code (with the PKCE verifier) for tokens.
9
+ //
10
+ // Pure stdlib: node:crypto for PKCE, node:http for the loopback server,
11
+ // child_process for the platform-specific browser open. No third-party
12
+ // HTTP/OAuth client.
13
+ //
14
+ // RFC compliance: OAuth 2.1 + RFC 7636 PKCE S256 + RFC 8252 native-app
15
+ // loopback flow + RFC 8707 audience parameter.
16
+
17
+ import { createHash, randomBytes } from "crypto";
18
+ import http from "http";
19
+ import { spawn } from "child_process";
20
+
21
+ const CLIENT_ID = "llama-cli-official";
22
+ const REDIRECT_PATH = "/callback";
23
+ const FLOW_TIMEOUT_MS = 5 * 60 * 1000; // 5 min — generous for slow Google sign-in
24
+
25
+ // ============================================================
26
+ // PKCE primitives
27
+ // ============================================================
28
+
29
+ function base64url(buf) {
30
+ return buf
31
+ .toString("base64")
32
+ .replace(/=/g, "")
33
+ .replace(/\+/g, "-")
34
+ .replace(/\//g, "_");
35
+ }
36
+
37
+ export function generateVerifier() {
38
+ // RFC 7636 §4.1: 43-128 chars from unreserved alphabet. 32 random bytes
39
+ // → 43 base64url chars (256 bits entropy).
40
+ return base64url(randomBytes(32));
41
+ }
42
+
43
+ export function challengeFor(verifier) {
44
+ return base64url(createHash("sha256").update(verifier).digest());
45
+ }
46
+
47
+ // ============================================================
48
+ // Browser launcher
49
+ // ============================================================
50
+
51
+ function openBrowser(url) {
52
+ // Platform-native open. We never block on it (the user closes the
53
+ // browser when they're done; the loopback server is what we wait for).
54
+ let cmd, args;
55
+ if (process.platform === "darwin") {
56
+ cmd = "open";
57
+ args = [url];
58
+ } else if (process.platform === "win32") {
59
+ cmd = "cmd";
60
+ args = ["/c", "start", "", url];
61
+ } else {
62
+ cmd = "xdg-open";
63
+ args = [url];
64
+ }
65
+ try {
66
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
67
+ } catch {
68
+ // Best-effort — if we can't open the browser, the user can copy the
69
+ // URL from stderr. The loopback server keeps listening either way.
70
+ }
71
+ }
72
+
73
+ // ============================================================
74
+ // Loopback server response page
75
+ // ============================================================
76
+
77
+ function respondHtml(res, ok, message) {
78
+ const color = ok ? "#16a34a" : "#dc2626";
79
+ const title = ok ? "Llama CLI — Signed in" : "Llama CLI — Sign-in failed";
80
+ res.statusCode = ok ? 200 : 400;
81
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
82
+ res.end(`<!doctype html>
83
+ <html><head><meta charset="utf-8"><title>${title}</title>
84
+ <style>
85
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
86
+ background: #fafaf9; color: #292524; display: grid; place-items: center;
87
+ min-height: 100vh; margin: 0; }
88
+ .card { background: white; border: 1px solid #e7e5e4; border-radius: 8px;
89
+ padding: 32px 40px; max-width: 400px; text-align: center; }
90
+ h1 { margin: 0 0 12px; font-size: 18px; color: ${color}; }
91
+ p { margin: 0; color: #57534e; font-size: 14px; }
92
+ </style></head><body>
93
+ <div class="card"><h1>${title}</h1><p>${message}</p></div>
94
+ </body></html>`);
95
+ }
96
+
97
+ // ============================================================
98
+ // PKCE + loopback driver
99
+ // ============================================================
100
+
101
+ /**
102
+ * Run the full PKCE + loopback OAuth flow.
103
+ *
104
+ * @param {Object} opts
105
+ * @param {string} opts.baseUrl AS issuer (e.g. https://command.llamaventures.vc)
106
+ * @param {string} opts.scope Space-separated scope request (e.g. "read write")
107
+ * @param {string} opts.resource RFC 8707 audience the access token will bind to
108
+ * @returns {Promise<Object>} {access_token, refresh_token, expires_in, scope, token_type, redirect_uri}
109
+ */
110
+ export async function pkceLoopbackFlow({ baseUrl, scope, resource }) {
111
+ const verifier = generateVerifier();
112
+ const challenge = challengeFor(verifier);
113
+ const state = base64url(randomBytes(16));
114
+
115
+ // Bind the loopback server FIRST so we know the port for redirect_uri.
116
+ const server = http.createServer();
117
+ await new Promise((resolve, reject) => {
118
+ server.once("error", reject);
119
+ server.listen(0, "127.0.0.1", resolve);
120
+ });
121
+ const { port } = server.address();
122
+ const redirectUri = `http://127.0.0.1:${port}${REDIRECT_PATH}`;
123
+
124
+ // Set up the request handler now that we have the port.
125
+ const codePromise = new Promise((resolve, reject) => {
126
+ const timeoutId = setTimeout(() => {
127
+ try { server.close(); } catch { /* */ }
128
+ reject(new Error(
129
+ "Error[OAUTH_TIMEOUT]: Browser flow did not complete within " +
130
+ Math.round(FLOW_TIMEOUT_MS / 1000) + "s. Re-run `llama auth login`."
131
+ ));
132
+ }, FLOW_TIMEOUT_MS);
133
+
134
+ server.on("request", (req, res) => {
135
+ const url = new URL(req.url, "http://127.0.0.1");
136
+ if (url.pathname !== REDIRECT_PATH) {
137
+ res.statusCode = 404;
138
+ res.end();
139
+ return;
140
+ }
141
+ const code = url.searchParams.get("code");
142
+ const respState = url.searchParams.get("state");
143
+ const error = url.searchParams.get("error");
144
+ const errorDescription = url.searchParams.get("error_description") ?? "";
145
+
146
+ if (error) {
147
+ respondHtml(res, false, `${error}: ${errorDescription}`);
148
+ clearTimeout(timeoutId);
149
+ server.close();
150
+ reject(new Error(`Error[OAUTH_DENIED]: ${error} — ${errorDescription}`));
151
+ return;
152
+ }
153
+ if (respState !== state) {
154
+ respondHtml(res, false, "state parameter mismatch (CSRF defense)");
155
+ clearTimeout(timeoutId);
156
+ server.close();
157
+ reject(new Error("Error[OAUTH_BAD_STATE]: state mismatch — possible CSRF or stale callback"));
158
+ return;
159
+ }
160
+ if (!code) {
161
+ respondHtml(res, false, "missing code parameter");
162
+ clearTimeout(timeoutId);
163
+ server.close();
164
+ reject(new Error("Error[OAUTH_BAD_CALLBACK]: callback missing code parameter"));
165
+ return;
166
+ }
167
+
168
+ respondHtml(res, true, "You can close this window and return to the terminal.");
169
+ clearTimeout(timeoutId);
170
+ server.close();
171
+ resolve(code);
172
+ });
173
+ });
174
+
175
+ // Build authorize URL and open browser.
176
+ const authorizeUrl = new URL(`${baseUrl}/api/oauth/authorize`);
177
+ authorizeUrl.searchParams.set("response_type", "code");
178
+ authorizeUrl.searchParams.set("client_id", CLIENT_ID);
179
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
180
+ authorizeUrl.searchParams.set("scope", scope);
181
+ authorizeUrl.searchParams.set("state", state);
182
+ authorizeUrl.searchParams.set("code_challenge", challenge);
183
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
184
+ authorizeUrl.searchParams.set("resource", resource);
185
+
186
+ console.error(`Opening browser to ${baseUrl} for sign-in...`);
187
+ console.error(`(If the browser does not open, visit this URL manually:\n ${authorizeUrl.toString()}\n)`);
188
+ openBrowser(authorizeUrl.toString());
189
+
190
+ const code = await codePromise;
191
+
192
+ // Exchange code → tokens.
193
+ const tokenBody = new URLSearchParams({
194
+ grant_type: "authorization_code",
195
+ code,
196
+ redirect_uri: redirectUri,
197
+ client_id: CLIENT_ID,
198
+ code_verifier: verifier,
199
+ resource,
200
+ }).toString();
201
+
202
+ const tokenRes = await fetch(`${baseUrl}/api/oauth/token`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
205
+ body: tokenBody,
206
+ });
207
+ const tokenJson = await tokenRes.json().catch(() => ({}));
208
+ if (!tokenRes.ok) {
209
+ throw new Error(
210
+ `Error[OAUTH_TOKEN_EXCHANGE_FAILED]: ${tokenJson.error ?? tokenRes.status} — ${tokenJson.error_description ?? "no description"}`
211
+ );
212
+ }
213
+
214
+ return {
215
+ access_token: tokenJson.access_token,
216
+ refresh_token: tokenJson.refresh_token,
217
+ expires_in: tokenJson.expires_in ?? 3600,
218
+ scope: tokenJson.scope ?? scope,
219
+ token_type: tokenJson.token_type ?? "Bearer",
220
+ client_id: CLIENT_ID,
221
+ resource,
222
+ issuer: baseUrl,
223
+ };
224
+ }
225
+
226
+ // ============================================================
227
+ // Token revoke (used by `llama auth logout`)
228
+ // ============================================================
229
+
230
+ export async function revokeToken({ baseUrl, token, tokenTypeHint }) {
231
+ const body = new URLSearchParams({
232
+ token,
233
+ client_id: CLIENT_ID,
234
+ ...(tokenTypeHint ? { token_type_hint: tokenTypeHint } : {}),
235
+ }).toString();
236
+ const res = await fetch(`${baseUrl}/api/oauth/revoke`, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
239
+ body,
240
+ });
241
+ // RFC 7009 §2.2: 200 on success OR unknown token. Anything else is unexpected.
242
+ return res.ok;
243
+ }
244
+
245
+ export const LLAMA_CLI_CLIENT_ID = CLIENT_ID;
@@ -0,0 +1,87 @@
1
+ // OAuth refresh-token rotation for the Llama CLI.
2
+ //
3
+ // Called from lib/client.mjs::request when an OAuth-bearing call returns
4
+ // 401. We exchange the stored refresh_token for a new (access, refresh)
5
+ // pair via POST /api/oauth/token, persist the new bundle, and surface
6
+ // the new access_token so the caller can retry once.
7
+ //
8
+ // Cross-process locking via oauth-storage.withRefreshLock so two shells
9
+ // hitting 401 simultaneously don't burn each other's refresh token.
10
+ // After acquiring the lock we re-read the bundle in case the other
11
+ // shell has already refreshed.
12
+
13
+ import { LLAMA_CLI_CLIENT_ID } from "./oauth-flow.mjs";
14
+ import { readBundle, withRefreshLock, writeBundle } from "./oauth-storage.mjs";
15
+
16
+ const ACCESS_TOKEN_SKEW_MS = 30_000; // refresh proactively 30s before expiry
17
+
18
+ /**
19
+ * Returns the current access token if non-expired, else attempts
20
+ * refresh. Returns null if no bundle is stored, refresh fails, or the
21
+ * refresh token itself is expired/revoked (caller should fall through
22
+ * to the next auth method or surface NO_AUTH).
23
+ */
24
+ export async function getValidAccessToken() {
25
+ const bundle = await readBundle();
26
+ if (!bundle?.access_token) return null;
27
+ if (bundle.expires_at - Date.now() > ACCESS_TOKEN_SKEW_MS) {
28
+ return bundle.access_token;
29
+ }
30
+ // Near or past expiry — refresh under lock.
31
+ return refreshUnderLock();
32
+ }
33
+
34
+ /**
35
+ * Force a refresh regardless of expiry. Used by client.mjs on a 401
36
+ * with an OAuth bundle present (the access token may have been revoked
37
+ * server-side, in which case the refresh might still work).
38
+ */
39
+ export async function forceRefresh() {
40
+ return refreshUnderLock();
41
+ }
42
+
43
+ async function refreshUnderLock() {
44
+ return withRefreshLock(async () => {
45
+ // Re-read inside the lock — another shell may have refreshed already.
46
+ const fresh = await readBundle();
47
+ if (!fresh?.refresh_token) return null;
48
+ if (fresh.expires_at - Date.now() > ACCESS_TOKEN_SKEW_MS) {
49
+ // Another shell already refreshed; we're good.
50
+ return fresh.access_token;
51
+ }
52
+ return performRefresh(fresh);
53
+ });
54
+ }
55
+
56
+ async function performRefresh(bundle) {
57
+ const body = new URLSearchParams({
58
+ grant_type: "refresh_token",
59
+ refresh_token: bundle.refresh_token,
60
+ client_id: bundle.client_id ?? LLAMA_CLI_CLIENT_ID,
61
+ resource: bundle.resource,
62
+ }).toString();
63
+
64
+ const res = await fetch(`${bundle.issuer}/api/oauth/token`, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
67
+ body,
68
+ });
69
+ if (!res.ok) {
70
+ // Refresh failed — most likely refresh expired or grant was revoked.
71
+ // Don't delete the bundle automatically; the user might want to
72
+ // inspect it or `llama auth logout` themselves to clear it.
73
+ return null;
74
+ }
75
+ const json = await res.json().catch(() => null);
76
+ if (!json?.access_token || !json?.refresh_token) return null;
77
+
78
+ const newBundle = {
79
+ ...bundle,
80
+ access_token: json.access_token,
81
+ refresh_token: json.refresh_token,
82
+ expires_at: Date.now() + (json.expires_in ?? 3600) * 1000,
83
+ scope: json.scope ?? bundle.scope,
84
+ };
85
+ await writeBundle(newBundle);
86
+ return newBundle.access_token;
87
+ }
@@ -0,0 +1,191 @@
1
+ // OAuth credential storage for the Llama CLI.
2
+ //
3
+ // Persists the access_token / refresh_token / expires_at bundle returned
4
+ // by the Llama Command authorization server. Two backends, in order:
5
+ //
6
+ // 1. OS Keychain via @napi-rs/keyring — macOS Keychain, Windows
7
+ // Credential Manager, Linux Secret Service (libsecret). Industry
8
+ // standard for desktop CLIs (gh, gcloud, Azure SDK).
9
+ //
10
+ // 2. Plain file `~/.llama/oauth.json` mode 0600 — used when the
11
+ // Keychain backend isn't available (Linux container with no
12
+ // libsecret, headless CI runner). Same posture as the existing
13
+ // `~/.llama/token` for PATs, and the same posture gh/gcloud/aws
14
+ // ship with on Linux servers.
15
+ //
16
+ // Cross-process lock: the refresh-token rotation contract requires that
17
+ // two shells refreshing simultaneously don't burn each other's refresh
18
+ // token. We coordinate via atomic O_CREAT|O_EXCL on `~/.llama/oauth.lock`
19
+ // with a short retry window, and after acquiring re-read the credentials
20
+ // in case the other shell already refreshed.
21
+
22
+ import fs from "fs";
23
+ import os from "os";
24
+ import path from "path";
25
+
26
+ const SERVICE = "com.llamaventures.cli";
27
+ const ACCOUNT = "oauth";
28
+
29
+ const STORE_DIR = path.join(os.homedir(), ".llama");
30
+ const FILE_PATH = path.join(STORE_DIR, "oauth.json");
31
+ const LOCK_PATH = path.join(STORE_DIR, "oauth.lock");
32
+
33
+ // ============================================================
34
+ // Keychain backend (lazy-loaded — keep startup fast)
35
+ // ============================================================
36
+
37
+ let _keychainEntry = null;
38
+ let _keychainTried = false;
39
+
40
+ async function getKeychainEntry() {
41
+ if (_keychainTried) return _keychainEntry;
42
+ _keychainTried = true;
43
+ try {
44
+ const { Entry } = await import("@napi-rs/keyring");
45
+ _keychainEntry = new Entry(SERVICE, ACCOUNT);
46
+ // Probe — if the platform backend is missing (e.g. Linux without
47
+ // libsecret), the Entry methods throw on first use. Surface that
48
+ // here so callers route to the file backend.
49
+ try {
50
+ _keychainEntry.getPassword();
51
+ } catch (err) {
52
+ const msg = String(err?.message ?? err);
53
+ // "no entry" / "not found" is fine — backend works, just empty.
54
+ // Any other error means the backend itself is unavailable.
55
+ if (!/no entry|not found|no such/i.test(msg)) {
56
+ _keychainEntry = null;
57
+ }
58
+ }
59
+ } catch {
60
+ _keychainEntry = null;
61
+ }
62
+ return _keychainEntry;
63
+ }
64
+
65
+ // ============================================================
66
+ // Bundle shape
67
+ // ============================================================
68
+
69
+ /**
70
+ * @typedef {Object} OAuthBundle
71
+ * @property {string} access_token
72
+ * @property {string} refresh_token
73
+ * @property {number} expires_at absolute ms epoch when access_token expires
74
+ * @property {string} scope space-separated, OAuth wire format
75
+ * @property {string} client_id which AS client minted this bundle
76
+ * @property {string} issuer AS issuer URL — bundle is bound to it
77
+ * @property {string} resource RFC 8707 audience the access_token is for
78
+ * @property {number} created_at ms epoch when bundle was first stored
79
+ */
80
+
81
+ // ============================================================
82
+ // Read / write / delete
83
+ // ============================================================
84
+
85
+ export async function readBundle() {
86
+ const entry = await getKeychainEntry();
87
+ if (entry) {
88
+ try {
89
+ const raw = entry.getPassword();
90
+ if (raw) return JSON.parse(raw);
91
+ } catch {
92
+ // fall through to file
93
+ }
94
+ }
95
+ try {
96
+ const raw = fs.readFileSync(FILE_PATH, "utf8");
97
+ return JSON.parse(raw);
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export async function writeBundle(bundle) {
104
+ const json = JSON.stringify(bundle);
105
+ const entry = await getKeychainEntry();
106
+ if (entry) {
107
+ try {
108
+ entry.setPassword(json);
109
+ // Best-effort cleanup: if a stale plaintext file exists from a
110
+ // pre-Keychain install, remove it so we don't have two copies of
111
+ // the credential drifting.
112
+ try { fs.unlinkSync(FILE_PATH); } catch { /* not present */ }
113
+ return { backend: "keychain" };
114
+ } catch {
115
+ // fall through to file
116
+ }
117
+ }
118
+ fs.mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
119
+ fs.writeFileSync(FILE_PATH, `${json}\n`, { mode: 0o600 });
120
+ fs.chmodSync(FILE_PATH, 0o600);
121
+ return { backend: "file" };
122
+ }
123
+
124
+ export async function deleteBundle() {
125
+ const entry = await getKeychainEntry();
126
+ if (entry) {
127
+ try { entry.deletePassword(); } catch { /* may not be present */ }
128
+ }
129
+ try { fs.unlinkSync(FILE_PATH); } catch { /* may not be present */ }
130
+ }
131
+
132
+ export async function detectBackend() {
133
+ const entry = await getKeychainEntry();
134
+ return entry ? "keychain" : "file";
135
+ }
136
+
137
+ // ============================================================
138
+ // Cross-process lock
139
+ // ============================================================
140
+ //
141
+ // Refresh rotation requires that only ONE process at a time exchange
142
+ // the current refresh token. Without a lock, two CLI invocations racing
143
+ // on token expiry would both POST /oauth/token; the first wins, the
144
+ // second gets `invalid_grant` (because the first already rotated), and
145
+ // the user sees a confusing failure.
146
+ //
147
+ // Pattern: atomic O_CREAT | O_EXCL on a sentinel file. If we get the
148
+ // fd, we own the lock; on EEXIST, another process owns it — wait briefly
149
+ // and retry. After acquiring, ALWAYS re-read the bundle from storage in
150
+ // case the other process has refreshed in the meantime (then we don't
151
+ // need to refresh ourselves).
152
+
153
+ const LOCK_RETRY_MS = 100;
154
+ const LOCK_TIMEOUT_MS = 5_000;
155
+
156
+ export async function withRefreshLock(fn) {
157
+ fs.mkdirSync(STORE_DIR, { recursive: true, mode: 0o700 });
158
+ const start = Date.now();
159
+ let fd;
160
+ while (true) {
161
+ try {
162
+ fd = fs.openSync(LOCK_PATH, "wx", 0o600);
163
+ break;
164
+ } catch (err) {
165
+ if (err.code !== "EEXIST") throw err;
166
+ // Stale lock cleanup: if the lock file is older than the timeout,
167
+ // the holding process likely crashed. Remove and retry.
168
+ try {
169
+ const stat = fs.statSync(LOCK_PATH);
170
+ if (Date.now() - stat.mtimeMs > LOCK_TIMEOUT_MS) {
171
+ fs.unlinkSync(LOCK_PATH);
172
+ continue;
173
+ }
174
+ } catch { /* lock disappeared between EEXIST and stat — fine */ }
175
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
176
+ throw new Error(
177
+ "Error[OAUTH_LOCK_TIMEOUT]: Could not acquire OAuth refresh lock at " +
178
+ LOCK_PATH + ". Another `llama` process may be hung. Remove the " +
179
+ "lock file manually if you're sure no other CLI is running."
180
+ );
181
+ }
182
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
183
+ }
184
+ }
185
+ try {
186
+ return await fn();
187
+ } finally {
188
+ try { fs.closeSync(fd); } catch { /* already closed */ }
189
+ try { fs.unlinkSync(LOCK_PATH); } catch { /* already gone */ }
190
+ }
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llamaventures/cli",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Llama Ventures CLI + MCP server. Internal team tool for command.llamaventures.vc.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@modelcontextprotocol/sdk": "1.29.0",
48
+ "@napi-rs/keyring": "^1.3.0",
48
49
  "zod": "^4.4.3"
49
50
  }
50
51
  }