@anyslate/cli 0.2.0 → 0.3.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/README.md +199 -42
- package/package.json +1 -1
- package/src/auth.mjs +310 -0
- package/src/commands/checkpoint.mjs +12 -9
- package/src/commands/doctor.mjs +98 -5
- package/src/commands/hook.mjs +15 -8
- package/src/commands/login.mjs +323 -53
- package/src/commands/logout.mjs +131 -0
- package/src/commands/upload-artifact.mjs +13 -9
- package/src/config.mjs +54 -3
- package/src/credentials.mjs +162 -0
- package/src/index.mjs +30 -9
- package/src/oauth.mjs +633 -0
package/src/auth.mjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Bearer resolution and refresh for every command that talks to the server.
|
|
2
|
+
//
|
|
3
|
+
// THIS IS THE LOAD-BEARING PART OF THE OAUTH WORK. A static `as_mcp_` token
|
|
4
|
+
// never expires, so `loadConfig().mcpToken` was the whole auth story. An OAuth
|
|
5
|
+
// access token lives 3600 seconds, and hooks fire unattended in
|
|
6
|
+
// non-interactive shells — so a token WILL expire mid-session, in a process
|
|
7
|
+
// with no terminal, no browser, and a hard "must exit 0" contract.
|
|
8
|
+
//
|
|
9
|
+
// The rules that fall out of that:
|
|
10
|
+
//
|
|
11
|
+
// * Refresh PROACTIVELY, 5 minutes ahead of expiry, before the call. Waiting
|
|
12
|
+
// for the 401 spends a round trip and, on the hook path, risks the 15s
|
|
13
|
+
// budget.
|
|
14
|
+
// * On a 401, refresh EXACTLY ONCE and retry EXACTLY ONCE. A loop here is a
|
|
15
|
+
// self-inflicted rate limit against an endpoint that is already refusing us.
|
|
16
|
+
// * Persist the rotated refresh token immediately (rotation is on server-side).
|
|
17
|
+
// * NEVER open a browser. This module is imported by `hook`; a browser launch
|
|
18
|
+
// from a lifecycle hook would spray tabs across the user's screen and hang
|
|
19
|
+
// a process that must not hang.
|
|
20
|
+
// * Never throw. Callers are fail-open paths; a rejected promise here would
|
|
21
|
+
// become an uncaught exception in a hook and a non-zero exit.
|
|
22
|
+
|
|
23
|
+
import { readConfigFile, updateConfigFile, withRefreshLock } from './credentials.mjs';
|
|
24
|
+
import { callTool } from './mcp-client.mjs';
|
|
25
|
+
import { discover, refreshAccessToken, REFRESH_SKEW_MS } from './oauth.mjs';
|
|
26
|
+
|
|
27
|
+
export { REFRESH_SKEW_MS };
|
|
28
|
+
|
|
29
|
+
/** Everything a hook/checkpoint/upload path is allowed to say about auth. */
|
|
30
|
+
export const RELOGIN_HINT = 'Run `anyslate login` to sign in again.';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {unknown} expiresAt ISO 8601
|
|
34
|
+
* @returns {number|null} epoch ms, or null when unparseable
|
|
35
|
+
*/
|
|
36
|
+
export function expiryMs(expiresAt) {
|
|
37
|
+
if (typeof expiresAt !== 'string' || !expiresAt) return null;
|
|
38
|
+
const t = Date.parse(expiresAt);
|
|
39
|
+
return Number.isFinite(t) ? t : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Is this credential inside the refresh window (or already dead)?
|
|
44
|
+
*
|
|
45
|
+
* An UNPARSEABLE or ABSENT expiry counts as expiring. Treating "I do not know"
|
|
46
|
+
* as "still valid" is how a config written by a future version, or hand-edited,
|
|
47
|
+
* turns into a silent 401 loop.
|
|
48
|
+
*
|
|
49
|
+
* @param {{expires_at?: string}|null|undefined} oauth
|
|
50
|
+
* @param {number} [skewMs]
|
|
51
|
+
* @param {number} [now]
|
|
52
|
+
*/
|
|
53
|
+
export function isExpiring(oauth, skewMs = REFRESH_SKEW_MS, now = Date.now()) {
|
|
54
|
+
if (!oauth?.access_token) return true;
|
|
55
|
+
const t = expiryMs(oauth.expires_at);
|
|
56
|
+
if (t == null) return true;
|
|
57
|
+
return t - now <= skewMs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Hard expiry, no skew — used for reporting, not for refresh decisions. */
|
|
61
|
+
export function isExpired(oauth, now = Date.now()) {
|
|
62
|
+
return isExpiring(oauth, 0, now);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Minutes until expiry, for human output. Negative means already expired. */
|
|
66
|
+
export function minutesUntilExpiry(oauth, now = Date.now()) {
|
|
67
|
+
const t = expiryMs(oauth?.expires_at);
|
|
68
|
+
if (t == null) return null;
|
|
69
|
+
return Math.round((t - now) / 60_000);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Does this config hold OAuth credentials worth using or refreshing? */
|
|
73
|
+
export function hasOauthCredentials(oauth) {
|
|
74
|
+
return !!(oauth && (oauth.access_token || oauth.refresh_token));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the endpoints used for a refresh.
|
|
79
|
+
*
|
|
80
|
+
* `login` caches `token_endpoint`/`resource`/`root` alongside the tokens, so
|
|
81
|
+
* the common refresh costs ONE request rather than three. The cache is only
|
|
82
|
+
* trusted when it was recorded for the root we are actually calling — a
|
|
83
|
+
* `--api-url` switch from prod to dev must re-discover, because the two issue
|
|
84
|
+
* different client ids and different `resource` values.
|
|
85
|
+
*
|
|
86
|
+
* @returns {Promise<{ok: true, tokenEndpoint: string, resource: string}
|
|
87
|
+
* | {ok: false, code: string, message: string}>}
|
|
88
|
+
*/
|
|
89
|
+
async function refreshEndpoints({ oauth, root, fetchImpl }) {
|
|
90
|
+
if (oauth?.root === root && oauth?.token_endpoint && oauth?.resource) {
|
|
91
|
+
return { ok: true, tokenEndpoint: oauth.token_endpoint, resource: oauth.resource };
|
|
92
|
+
}
|
|
93
|
+
const found = await discover({ root, fetchImpl });
|
|
94
|
+
if (!found.ok) return found;
|
|
95
|
+
return { ok: true, tokenEndpoint: found.tokenEndpoint, resource: found.resource };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Perform one refresh, under the lock, persisting the rotated token.
|
|
100
|
+
*
|
|
101
|
+
* `staleToken` is the access token that just failed. It exists because expiry
|
|
102
|
+
* alone cannot decide whether a refresh is still needed: a token the server
|
|
103
|
+
* rejected with a 401 may have an `expires_at` an hour in the future (revoked
|
|
104
|
+
* early, clock skew, a config restored from a backup). Without it, the
|
|
105
|
+
* "somebody else already refreshed" short-circuit hands the caller back the
|
|
106
|
+
* very token that just 401'd, and the retry fails identically.
|
|
107
|
+
*
|
|
108
|
+
* @param {{env: NodeJS.ProcessEnv, root: string, fetchImpl?: typeof fetch, now?: number,
|
|
109
|
+
* staleToken?: string|null}} opts
|
|
110
|
+
* @returns {Promise<{ok: true, token: string, rotated: boolean, oauth: object}
|
|
111
|
+
* | {ok: false, code: string, message: string}>}
|
|
112
|
+
*/
|
|
113
|
+
export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null }) {
|
|
114
|
+
return withRefreshLock(
|
|
115
|
+
async () => {
|
|
116
|
+
// Re-read INSIDE the lock. While we queued, a sibling hook may have done
|
|
117
|
+
// the whole refresh already — redeeming our (now-replaced) refresh token
|
|
118
|
+
// would fail and, worse, would overwrite theirs.
|
|
119
|
+
const disk = readConfigFile(env);
|
|
120
|
+
const oauth = disk.oauth && typeof disk.oauth === 'object' ? disk.oauth : null;
|
|
121
|
+
|
|
122
|
+
if (!hasOauthCredentials(oauth)) {
|
|
123
|
+
return { ok: false, code: 'no_oauth_credentials', message: 'anyslate: no OAuth credentials to refresh.' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Skip only when the on-disk token is BOTH unexpired AND different from
|
|
127
|
+
// the one we are replacing — i.e. genuinely somebody else's fresh token.
|
|
128
|
+
const supersededByPeer = staleToken ? oauth.access_token && oauth.access_token !== staleToken : true;
|
|
129
|
+
if (supersededByPeer && !isExpiring(oauth, REFRESH_SKEW_MS, now)) {
|
|
130
|
+
return { ok: true, token: oauth.access_token, rotated: false, oauth };
|
|
131
|
+
}
|
|
132
|
+
if (!oauth.refresh_token) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
code: 'no_refresh_token',
|
|
136
|
+
message: `anyslate: the stored OAuth session has no refresh token and its access token has expired. ${RELOGIN_HINT}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const endpoints = await refreshEndpoints({ oauth, root, fetchImpl });
|
|
141
|
+
if (!endpoints.ok) return endpoints;
|
|
142
|
+
|
|
143
|
+
const res = await refreshAccessToken({
|
|
144
|
+
tokenEndpoint: endpoints.tokenEndpoint,
|
|
145
|
+
refreshToken: oauth.refresh_token,
|
|
146
|
+
clientId: oauth.client_id,
|
|
147
|
+
resource: endpoints.resource,
|
|
148
|
+
fetchImpl,
|
|
149
|
+
now,
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
code: res.code || 'refresh_failed',
|
|
155
|
+
message: `${res.message} ${RELOGIN_HINT}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Persist BEFORE returning. Rotation means the old refresh token is dead
|
|
160
|
+
// the instant the server answered; a crash between here and the next
|
|
161
|
+
// write would lose the only usable credential.
|
|
162
|
+
const next = {
|
|
163
|
+
...oauth,
|
|
164
|
+
access_token: res.tokens.access_token,
|
|
165
|
+
// A response that omits refresh_token means "keep the one you have".
|
|
166
|
+
refresh_token: res.tokens.refresh_token || oauth.refresh_token,
|
|
167
|
+
expires_at: res.tokens.expires_at,
|
|
168
|
+
token_endpoint: endpoints.tokenEndpoint,
|
|
169
|
+
resource: endpoints.resource,
|
|
170
|
+
root,
|
|
171
|
+
};
|
|
172
|
+
try {
|
|
173
|
+
updateConfigFile((current) => ({ ...current, oauth: { ...(current.oauth ?? {}), ...next } }), env);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
code: 'persist_failed',
|
|
178
|
+
message: `anyslate: refreshed the OAuth session but could not write the config (${e?.message ?? e}).`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, token: next.access_token, rotated: true, oauth: next };
|
|
182
|
+
},
|
|
183
|
+
{ env },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The bearer for the next call, refreshing if needed.
|
|
189
|
+
*
|
|
190
|
+
* Precedence mirrors loadConfig: ANYSLATE_MCP_TOKEN → OAuth → static
|
|
191
|
+
* `mcp_token` → nothing. The one deliberate divergence: loadConfig cannot
|
|
192
|
+
* refresh (it is synchronous), so an EXPIRED OAuth credential falls through
|
|
193
|
+
* there while here it is refreshed and preferred. If the refresh fails and a
|
|
194
|
+
* static token also exists, we fall back to it rather than failing the call —
|
|
195
|
+
* with `warning` set, so the caller can say so.
|
|
196
|
+
*
|
|
197
|
+
* @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean, now?: number}} opts
|
|
198
|
+
* @returns {Promise<{ok: true, token: string, mode: 'env'|'oauth'|'static', refreshed: boolean, warning?: string}
|
|
199
|
+
* | {ok: false, code: string, message: string, mode: string}>}
|
|
200
|
+
*/
|
|
201
|
+
export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now }) {
|
|
202
|
+
if (cfg?.sources?.mcpToken === 'env' && cfg.mcpToken) {
|
|
203
|
+
return { ok: true, token: cfg.mcpToken, mode: 'env', refreshed: false };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const oauth = cfg?.oauth ?? null;
|
|
207
|
+
if (hasOauthCredentials(oauth)) {
|
|
208
|
+
if (!force && !isExpiring(oauth, REFRESH_SKEW_MS, now)) {
|
|
209
|
+
return { ok: true, token: oauth.access_token, mode: 'oauth', refreshed: false };
|
|
210
|
+
}
|
|
211
|
+
// `force` means "the token we just used was rejected" — name it, so the
|
|
212
|
+
// lock's peer-refresh check cannot hand the same rejected token back.
|
|
213
|
+
const refreshed = await refreshCredentials({
|
|
214
|
+
env,
|
|
215
|
+
root: cfg.apiUrl,
|
|
216
|
+
fetchImpl,
|
|
217
|
+
now,
|
|
218
|
+
staleToken: force ? (oauth.access_token ?? null) : null,
|
|
219
|
+
});
|
|
220
|
+
if (refreshed.ok) {
|
|
221
|
+
return { ok: true, token: refreshed.token, mode: 'oauth', refreshed: refreshed.rotated };
|
|
222
|
+
}
|
|
223
|
+
if (cfg.staticToken) {
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
token: cfg.staticToken,
|
|
227
|
+
mode: 'static',
|
|
228
|
+
refreshed: false,
|
|
229
|
+
warning: `anyslate: OAuth refresh failed (${refreshed.code}) — falling back to the static token in your config.`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return { ...refreshed, mode: 'oauth' };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (cfg?.mcpToken) {
|
|
236
|
+
return { ok: true, token: cfg.mcpToken, mode: 'static', refreshed: false };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
mode: 'none',
|
|
242
|
+
code: 'no_credentials',
|
|
243
|
+
message:
|
|
244
|
+
'no credentials configured. Run `anyslate login` to sign in with your browser, or `anyslate login --token <BEARER>` for CI. Mint a token in the AnySlate desktop app at Avatar (top-right) → API Tokens.',
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `callTool` with the auth lifecycle wrapped around it: proactive refresh, then
|
|
250
|
+
* at most one refresh-and-retry on a 401.
|
|
251
|
+
*
|
|
252
|
+
* The retry is gated on `mode === 'oauth'`: a static token that 401s is revoked
|
|
253
|
+
* or mistyped, and re-sending it cannot help.
|
|
254
|
+
*
|
|
255
|
+
* @param {{cfg: object, env: NodeJS.ProcessEnv, toolName: string, args: object,
|
|
256
|
+
* fetchImpl?: typeof fetch, timeoutMs?: number}} opts
|
|
257
|
+
* @returns {Promise<object>} the callTool result, plus `authMode` / `authWarning`,
|
|
258
|
+
* or an auth failure shaped like a callTool failure (`authError: true`).
|
|
259
|
+
*/
|
|
260
|
+
export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs }) {
|
|
261
|
+
const first = await resolveBearer({ cfg, env, fetchImpl });
|
|
262
|
+
if (!first.ok) {
|
|
263
|
+
return { ok: false, status: 0, data: first.message, raw: null, authError: true, authMode: first.mode };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const invoke = (token) =>
|
|
267
|
+
callTool({ apiUrl: cfg.apiUrl, token, toolName, args, fetchImpl, timeoutMs });
|
|
268
|
+
|
|
269
|
+
let res = await invoke(first.token);
|
|
270
|
+
res.authMode = first.mode;
|
|
271
|
+
if (first.warning) res.authWarning = first.warning;
|
|
272
|
+
|
|
273
|
+
const retryable = !res.ok && res.status === 401 && first.mode === 'oauth' && !first.refreshed;
|
|
274
|
+
if (!retryable) return res;
|
|
275
|
+
|
|
276
|
+
const second = await resolveBearer({ cfg, env, fetchImpl, force: true });
|
|
277
|
+
if (!second.ok) {
|
|
278
|
+
res.authError = true;
|
|
279
|
+
res.authRefreshFailed = second.message;
|
|
280
|
+
return res;
|
|
281
|
+
}
|
|
282
|
+
const retried = await invoke(second.token);
|
|
283
|
+
retried.authMode = second.mode;
|
|
284
|
+
retried.authRetried = true;
|
|
285
|
+
if (second.warning) retried.authWarning = second.warning;
|
|
286
|
+
return retried;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The failure line for a result that failed on AUTH rather than on transport.
|
|
291
|
+
*
|
|
292
|
+
* `formatCallFailure` renders `server ${status} — ${data}`, which for an auth
|
|
293
|
+
* failure that never reached the server would print `server 0` — an HTTP status
|
|
294
|
+
* that did not happen. Returns null when this is an ordinary failure and the
|
|
295
|
+
* existing formatter should be used instead.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} prefix
|
|
298
|
+
* @param {object} res
|
|
299
|
+
* @returns {string|null}
|
|
300
|
+
*/
|
|
301
|
+
export function formatAuthFailure(prefix, res) {
|
|
302
|
+
if (!res?.authError) return null;
|
|
303
|
+
if (res.authRefreshFailed) {
|
|
304
|
+
return `${prefix}: server ${res.status} and the OAuth refresh failed — ${res.authRefreshFailed}\n`;
|
|
305
|
+
}
|
|
306
|
+
if (res.status === 0 && !res.networkError) {
|
|
307
|
+
return `${prefix}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}\n`;
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// stdout carries results only. Every error goes to stderr.
|
|
13
13
|
|
|
14
14
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
15
|
-
import {
|
|
15
|
+
import { formatCallFailure } from '../mcp-client.mjs';
|
|
16
|
+
import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
|
|
16
17
|
import { recordRun } from '../runlog.mjs';
|
|
17
18
|
import { VERSION } from '../version.mjs';
|
|
18
19
|
import { makeIo } from '../io.mjs';
|
|
@@ -51,10 +52,11 @@ export async function runCheckpoint(argv, deps = {}) {
|
|
|
51
52
|
const notice = apiUrlNormalizationNotice(cfg);
|
|
52
53
|
if (notice) err.write(notice);
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// Presence only — an expired OAuth token is refreshed by callToolWithAuth.
|
|
56
|
+
if (cfg.authMode === 'none') {
|
|
57
|
+
const error = requireToken({ mcpToken: null }).error;
|
|
58
|
+
err.write(`anyslate checkpoint: ${error}\n`);
|
|
59
|
+
recordRun({ command: 'checkpoint', ok: false, apiUrl: cfg.apiUrl, error, version: VERSION, exitCode: 1 }, env);
|
|
58
60
|
return 1;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -74,15 +76,16 @@ export async function runCheckpoint(argv, deps = {}) {
|
|
|
74
76
|
if (flags.session) args.session_id_hint = flags.session;
|
|
75
77
|
|
|
76
78
|
try {
|
|
77
|
-
const res = await
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
const res = await callToolWithAuth({
|
|
80
|
+
cfg,
|
|
81
|
+
env,
|
|
80
82
|
toolName: 'activity_submit',
|
|
81
83
|
args,
|
|
82
84
|
fetchImpl: deps.fetchImpl,
|
|
83
85
|
});
|
|
86
|
+
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
84
87
|
if (!res.ok) {
|
|
85
|
-
const message = formatCallFailure('anyslate checkpoint', res);
|
|
88
|
+
const message = formatAuthFailure('anyslate checkpoint', res) ?? formatCallFailure('anyslate checkpoint', res);
|
|
86
89
|
err.write(message);
|
|
87
90
|
recordRun(
|
|
88
91
|
{
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
requireToken,
|
|
27
27
|
} from '../config.mjs';
|
|
28
28
|
import { checkUrlShape, hasWriteScope, isValidTokenFormat, probeVerify, tokenPreview } from '../verify.mjs';
|
|
29
|
+
import { hasOauthCredentials, isExpired, minutesUntilExpiry, resolveBearer } from '../auth.mjs';
|
|
29
30
|
import { lastRunPath, readLastRun } from '../runlog.mjs';
|
|
30
31
|
import { callTool } from '../mcp-client.mjs';
|
|
31
32
|
import { VERSION, USER_AGENT } from '../version.mjs';
|
|
@@ -128,9 +129,21 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
// ---- 2. Token present --------------------------------------------------
|
|
132
|
+
// "Present" now has three shapes: an env/static token, a live OAuth access
|
|
133
|
+
// token, or OAuth credentials whose access token has aged out but which carry
|
|
134
|
+
// a refresh token. The third is NOT a failure — the refresh happens further
|
|
135
|
+
// down, once the URL and reachability checks have cleared.
|
|
136
|
+
const oauthPresent = hasOauthCredentials(cfg.oauth);
|
|
131
137
|
const tokenCheck = requireToken(cfg);
|
|
132
|
-
if (!tokenCheck.ok) {
|
|
138
|
+
if (!tokenCheck.ok && !oauthPresent) {
|
|
133
139
|
add(FAIL, 'token-present', 'No MCP token configured.', tokenCheck.error);
|
|
140
|
+
} else if (!tokenCheck.ok) {
|
|
141
|
+
add(
|
|
142
|
+
WARN,
|
|
143
|
+
'token-present',
|
|
144
|
+
'OAuth access token has expired; a refresh is needed before the next call.',
|
|
145
|
+
'This is normal — access tokens live one hour. The refresh is exercised by the oauth-refresh check below.',
|
|
146
|
+
);
|
|
134
147
|
} else {
|
|
135
148
|
add(
|
|
136
149
|
PASS,
|
|
@@ -139,6 +152,44 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
139
152
|
);
|
|
140
153
|
}
|
|
141
154
|
|
|
155
|
+
// ---- 2a. Auth mode -----------------------------------------------------
|
|
156
|
+
// Pure config inspection, no network. Which credential the CLI would use is
|
|
157
|
+
// the first thing anyone debugging "capture stopped" needs to know, and it
|
|
158
|
+
// must be answerable even when the host is down.
|
|
159
|
+
if (cfg.sources.mcpToken === 'env' && cfg.mcpToken) {
|
|
160
|
+
add(PASS, 'auth-mode', 'Static token from ANYSLATE_MCP_TOKEN (env) — no refresh, never expires.');
|
|
161
|
+
if (oauthPresent) {
|
|
162
|
+
add(
|
|
163
|
+
WARN,
|
|
164
|
+
'auth-override',
|
|
165
|
+
'ANYSLATE_MCP_TOKEN is overriding a stored OAuth session.',
|
|
166
|
+
'Unset ANYSLATE_MCP_TOKEN to go back to the browser-issued credential.',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
} else if (oauthPresent) {
|
|
170
|
+
const mins = minutesUntilExpiry(cfg.oauth);
|
|
171
|
+
const when =
|
|
172
|
+
mins == null
|
|
173
|
+
? 'expiry unknown (no parseable expires_at)'
|
|
174
|
+
: mins >= 0
|
|
175
|
+
? `expires in ${mins} min (${cfg.oauth.expires_at})`
|
|
176
|
+
: `expired ${Math.abs(mins)} min ago (${cfg.oauth.expires_at})`;
|
|
177
|
+
if (!cfg.oauth.refresh_token) {
|
|
178
|
+
add(
|
|
179
|
+
WARN,
|
|
180
|
+
'auth-mode',
|
|
181
|
+
`OAuth (browser sign-in), ${when}, no refresh token stored.`,
|
|
182
|
+
'Without a refresh token the session dies at expiry. Run `anyslate login` to get one.',
|
|
183
|
+
);
|
|
184
|
+
} else {
|
|
185
|
+
add(PASS, 'auth-mode', `OAuth (browser sign-in), ${when}, refresh token stored.`);
|
|
186
|
+
}
|
|
187
|
+
} else if (cfg.staticToken) {
|
|
188
|
+
add(PASS, 'auth-mode', `Static MCP token from ${cfg.sources.mcpToken} — no refresh, never expires.`);
|
|
189
|
+
} else {
|
|
190
|
+
add(SKIP, 'auth-mode', 'No credentials to classify.');
|
|
191
|
+
}
|
|
192
|
+
|
|
142
193
|
// ---- 2b. Handle FORMAT — never gated behind a server round trip ---------
|
|
143
194
|
// This is the exact live failure that produced a 100% invisible capture
|
|
144
195
|
// loss: cli/README.md told users to export `ANYSLATE_HANDLE=h_xxx`, but real
|
|
@@ -171,6 +222,8 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
171
222
|
'Mint one in the desktop app at Avatar (top-right) → API Tokens → Create Token.',
|
|
172
223
|
);
|
|
173
224
|
}
|
|
225
|
+
} else if (oauthPresent) {
|
|
226
|
+
add(SKIP, 'token-format', 'OAuth access token is past its expiry; the format is checked after it is refreshed.');
|
|
174
227
|
} else {
|
|
175
228
|
add(SKIP, 'token-format', 'No token to check.');
|
|
176
229
|
}
|
|
@@ -224,10 +277,49 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
224
277
|
add(SKIP, 'reachability', 'URL shape failed; not probing.');
|
|
225
278
|
}
|
|
226
279
|
|
|
280
|
+
// ---- 5b. OAuth refresh -------------------------------------------------
|
|
281
|
+
// Deliberately AFTER url-shape and reachability. A refresh against an
|
|
282
|
+
// unreachable host fails for a reason that has nothing to do with the
|
|
283
|
+
// credential, and reporting that as "refresh is broken" sends the user
|
|
284
|
+
// hunting for the wrong bug.
|
|
285
|
+
let bearer = cfg.mcpToken;
|
|
286
|
+
if (shape.ok && reachable && oauthPresent && cfg.sources.mcpToken !== 'env') {
|
|
287
|
+
const wasStale = isExpired(cfg.oauth) || flags.refresh;
|
|
288
|
+
const resolved = await resolveBearer({ cfg, env, fetchImpl, force: flags.refresh });
|
|
289
|
+
if (resolved.ok) {
|
|
290
|
+
bearer = resolved.token;
|
|
291
|
+
if (resolved.refreshed) {
|
|
292
|
+
add(PASS, 'oauth-refresh', 'Refreshed the access token successfully; the rotated refresh token was persisted.');
|
|
293
|
+
} else if (wasStale) {
|
|
294
|
+
add(PASS, 'oauth-refresh', 'Access token was already renewed by another process; nothing to do.');
|
|
295
|
+
} else {
|
|
296
|
+
add(
|
|
297
|
+
PASS,
|
|
298
|
+
'oauth-refresh',
|
|
299
|
+
'Refresh token is stored and the access token is still valid — refresh not exercised.',
|
|
300
|
+
'Run `anyslate doctor --refresh` to force a real refresh round trip (it rotates the refresh token).',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
add(
|
|
305
|
+
FAIL,
|
|
306
|
+
'oauth-refresh',
|
|
307
|
+
`OAuth refresh failed: ${String(resolved.message).replace(/^anyslate: /, '')}`,
|
|
308
|
+
'Run `anyslate login` to sign in again. Refresh tokens live 30 days and are single-use (rotated on every refresh).',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
} else if (oauthPresent && cfg.sources.mcpToken === 'env') {
|
|
312
|
+
add(SKIP, 'oauth-refresh', 'ANYSLATE_MCP_TOKEN is in use; the stored OAuth session is not consulted.');
|
|
313
|
+
} else if (oauthPresent) {
|
|
314
|
+
add(SKIP, 'oauth-refresh', 'Skipped — the URL and reachability checks must pass before a refresh is meaningful.');
|
|
315
|
+
} else {
|
|
316
|
+
add(SKIP, 'oauth-refresh', 'Not an OAuth session.');
|
|
317
|
+
}
|
|
318
|
+
|
|
227
319
|
// ---- 6. Token valid ----------------------------------------------------
|
|
228
320
|
let verified = null;
|
|
229
|
-
if (shape.ok &&
|
|
230
|
-
verified = await probeVerify({ root, token:
|
|
321
|
+
if (shape.ok && reachable && bearer) {
|
|
322
|
+
verified = await probeVerify({ root, token: bearer, fetchImpl });
|
|
231
323
|
if (verified.ok) {
|
|
232
324
|
add(PASS, 'token-valid', `${verified.message.replace(/^anyslate: /, '')}`);
|
|
233
325
|
} else {
|
|
@@ -345,7 +437,7 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
345
437
|
if (verified?.ok) {
|
|
346
438
|
const res = await callTool({
|
|
347
439
|
apiUrl: root,
|
|
348
|
-
token:
|
|
440
|
+
token: bearer,
|
|
349
441
|
toolName: 'activity_submit',
|
|
350
442
|
args: {
|
|
351
443
|
source: 'api',
|
|
@@ -513,9 +605,10 @@ function indent(block) {
|
|
|
513
605
|
|
|
514
606
|
/** @param {string[]} argv */
|
|
515
607
|
function parseFlags(argv) {
|
|
516
|
-
const out = { deep: false };
|
|
608
|
+
const out = { deep: false, refresh: false };
|
|
517
609
|
for (const a of argv) {
|
|
518
610
|
if (a === '--deep') out.deep = true;
|
|
611
|
+
else if (a === '--refresh') out.refresh = true;
|
|
519
612
|
}
|
|
520
613
|
return out;
|
|
521
614
|
}
|
package/src/commands/hook.mjs
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
// nothing and exited 0.
|
|
20
20
|
|
|
21
21
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
22
|
-
import {
|
|
22
|
+
import { formatCallFailure } from '../mcp-client.mjs';
|
|
23
|
+
import { callToolWithAuth, formatAuthFailure } from '../auth.mjs';
|
|
23
24
|
import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
|
|
24
25
|
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
25
26
|
import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
|
|
@@ -65,9 +66,12 @@ export async function runHook(argv, deps = {}) {
|
|
|
65
66
|
const notice = apiUrlNormalizationNotice(cfg);
|
|
66
67
|
if (notice) err.write(notice);
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// Credential PRESENCE only. An expired OAuth access token is not "missing" —
|
|
70
|
+
// callToolWithAuth refreshes it below — so gating on `cfg.mcpToken` here (as
|
|
71
|
+
// this did before OAuth) would turn every hour-old session into a silent
|
|
72
|
+
// no-capture with a misleading "no token configured" in the run log.
|
|
73
|
+
if (cfg.authMode === 'none') {
|
|
74
|
+
return fail(`${prefix}: ${requireToken({ mcpToken: null }).error}\n`);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
let stdinRaw = '';
|
|
@@ -95,15 +99,18 @@ export async function runHook(argv, deps = {}) {
|
|
|
95
99
|
if (submission.sessionIdHint) args.session_id_hint = submission.sessionIdHint;
|
|
96
100
|
|
|
97
101
|
try {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
102
|
+
// Never opens a browser: this path runs unattended inside Claude Code.
|
|
103
|
+
// A refresh failure surfaces as an ordinary failure and still exits 0.
|
|
104
|
+
const res = await callToolWithAuth({
|
|
105
|
+
cfg,
|
|
106
|
+
env,
|
|
101
107
|
toolName: 'activity_submit',
|
|
102
108
|
args,
|
|
103
109
|
fetchImpl: deps.fetchImpl,
|
|
104
110
|
});
|
|
111
|
+
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
105
112
|
if (!res.ok) {
|
|
106
|
-
return fail(formatCallFailure(prefix, res), {
|
|
113
|
+
return fail(formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res), {
|
|
107
114
|
status: res.status,
|
|
108
115
|
isError: !!res.isError,
|
|
109
116
|
networkError: !!res.networkError,
|