@llamaventures/cli 2.5.0 → 2.5.2
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 +19 -2
- package/CHANGELOG.md +22 -0
- package/README.md +16 -1
- package/README.zh-CN.md +12 -1
- package/bin/llama-mcp.mjs +2 -2
- package/bin/llama.mjs +23 -5
- package/lib/build-manifest.json +2 -2
- package/lib/client.mjs +9 -6
- package/lib/oauth-flow.mjs +17 -12
- package/lib/oauth-refresh.mjs +68 -66
- package/package.json +4 -3
package/AGENT_BRIEFING.md
CHANGED
|
@@ -219,8 +219,25 @@ language is invalid. New scalar Page prose is also invalid; historical scalar
|
|
|
219
219
|
prose is read-compatible only. Information and raw Input retain their original
|
|
220
220
|
language and provenance; do not manufacture a second source quote.
|
|
221
221
|
|
|
222
|
-
Artifacts use `artifact.put`.
|
|
223
|
-
|
|
222
|
+
Artifacts use `artifact.put`. Read the Deal files first. To revise an existing
|
|
223
|
+
file, reuse its `artifactId` and send the complete revised file, preserving
|
|
224
|
+
applicable metadata; this adds an immutable version even when the title changes.
|
|
225
|
+
`page.patch` does not edit file bytes. Omit the ID for a new document without a
|
|
226
|
+
title collision; use a fresh UUID explicitly for a distinct same-name source.
|
|
227
|
+
On `409 OCCAM_CONFLICT`, read the candidate documents and select an existing ID
|
|
228
|
+
or intentionally create a distinct source. Do not treat a title as identity or
|
|
229
|
+
rename a file to bypass the check. Ask only when the intended target remains
|
|
230
|
+
unclear after reading. Verify the returned ID and version; preserve old links.
|
|
231
|
+
Reuse the reference when file and metadata are unchanged. Separately decide
|
|
232
|
+
whether its content supports Information or a Page update.
|
|
233
|
+
|
|
234
|
+
Send complete bytes and the actual MIME type through `artifact.put`. Core hosts
|
|
235
|
+
`text/html` in Command; never mirror it to Drive or require a Drive link for
|
|
236
|
+
success. Other source files, including original transcripts and recordings, go
|
|
237
|
+
to the Deal's Drive folder; the database retains metadata and references. Do
|
|
238
|
+
not duplicate source-file bodies in Input, Information, Page or metadata. Save
|
|
239
|
+
extracted facts and summaries with source references, preserve the user's
|
|
240
|
+
request, and verify the exact Artifact version and hash through Command.
|
|
224
241
|
|
|
225
242
|
## Verification and safety
|
|
226
243
|
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ this project adheres to [Semantic Versioning](https://semver.org).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Renew rejected OAuth access credentials even when the cached expiry is still
|
|
12
|
+
in the future; concurrent requests reuse one renewed credential.
|
|
13
|
+
- Request browser sign-in when authorization expires or is revoked, and retain
|
|
14
|
+
the selected OAuth identity instead of falling back to another credential.
|
|
15
|
+
- Validate callback state on denial and escape browser callback error text.
|
|
16
|
+
- Document automatic renewal and the server's fixed 90-day authorization policy.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [2.5.1] - 2026-09-28
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Align CLI help and MCP guidance with Core storage routing: HTML is hosted in Command; non-HTML source files, including transcripts and recordings, live in Drive. Verify exact versions without requiring a Drive link for HTML.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Clarify when to append a version to an existing artifact versus create a distinct source, preserving document identity and old links.
|
|
28
|
+
|
|
29
|
+
## [2.5.0] - 2026-09-10
|
|
30
|
+
|
|
9
31
|
### Added
|
|
10
32
|
|
|
11
33
|
- Submit user/agent UX friction with `llama feedback submit` and MCP `feedback_submit`; read your receipt and resolution with `feedback show` / `feedback_show`.
|
package/README.md
CHANGED
|
@@ -6,6 +6,13 @@ CLI 2 replaces the split Deal command surface with exactly four actions. Core
|
|
|
6
6
|
owns database writes, Drive provisioning, audit Events, provenance, and
|
|
7
7
|
idempotency. Agents never touch PostgreSQL directly.
|
|
8
8
|
|
|
9
|
+
Sign in with `llama auth login`: approve access in your browser; the CLI stores
|
|
10
|
+
credentials in the OS credential store when available and renews them automatically.
|
|
11
|
+
New CLI browser authorizations last up to 90 days, then require another browser
|
|
12
|
+
sign-in. Logout, administrator revocation, or account deactivation can end access
|
|
13
|
+
sooner. On systems without a credential store, credentials are stored in a local
|
|
14
|
+
file readable only by your user.
|
|
15
|
+
|
|
9
16
|
## Install or upgrade
|
|
10
17
|
|
|
11
18
|
```bash
|
|
@@ -84,7 +91,15 @@ the initial Live Page and Information, and appends Events atomically.
|
|
|
84
91
|
- `input.submit` — preserve raw input in the Event Feed.
|
|
85
92
|
- `information.put` — add or update one structured memory unit.
|
|
86
93
|
- `page.patch` — update the human-visible Live Deal Page.
|
|
87
|
-
- `artifact.put` —
|
|
94
|
+
- `artifact.put` — create a file or append an immutable version by reusing its `artifactId`.
|
|
95
|
+
|
|
96
|
+
Read Deal files before uploading revisions. Reuse the existing `artifactId`
|
|
97
|
+
even when renaming; send complete revised bytes and preserve applicable metadata.
|
|
98
|
+
Omitting the ID creates a new file only when no current same-kind/title candidate
|
|
99
|
+
exists. On `409 OCCAM_CONFLICT`, read the candidates and select the intended ID;
|
|
100
|
+
for a genuinely distinct same-name source, explicitly supply a fresh UUID.
|
|
101
|
+
Titles are not document identities. `page.patch` does not edit file bytes.
|
|
102
|
+
Verify the intended ID/version and preserve old source links.
|
|
88
103
|
|
|
89
104
|
When Page prose is human-visible, write one bilingual field value as
|
|
90
105
|
`{"en":"natural English","zh":"自然中文"}`. The Web language switch selects
|
package/README.zh-CN.md
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
CLI 2 不再保留分裂的 Deal 命令。Core 负责数据库写入、Google Drive
|
|
6
6
|
文件夹、Event、provenance 和幂等;Agent 不直接操作 PostgreSQL。
|
|
7
7
|
|
|
8
|
+
运行 `llama auth login`,在浏览器中确认授权。CLI 优先使用系统钥匙串保存凭证,
|
|
9
|
+
并自动续期;新 CLI 浏览器授权最多持续 90 天,之后需要重新在浏览器登录。
|
|
10
|
+
退出登录、管理员撤销或账号停用可提前终止访问。没有系统钥匙串时,凭证保存在
|
|
11
|
+
仅当前用户可读的本地文件中。
|
|
12
|
+
|
|
8
13
|
## 安装或升级
|
|
9
14
|
|
|
10
15
|
```bash
|
|
@@ -82,7 +87,13 @@ Core 会补上 `operation: deal.create`,创建或复用 Drive 文件夹,并
|
|
|
82
87
|
- `input.submit`:把完整原始输入保存在 Event Feed;
|
|
83
88
|
- `information.put`:写入一个结构化工作记忆单元;
|
|
84
89
|
- `page.patch`:更新人类直接看到的 Live Deal Page;
|
|
85
|
-
- `artifact.put
|
|
90
|
+
- `artifact.put`:创建文件,或复用 `artifactId` 为已有文件追加不可变版本。
|
|
91
|
+
|
|
92
|
+
修改文件前先读项目的文件列表;即使改名,也复用原来的 `artifactId`,
|
|
93
|
+
上传完整的新文件并保留适用的 metadata。省略 ID 时,只有不存在当前同类型、
|
|
94
|
+
同标题文件才会新建。遇到 `409 OCCAM_CONFLICT`,读取候选内容来确定目标 ID;
|
|
95
|
+
确实是另一份同名材料时,明确生成新的 UUID 作为 `artifactId`。
|
|
96
|
+
标题不是文件身份,`page.patch` 也不修改文件内容。回读核对 ID 和版本,保留旧链接。
|
|
86
97
|
|
|
87
98
|
给人看的 Page 文字必须在写入时同时提供自然的中英文:
|
|
88
99
|
`{"en":"natural English","zh":"自然中文"}`。Web 的语言开关从同一个
|
package/bin/llama-mcp.mjs
CHANGED
|
@@ -113,7 +113,7 @@ const writeDealCommandSchema = z.discriminatedUnion("operation", [
|
|
|
113
113
|
z.object({
|
|
114
114
|
operation: z.literal("artifact.put"),
|
|
115
115
|
dealId: z.string().uuid(),
|
|
116
|
-
artifactId: z.string().uuid().optional(),
|
|
116
|
+
artifactId: z.string().uuid().optional().describe("Reuse the existing ID to append a complete file version, including on rename. Omit for a new document without a title collision, or explicitly use a fresh UUID for a distinct same-name source."),
|
|
117
117
|
kind: z.string().min(1).max(120),
|
|
118
118
|
title: z.string().min(1).max(500),
|
|
119
119
|
mimeType: z.string().min(1).max(240),
|
|
@@ -189,7 +189,7 @@ server.registerTool(
|
|
|
189
189
|
server.registerTool(
|
|
190
190
|
"write_deal",
|
|
191
191
|
{
|
|
192
|
-
description: "The only Deal mutation tool. Pass one command: input.submit, information.put, page.patch, or artifact.put. All Page content fields are Agent-writable; author controls attribution, not permission. Before page.patch, use get_live_deal_page_schema for the exact fields being changed and write bilingual Page prose. page.patch is JSON Merge Patch: arrays replace whole arrays, so read-modify-write and preserve sibling slots. A Llama user's own judgment is human_subjective_view.people or human_subjective_view.business and must carry value.speaker, value.rawText quoted verbatim from origin.originalUserUtterance, and value.summary (the agent's restatement); it is rejected otherwise.",
|
|
192
|
+
description: "The only Deal mutation tool. Pass one command: input.submit, information.put, page.patch, or artifact.put. Before artifact.put, read Deal files: reuse artifactId for a revision; a same-kind/title collision without an ID returns OCCAM_CONFLICT. Titles are candidates, not identity. Select the intended existing ID from the request/content, or explicitly generate a fresh UUID for a distinct same-name source. Send complete revised bytes, preserve applicable metadata, and verify the ID/version. Core hosts text/html in Command; do not mirror HTML to Drive or require a Drive link. Non-HTML source files, including original transcripts and recordings, go to Drive through artifact.put. Keep source-file bodies out of Input, Information, Page and metadata; save extracted facts and summaries with source references and preserve the user request. page.patch does not edit file bytes. All Page content fields are Agent-writable; author controls attribution, not permission. Before page.patch, use get_live_deal_page_schema for the exact fields being changed and write bilingual Page prose. page.patch is JSON Merge Patch: arrays replace whole arrays, so read-modify-write and preserve sibling slots. A Llama user's own judgment is human_subjective_view.people or human_subjective_view.business and must carry value.speaker, value.rawText quoted verbatim from origin.originalUserUtterance, and value.summary (the agent's restatement); it is rejected otherwise.",
|
|
193
193
|
// MCP SDK 1.30 only publishes object schemas at the tool root. Nesting one
|
|
194
194
|
// command preserves the real discriminated union instead of flattening four
|
|
195
195
|
// incompatible operations into false affordances.
|
package/bin/llama.mjs
CHANGED
|
@@ -122,11 +122,29 @@ write JSON (CLI derives idempotencyKey when omitted):
|
|
|
122
122
|
page.patch: {"operation":"page.patch","dealId":"<uuid>","patch":{"description":{"en":"...","zh":"..."}},"origin":{"kind":"agent"}}
|
|
123
123
|
artifact.put: {"operation":"artifact.put","dealId":"<uuid>","kind":"deck","title":"deck.pdf","mimeType":"application/pdf","contentBase64":"<file bytes, base64>","origin":{"kind":"user","originalUserUtterance":"..."}}
|
|
124
124
|
|
|
125
|
-
artifact.put
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
Before artifact.put, read the Deal files and distinguish a revision from a new source.
|
|
126
|
+
To revise a document, reuse its artifactId and send the complete new file, even
|
|
127
|
+
if its title changes. This appends a version; page.patch does not edit files.
|
|
128
|
+
For a new document, omit artifactId. A current same-kind/title collision returns
|
|
129
|
+
409 OCCAM_CONFLICT: read the candidate files and choose the intended existing ID,
|
|
130
|
+
or supply a fresh UUID as artifactId for a genuinely distinct same-name source.
|
|
131
|
+
A title is only a candidate, not identity. Do not rename just to bypass a conflict.
|
|
132
|
+
Preserve applicable metadata and old links. Verify the same artifactId and an
|
|
133
|
+
increased version (or idempotent replay). Reuse an unchanged file's reference.
|
|
134
|
+
|
|
135
|
+
Revision example:
|
|
136
|
+
{"operation":"artifact.put","dealId":"<uuid>","artifactId":"<existing artifact uuid>","kind":"memo","title":"Memo.html","mimeType":"text/html","contentBase64":"<complete revised file, base64>","origin":{"kind":"user","originalUserUtterance":"Revise the memo"}}
|
|
137
|
+
|
|
138
|
+
artifact.put sends complete bytes as contentBase64 with the actual mimeType.
|
|
139
|
+
Core hosts text/html in Command; do not upload or mirror HTML to Drive.
|
|
140
|
+
Non-HTML files, including original transcripts and recordings, go to the Deal's
|
|
141
|
+
Drive folder; the database keeps metadata and references, not the file bytes.
|
|
142
|
+
Do not copy source-file contents into input.submit, Information, Page or metadata.
|
|
143
|
+
Extract facts and summaries with source references; preserve the user's request.
|
|
144
|
+
Core derives byteSize and sha256. Do not supply a folder, storage key or URL for
|
|
145
|
+
a file you hold. Write the JSON to a file and pass its path. Existing non-HTML
|
|
146
|
+
Drive files may use storageKey and storageUrl plus byteSize and sha256 instead.
|
|
147
|
+
Verify the exact Artifact version and hash; hosted HTML needs no Drive link.
|
|
130
148
|
|
|
131
149
|
For Information, origin records who caused the write and preserves user words;
|
|
132
150
|
value.source records where the evidence came from.
|
package/lib/build-manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"format": "llama.cli-build.v1",
|
|
3
3
|
"packageName": "@llamaventures/cli",
|
|
4
|
-
"packageVersion": "2.5.
|
|
5
|
-
"sourceSha": "
|
|
4
|
+
"packageVersion": "2.5.2",
|
|
5
|
+
"sourceSha": "9acf49a35f9f08b75d844df906cf0744281e9870",
|
|
6
6
|
"sourceKind": "github",
|
|
7
7
|
"sourceDirty": false,
|
|
8
8
|
"coreApiContract": {
|
package/lib/client.mjs
CHANGED
|
@@ -459,7 +459,8 @@ export async function getAuthHeaders() {
|
|
|
459
459
|
try {
|
|
460
460
|
const { getValidAccessToken } = await import("./oauth-refresh.mjs");
|
|
461
461
|
oauthAccess = await getValidAccessToken();
|
|
462
|
-
} catch {
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (/^Error\[OAUTH_(REAUTH_REQUIRED|REFRESH_FAILED)\]/.test(error?.message ?? "")) throw error;
|
|
463
464
|
// OAuth modules failed to load (e.g. keyring native binding missing
|
|
464
465
|
// on this platform) — fall through to gcloud / PAT silently.
|
|
465
466
|
}
|
|
@@ -470,7 +471,7 @@ export async function getAuthHeaders() {
|
|
|
470
471
|
if (bearer) headers["Authorization"] = `Bearer ${bearer}`;
|
|
471
472
|
}
|
|
472
473
|
const token = getToken();
|
|
473
|
-
if (token) headers["X-Llama-Token"] = token;
|
|
474
|
+
if (token && !oauthAccess) headers["X-Llama-Token"] = token;
|
|
474
475
|
return headers;
|
|
475
476
|
}
|
|
476
477
|
|
|
@@ -630,8 +631,9 @@ async function requestWithRetry(method, endpoint, body, opts, allowRetry) {
|
|
|
630
631
|
let refreshed = null;
|
|
631
632
|
try {
|
|
632
633
|
const { forceRefresh } = await import("./oauth-refresh.mjs");
|
|
633
|
-
refreshed = await forceRefresh();
|
|
634
|
-
} catch {
|
|
634
|
+
refreshed = await forceRefresh(authHeaders.Authorization?.replace(/^Bearer /, ""));
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (/^Error\[OAUTH_(REAUTH_REQUIRED|REFRESH_FAILED)\]/.test(error?.message ?? "")) throw error;
|
|
635
637
|
refreshed = null;
|
|
636
638
|
}
|
|
637
639
|
if (refreshed) {
|
|
@@ -700,8 +702,9 @@ async function requestSseWithRetry(method, endpoint, body, opts, allowRetry) {
|
|
|
700
702
|
let refreshed = null;
|
|
701
703
|
try {
|
|
702
704
|
const { forceRefresh } = await import("./oauth-refresh.mjs");
|
|
703
|
-
refreshed = await forceRefresh();
|
|
704
|
-
} catch {
|
|
705
|
+
refreshed = await forceRefresh(authHeaders.Authorization?.replace(/^Bearer /, ""));
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (/^Error\[OAUTH_(REAUTH_REQUIRED|REFRESH_FAILED)\]/.test(error?.message ?? "")) throw error;
|
|
705
708
|
refreshed = null;
|
|
706
709
|
}
|
|
707
710
|
if (refreshed) {
|
package/lib/oauth-flow.mjs
CHANGED
|
@@ -63,7 +63,9 @@ function openBrowser(url) {
|
|
|
63
63
|
args = [url];
|
|
64
64
|
}
|
|
65
65
|
try {
|
|
66
|
-
spawn(cmd, args, { detached: true, stdio: "ignore" })
|
|
66
|
+
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
|
67
|
+
child.on("error", () => {}); // The printed URL remains available for manual sign-in.
|
|
68
|
+
child.unref();
|
|
67
69
|
} catch {
|
|
68
70
|
// Best-effort — if we can't open the browser, the user can copy the
|
|
69
71
|
// URL from stderr. The loopback server keeps listening either way.
|
|
@@ -79,6 +81,9 @@ function respondHtml(res, ok, message) {
|
|
|
79
81
|
const title = ok ? "Llama CLI — Signed in" : "Llama CLI — Sign-in failed";
|
|
80
82
|
res.statusCode = ok ? 200 : 400;
|
|
81
83
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
84
|
+
const safeMessage = message.replace(/[&<>"']/g, (c) => ({
|
|
85
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
86
|
+
})[c]);
|
|
82
87
|
res.end(`<!doctype html>
|
|
83
88
|
<html><head><meta charset="utf-8"><title>${title}</title>
|
|
84
89
|
<style>
|
|
@@ -90,7 +95,7 @@ function respondHtml(res, ok, message) {
|
|
|
90
95
|
h1 { margin: 0 0 12px; font-size: 18px; color: ${color}; }
|
|
91
96
|
p { margin: 0; color: #57534e; font-size: 14px; }
|
|
92
97
|
</style></head><body>
|
|
93
|
-
<div class="card"><h1>${title}</h1><p>${
|
|
98
|
+
<div class="card"><h1>${title}</h1><p>${safeMessage}</p></div>
|
|
94
99
|
</body></html>`);
|
|
95
100
|
}
|
|
96
101
|
|
|
@@ -103,11 +108,11 @@ function respondHtml(res, ok, message) {
|
|
|
103
108
|
*
|
|
104
109
|
* @param {Object} opts
|
|
105
110
|
* @param {string} opts.baseUrl AS issuer (e.g. https://command.llamaventures.vc)
|
|
106
|
-
* @param {string} opts.scope Space-separated
|
|
111
|
+
* @param {string} opts.scope Space-separated scopes, such as read and write
|
|
107
112
|
* @param {string} opts.resource RFC 8707 audience the access token will bind to
|
|
108
113
|
* @returns {Promise<Object>} {access_token, refresh_token, expires_in, scope, token_type, redirect_uri}
|
|
109
114
|
*/
|
|
110
|
-
export async function pkceLoopbackFlow({ baseUrl, scope, resource }) {
|
|
115
|
+
export async function pkceLoopbackFlow({ baseUrl, scope, resource, launchBrowser = openBrowser }) {
|
|
111
116
|
const verifier = generateVerifier();
|
|
112
117
|
const challenge = challengeFor(verifier);
|
|
113
118
|
const state = base64url(randomBytes(16));
|
|
@@ -143,18 +148,18 @@ export async function pkceLoopbackFlow({ baseUrl, scope, resource }) {
|
|
|
143
148
|
const error = url.searchParams.get("error");
|
|
144
149
|
const errorDescription = url.searchParams.get("error_description") ?? "";
|
|
145
150
|
|
|
146
|
-
if (
|
|
147
|
-
respondHtml(res, false,
|
|
151
|
+
if (respState !== state) {
|
|
152
|
+
respondHtml(res, false, "state parameter mismatch (CSRF defense)");
|
|
148
153
|
clearTimeout(timeoutId);
|
|
149
154
|
server.close();
|
|
150
|
-
reject(new Error(
|
|
155
|
+
reject(new Error("Error[OAUTH_BAD_STATE]: state mismatch — possible CSRF or stale callback"));
|
|
151
156
|
return;
|
|
152
157
|
}
|
|
153
|
-
if (
|
|
154
|
-
respondHtml(res, false,
|
|
158
|
+
if (error) {
|
|
159
|
+
respondHtml(res, false, `${error}: ${errorDescription}`);
|
|
155
160
|
clearTimeout(timeoutId);
|
|
156
161
|
server.close();
|
|
157
|
-
reject(new Error(
|
|
162
|
+
reject(new Error(`Error[OAUTH_DENIED]: ${error} — ${errorDescription}`));
|
|
158
163
|
return;
|
|
159
164
|
}
|
|
160
165
|
if (!code) {
|
|
@@ -165,7 +170,7 @@ export async function pkceLoopbackFlow({ baseUrl, scope, resource }) {
|
|
|
165
170
|
return;
|
|
166
171
|
}
|
|
167
172
|
|
|
168
|
-
respondHtml(res, true, "
|
|
173
|
+
respondHtml(res, true, "Authorization received. Return to the terminal to confirm sign-in completed.");
|
|
169
174
|
clearTimeout(timeoutId);
|
|
170
175
|
server.close();
|
|
171
176
|
resolve(code);
|
|
@@ -186,7 +191,7 @@ export async function pkceLoopbackFlow({ baseUrl, scope, resource }) {
|
|
|
186
191
|
|
|
187
192
|
console.error(`Opening browser to ${baseUrl} for sign-in...`);
|
|
188
193
|
console.error(`(If the browser does not open, visit this URL manually:\n ${authorizeUrl.toString()}\n)`);
|
|
189
|
-
|
|
194
|
+
launchBrowser(authorizeUrl.toString());
|
|
190
195
|
|
|
191
196
|
const code = await codePromise;
|
|
192
197
|
|
package/lib/oauth-refresh.mjs
CHANGED
|
@@ -13,75 +13,77 @@
|
|
|
13
13
|
import { LLAMA_CLI_CLIENT_ID } from "./oauth-flow.mjs";
|
|
14
14
|
import { readBundle, withRefreshLock, writeBundle } from "./oauth-storage.mjs";
|
|
15
15
|
|
|
16
|
-
const ACCESS_TOKEN_SKEW_MS = 30_000;
|
|
16
|
+
const ACCESS_TOKEN_SKEW_MS = 30_000;
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (!bundle?.access_token) return null;
|
|
27
|
-
if (bundle.expires_at - Date.now() > ACCESS_TOKEN_SKEW_MS) {
|
|
28
|
-
return bundle.access_token;
|
|
18
|
+
// The manager keeps credential I/O separate so renewal can be verified without
|
|
19
|
+
// accessing a user's keychain or sending requests to a production issuer.
|
|
20
|
+
export function createOAuthTokenManager({ readBundle, writeBundle, withRefreshLock, fetch }) {
|
|
21
|
+
async function getValidAccessToken() {
|
|
22
|
+
const bundle = await readBundle();
|
|
23
|
+
if (!bundle?.access_token) return null;
|
|
24
|
+
if (bundle.expires_at - Date.now() > ACCESS_TOKEN_SKEW_MS) return bundle.access_token;
|
|
25
|
+
return refreshUnderLock();
|
|
29
26
|
}
|
|
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
27
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
28
|
+
async function forceRefresh(rejectedAccessToken) {
|
|
29
|
+
const rejected = rejectedAccessToken ?? (await readBundle())?.access_token;
|
|
30
|
+
return refreshUnderLock(rejected);
|
|
74
31
|
}
|
|
75
|
-
const json = await res.json().catch(() => null);
|
|
76
|
-
if (!json?.access_token || !json?.refresh_token) return null;
|
|
77
32
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
33
|
+
async function refreshUnderLock(rejectedAccessToken) {
|
|
34
|
+
return withRefreshLock(async () => {
|
|
35
|
+
const fresh = await readBundle();
|
|
36
|
+
if (!fresh?.refresh_token) return null;
|
|
37
|
+
// Reuse a token renewed by another process, but never retry the token
|
|
38
|
+
// the API just rejected merely because its local expiry is in the future.
|
|
39
|
+
if (fresh.expires_at - Date.now() > ACCESS_TOKEN_SKEW_MS &&
|
|
40
|
+
(!rejectedAccessToken || fresh.access_token !== rejectedAccessToken)) {
|
|
41
|
+
return fresh.access_token;
|
|
42
|
+
}
|
|
43
|
+
const body = new URLSearchParams({
|
|
44
|
+
grant_type: "refresh_token",
|
|
45
|
+
refresh_token: fresh.refresh_token,
|
|
46
|
+
client_id: fresh.client_id ?? LLAMA_CLI_CLIENT_ID,
|
|
47
|
+
resource: fresh.resource,
|
|
48
|
+
}).toString();
|
|
49
|
+
let res;
|
|
50
|
+
try {
|
|
51
|
+
// @core-api-operation POST /api/oauth/token
|
|
52
|
+
res = await fetch(`${fresh.issuer}/api/oauth/token`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
55
|
+
body,
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error("Error[OAUTH_REFRESH_FAILED]: Could not reach the authorization server. Retry the command.");
|
|
59
|
+
}
|
|
60
|
+
const json = await res.json().catch(() => null);
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
if (json?.error === "invalid_grant") {
|
|
63
|
+
throw new Error("Error[OAUTH_REAUTH_REQUIRED]: Authorization expired or was revoked. Run `llama auth login` to sign in again in your browser.");
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Error[OAUTH_REFRESH_FAILED]: Could not renew authorization (HTTP ${res.status}). Retry the command.`);
|
|
66
|
+
}
|
|
67
|
+
if (!json?.access_token || !json?.refresh_token) {
|
|
68
|
+
throw new Error("Error[OAUTH_REFRESH_FAILED]: Invalid token response. Retry the command.");
|
|
69
|
+
}
|
|
70
|
+
const newBundle = {
|
|
71
|
+
...fresh,
|
|
72
|
+
access_token: json.access_token,
|
|
73
|
+
refresh_token: json.refresh_token,
|
|
74
|
+
expires_at: Date.now() + (json.expires_in ?? 3600) * 1000,
|
|
75
|
+
scope: json.scope ?? fresh.scope,
|
|
76
|
+
};
|
|
77
|
+
await writeBundle(newBundle);
|
|
78
|
+
return newBundle.access_token;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return { getValidAccessToken, forceRefresh };
|
|
87
82
|
}
|
|
83
|
+
|
|
84
|
+
const manager = createOAuthTokenManager({
|
|
85
|
+
readBundle, writeBundle, withRefreshLock,
|
|
86
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
87
|
+
});
|
|
88
|
+
export const getValidAccessToken = manager.getValidAccessToken;
|
|
89
|
+
export const forceRefresh = manager.forceRefresh;
|
package/package.json
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llamaventures/cli",
|
|
3
|
-
"version": "2.5.
|
|
3
|
+
"version": "2.5.2",
|
|
4
4
|
"description": "CLI + MCP server for the Llama Ventures investment workbench (command.llamaventures.vc).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "npm run test:agent-routing && npm run test:contract",
|
|
7
|
+
"test": "npm run test:auth && npm run test:agent-routing && npm run test:contract",
|
|
8
8
|
"test:agent-routing": "node scripts/verify-agent-routing.mjs",
|
|
9
9
|
"test:contract": "node --test scripts/feedback.test.mjs scripts/content-read.test.mjs scripts/build-manifest.test.mjs scripts/core-api-call-sites.test.mjs scripts/deal-actions-cli.test.mjs scripts/deal-actions.test.mjs scripts/deal-memory-actions-cli.test.mjs scripts/deal-memory-actions.test.mjs scripts/mcp-four-action-surface.test.mjs scripts/page-schema-cli.test.mjs scripts/page-schema.test.mjs scripts/server-compatibility.test.mjs scripts/verify-core-api-contract.test.mjs && node scripts/verify-core-api-contract.mjs",
|
|
10
10
|
"verify:artifact": "node scripts/verify-release-artifact.mjs",
|
|
11
11
|
"verify:release": "npm test && npm run verify:artifact && node scripts/verify-tarball-clean.mjs",
|
|
12
12
|
"prepack": "node scripts/prepare-build-manifest.mjs",
|
|
13
|
-
"postpack": "node scripts/cleanup-build-manifest.mjs"
|
|
13
|
+
"postpack": "node scripts/cleanup-build-manifest.mjs",
|
|
14
|
+
"test:auth": "node --test scripts/oauth-refresh.test.mjs scripts/oauth-flow.test.mjs"
|
|
14
15
|
},
|
|
15
16
|
"bin": {
|
|
16
17
|
"llama": "bin/llama.mjs",
|