@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 +1 -1
- package/src/auth.mjs +201 -19
- package/src/commands/hook.mjs +10 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyslate/cli",
|
|
3
|
-
"version": "0.4.
|
|
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
|
|
137
|
-
* the user's editor
|
|
138
|
-
*
|
|
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({
|
|
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
|
|
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:
|
|
178
|
-
clientId:
|
|
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
|
-
...
|
|
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 ||
|
|
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:
|
|
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
|
-
|
|
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;
|
package/src/commands/hook.mjs
CHANGED
|
@@ -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
|
|
43
|
-
* tools/call). Deliberately far below the 15s every other
|
|
44
|
-
* runs in front of a human, and a slow server must not
|
|
45
|
-
* `ANYSLATE_HOOK_TIMEOUT_MS` raises it for pathologically
|
|
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.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 =
|
|
56
|
+
export const HOOK_LOCK_WAIT_MS = 1_500;
|
|
55
57
|
|
|
56
58
|
/** @param {NodeJS.ProcessEnv} env */
|
|
57
59
|
function hookTimeoutMs(env) {
|