@anyslate/cli 0.4.0 → 0.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anyslate/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "AnySlate CLI - lifecycle hooks, git/CI capture, and manual checkpoints for AI memory. Validates its connection at login, diagnoses itself with `anyslate doctor`, backs off and circuit-breaks rather than retrying, and fails open without failing silent.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/auth.mjs CHANGED
@@ -113,15 +113,59 @@ export function hasOauthCredentials(oauth) {
113
113
  * @returns {Promise<{ok: true, tokenEndpoint: string, resource: string}
114
114
  * | {ok: false, code: string, message: string}>}
115
115
  */
116
- async function refreshEndpoints({ oauth, root, fetchImpl }) {
116
+ async function refreshEndpoints({ oauth, root, fetchImpl, timeoutMs }) {
117
117
  if (oauth?.root === root && oauth?.token_endpoint && oauth?.resource) {
118
118
  return { ok: true, tokenEndpoint: oauth.token_endpoint, resource: oauth.resource };
119
119
  }
120
- const found = await discover({ root, fetchImpl });
120
+ const found = await discover({ root, fetchImpl, timeoutMs });
121
121
  if (!found.ok) return found;
122
122
  return { ok: true, tokenEndpoint: found.tokenEndpoint, resource: found.resource };
123
123
  }
124
124
 
125
+ /**
126
+ * Prefer a peer's already-persisted OAuth session over minting a new one.
127
+ *
128
+ * Refresh tokens are single-use server-side. Concurrent hooks (each a fresh
129
+ * process) that both redeem the same refresh token leave the loser with
130
+ * `invalid_grant` even though a usable access token is already on disk —
131
+ * and historically that loser opened the circuit breaker and told the user
132
+ * to re-login. Reading disk again closes that hole.
133
+ *
134
+ * @param {{env: NodeJS.ProcessEnv, staleToken?: string|null, now?: number,
135
+ * startingRefresh?: string|null}} opts
136
+ * @returns {{ok: true, token: string, rotated: false, oauth: object}|null}
137
+ */
138
+ export function readPeerRefreshedCredentials({ env, staleToken = null, now = Date.now(), startingRefresh = null }) {
139
+ const disk = readConfigFile(env);
140
+ const oauth = disk.oauth && typeof disk.oauth === 'object' ? disk.oauth : null;
141
+ if (!oauth?.access_token) return null;
142
+
143
+ // A peer rotated when the refresh token on disk changed, or when the access
144
+ // token differs from the one we are replacing.
145
+ const refreshMoved =
146
+ startingRefresh && oauth.refresh_token && oauth.refresh_token !== startingRefresh;
147
+ const accessMoved = staleToken ? oauth.access_token !== staleToken : false;
148
+ if (!refreshMoved && !accessMoved && staleToken) return null;
149
+
150
+ // Accept any not-hard-expired peer token. Requiring the full 5-minute skew
151
+ // would reject a peer refresh that landed seconds ago for no good reason.
152
+ if (isExpired(oauth, now)) return null;
153
+ if (staleToken && oauth.access_token === staleToken && !refreshMoved) return null;
154
+
155
+ return { ok: true, token: oauth.access_token, rotated: false, oauth };
156
+ }
157
+
158
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
159
+
160
+ /** Default lock wait when the caller does not override (interactive commands). */
161
+ const LOCK_WAIT_DEFAULT = 10_000;
162
+
163
+ /** Milliseconds left before an absolute deadline, or null when unbounded. */
164
+ export function remainingMs(deadline, now = Date.now()) {
165
+ if (deadline == null || !Number.isFinite(deadline)) return null;
166
+ return Math.max(0, deadline - now);
167
+ }
168
+
125
169
  /**
126
170
  * Perform one refresh, under the lock, persisting the rotated token.
127
171
  *
@@ -133,18 +177,41 @@ async function refreshEndpoints({ oauth, root, fetchImpl }) {
133
177
  * very token that just 401'd, and the retry fails identically.
134
178
  *
135
179
  * `lockWaitMs` exists for the hook path. The default 10s wait is fine for a
136
- * user-initiated command but is 10s of a lifecycle hook standing in front of
137
- * the user's editor, and a machine whose refreshes are all failing is exactly
138
- * the machine where every hook queues behind the last one.
180
+ * user-initiated command but is too long for a lifecycle hook standing in
181
+ * front of the user's editor.
182
+ *
183
+ * When the lock is NOT held (wait timed out), we poll disk for a peer refresh
184
+ * instead of racing the single-use refresh token. Only if nothing usable
185
+ * appears do we attempt the network refresh — and on `invalid_grant` we
186
+ * re-check disk one last time before declaring the session dead.
187
+ *
188
+ * `deadline` (epoch ms) caps lock wait, unlocked peer-poll, discovery, and the
189
+ * refresh HTTP call so a hook's advertised budget cannot be exceeded before
190
+ * the MCP call even starts. When omitted, behaviour matches the unbounded
191
+ * interactive path (15s per HTTP call).
139
192
  *
140
193
  * @param {{env: NodeJS.ProcessEnv, root: string, fetchImpl?: typeof fetch, now?: number,
141
- * staleToken?: string|null, lockWaitMs?: number}} opts
194
+ * staleToken?: string|null, lockWaitMs?: number, deadline?: number|null}} opts
142
195
  * @returns {Promise<{ok: true, token: string, rotated: boolean, oauth: object}
143
196
  * | {ok: false, code: string, message: string}>}
144
197
  */
145
- export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null, lockWaitMs }) {
198
+ export async function refreshCredentials({
199
+ env,
200
+ root,
201
+ fetchImpl = fetch,
202
+ now,
203
+ staleToken = null,
204
+ lockWaitMs,
205
+ deadline = null,
206
+ }) {
207
+ const lockCap = remainingMs(deadline);
208
+ const effectiveLockWait =
209
+ lockCap == null
210
+ ? lockWaitMs
211
+ : Math.min(typeof lockWaitMs === 'number' ? lockWaitMs : LOCK_WAIT_DEFAULT, lockCap);
212
+
146
213
  return withRefreshLock(
147
- async () => {
214
+ async ({ held }) => {
148
215
  // Re-read INSIDE the lock. While we queued, a sibling hook may have done
149
216
  // the whole refresh already — redeeming our (now-replaced) refresh token
150
217
  // would fail and, worse, would overwrite theirs.
@@ -169,18 +236,107 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
169
236
  };
170
237
  }
171
238
 
172
- const endpoints = await refreshEndpoints({ oauth, root, fetchImpl });
239
+ const startingRefresh = oauth.refresh_token;
240
+
241
+ // Unlocked callers must not stampede the token endpoint. Poll briefly
242
+ // for a peer that held the lock; if one lands, we are done. Poll time is
243
+ // taken from the SHARED deadline (not an extra lockWait on top).
244
+ if (!held) {
245
+ const rem = remainingMs(deadline);
246
+ const pollMs =
247
+ rem == null
248
+ ? Math.min(typeof lockWaitMs === 'number' ? lockWaitMs : 2_000, 1_500)
249
+ : Math.min(rem, 500);
250
+ const pollDeadline = Date.now() + pollMs;
251
+ while (Date.now() < pollDeadline) {
252
+ await sleepMs(50);
253
+ const peer = readPeerRefreshedCredentials({
254
+ env,
255
+ staleToken: staleToken ?? oauth.access_token,
256
+ now,
257
+ startingRefresh,
258
+ });
259
+ if (peer) return peer;
260
+ }
261
+ }
262
+
263
+ // Re-read after the poll — a peer may have finished in the last tick.
264
+ const latest = readConfigFile(env);
265
+ const latestOauth = latest.oauth && typeof latest.oauth === 'object' ? latest.oauth : oauth;
266
+ if (
267
+ latestOauth?.access_token &&
268
+ latestOauth.access_token !== (staleToken ?? oauth.access_token) &&
269
+ !isExpiring(latestOauth, REFRESH_SKEW_MS, now)
270
+ ) {
271
+ return { ok: true, token: latestOauth.access_token, rotated: false, oauth: latestOauth };
272
+ }
273
+ if (!latestOauth?.refresh_token) {
274
+ return {
275
+ ok: false,
276
+ code: 'no_refresh_token',
277
+ message: `anyslate: the stored OAuth session has no refresh token and its access token has expired. ${RELOGIN_HINT}`,
278
+ };
279
+ }
280
+
281
+ const httpTimeout = remainingMs(deadline) ?? 15_000;
282
+ if (httpTimeout <= 0) {
283
+ const peer = readPeerRefreshedCredentials({
284
+ env,
285
+ staleToken: staleToken ?? oauth.access_token,
286
+ now,
287
+ startingRefresh,
288
+ });
289
+ if (peer) return peer;
290
+ return {
291
+ ok: false,
292
+ code: 'refresh_timeout',
293
+ message: `anyslate: timed out before the OAuth refresh could run. ${RELOGIN_HINT}`,
294
+ };
295
+ }
296
+
297
+ const endpoints = await refreshEndpoints({
298
+ oauth: latestOauth,
299
+ root,
300
+ fetchImpl,
301
+ timeoutMs: httpTimeout,
302
+ });
173
303
  if (!endpoints.ok) return endpoints;
174
304
 
305
+ const refreshTimeout = remainingMs(deadline) ?? httpTimeout;
306
+ if (refreshTimeout <= 0) {
307
+ const peer = readPeerRefreshedCredentials({
308
+ env,
309
+ staleToken: staleToken ?? oauth.access_token,
310
+ now,
311
+ startingRefresh,
312
+ });
313
+ if (peer) return peer;
314
+ return {
315
+ ok: false,
316
+ code: 'refresh_timeout',
317
+ message: `anyslate: timed out before the OAuth refresh could run. ${RELOGIN_HINT}`,
318
+ };
319
+ }
320
+
175
321
  const res = await refreshAccessToken({
176
322
  tokenEndpoint: endpoints.tokenEndpoint,
177
- refreshToken: oauth.refresh_token,
178
- clientId: oauth.client_id,
323
+ refreshToken: latestOauth.refresh_token,
324
+ clientId: latestOauth.client_id,
179
325
  resource: endpoints.resource,
180
326
  fetchImpl,
181
327
  now,
328
+ timeoutMs: refreshTimeout,
182
329
  });
183
330
  if (!res.ok) {
331
+ // Loser of a refresh race: peer already rotated and persisted. Prefer
332
+ // their tokens over "run anyslate login" + circuit-breaker pause.
333
+ const peer = readPeerRefreshedCredentials({
334
+ env,
335
+ staleToken: staleToken ?? oauth.access_token,
336
+ now,
337
+ startingRefresh,
338
+ });
339
+ if (peer) return peer;
184
340
  return {
185
341
  ok: false,
186
342
  code: res.code || 'refresh_failed',
@@ -192,10 +348,10 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
192
348
  // the instant the server answered; a crash between here and the next
193
349
  // write would lose the only usable credential.
194
350
  const next = {
195
- ...oauth,
351
+ ...latestOauth,
196
352
  access_token: res.tokens.access_token,
197
353
  // A response that omits refresh_token means "keep the one you have".
198
- refresh_token: res.tokens.refresh_token || oauth.refresh_token,
354
+ refresh_token: res.tokens.refresh_token || latestOauth.refresh_token,
199
355
  expires_at: res.tokens.expires_at,
200
356
  token_endpoint: endpoints.tokenEndpoint,
201
357
  resource: endpoints.resource,
@@ -212,7 +368,7 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
212
368
  }
213
369
  return { ok: true, token: next.access_token, rotated: true, oauth: next };
214
370
  },
215
- { env, waitMs: lockWaitMs },
371
+ { env, waitMs: effectiveLockWait },
216
372
  );
217
373
  }
218
374
 
@@ -227,11 +383,11 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
227
383
  * with `warning` set, so the caller can say so.
228
384
  *
229
385
  * @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean,
230
- * now?: number, lockWaitMs?: number}} opts
386
+ * now?: number, lockWaitMs?: number, deadline?: number|null}} opts
231
387
  * @returns {Promise<{ok: true, token: string, mode: 'env'|'oauth'|'static', refreshed: boolean, warning?: string}
232
388
  * | {ok: false, code: string, message: string, mode: string}>}
233
389
  */
234
- export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now, lockWaitMs }) {
390
+ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now, lockWaitMs, deadline = null }) {
235
391
  if (cfg?.sources?.mcpToken === 'env' && cfg.mcpToken) {
236
392
  return { ok: true, token: cfg.mcpToken, mode: 'env', refreshed: false };
237
393
  }
@@ -250,6 +406,7 @@ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false
250
406
  now,
251
407
  staleToken: force ? (oauth.access_token ?? null) : null,
252
408
  lockWaitMs,
409
+ deadline,
253
410
  });
254
411
  if (refreshed.ok) {
255
412
  return { ok: true, token: refreshed.token, mode: 'oauth', refreshed: refreshed.rotated };
@@ -303,6 +460,11 @@ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false
303
460
  export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs, retry, lockWaitMs }) {
304
461
  const policy = { ...DEFAULT_RETRY, ...(retry ?? {}) };
305
462
 
463
+ // When the caller names a timeout (hooks: 5s), treat it as a WALL budget for
464
+ // refresh + MCP call combined — not "15s refresh then 5s call". Interactive
465
+ // commands leave timeoutMs undefined and keep the unbounded refresh path.
466
+ const deadline = typeof timeoutMs === 'number' && timeoutMs > 0 ? Date.now() + timeoutMs : null;
467
+
306
468
  // Cheapest possible check first: an open breaker resolves no credential,
307
469
  // opens no socket, and mints no token. That is the entire point — the
308
470
  // incident cost ~50 requests a minute for days precisely because a client
@@ -325,7 +487,7 @@ export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, ti
325
487
  return { ok: false, status: 0, data: ceilingNotice(slot), raw: null, rateCapped: true, rateSlot: slot };
326
488
  }
327
489
 
328
- const first = await resolveBearer({ cfg, env, fetchImpl, lockWaitMs });
490
+ const first = await resolveBearer({ cfg, env, fetchImpl, lockWaitMs, deadline });
329
491
  if (!first.ok) {
330
492
  const breaker = recordCallFailure({ cfg, env, kind: 'auth', code: first.code, detail: first.code });
331
493
  return {
@@ -353,7 +515,27 @@ export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, ti
353
515
 
354
516
  while (attempt < hardCap) {
355
517
  attempt += 1;
356
- res = await callTool({ apiUrl: cfg.apiUrl, token, toolName, args, fetchImpl, timeoutMs });
518
+ const callTimeout = remainingMs(deadline) ?? timeoutMs;
519
+ if (deadline != null && (callTimeout == null || callTimeout <= 0)) {
520
+ res = {
521
+ ok: false,
522
+ status: 0,
523
+ data: `anyslate: timed out after ${timeoutMs}ms (spent on credential refresh).`,
524
+ raw: null,
525
+ networkError: true,
526
+ authMode: mode,
527
+ };
528
+ if (warning) res.authWarning = warning;
529
+ break;
530
+ }
531
+ res = await callTool({
532
+ apiUrl: cfg.apiUrl,
533
+ token,
534
+ toolName,
535
+ args,
536
+ fetchImpl,
537
+ timeoutMs: callTimeout,
538
+ });
357
539
  res.authMode = mode;
358
540
  if (warning) res.authWarning = warning;
359
541
  if (retried) res.authRetried = true;
@@ -368,7 +550,7 @@ export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, ti
368
550
  // per hook instead of one.
369
551
  if (kind === 'auth' && mode === 'oauth' && !refreshSpent) {
370
552
  refreshSpent = true;
371
- const second = await resolveBearer({ cfg, env, fetchImpl, force: true, lockWaitMs });
553
+ const second = await resolveBearer({ cfg, env, fetchImpl, force: true, lockWaitMs, deadline });
372
554
  if (!second.ok) {
373
555
  res.authError = true;
374
556
  res.authRefreshFailed = second.message;
@@ -39,19 +39,21 @@ import { makeIo } from '../io.mjs';
39
39
  const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
40
40
 
41
41
  /**
42
- * Total budget for the two requests a submission costs (initialize +
43
- * tools/call). Deliberately far below the 15s every other command gets: a hook
44
- * runs in front of a human, and a slow server must not become a slow editor.
45
- * `ANYSLATE_HOOK_TIMEOUT_MS` raises it for pathologically slow links.
42
+ * Total budget for credential refresh + the two requests a submission costs
43
+ * (initialize + tools/call). Deliberately far below the 15s every other
44
+ * command gets: a hook runs in front of a human, and a slow server must not
45
+ * become a slow editor. `ANYSLATE_HOOK_TIMEOUT_MS` raises it for pathologically
46
+ * slow links. Auth (lock wait / peer poll / refresh HTTP) shares this budget
47
+ * with the MCP call — it is not stacked on top.
46
48
  */
47
49
  export const HOOK_TIMEOUT_MS = 5_000;
48
50
 
49
51
  /**
50
- * How long a hook will queue behind another process's token refresh. The shared
51
- * default is 10s, which on a machine where every refresh is failing means every
52
- * hook waits the full ten seconds to learn nothing.
52
+ * How long a hook will queue behind another process's token refresh. Kept well
53
+ * below HOOK_TIMEOUT_MS so an unlocked loser still has time for a short peer
54
+ * poll + refresh HTTP + the activity_submit call inside the same wall budget.
53
55
  */
54
- export const HOOK_LOCK_WAIT_MS = 2_000;
56
+ export const HOOK_LOCK_WAIT_MS = 1_500;
55
57
 
56
58
  /** @param {NodeJS.ProcessEnv} env */
57
59
  function hookTimeoutMs(env) {