@vultisig/cli 2.8.1 → 2.8.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/index.js +115 -50
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # @vultisig/cli
2
2
 
3
+ ## 2.8.2
4
+
5
+ ### Patch Changes
6
+
7
+ - [#869](https://github.com/vultisig/vultisig-sdk/pull/869) [`2623ca0`](https://github.com/vultisig/vultisig-sdk/commit/2623ca0dd04c5ca3c65126b6008a93c1d7559793) Thanks [@neavra](https://github.com/neavra)! - Harden agent conversation auth across every path. A revoked-but-unexpired
8
+ cached token now recovers uniformly: a new `withAuthRetry` helper does
9
+ clear→reauth→retry-once and wraps the fresh-conversation `createConversation`
10
+ (previously unguarded — it hard-threw `Authentication failed`), the resume
11
+ `getConversation`, the send-message stream, and the `agent sessions
12
+ list`/`delete` commands.
13
+
14
+ A `--session-id` resume whose 401 survives the single retry — or that fails for
15
+ any other reason — now falls back to a fresh conversation instead of throwing
16
+ uncaught, and emits a typed, non-fatal `SESSION_NOT_FOUND` signal carrying the
17
+ new conversation id so a headless caller knows prior context was dropped. In
18
+ `agent ask`, that initialize-time signal is now preserved into the first turn's
19
+ result envelope (it was previously cleared at turn start, so a stale-session
20
+ `ask --json` returned a false-success envelope instead of the promised non-zero
21
+ `SESSION_NOT_FOUND`).
22
+
23
+ Models the backend's `refresh_token`/`access_token` in `AuthTokenResponse` and
24
+ persists the refresh token in the token cache (0o600) for a future
25
+ `POST /auth/refresh` exchange; the retry path re-auths via MPC re-sign today.
26
+ The retry preserves a previously cached refresh token when the re-auth response
27
+ omits one, and the cache directory is chmod'd to 0o700 on every write (not only
28
+ on create) so a pre-existing `~/.vultisig` can't retain looser perms on upgrade.
29
+
30
+ Adds the first `auth.ts` unit tests (EIP-191 hash cross-checked against viem,
31
+ DER→65-byte formatting, signing-retry classification) plus session
32
+ auth-retry/fallback coverage.
33
+
34
+ - Updated dependencies [[`0dc1620`](https://github.com/vultisig/vultisig-sdk/commit/0dc16206bedcdde8832a068b15383565a6b98896)]:
35
+ - @vultisig/sdk@2.8.2
36
+
3
37
  ## 2.8.1
4
38
 
5
39
  ### Patch Changes
package/dist/index.js CHANGED
@@ -8074,6 +8074,7 @@ var AgentErrorCode = /* @__PURE__ */ ((AgentErrorCode3) => {
8074
8074
  AgentErrorCode3["TRANSACTION_FAILED"] = "TRANSACTION_FAILED";
8075
8075
  AgentErrorCode3["SIGNING_FAILED"] = "SIGNING_FAILED";
8076
8076
  AgentErrorCode3["SESSION_NOT_INITIALIZED"] = "SESSION_NOT_INITIALIZED";
8077
+ AgentErrorCode3["SESSION_NOT_FOUND"] = "SESSION_NOT_FOUND";
8077
8078
  AgentErrorCode3["UNKNOWN_ERROR"] = "UNKNOWN_ERROR";
8078
8079
  return AgentErrorCode3;
8079
8080
  })(AgentErrorCode || {});
@@ -8185,6 +8186,11 @@ var AskInterface = class {
8185
8186
  transactions = [];
8186
8187
  cards = [];
8187
8188
  error;
8189
+ // Whether ask() has run at least once. initialize() drives getCallbacks()
8190
+ // BEFORE the first ask() — a stale --session fallback fires
8191
+ // onError(SESSION_NOT_FOUND) there. ask() must NOT clear that initialize-time
8192
+ // error on its first turn, or the result envelope reports false success.
8193
+ hasAsked = false;
8188
8194
  constructor(session, verbose = false, autoApprove = false) {
8189
8195
  this.session = session;
8190
8196
  this.verbose = verbose;
@@ -8269,7 +8275,10 @@ var AskInterface = class {
8269
8275
  this.toolCalls = [];
8270
8276
  this.transactions = [];
8271
8277
  this.cards = [];
8272
- this.error = void 0;
8278
+ if (this.hasAsked) {
8279
+ this.error = void 0;
8280
+ }
8281
+ this.hasAsked = true;
8273
8282
  const callbacks = this.getCallbacks();
8274
8283
  await this.session.sendMessage(message, callbacks);
8275
8284
  return this.partialResult();
@@ -8624,7 +8633,13 @@ async function authenticateVault(client, vault, password, maxAttempts = 3) {
8624
8633
  });
8625
8634
  return {
8626
8635
  token: authResponse.token,
8627
- expiresAt: authResponse.expires_at
8636
+ expiresAt: authResponse.expires_at,
8637
+ // Captured + persisted by the session token cache. The backend exposes
8638
+ // POST /auth/refresh to exchange it for a fresh access token without a
8639
+ // new MPC round; wiring that exchange is a future enhancement — today
8640
+ // the CLI re-auths via a full MPC re-sign (authenticateVault), which is
8641
+ // always available and avoids depending on refresh-token rotation.
8642
+ refreshToken: authResponse.refresh_token
8628
8643
  };
8629
8644
  } catch (err) {
8630
8645
  lastError = err;
@@ -12072,7 +12087,7 @@ var PipeInterface = class {
12072
12087
  };
12073
12088
 
12074
12089
  // src/agent/session.ts
12075
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
12090
+ import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
12076
12091
  import { homedir as homedir2 } from "node:os";
12077
12092
  import { join as join2 } from "node:path";
12078
12093
  import { MemoryStorage, PushNotificationService } from "@vultisig/sdk";
@@ -12144,7 +12159,7 @@ var AgentSession = class {
12144
12159
  } else {
12145
12160
  const auth = await authenticateVault(this.client, this.vault, this.config.password);
12146
12161
  this.client.setAuthToken(auth.token);
12147
- saveCachedToken(this.publicKey, auth.token, auth.expiresAt);
12162
+ saveCachedToken(this.publicKey, auth.token, auth.expiresAt, auth.refreshToken);
12148
12163
  }
12149
12164
  } catch (err) {
12150
12165
  throw new Error(`Authentication failed: ${err.message}`);
@@ -12152,25 +12167,20 @@ var AgentSession = class {
12152
12167
  if (this.config.sessionId) {
12153
12168
  this.conversationId = this.config.sessionId;
12154
12169
  try {
12155
- const conv = await this.client.getConversation(this.conversationId, this.publicKey);
12170
+ const conv = await this.withAuthRetry(() => this.client.getConversation(this.conversationId, this.publicKey));
12156
12171
  this.historyMessages = conv.messages || [];
12157
12172
  } catch (err) {
12158
- if (err.message?.includes("401") || err.message?.includes("403")) {
12159
- clearCachedToken(this.publicKey);
12160
- const auth = await authenticateVault(this.client, this.vault, this.config.password);
12161
- this.client.setAuthToken(auth.token);
12162
- saveCachedToken(this.publicKey, auth.token, auth.expiresAt);
12163
- const conv = await this.client.getConversation(this.conversationId, this.publicKey);
12164
- this.historyMessages = conv.messages || [];
12165
- } else {
12166
- this.conversationId = null;
12167
- this.historyMessages = [];
12168
- const conv = await this.client.createConversation(this.publicKey);
12169
- this.conversationId = conv.id;
12170
- }
12173
+ this.conversationId = null;
12174
+ this.historyMessages = [];
12175
+ const conv = await this.withAuthRetry(() => this.client.createConversation(this.publicKey));
12176
+ this.conversationId = conv.id;
12177
+ ui.onError(
12178
+ `Session ${this.config.sessionId} could not be resumed (${err?.message ?? "unknown error"}); started a new conversation ${conv.id}`,
12179
+ "SESSION_NOT_FOUND" /* SESSION_NOT_FOUND */
12180
+ );
12171
12181
  }
12172
12182
  } else {
12173
- const conv = await this.client.createConversation(this.publicKey);
12183
+ const conv = await this.withAuthRetry(() => this.client.createConversation(this.publicKey));
12174
12184
  this.conversationId = conv.id;
12175
12185
  }
12176
12186
  this.cachedContext = this.config.viaAgent || this.config.askMode ? await buildMinimalContext(this.vault) : await buildMessageContext(this.vault);
@@ -12204,6 +12214,40 @@ var AgentSession = class {
12204
12214
  }
12205
12215
  }
12206
12216
  }
12217
+ /**
12218
+ * Run an authenticated backend request and, on a 401/403, do a single
12219
+ * clear → re-auth → retry. This is the ONE chokepoint every conversation
12220
+ * request shares (resume fetch, fresh-convo create, error-fallback create,
12221
+ * and the send-message stream) so a revoked-but-unexpired cached token
12222
+ * recovers identically everywhere instead of throwing on some paths.
12223
+ *
12224
+ * The retry replays the EXACT same `request` closure, which matters for the
12225
+ * send-message path: the replayed body must carry the same content +
12226
+ * recent_actions or the LLM re-emits tool calls (runaway loop). Re-auth is a
12227
+ * full MPC re-sign via authenticateVault — the backend also exposes
12228
+ * POST /auth/refresh, but exchanging the refresh token is a future
12229
+ * enhancement (see auth.ts); the re-sign is always available.
12230
+ *
12231
+ * `onReauth` (optional) fires the instant a re-auth is committed to — BEFORE
12232
+ * authenticateVault runs — so a caller in a retry loop (recoverDisconnectedTurn)
12233
+ * can record that its single re-auth has been spent even if the MPC re-sign
12234
+ * itself then throws. Without this hook a re-auth that fails with a non-auth
12235
+ * error would let the caller re-enter and re-sign on every iteration.
12236
+ */
12237
+ async withAuthRetry(request, onReauth) {
12238
+ try {
12239
+ return await request();
12240
+ } catch (err) {
12241
+ if (!isAuthError(err)) throw err;
12242
+ onReauth?.();
12243
+ const previousRefreshToken = readTokenStore()[this.publicKey]?.refreshToken;
12244
+ clearCachedToken(this.publicKey);
12245
+ const auth = await authenticateVault(this.client, this.vault, this.config.password);
12246
+ this.client.setAuthToken(auth.token);
12247
+ saveCachedToken(this.publicKey, auth.token, auth.expiresAt, auth.refreshToken ?? previousRefreshToken);
12248
+ return await request();
12249
+ }
12250
+ }
12207
12251
  getConversationId() {
12208
12252
  return this.conversationId;
12209
12253
  }
@@ -12328,31 +12372,15 @@ var AgentSession = class {
12328
12372
  }
12329
12373
  };
12330
12374
  let streamResult;
12331
- let authRetried = false;
12332
- while (true) {
12333
- try {
12334
- streamResult = await this.client.sendMessageStream(
12335
- this.conversationId,
12336
- request,
12337
- callbacks,
12338
- this.abortController?.signal
12339
- );
12340
- break;
12341
- } catch (err) {
12342
- const isAuthErr = err.message?.includes("401") || err.message?.includes("403");
12343
- if (isAuthErr && !authRetried) {
12344
- authRetried = true;
12345
- clearCachedToken(this.publicKey);
12346
- const auth = await authenticateVault(this.client, this.vault, this.config.password);
12347
- this.client.setAuthToken(auth.token);
12348
- saveCachedToken(this.publicKey, auth.token, auth.expiresAt);
12349
- continue;
12350
- }
12351
- if (flushedThisCall.length > 0) {
12352
- this.pendingToolResults = [...flushedThisCall, ...this.pendingToolResults];
12353
- }
12354
- throw err;
12375
+ try {
12376
+ streamResult = await this.withAuthRetry(
12377
+ () => this.client.sendMessageStream(this.conversationId, request, callbacks, this.abortController?.signal)
12378
+ );
12379
+ } catch (err) {
12380
+ if (flushedThisCall.length > 0) {
12381
+ this.pendingToolResults = [...flushedThisCall, ...this.pendingToolResults];
12355
12382
  }
12383
+ throw err;
12356
12384
  }
12357
12385
  if (pendingDispatches.length > 0) {
12358
12386
  await Promise.all(pendingDispatches);
@@ -12415,10 +12443,16 @@ var AgentSession = class {
12415
12443
  const since = serverAnchor ?? new Date(Date.now() - 2e3).toISOString();
12416
12444
  const replaySignableCards = serverAnchor !== null;
12417
12445
  let cursor;
12446
+ let authRecovered = false;
12418
12447
  for (let attempt = 0; attempt < this.recoveryMaxPolls; attempt++) {
12419
12448
  let resp;
12420
12449
  try {
12421
- resp = await this.client.messagesSince(this.conversationId, cursor ? { cursor } : { since });
12450
+ resp = authRecovered ? await this.client.messagesSince(this.conversationId, cursor ? { cursor } : { since }) : await this.withAuthRetry(
12451
+ () => this.client.messagesSince(this.conversationId, cursor ? { cursor } : { since }),
12452
+ () => {
12453
+ authRecovered = true;
12454
+ }
12455
+ );
12422
12456
  } catch (err) {
12423
12457
  if (this.config.verbose) {
12424
12458
  process.stderr.write(`[session] recovery poll ${attempt + 1} failed: ${err?.message ?? err}
@@ -12708,6 +12742,10 @@ function serverNowToIso(serverNow) {
12708
12742
  function hasTxReadyPart(parts) {
12709
12743
  return !!parts?.some((p) => p.type === "data-tx_ready" && !!p.data);
12710
12744
  }
12745
+ function isAuthError(err) {
12746
+ const msg = err instanceof Error ? err.message : String(err ?? "");
12747
+ return /\b(401|403)\b/.test(msg);
12748
+ }
12711
12749
  function getTokenCachePath() {
12712
12750
  const dir = process.env.VULTISIG_CONFIG_DIR ?? join2(homedir2(), ".vultisig");
12713
12751
  return join2(dir, "agent-tokens.json");
@@ -12724,8 +12762,16 @@ function readTokenStore() {
12724
12762
  function writeTokenStore(store) {
12725
12763
  const path4 = getTokenCachePath();
12726
12764
  const dir = join2(path4, "..");
12727
- if (!existsSync(dir)) mkdirSync2(dir, { recursive: true });
12765
+ if (!existsSync(dir)) mkdirSync2(dir, { recursive: true, mode: 448 });
12766
+ try {
12767
+ chmodSync(dir, 448);
12768
+ } catch {
12769
+ }
12728
12770
  writeFileSync2(path4, JSON.stringify(store, null, 2), { mode: 384 });
12771
+ try {
12772
+ chmodSync(path4, 384);
12773
+ } catch {
12774
+ }
12729
12775
  }
12730
12776
  function loadCachedToken(publicKey) {
12731
12777
  const store = readTokenStore();
@@ -12743,9 +12789,13 @@ function loadCachedToken(publicKey) {
12743
12789
  }
12744
12790
  return entry.token;
12745
12791
  }
12746
- function saveCachedToken(publicKey, token, expiresAt) {
12792
+ function saveCachedToken(publicKey, token, expiresAt, refreshToken) {
12747
12793
  const store = readTokenStore();
12748
- store[publicKey] = { token, expiresAt };
12794
+ store[publicKey] = {
12795
+ token,
12796
+ expiresAt,
12797
+ refreshToken: refreshToken ?? store[publicKey]?.refreshToken
12798
+ };
12749
12799
  try {
12750
12800
  writeTokenStore(store);
12751
12801
  } catch {
@@ -13293,7 +13343,12 @@ async function executeAgentSessionsList(ctx2, options) {
13293
13343
  let totalCount = 0;
13294
13344
  let skip = 0;
13295
13345
  while (true) {
13296
- const page = await client.listConversations(publicKey, skip, PAGE_SIZE);
13346
+ const page = await withClientAuthRetry(
13347
+ client,
13348
+ vault,
13349
+ options.password,
13350
+ () => client.listConversations(publicKey, skip, PAGE_SIZE)
13351
+ );
13297
13352
  totalCount = page.total_count;
13298
13353
  allConversations.push(...page.conversations);
13299
13354
  if (allConversations.length >= totalCount || page.conversations.length < PAGE_SIZE) break;
@@ -13335,7 +13390,7 @@ async function executeAgentSessionsDelete(ctx2, sessionId, options) {
13335
13390
  const backendUrl = options.backendUrl || process.env.VULTISIG_AGENT_URL || "https://abe.vultisig.com";
13336
13391
  const client = await createAuthenticatedClient(backendUrl, vault, options.password);
13337
13392
  const publicKey = vault.publicKeys.ecdsa;
13338
- await client.deleteConversation(sessionId, publicKey);
13393
+ await withClientAuthRetry(client, vault, options.password, () => client.deleteConversation(sessionId, publicKey));
13339
13394
  if (isJsonOutput()) {
13340
13395
  outputJson({ deleted: sessionId });
13341
13396
  return;
@@ -13348,6 +13403,16 @@ async function createAuthenticatedClient(backendUrl, vault, password) {
13348
13403
  client.setAuthToken(auth.token);
13349
13404
  return client;
13350
13405
  }
13406
+ async function withClientAuthRetry(client, vault, password, request) {
13407
+ try {
13408
+ return await request();
13409
+ } catch (err) {
13410
+ if (!isAuthError(err)) throw err;
13411
+ const auth = await authenticateVault(client, vault, password);
13412
+ client.setAuthToken(auth.token);
13413
+ return await request();
13414
+ }
13415
+ }
13351
13416
  function formatDate(iso) {
13352
13417
  try {
13353
13418
  const d = new Date(iso);
@@ -13366,7 +13431,7 @@ var cachedVersion = null;
13366
13431
  function getVersion() {
13367
13432
  if (cachedVersion) return cachedVersion;
13368
13433
  if (true) {
13369
- cachedVersion = "2.8.1";
13434
+ cachedVersion = "2.8.2";
13370
13435
  return cachedVersion;
13371
13436
  }
13372
13437
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vultisig/cli",
3
- "version": "2.8.1",
3
+ "version": "2.8.2",
4
4
  "description": "The self-custody MPC wallet CLI for AI coding agents (Claude Code, Cursor, OpenCode). Natural-language agent mode, 36+ chains, DKLS23 threshold signatures. Seedless.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -76,7 +76,7 @@
76
76
  "@vultisig/client-shared": "^0.2.16",
77
77
  "@vultisig/core-chain": "^2.17.8",
78
78
  "@vultisig/rujira": "^41.0.0",
79
- "@vultisig/sdk": "^2.8.0",
79
+ "@vultisig/sdk": "^2.8.2",
80
80
  "chalk": "^5.6.2",
81
81
  "cli-table3": "^0.6.5",
82
82
  "commander": "^15.0.0",