@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 +5 -3
- package/README.md +30 -9
- package/bin/llama.mjs +140 -5
- package/lib/client.mjs +67 -5
- package/lib/external.mjs +13 -0
- package/lib/oauth-flow.mjs +245 -0
- package/lib/oauth-refresh.mjs +87 -0
- package/lib/oauth-storage.mjs +191 -0
- package/package.json +2 -1
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
|
|
80
|
-
| `Error[UNAUTHORIZED]` | Credentials rejected (revoked / expired / wrong account).
|
|
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
|
|
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 | `
|
|
107
|
-
| 2 |
|
|
108
|
-
| 3 |
|
|
109
|
-
| 4 | `~/.llama
|
|
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
|
-
|
|
112
|
-
first
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
143
|
-
//
|
|
144
|
-
//
|
|
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
|
-
|
|
148
|
-
|
|
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.
|
|
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
|
}
|