@wirecat/cli-core 0.493.0 → 0.494.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/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ shape of this file.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.494.0 — 31.10.2026
11
+
12
+ ### Security
13
+
14
+ - **A run with moved folders no longer copies the owner's sign-in into the keyring when it refreshes a token.** Since
15
+ cli-base 0.65.5 such a run (`<PREFIX>_*_DIR` overrides, or `HOME` or XDG folders away from the account's home) writes
16
+ to a temporary entry `<app>:<config folder>`, so every refresh of an expired Google token left a full copy of the
17
+ owner's sign-in there until `session end`. `tokenKeeper` now keeps such a refresh in memory for the rest of the
18
+ process and saves nothing; a new sign-in, a token saved by the CLI's own flow, and a refresh that rotated the
19
+ refresh token are still saved. `credentialStorage` also no longer copies an older account's entry to the new
20
+ account in such a run. New optional `TokenStorage.moved`.
21
+
10
22
  ## 0.493.0 — 31.10.2026
11
23
 
12
24
  ### Added
@@ -6,6 +6,7 @@ import { acquireLock } from "../files/index.js";
6
6
  import { authorize } from "./authorize.js";
7
7
  import { requestTokens, revokeToken, TOKEN_LOG_EVENT } from "./requests.js";
8
8
  const inFlight = new Map();
9
+ const refreshedInMemory = new Map();
9
10
  export const tokenKeeper = (options) => {
10
11
  const { provider, storage, signal, log } = options;
11
12
  const now = options.now ?? Date.now;
@@ -81,12 +82,20 @@ export const tokenKeeper = (options) => {
81
82
  inFlight.set(lockPath, started);
82
83
  return started;
83
84
  };
85
+ const remembered = (saved) => {
86
+ const kept = refreshedInMemory.get(lockPath);
87
+ return kept && fresh(kept) && kept.refreshToken === saved.refreshToken ? kept : undefined;
88
+ };
89
+ const forget = () => refreshedInMemory.delete(lockPath);
84
90
  const tokens = async () => {
85
91
  const seen = storage.read();
86
92
  if (!seen)
87
93
  throw notSignedIn();
88
94
  if (fresh(seen.tokens))
89
95
  return seen.tokens;
96
+ const kept = remembered(seen.tokens);
97
+ if (kept)
98
+ return kept;
90
99
  return shared(async () => {
91
100
  const current = storage.read();
92
101
  if (!current)
@@ -94,9 +103,16 @@ export const tokenKeeper = (options) => {
94
103
  // Another process refreshed while this one waited for the lock: its result is the one to use.
95
104
  if (fresh(current.tokens) || current.tokens.refreshToken !== seen.tokens.refreshToken)
96
105
  return current.tokens;
106
+ const keptMeanwhile = remembered(current.tokens);
107
+ if (keptMeanwhile)
108
+ return keptMeanwhile;
97
109
  const client = clientOf(current);
98
110
  const refreshed = await exchange({ grant_type: "refresh_token", refresh_token: current.tokens.refreshToken }, context(client, current.tokens));
99
- storage.write({ tokens: refreshed, client });
111
+ // A rotated refresh token is saved even so: kept only in memory, it would die with the process.
112
+ if (storage.moved && refreshed.refreshToken === current.tokens.refreshToken)
113
+ refreshedInMemory.set(lockPath, refreshed);
114
+ else
115
+ storage.write({ tokens: refreshed, client });
100
116
  return refreshed;
101
117
  });
102
118
  };
@@ -119,11 +135,18 @@ export const tokenKeeper = (options) => {
119
135
  code_verifier: authorization.codeVerifier,
120
136
  }, context(client));
121
137
  const extra = (await accept?.(received)) ?? undefined;
122
- await locked(async () => storage.write({ tokens: received, client, ...(extra ? { extra } : {}) }));
138
+ await locked(async () => {
139
+ forget();
140
+ storage.write({ tokens: received, client, ...(extra ? { extra } : {}) });
141
+ });
123
142
  return { scopes: received.scopes };
124
143
  },
125
- save: (saved, extra) => locked(async () => storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) })),
144
+ save: (saved, extra) => locked(async () => {
145
+ forget();
146
+ storage.write({ tokens: saved, client: clientOf(storage.read()), ...(extra ? { extra } : {}) });
147
+ }),
126
148
  signOut: ({ revoke = false } = {}) => locked(async () => {
149
+ forget();
127
150
  const current = storage.read();
128
151
  let revoked = false;
129
152
  if (revoke && current) {
@@ -21,6 +21,11 @@ export interface TokenCodec {
21
21
  export interface TokenStorage {
22
22
  /** Names the entry across processes; the lock is keyed by it. */
23
23
  readonly key: string;
24
+ /**
25
+ * A run with moved folders: cli-base sends its writes to a temporary keyring entry, so a refresh is kept in memory
26
+ * instead of copying the owner's sign-in there. Only a new sign-in is saved.
27
+ */
28
+ readonly moved?: boolean;
24
29
  /** Throws when the entry cannot be saved, so a sign-in fails before the browser, not after consent. */
25
30
  probe(): void;
26
31
  read(): StoredTokens | undefined;
@@ -1,4 +1,4 @@
1
- import { CliError, Credentials, keyringService, } from "@wirecat/cli-base";
1
+ import { CliError, Credentials, isMovedRun, keyringService, systemKeyring, } from "@wirecat/cli-base";
2
2
  const invalid = () => new CliError("authentication_error", "the saved sign-in cannot be read; sign in again", { reason: "oauth.invalid" });
3
3
  const parse = (raw) => {
4
4
  let value;
@@ -149,6 +149,10 @@ export const credentialStorage = (options) => {
149
149
  throw new CliError("configuration_error", "a sign-in store needs its keyring passed in, never a default");
150
150
  const credentials = new Credentials(options.credentials);
151
151
  const { account, codec } = options;
152
+ // cli-base also counts a moved HOME as moved, but only with the system keyring; the sandboxed tests move HOME.
153
+ const moved = options.credentials.isolated === true ||
154
+ (options.credentials.keyring === systemKeyring &&
155
+ isMovedRun({ appName: options.credentials.service, env: options.credentials.env ?? process.env }));
152
156
  const service = keyringService(options.credentials.service, options.credentials.configDir, options.credentials.isolated);
153
157
  const raw = (name) => {
154
158
  try {
@@ -175,13 +179,15 @@ export const credentialStorage = (options) => {
175
179
  if (found === undefined)
176
180
  continue;
177
181
  codec.decode(found);
178
- save(found);
182
+ if (!moved)
183
+ save(found);
179
184
  return found;
180
185
  }
181
186
  return undefined;
182
187
  };
183
188
  return {
184
189
  key: `${service}\u0000${account}`,
190
+ moved,
185
191
  probe: () => {
186
192
  raw(account);
187
193
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.493.0",
3
+ "version": "0.494.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -247,7 +247,7 @@
247
247
  "@types/node": "^22.20.5",
248
248
  "@types/pngjs": "6.0.5",
249
249
  "@vitest/coverage-v8": "^5.0.5",
250
- "@wirecat/cli-base": "0.65.2",
250
+ "@wirecat/cli-base": "0.65.5",
251
251
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
252
252
  "commander": "15.0.0",
253
253
  "cspell": "10.4.0",