@tiens.nguyen/gu-cli 1.0.706 → 1.0.707
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/cli-token.mjs +60 -4
- package/credential-recovery.mjs +82 -0
- package/gu-repl.mjs +88 -17
- package/package.json +2 -1
package/cli-token.mjs
CHANGED
|
@@ -86,11 +86,13 @@ export function verifyCliToken(token, publicKeyB64) {
|
|
|
86
86
|
(p.issuedBy !== "refresh" && p.issuedBy !== "account" && p.issuedBy !== "admin") ||
|
|
87
87
|
!(p.device === null || (typeof p.device === "string" && /^[0-9a-f]{64}$/.test(p.device))) ||
|
|
88
88
|
!(p.activeDevices === null || (Number.isInteger(p.activeDevices) && p.activeDevices >= 0)) ||
|
|
89
|
-
!(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0)
|
|
89
|
+
!(Number.isInteger(p.deviceLimit) && p.deviceLimit > 0) ||
|
|
90
|
+
// Missing means OFF — tokens issued before the switch existed (#238) are in CLIs right now.
|
|
91
|
+
!(p.enforce === undefined || typeof p.enforce === "boolean")
|
|
90
92
|
) {
|
|
91
93
|
return { ok: false, why: "bad-payload" };
|
|
92
94
|
}
|
|
93
|
-
return { ok: true, payload: p };
|
|
95
|
+
return { ok: true, payload: { ...p, enforce: p.enforce === true } };
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
// ---------------------------------------------------------------------------
|
|
@@ -251,7 +253,7 @@ export function barNotice({ token, publicKeyB64, mode, now = new Date(), attempt
|
|
|
251
253
|
if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
252
254
|
return {
|
|
253
255
|
kind: "over-limit",
|
|
254
|
-
text: `gu is active on ${p.activeDevices}
|
|
256
|
+
text: `gu is active on ${p.activeDevices} devices — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
|
|
255
257
|
};
|
|
256
258
|
}
|
|
257
259
|
const age = now.getTime() - Date.parse(p.issuedAt);
|
|
@@ -277,9 +279,63 @@ export function tokenSummary(payload, now = new Date()) {
|
|
|
277
279
|
}
|
|
278
280
|
if (Number.isInteger(payload.activeDevices)) {
|
|
279
281
|
const n = payload.activeDevices;
|
|
280
|
-
lines.push(`Active on ${n} ${n === 1 ? "
|
|
282
|
+
lines.push(`Active on ${n} ${n === 1 ? "device" : "devices"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
|
|
281
283
|
}
|
|
282
284
|
lines.push(`Issued ${shortDate(payload.issuedAt)}.`);
|
|
283
285
|
return lines;
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// ENFORCEMENT (#238) — decided ONCE, when gu starts
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* May this session send turns?
|
|
294
|
+
*
|
|
295
|
+
* The switch lives on the server and rides in the token (payload.enforce). This is read ONCE at
|
|
296
|
+
* startup, by the owner's requirement — a session that began is never interrupted by a later renewal,
|
|
297
|
+
* and nothing asks the network again per turn.
|
|
298
|
+
*
|
|
299
|
+
* WITH THE SWITCH OFF (the default and what ships today) the answer is always yes: the licence stays
|
|
300
|
+
* soft — barNotice() says its one line and nothing is blocked.
|
|
301
|
+
*
|
|
302
|
+
* WITH IT ON, a session may send turns when the token is valid, the plan is Pro, its paid time has not
|
|
303
|
+
* run out, and the account is within its computer limit. Otherwise gu says why and refuses to SEND
|
|
304
|
+
* TURNS — `gu login`, `gu token`, /model and every other command keep working, because the person has
|
|
305
|
+
* to be able to fix it from where they are.
|
|
306
|
+
*/
|
|
307
|
+
export function enforcementDecision({ token, publicKeyB64, now = new Date(), site = DEFAULT_SITE }) {
|
|
308
|
+
if (!publicKeyB64) return { enforced: false, allowed: true, why: "" };
|
|
309
|
+
const check = token ? verifyCliToken(token, publicKeyB64) : { ok: false, why: "missing" };
|
|
310
|
+
// An unreadable or missing token cannot say whether enforcement is on, and this must not be a way
|
|
311
|
+
// to switch it off by deleting a file — but it is also not a reason to block someone whose server
|
|
312
|
+
// has enforcement OFF. Unknown means soft, exactly like a server that cannot be reached.
|
|
313
|
+
if (!check.ok) return { enforced: false, allowed: true, why: "" };
|
|
314
|
+
const p = check.payload;
|
|
315
|
+
if (!p.enforce) return { enforced: false, allowed: true, why: "" };
|
|
316
|
+
|
|
317
|
+
const host = site.replace(/^https?:\/\//, "");
|
|
318
|
+
const pricingUrl = `${host}/pricing`;
|
|
319
|
+
const accountUrl = `${host}/account`;
|
|
320
|
+
const ended = p.paidUntil && Date.parse(p.paidUntil) <= now.getTime();
|
|
321
|
+
if (p.plan !== "pro" || ended) {
|
|
322
|
+
return {
|
|
323
|
+
enforced: true,
|
|
324
|
+
allowed: false,
|
|
325
|
+
why: ended
|
|
326
|
+
? `Your gu Pro plan ended on ${shortDate(p.paidUntil)}. Renew at ${pricingUrl} to keep using the agent.`
|
|
327
|
+
: `gu needs a Pro plan on this account. See ${pricingUrl} — the rest of gu still works.`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (Number.isInteger(p.activeDevices) && p.activeDevices > p.deviceLimit) {
|
|
331
|
+
return {
|
|
332
|
+
enforced: true,
|
|
333
|
+
allowed: false,
|
|
334
|
+
why:
|
|
335
|
+
`This account is active on ${p.activeDevices} devices and Pro covers ${p.deviceLimit}. ` +
|
|
336
|
+
`Sign out of one, or remove it at ${accountUrl}.`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { enforced: true, allowed: true, why: "" };
|
|
340
|
+
}
|
|
341
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHEN THE CREDENTIALS ON THIS MACHINE NO LONGER WORK (task #240).
|
|
3
|
+
*
|
|
4
|
+
* THE DEAD END THIS EXISTS TO CLOSE, reproduced in a clean Ubuntu container: a machine whose
|
|
5
|
+
* ~/.gonext/worker.env holds a key the API rejects (rotated, revoked, pasted from another
|
|
6
|
+
* account, or written by the wizard for a different server) could not reach the first-run flow
|
|
7
|
+
* at all. `gu` printed one red line and exited — every time, forever. The questions a first run
|
|
8
|
+
* asks were unreachable, and the only route back was `gu clear`, which is a RESET: it deletes
|
|
9
|
+
* conversation history, registered workspaces, deploy targets, the agent's edit backups and the
|
|
10
|
+
* RAG indexes. Reported as "first run question doesn't appear … I then needed gu clear then gu
|
|
11
|
+
* again", and that is exactly what the user had been driven to.
|
|
12
|
+
*
|
|
13
|
+
* A REJECTED KEY IS THE SAME SITUATION AS NO KEY. Both mean this machine is not connected to an
|
|
14
|
+
* account, and the answer to both is the sign-in that the first run already offers. So gu offers
|
|
15
|
+
* it here too, in place, keeping everything else on the machine.
|
|
16
|
+
*
|
|
17
|
+
* WHY A MODULE AND NOT AN `if` IN THE REPL: so the rule can be called by a test. The conduct
|
|
18
|
+
* being pinned is "which situations offer a sign-in", and open-coded it could only be guarded by
|
|
19
|
+
* a regex over gu-repl.mjs — which pins the WORDING and lets the conduct change underneath.
|
|
20
|
+
*
|
|
21
|
+
* THE CODE, NOT THE WORDING, decides. payloadError() stamps `code` on the error the API call
|
|
22
|
+
* throws; nothing downstream matches on the message text, which is free to be reworded.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The error an agent-payload call failed with, carrying a code the recovery can switch on. */
|
|
26
|
+
export function payloadError(status, data) {
|
|
27
|
+
const message = (data && typeof data === "object" && data.error) || "";
|
|
28
|
+
if (status === 404) {
|
|
29
|
+
const e = new Error(
|
|
30
|
+
"the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
|
|
31
|
+
);
|
|
32
|
+
e.code = "API_TOO_OLD";
|
|
33
|
+
return e;
|
|
34
|
+
}
|
|
35
|
+
// 401/403: the key is present and WRONG. We never re-validate a stored key proactively, so
|
|
36
|
+
// this is where a rotated or revoked one surfaces — the first thing gu does on startup.
|
|
37
|
+
if (status === 401 || status === 403) {
|
|
38
|
+
const e = new Error(
|
|
39
|
+
"your worker key was rejected (it may have been rotated or revoked). " +
|
|
40
|
+
"Run `gu login` to sign in again, or update it with `gu set <workerKey> --api-base <url>`."
|
|
41
|
+
);
|
|
42
|
+
e.code = "KEY_REJECTED";
|
|
43
|
+
return e;
|
|
44
|
+
}
|
|
45
|
+
const e = new Error(message || `agent-payload failed (HTTP ${status})`);
|
|
46
|
+
e.code = "API_ERROR";
|
|
47
|
+
return e;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What should happen when the startup probe fails?
|
|
52
|
+
*
|
|
53
|
+
* "offer-signin" — ask, here and now, and carry on with the session when it works.
|
|
54
|
+
* "report" — print what is wrong and stop. The caller's existing message.
|
|
55
|
+
*
|
|
56
|
+
* ONLY a rejected key, and only with somebody there to answer. A network failure is not a
|
|
57
|
+
* credential problem (the key may be perfect and the wifi off), and a pipe or CI job has nobody
|
|
58
|
+
* at the keyboard — offering there would hang a script on a prompt no one can see, which is the
|
|
59
|
+
* failure mode that made `gu` unusable in the deploy app's stages.
|
|
60
|
+
*
|
|
61
|
+
* NOT ON A CLIENT EXPERT MACHINE. Its API is local and issues its own key; a hosted device-login
|
|
62
|
+
* there points the terminal away from the very stack it was set up to use. The same reasoning
|
|
63
|
+
* that made first-run setup skip the sign-in after Client Expert applies to the recovery.
|
|
64
|
+
*/
|
|
65
|
+
export function recoveryPlan({ code, isTty, clientExpert } = {}) {
|
|
66
|
+
if (code !== "KEY_REJECTED") return { action: "report" };
|
|
67
|
+
if (!isTty) return { action: "report" };
|
|
68
|
+
if (clientExpert) return { action: "report" };
|
|
69
|
+
return {
|
|
70
|
+
action: "offer-signin",
|
|
71
|
+
// What it says matters as much as that it asks: someone who has just been told their key is
|
|
72
|
+
// bad needs to know that answering will not cost them their work.
|
|
73
|
+
lines: [
|
|
74
|
+
"gu: this machine's worker key was rejected — it may have been rotated or revoked.",
|
|
75
|
+
" Sign in again to reconnect this machine to an account.",
|
|
76
|
+
" Nothing else is touched: your folders, chat history and RAG indexes stay as they are.",
|
|
77
|
+
],
|
|
78
|
+
// UN-INDENTED, like every other ask() caller: ask() insets by the gutter itself, and a
|
|
79
|
+
// prompt carrying its own spaces comes out sitting two columns right of every other question.
|
|
80
|
+
prompt: "worker key (or Enter to sign in): ",
|
|
81
|
+
};
|
|
82
|
+
}
|
package/gu-repl.mjs
CHANGED
|
@@ -137,6 +137,9 @@ import { REQUIRED_AGENT_MODEL, REQUIRED_AGENT_PREFIX, REQUIRED_EMBED_MODEL } fro
|
|
|
137
137
|
// plain SGR code: terminals disagree about colour, so accent.mjs picks 24-bit, 256 or the
|
|
138
138
|
// sixteen-colour magenta from what this one actually claims to support.
|
|
139
139
|
import { accent } from "./accent.mjs";
|
|
140
|
+
// Task #240: what a failed agent-payload call means, and when a rejected key may be recovered
|
|
141
|
+
// from in place instead of ending the session.
|
|
142
|
+
import { payloadError, recoveryPlan } from "./credential-recovery.mjs";
|
|
140
143
|
import { isNoCodeRetry, splitVerboseLine, stepHasCode } from "./verbose-lines.mjs";
|
|
141
144
|
import {
|
|
142
145
|
createTranscript,
|
|
@@ -1863,6 +1866,13 @@ async function refreshAvailableUpdates() {
|
|
|
1863
1866
|
let tokenNotice = null;
|
|
1864
1867
|
let tokenBusy = false;
|
|
1865
1868
|
let startupRenewDone = false;
|
|
1869
|
+
/**
|
|
1870
|
+
* THE ENFORCEMENT DECISION (#238) — taken ONCE, on the first refresh after gu starts, and never
|
|
1871
|
+
* revisited. The owner's requirement: check when gu starts, not repeatedly. A session already running
|
|
1872
|
+
* is never cut off by a switch flipped elsewhere, and a later renewal only updates the bar's line.
|
|
1873
|
+
*/
|
|
1874
|
+
let licenceBlock = null;
|
|
1875
|
+
let licenceDecided = false;
|
|
1866
1876
|
async function refreshCliToken() {
|
|
1867
1877
|
if (tokenBusy) return;
|
|
1868
1878
|
tokenBusy = true;
|
|
@@ -1902,6 +1912,17 @@ async function refreshCliToken() {
|
|
|
1902
1912
|
await ct.writeState(dir, state).catch(() => {});
|
|
1903
1913
|
tokenNotice = noticeNow();
|
|
1904
1914
|
}
|
|
1915
|
+
if (!licenceDecided) {
|
|
1916
|
+
licenceDecided = true;
|
|
1917
|
+
const d = ct.enforcementDecision({ token: state.token, publicKeyB64: pk, site });
|
|
1918
|
+
licenceBlock = d.enforced && !d.allowed ? d.why : null;
|
|
1919
|
+
if (licenceBlock) {
|
|
1920
|
+
console.log("");
|
|
1921
|
+
console.log(GUTTER + orange("gu cannot run turns on this account.") );
|
|
1922
|
+
console.log(GUTTER + dim(" " + licenceBlock));
|
|
1923
|
+
console.log(GUTTER + dim(" Everything else still works: /model, gu token, gu login.") + "\n");
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1905
1926
|
} catch {
|
|
1906
1927
|
/* never a reason to disturb a session */
|
|
1907
1928
|
} finally {
|
|
@@ -7624,27 +7645,65 @@ async function fetchAgentPayload(messages, codingModelOverride = "") {
|
|
|
7624
7645
|
data = {};
|
|
7625
7646
|
}
|
|
7626
7647
|
if (!res.ok) {
|
|
7627
|
-
if (res.status === 404) {
|
|
7628
|
-
throw new Error(
|
|
7629
|
-
"the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
|
|
7630
|
-
);
|
|
7631
|
-
}
|
|
7632
7648
|
// Task #127 (BC1b): a present-but-INVALID worker key (rotated/revoked while worker.env
|
|
7633
|
-
// kept the stale one) shows up as 401/403.
|
|
7634
|
-
//
|
|
7635
|
-
//
|
|
7636
|
-
|
|
7637
|
-
|
|
7638
|
-
|
|
7639
|
-
|
|
7640
|
-
"`gu-cli set <workerKey> --api-base <url>`."
|
|
7641
|
-
);
|
|
7642
|
-
}
|
|
7643
|
-
throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
|
|
7649
|
+
// kept the stale one) shows up as 401/403. We don't proactively re-validate the key, so
|
|
7650
|
+
// the first rejection is where this surfaces.
|
|
7651
|
+
//
|
|
7652
|
+
// THE MESSAGES AND THE CODES NOW LIVE IN credential-recovery.mjs (task #240), because the
|
|
7653
|
+
// startup probe does more than print them: a rejected key there OFFERS THE SIGN-IN rather
|
|
7654
|
+
// than exiting, and that decision must switch on a code, not on a regex over wording.
|
|
7655
|
+
throw payloadError(res.status, data);
|
|
7644
7656
|
}
|
|
7645
7657
|
return data;
|
|
7646
7658
|
}
|
|
7647
7659
|
|
|
7660
|
+
/**
|
|
7661
|
+
* The STARTUP probe, with one way back from a rejected key (task #240).
|
|
7662
|
+
*
|
|
7663
|
+
* Reproduced in a clean Ubuntu container: with a key the API rejects, `gu` printed one red line
|
|
7664
|
+
* and exited — every time. The first-run questions were unreachable from that state, so the only
|
|
7665
|
+
* route back was `gu clear`, which also deletes chat history, workspaces, edit backups and RAG
|
|
7666
|
+
* indexes. A rejected key means this machine is not connected to an account, which is the same
|
|
7667
|
+
* situation a first run is for; so the sign-in is offered here, keeping everything else.
|
|
7668
|
+
*
|
|
7669
|
+
* EXACTLY ONE OFFER. The retry is outside the recovery, so a key that is rejected AGAIN (the
|
|
7670
|
+
* account has no worker, the API is confused) falls through to the normal error and exit instead
|
|
7671
|
+
* of asking in a loop.
|
|
7672
|
+
*
|
|
7673
|
+
* Only the startup probe recovers. The other fetchAgentPayload callers run mid-session, where a
|
|
7674
|
+
* sign-in prompt would interrupt work in progress; they keep their existing error handling.
|
|
7675
|
+
*/
|
|
7676
|
+
async function probeWithRecovery() {
|
|
7677
|
+
const ping = [{ role: "user", content: "ping" }];
|
|
7678
|
+
try {
|
|
7679
|
+
return await fetchAgentPayload(ping);
|
|
7680
|
+
} catch (err) {
|
|
7681
|
+
const plan = recoveryPlan({
|
|
7682
|
+
code: err?.code,
|
|
7683
|
+
isTty: IS_TTY,
|
|
7684
|
+
clientExpert: isClientExpert(),
|
|
7685
|
+
});
|
|
7686
|
+
if (plan.action !== "offer-signin") throw err;
|
|
7687
|
+
console.log("");
|
|
7688
|
+
console.log(red(plan.lines[0]));
|
|
7689
|
+
for (const line of plan.lines.slice(1)) console.log(dim(line));
|
|
7690
|
+
console.log("");
|
|
7691
|
+
const { deviceLogin, saveWorkerKey, DEFAULT_API_BASE } = await import("./device-login.mjs");
|
|
7692
|
+
const typed = (await ask(plan.prompt)).trim();
|
|
7693
|
+
const r = typed
|
|
7694
|
+
? await saveWorkerKey({
|
|
7695
|
+
apiBase: apiBase || DEFAULT_API_BASE,
|
|
7696
|
+
workerKey: typed,
|
|
7697
|
+
log: (s) => console.log(s),
|
|
7698
|
+
})
|
|
7699
|
+
: await deviceLogin({ apiBase: apiBase || DEFAULT_API_BASE, log: (s) => console.log(s) });
|
|
7700
|
+
// worker.env is written by those two; this process is still holding the rejected values.
|
|
7701
|
+
workerKey = r.workerKey;
|
|
7702
|
+
apiBase = r.apiBase;
|
|
7703
|
+
return await fetchAgentPayload(ping);
|
|
7704
|
+
}
|
|
7705
|
+
}
|
|
7706
|
+
|
|
7648
7707
|
// ---------- run one agent turn via the JOB QUEUE (the daemon executes it) ----------
|
|
7649
7708
|
// The question is enqueued exactly like a web question, so the RUNNING
|
|
7650
7709
|
// gu-cli daemon claims + executes it — its terminal shows the full
|
|
@@ -8357,6 +8416,12 @@ const wrapThought = (s, width, maxLines) => {
|
|
|
8357
8416
|
* own conversation.
|
|
8358
8417
|
*/
|
|
8359
8418
|
async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
8419
|
+
// #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
|
|
8420
|
+
// startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
|
|
8421
|
+
if (licenceBlock) {
|
|
8422
|
+
console.log(GUTTER + orange("Not sent.") + dim(" " + licenceBlock) + "\n");
|
|
8423
|
+
return;
|
|
8424
|
+
}
|
|
8360
8425
|
const attached = Boolean(attachJobId);
|
|
8361
8426
|
inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
|
|
8362
8427
|
// Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
|
|
@@ -9783,7 +9848,7 @@ async function main() {
|
|
|
9783
9848
|
}
|
|
9784
9849
|
// Show which models will answer, straight from user settings (also validates auth).
|
|
9785
9850
|
try {
|
|
9786
|
-
const probe = await
|
|
9851
|
+
const probe = await probeWithRecovery();
|
|
9787
9852
|
const p = probe?.payload ?? {};
|
|
9788
9853
|
// Remember the default coder id for the token footer's global-total label, and the
|
|
9789
9854
|
// default backend's coding URL so the GLOBAL output-token total is keyed to it (task
|
|
@@ -10178,6 +10243,12 @@ async function main() {
|
|
|
10178
10243
|
dim("\n If that fails, check MongoDB is running and see ~/.gonext/local-api.log\n")
|
|
10179
10244
|
);
|
|
10180
10245
|
}
|
|
10246
|
+
} else if (err?.code === "KEY_REJECTED") {
|
|
10247
|
+
// WE REACHED THE API PERFECTLY WELL — it told us the key is no good (task #240). Wrapping
|
|
10248
|
+
// that in "couldn't reach the API" sends the reader off debugging DNS, a proxy and their
|
|
10249
|
+
// wifi for a problem the server already named. Seen in the container: a second bad key
|
|
10250
|
+
// pasted at the recovery prompt produced exactly that sentence.
|
|
10251
|
+
console.error(red("gu: " + err.message));
|
|
10181
10252
|
} else {
|
|
10182
10253
|
// The SAME bare "fetch failed" the Client Expert branch above was written to fix — that
|
|
10183
10254
|
// one names the host and offers a next step, this one printed node's four words and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiens.nguyen/gu-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.707",
|
|
4
4
|
"description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"version-check.mjs",
|
|
53
53
|
"rag-selftest.mjs",
|
|
54
54
|
"cli-token.mjs",
|
|
55
|
+
"credential-recovery.mjs",
|
|
55
56
|
"device-login.mjs",
|
|
56
57
|
"daemon-control.mjs",
|
|
57
58
|
"poll-errors.mjs",
|