@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 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} computers — ${p.plan === "pro" ? "Pro" : "Free"} covers ${p.deviceLimit}`,
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 ? "computer" : "computers"} in the last 30 days — ${payload.plan === "pro" ? "Pro" : "Free"} covers ${payload.deviceLimit}.`);
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. Point the user at re-login rather than a
7634
- // generic error — we don't proactively re-validate the key, so the first rejection is
7635
- // where this surfaces.
7636
- if (res.status === 401 || res.status === 403) {
7637
- throw new Error(
7638
- "your worker key was rejected (it may have been rotated or revoked). " +
7639
- "Run `gu-cli login` to sign in again, or update it with " +
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 fetchAgentPayload([{ role: "user", content: "ping" }]);
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.706",
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",