@cantinasecurity/apex-cli 0.1.16 → 0.1.18

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.
@@ -1,13 +1,60 @@
1
- import { clearCredentials, loadConfig, loadCredentials, saveCredentials } from "./config.js";
1
+ import { bindCredentialIssuerOriginIfCurrent, clearCredentials, clearCredentialsIfRefreshTokenMatches, loadConfig, loadCredentials, saveRefreshedCredentialsIfCurrent, } from "./config.js";
2
2
  import { getTelemetryRequestHeaders } from "./telemetry.js";
3
+ import { redactSensitiveText, redactSensitiveValue, } from "./redaction.js";
4
+ export { redactSensitiveText } from "./redaction.js";
5
+ const CREDENTIAL_GENERATION_CHANGED_MESSAGE = "Apex credentials changed while this request was refreshing. The newer credentials were kept; retry the command.";
6
+ const HOSTED_APEX_ORIGIN = "https://ai.cantina.xyz";
7
+ const CREDENTIAL_ORIGIN_MISMATCH_MESSAGE = "Refusing to send stored Apex credentials because their issuer origin does not match the configured APEX_BASE_URL. Restore the origin used to sign in, or run `apex logout` before signing in to this origin.";
8
+ export class CredentialGenerationChangedError extends Error {
9
+ constructor() {
10
+ super(CREDENTIAL_GENERATION_CHANGED_MESSAGE);
11
+ this.name = "CredentialGenerationChangedError";
12
+ }
13
+ }
14
+ export class CredentialOriginMismatchError extends Error {
15
+ constructor() {
16
+ super(CREDENTIAL_ORIGIN_MISMATCH_MESSAGE);
17
+ this.name = "CredentialOriginMismatchError";
18
+ }
19
+ }
20
+ function parseHttpOrigin(value) {
21
+ try {
22
+ const url = new URL(value);
23
+ return ["http:", "https:"].includes(url.protocol) ? url.origin : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function getApiErrorCode(error) {
30
+ if (!(error instanceof ApiError) || !error.body || typeof error.body !== "object") {
31
+ return null;
32
+ }
33
+ const code = error.body.code;
34
+ return typeof code === "string" && code.trim().length > 0 ? code.trim() : null;
35
+ }
36
+ const TERMINAL_GUEST_SESSION_CODES = new Set([
37
+ "GUEST_SESSION_EXPIRED",
38
+ "GUEST_SESSION_CLAIMED",
39
+ "GUEST_SESSION_REVOKED",
40
+ ]);
41
+ export function isTerminalGuestSessionError(error) {
42
+ const code = getApiErrorCode(error);
43
+ return code !== null && TERMINAL_GUEST_SESSION_CODES.has(code);
44
+ }
3
45
  export class ApiError extends Error {
4
46
  status;
5
47
  body;
48
+ code;
6
49
  constructor(message, status, body) {
7
- super(message);
50
+ super(redactSensitiveText(message));
8
51
  this.name = "ApiError";
9
52
  this.status = status;
10
- this.body = body;
53
+ this.body = redactSensitiveValue(body);
54
+ const code = this.body && typeof this.body === "object"
55
+ ? this.body.code
56
+ : null;
57
+ this.code = typeof code === "string" && code.trim().length > 0 ? code.trim() : null;
11
58
  }
12
59
  }
13
60
  function getApiErrorBodyMessage(body) {
@@ -17,11 +64,51 @@ function getApiErrorBodyMessage(body) {
17
64
  const error = body.error;
18
65
  return typeof error === "string" && error.trim().length > 0 ? error : null;
19
66
  }
67
+ function getFriendlyApiErrorMessage(error) {
68
+ switch (error.code) {
69
+ case "ACTIVE_GUEST_SESSION":
70
+ return "A different guest trial is already active on this machine. Re-run `apex redeem <code> --replace` to revoke it first.";
71
+ case "TRIAL_ALREADY_REDEEMED":
72
+ return "This trial code has already funded a guest scan on this machine. Run `apex claim` to keep an unclaimed result, or use your Cantina account.";
73
+ case "GUEST_SESSION_EXPIRED":
74
+ return "Your guest trial session has expired. Run `apex claim` on this machine to keep its results.";
75
+ case "GUEST_SESSION_CLAIMED":
76
+ return "This guest trial was already claimed. Run `apex login` to use the claimed account.";
77
+ case "GUEST_SESSION_REVOKED":
78
+ return "This guest trial was revoked and can no longer be claimed. Run `apex login` to continue with your Cantina account, or redeem a different trial code.";
79
+ case "GUEST_TRIAL_CREDITS_EXHAUSTED":
80
+ return "Your guest trial credits have been used. Run `apex claim` to keep the results and continue with your account.";
81
+ case "GUEST_SCAN_REFUND_PENDING":
82
+ return "Your guest scan failed and its retry credit is still being restored. Retry shortly.";
83
+ case "GUEST_MODE_UNSUPPORTED":
84
+ return "Guest trials support standard scans only. Run `apex claim` before starting another scan mode.";
85
+ case "GUEST_WORKSPACE_LIMIT":
86
+ return "This guest trial has reached its workspace limit. Run `apex claim` to continue with your account.";
87
+ case "GUEST_CLAIM_REQUIRED":
88
+ return "This action requires an account. Run `apex claim`, then retry it.";
89
+ case "CLAIM_EXPIRED":
90
+ return "The trial claim window has expired. Run `apex login` to continue with your Cantina account.";
91
+ case "CLAIM_INVALID":
92
+ return "This trial claim proof is invalid or already consumed. Local trial state was cleared; run `apex login` to use the claimed Cantina account, or contact Cantina support if the results are missing.";
93
+ case "CLAIM_PERSONAL_TENANT_EXISTS":
94
+ return "This Cantina account already owns a personal Apex tenant, so CLI claim linking is not available yet. Contact Cantina support before the trial claim deadline.";
95
+ case "EMAIL_UNVERIFIED":
96
+ return "Verify your email, then run `apex claim` again. Your guest trial remains recoverable on this machine.";
97
+ case "FINGERPRINT_MISMATCH":
98
+ return "Run `apex claim` on the same machine that redeemed and scanned with this trial.";
99
+ case "TRIAL_CAPACITY":
100
+ return "Apex trial capacity is temporarily full. Retry later; no trial credentials were saved.";
101
+ default:
102
+ return null;
103
+ }
104
+ }
20
105
  export function formatApiError(error) {
21
106
  if (error instanceof ApiError) {
22
- return getApiErrorBodyMessage(error.body) ?? error.message;
107
+ return redactSensitiveText(getFriendlyApiErrorMessage(error) ??
108
+ getApiErrorBodyMessage(error.body) ??
109
+ error.message);
23
110
  }
24
- return error instanceof Error ? error.message : String(error);
111
+ return redactSensitiveText(error instanceof Error ? error.message : String(error));
25
112
  }
26
113
  function expiresSoon(timestamp) {
27
114
  if (!timestamp)
@@ -46,24 +133,110 @@ export class ApexApiClient {
46
133
  async getCredentials() {
47
134
  return loadCredentials();
48
135
  }
49
- async getAccessToken() {
50
- const credentials = await this.refreshIfNeeded().catch(() => null);
51
- return credentials?.accessToken ?? null;
136
+ async ensureCredentialIssuerOrigin(credentials, baseUrl) {
137
+ if (!credentials ||
138
+ (!credentials.accessToken.trim() && !credentials.refreshToken.trim())) {
139
+ return;
140
+ }
141
+ const configuredOrigin = parseHttpOrigin(baseUrl);
142
+ if (!configuredOrigin) {
143
+ throw new CredentialOriginMismatchError();
144
+ }
145
+ const storedIssuer = credentials.issuerOrigin?.trim() ?? "";
146
+ if (storedIssuer) {
147
+ if (parseHttpOrigin(storedIssuer) !== configuredOrigin) {
148
+ throw new CredentialOriginMismatchError();
149
+ }
150
+ return;
151
+ }
152
+ // Credentials written before issuer binding shipped can be attributed only
153
+ // to the canonical hosted origin without trusting a newly supplied custom
154
+ // APEX_BASE_URL. Persist that one safe migration before any bearer leaves.
155
+ if (configuredOrigin !== HOSTED_APEX_ORIGIN) {
156
+ throw new CredentialOriginMismatchError();
157
+ }
158
+ const migrated = await bindCredentialIssuerOriginIfCurrent(credentials, HOSTED_APEX_ORIGIN);
159
+ if (!migrated.saved) {
160
+ throw new CredentialGenerationChangedError();
161
+ }
162
+ }
163
+ async getAccessCredentials(snapshot) {
164
+ const credentials = snapshot === undefined
165
+ ? await loadCredentials()
166
+ : snapshot;
167
+ try {
168
+ const baseUrl = await this.getBaseUrl();
169
+ return await this.refreshIfNeeded(credentials, baseUrl);
170
+ }
171
+ catch (error) {
172
+ // A generation change is an identity boundary, not an ordinary refresh
173
+ // failure that callers may silently replace with another auth flow.
174
+ if (error instanceof CredentialGenerationChangedError)
175
+ throw error;
176
+ if (error instanceof CredentialOriginMismatchError)
177
+ throw error;
178
+ if (isTerminalGuestSessionError(error)) {
179
+ await this.clearCredentialsForGeneration(credentials?.refreshToken ?? "", error);
180
+ throw error;
181
+ }
182
+ return null;
183
+ }
184
+ }
185
+ async getAccessToken(snapshot) {
186
+ return (await this.getAccessCredentials(snapshot))?.accessToken ?? null;
52
187
  }
53
188
  async clearCredentials() {
54
- await clearCredentials();
189
+ await clearCredentials({ preserveClaimProof: true });
55
190
  }
56
- async refreshIfNeeded() {
57
- const credentials = await loadCredentials();
191
+ /**
192
+ * Clears only the credential generation that received an unauthorized result.
193
+ * Typed terminal guest errors demote or remove the guest using the same policy
194
+ * as ordinary authenticated requests.
195
+ */
196
+ async clearCredentialsForGeneration(expectedRefreshToken, error) {
197
+ if (!expectedRefreshToken) {
198
+ return false;
199
+ }
200
+ const code = getApiErrorCode(error);
201
+ const guestSessionIsTerminal = isTerminalGuestSessionError(error);
202
+ const claimProofIsConsumed = code === "GUEST_SESSION_CLAIMED" ||
203
+ code === "GUEST_SESSION_REVOKED";
204
+ return clearCredentialsIfRefreshTokenMatches(expectedRefreshToken, {
205
+ preserveClaimProof: !claimProofIsConsumed,
206
+ preserveGuestSession: !guestSessionIsTerminal,
207
+ });
208
+ }
209
+ async clearCredentialsAfterUnauthorized(expectedRefreshToken, error) {
210
+ if (!expectedRefreshToken) {
211
+ return;
212
+ }
213
+ await this.clearCredentialsForGeneration(expectedRefreshToken, error);
214
+ }
215
+ async refreshIfNeeded(snapshot, requestBaseUrl) {
216
+ const credentials = snapshot === undefined
217
+ ? await loadCredentials()
218
+ : snapshot;
58
219
  if (!credentials)
59
220
  return null;
221
+ const baseUrl = requestBaseUrl ?? await this.getBaseUrl();
222
+ await this.ensureCredentialIssuerOrigin(credentials, baseUrl);
60
223
  if (!expiresSoon(credentials.accessTokenExpiresAt)) {
61
224
  return credentials;
62
225
  }
63
- return this.refreshSession(credentials.refreshToken);
226
+ return this.refreshSession(credentials.refreshToken, {
227
+ expectedAccessToken: credentials.accessToken,
228
+ requestBaseUrl: baseUrl,
229
+ });
64
230
  }
65
- async refreshSession(refreshToken) {
231
+ async refreshSession(refreshToken, options = {}) {
232
+ const baseUrl = options.requestBaseUrl ?? await this.getBaseUrl();
66
233
  const existing = await loadCredentials();
234
+ await this.ensureCredentialIssuerOrigin(existing, baseUrl);
235
+ if ((refreshToken !== undefined && existing?.refreshToken !== refreshToken) ||
236
+ (options.expectedAccessToken !== undefined &&
237
+ existing?.accessToken !== options.expectedAccessToken)) {
238
+ throw new CredentialGenerationChangedError();
239
+ }
67
240
  const effectiveRefreshToken = refreshToken ?? existing?.refreshToken ?? null;
68
241
  if (!effectiveRefreshToken)
69
242
  return null;
@@ -72,6 +245,8 @@ export class ApexApiClient {
72
245
  auth: false,
73
246
  json: { refreshToken: effectiveRefreshToken },
74
247
  retryOnUnauthorized: false,
248
+ telemetryAttribution: false,
249
+ requestBaseUrl: baseUrl,
75
250
  }));
76
251
  const nextCredentials = {
77
252
  accessToken: payload.accessToken,
@@ -79,55 +254,100 @@ export class ApexApiClient {
79
254
  accessTokenExpiresAt: payload.accessTokenExpiresAt,
80
255
  refreshTokenExpiresAt: payload.refreshTokenExpiresAt,
81
256
  };
82
- await saveCredentials(nextCredentials);
83
- return nextCredentials;
257
+ const persisted = await saveRefreshedCredentialsIfCurrent(effectiveRefreshToken, nextCredentials);
258
+ if (!persisted.saved) {
259
+ throw new CredentialGenerationChangedError();
260
+ }
261
+ return persisted.credentials;
84
262
  }
85
263
  async request(path, options = {}) {
86
- const baseUrl = await this.getBaseUrl();
264
+ const { auth, bearerToken, json, retryOnUnauthorized, telemetryAttribution, requestBaseUrl, ...requestInit } = options;
265
+ const baseUrl = requestBaseUrl ?? await this.getBaseUrl();
266
+ const requestUrl = new URL(path, baseUrl);
267
+ if (requestUrl.origin !== parseHttpOrigin(baseUrl)) {
268
+ throw new CredentialOriginMismatchError();
269
+ }
87
270
  const headers = new Headers(options.headers ?? {});
88
- const telemetryHeaders = await getTelemetryRequestHeaders();
89
- let credentials = options.auth === false ? null : await this.refreshIfNeeded().catch(() => null);
271
+ const telemetryHeaders = telemetryAttribution === false
272
+ ? {}
273
+ : await getTelemetryRequestHeaders();
274
+ const hasExplicitBearer = bearerToken !== undefined;
275
+ const usesStoredCredentials = auth !== false && !hasExplicitBearer;
276
+ let credentials = null;
277
+ if (usesStoredCredentials) {
278
+ const snapshot = await loadCredentials();
279
+ try {
280
+ credentials = await this.refreshIfNeeded(snapshot, baseUrl);
281
+ }
282
+ catch (error) {
283
+ if (!(error instanceof CredentialGenerationChangedError) &&
284
+ !(error instanceof CredentialOriginMismatchError)) {
285
+ await this.clearCredentialsAfterUnauthorized(snapshot?.refreshToken, error);
286
+ }
287
+ throw error;
288
+ }
289
+ }
90
290
  for (const [key, value] of Object.entries(telemetryHeaders)) {
91
291
  headers.set(key, value);
92
292
  }
93
- if (options.json !== undefined) {
293
+ if (json !== undefined) {
94
294
  headers.set("Content-Type", "application/json");
95
295
  }
96
- if (credentials?.accessToken) {
296
+ if (hasExplicitBearer && bearerToken) {
297
+ headers.set("Authorization", `Bearer ${bearerToken}`);
298
+ }
299
+ else if (credentials?.accessToken) {
97
300
  headers.set("Authorization", `Bearer ${credentials.accessToken}`);
98
301
  }
99
- const response = await fetch(new URL(path, baseUrl), {
100
- ...options,
302
+ const response = await fetch(requestUrl, {
303
+ ...requestInit,
101
304
  headers,
102
- body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
305
+ body: json !== undefined ? JSON.stringify(json) : requestInit.body,
103
306
  });
104
307
  if (response.status === 401 &&
105
- options.auth !== false &&
106
- options.retryOnUnauthorized !== false) {
107
- credentials = await this.refreshSession();
108
- if (credentials?.accessToken) {
308
+ usesStoredCredentials &&
309
+ retryOnUnauthorized !== false) {
310
+ const attemptedRefreshToken = credentials?.refreshToken ?? null;
311
+ if (attemptedRefreshToken) {
312
+ try {
313
+ credentials = await this.refreshSession(attemptedRefreshToken, {
314
+ expectedAccessToken: credentials?.accessToken,
315
+ requestBaseUrl: baseUrl,
316
+ });
317
+ }
318
+ catch (error) {
319
+ if (!(error instanceof CredentialGenerationChangedError) &&
320
+ !(error instanceof CredentialOriginMismatchError)) {
321
+ await this.clearCredentialsAfterUnauthorized(attemptedRefreshToken, error);
322
+ }
323
+ throw error;
324
+ }
325
+ }
326
+ if (attemptedRefreshToken && credentials?.accessToken) {
109
327
  headers.set("Authorization", `Bearer ${credentials.accessToken}`);
110
- const retryResponse = await fetch(new URL(path, baseUrl), {
111
- ...options,
328
+ const retryResponse = await fetch(requestUrl, {
329
+ ...requestInit,
112
330
  headers,
113
- body: options.json !== undefined ? JSON.stringify(options.json) : options.body,
331
+ body: json !== undefined ? JSON.stringify(json) : requestInit.body,
114
332
  });
115
333
  const retryBody = await parseResponse(retryResponse);
116
334
  if (!retryResponse.ok) {
335
+ const retryError = new ApiError(`Request failed with ${retryResponse.status}`, retryResponse.status, retryBody);
117
336
  if (retryResponse.status === 401) {
118
- await clearCredentials();
337
+ await this.clearCredentialsAfterUnauthorized(credentials.refreshToken, retryError);
119
338
  }
120
- throw new ApiError(`Request failed with ${retryResponse.status}`, retryResponse.status, retryBody);
339
+ throw retryError;
121
340
  }
122
341
  return retryBody;
123
342
  }
124
343
  }
125
344
  const body = await parseResponse(response);
126
345
  if (!response.ok) {
127
- if (response.status === 401) {
128
- await clearCredentials();
346
+ const requestError = new ApiError(`Request failed with ${response.status}`, response.status, body);
347
+ if (response.status === 401 && usesStoredCredentials) {
348
+ await this.clearCredentialsAfterUnauthorized(credentials?.refreshToken, requestError);
129
349
  }
130
- throw new ApiError(`Request failed with ${response.status}`, response.status, body);
350
+ throw requestError;
131
351
  }
132
352
  return body;
133
353
  }
package/dist/args.js CHANGED
@@ -1,4 +1,11 @@
1
- const BOOLEAN_FLAGS = new Set(["force", "non-interactive", "json", "no-open", "help"]);
1
+ const BOOLEAN_FLAGS = new Set([
2
+ "force",
3
+ "non-interactive",
4
+ "json",
5
+ "no-open",
6
+ "replace",
7
+ "help",
8
+ ]);
2
9
  const REPEATABLE_VALUE_FLAGS = new Set(["repo", "pr", "pr-path", "fix-pr-url", "label"]);
3
10
  export function parseArgs(argv) {
4
11
  const flags = {};
package/dist/auth.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { openInBrowser } from "./browser.js";
2
- import { saveCredentials } from "./config.js";
3
- import { ApiError } from "./api-client.js";
2
+ import { getClaimProofState } from "./claim-proof.js";
3
+ import { getCredentialGeneration, saveDeviceLoginCredentialsIfGenerationMatches, withCredentialLifecycleLock, } from "./config.js";
4
+ import { ApiError, formatApiError } from "./api-client.js";
4
5
  import { APEX_CLI_VERSION } from "./version.js";
5
6
  const DEVICE_CLIENT_NAME = "apex";
7
+ const DEVICE_LOGIN_PREFLIGHT_ATTEMPTS = 3;
6
8
  function sleep(ms) {
7
9
  return new Promise((resolve) => setTimeout(resolve, ms));
8
10
  }
@@ -19,44 +21,18 @@ function toCredentials(poll) {
19
21
  refreshTokenExpiresAt: poll.refreshTokenExpiresAt,
20
22
  };
21
23
  }
22
- export async function getMe(client) {
23
- try {
24
- return await client.request("/api/cli/v1/me");
25
- }
26
- catch (error) {
27
- if (error instanceof ApiError && error.status === 401) {
28
- return null;
29
- }
30
- throw error;
31
- }
32
- }
33
- export async function startDeviceLogin(client) {
34
- return client.request("/api/cli/v1/auth/device/start", {
35
- method: "POST",
36
- auth: false,
37
- json: {
38
- clientName: DEVICE_CLIENT_NAME,
39
- clientVersion: APEX_CLI_VERSION,
40
- },
41
- });
42
- }
43
- export async function pollDeviceLogin(client, deviceCode) {
44
- return client.request("/api/cli/v1/auth/device/poll", {
45
- method: "POST",
46
- auth: false,
47
- json: { deviceCode },
48
- retryOnUnauthorized: false,
49
- });
50
- }
51
- export async function waitForDeviceLoginApproval(client, options) {
24
+ async function waitForDeviceLoginCredentials(client, options) {
52
25
  let intervalSeconds = Math.max(0, options.intervalSeconds ?? 5);
53
26
  let expiresAt = options.expiresAt ?? null;
27
+ let pollingBaseUrl = options.requestBaseUrl ?? null;
54
28
  const timeoutAt = Date.now() + Math.max(0, options.timeoutMs ?? 5 * 60 * 1000);
55
29
  function remainingTimeoutMs() {
56
30
  return Math.max(0, timeoutAt - Date.now());
57
31
  }
58
32
  while (remainingTimeoutMs() > 0) {
59
- const expiresAtTime = expiresAt ? new Date(expiresAt).getTime() : Number.POSITIVE_INFINITY;
33
+ const expiresAtTime = expiresAt
34
+ ? new Date(expiresAt).getTime()
35
+ : Number.POSITIVE_INFINITY;
60
36
  if (Number.isFinite(expiresAtTime) && Date.now() >= expiresAtTime) {
61
37
  return {
62
38
  status: "expired",
@@ -71,16 +47,19 @@ export async function waitForDeviceLoginApproval(client, options) {
71
47
  break;
72
48
  }
73
49
  try {
74
- const polled = await pollDeviceLogin(client, options.deviceCode);
50
+ pollingBaseUrl ??= await client.getBaseUrl();
51
+ const polled = await pollDeviceLogin(client, options.deviceCode, pollingBaseUrl);
75
52
  if (polled.status === "pending") {
76
53
  intervalSeconds = Math.max(0, polled.intervalSeconds);
77
54
  expiresAt = polled.expiresAt;
78
55
  continue;
79
56
  }
80
- await saveCredentials(toCredentials(polled));
81
57
  return {
82
58
  status: "approved",
83
- me: await client.request("/api/cli/v1/me"),
59
+ credentials: {
60
+ ...toCredentials(polled),
61
+ issuerOrigin: new URL(pollingBaseUrl).origin,
62
+ },
84
63
  };
85
64
  }
86
65
  catch (error) {
@@ -99,12 +78,129 @@ export async function waitForDeviceLoginApproval(client, options) {
99
78
  expiresAt,
100
79
  };
101
80
  }
81
+ export async function getMe(client) {
82
+ try {
83
+ return await client.request("/api/cli/v1/me");
84
+ }
85
+ catch (error) {
86
+ if (error instanceof ApiError && error.status === 401) {
87
+ return null;
88
+ }
89
+ throw error;
90
+ }
91
+ }
92
+ export async function startDeviceLogin(client, requestBaseUrl) {
93
+ return client.request("/api/cli/v1/auth/device/start", {
94
+ method: "POST",
95
+ auth: false,
96
+ json: {
97
+ clientName: DEVICE_CLIENT_NAME,
98
+ clientVersion: APEX_CLI_VERSION,
99
+ },
100
+ ...(requestBaseUrl ? { requestBaseUrl } : {}),
101
+ });
102
+ }
103
+ export async function pollDeviceLogin(client, deviceCode, requestBaseUrl) {
104
+ return client.request("/api/cli/v1/auth/device/poll", {
105
+ method: "POST",
106
+ auth: false,
107
+ json: { deviceCode },
108
+ retryOnUnauthorized: false,
109
+ ...(requestBaseUrl ? { requestBaseUrl } : {}),
110
+ });
111
+ }
112
+ export async function waitForDeviceLoginApproval(client, options) {
113
+ const currentCredentials = await client.getCredentials();
114
+ if (getCredentialGeneration(currentCredentials) !== options.credentialGeneration) {
115
+ return {
116
+ status: "superseded",
117
+ approvalReceived: false,
118
+ reason: "generation_changed",
119
+ };
120
+ }
121
+ const result = await waitForDeviceLoginCredentials(client, options);
122
+ if (result.status === "approved") {
123
+ // A normal login may happen after guest API access expires but before the
124
+ // longer claim window closes. Switch the active auth kind without erasing
125
+ // the locally held claim proof needed by a later `apex claim`.
126
+ const commitResult = await saveDeviceLoginCredentialsIfGenerationMatches(options.credentialGeneration, {
127
+ ...result.credentials,
128
+ guest: false,
129
+ });
130
+ if (commitResult !== "saved") {
131
+ return {
132
+ status: "superseded",
133
+ approvalReceived: true,
134
+ reason: commitResult,
135
+ };
136
+ }
137
+ return {
138
+ status: "approved",
139
+ me: await client.request("/api/cli/v1/me"),
140
+ };
141
+ }
142
+ return result;
143
+ }
144
+ export async function readStableDeviceLoginState(client) {
145
+ for (let attempt = 0; attempt < DEVICE_LOGIN_PREFLIGHT_ATTEMPTS; attempt += 1) {
146
+ const before = await client.getCredentials();
147
+ const credentialGeneration = getCredentialGeneration(before);
148
+ const me = await getMe(client);
149
+ const after = await client.getCredentials();
150
+ if (getCredentialGeneration(after) === credentialGeneration) {
151
+ return {
152
+ me,
153
+ credentials: after,
154
+ credentialGeneration,
155
+ };
156
+ }
157
+ }
158
+ throw new Error("The Apex credential state kept changing while device login was starting. The current state was kept; retry `apex login`.");
159
+ }
160
+ export async function loginWithoutPersist(client, options = {}) {
161
+ const requestBaseUrl = options.requestBaseUrl ?? await client.getBaseUrl();
162
+ const started = await startDeviceLogin(client, requestBaseUrl);
163
+ await options.onVerification?.(started);
164
+ log(`Login code: ${started.userCode}`, options.quiet);
165
+ log(`Open: ${started.verificationUrl}`, options.quiet);
166
+ if (!options.noOpen) {
167
+ try {
168
+ await openInBrowser(started.verificationUrl);
169
+ }
170
+ catch (error) {
171
+ log(`Failed to open browser automatically: ${formatApiError(error)}`, options.quiet);
172
+ }
173
+ }
174
+ const result = await waitForDeviceLoginCredentials(client, {
175
+ deviceCode: started.deviceCode,
176
+ requestBaseUrl,
177
+ intervalSeconds: started.intervalSeconds,
178
+ expiresAt: started.expiresAt,
179
+ timeoutMs: Math.max(0, new Date(started.expiresAt).getTime() - Date.now()),
180
+ });
181
+ if (result.status === "approved") {
182
+ return result.credentials;
183
+ }
184
+ throw new Error("CLI login timed out before approval completed");
185
+ }
102
186
  export async function login(client, options) {
103
- const existing = await getMe(client);
187
+ const loginState = await readStableDeviceLoginState(client);
188
+ const existing = loginState.me;
104
189
  if (existing) {
105
- return existing;
190
+ const isGuest = existing.user.role?.trim().toLowerCase() === "guest";
191
+ if (!isGuest) {
192
+ return existing;
193
+ }
194
+ // An active guest normally stays in its recoverable trial flow. Once its
195
+ // locally known claim deadline has passed, explicit `apex login` is the
196
+ // supported escape hatch to an account. Starting device auth is
197
+ // non-destructive and approval preserves the local proof metadata.
198
+ if (getClaimProofState(loginState.credentials) !== "expired") {
199
+ return existing;
200
+ }
106
201
  }
107
- const started = await startDeviceLogin(client);
202
+ const requestBaseUrl = await client.getBaseUrl();
203
+ const started = await startDeviceLogin(client, requestBaseUrl);
108
204
  log(`Login code: ${started.userCode}`, options.quiet);
109
205
  log(`Open: ${started.verificationUrl}`, options.quiet);
110
206
  if (!options.noOpen) {
@@ -112,11 +208,13 @@ export async function login(client, options) {
112
208
  await openInBrowser(started.verificationUrl);
113
209
  }
114
210
  catch (error) {
115
- log(`Failed to open browser automatically: ${String(error)}`, options.quiet);
211
+ log(`Failed to open browser automatically: ${formatApiError(error)}`, options.quiet);
116
212
  }
117
213
  }
118
214
  const result = await waitForDeviceLoginApproval(client, {
119
215
  deviceCode: started.deviceCode,
216
+ credentialGeneration: loginState.credentialGeneration,
217
+ requestBaseUrl,
120
218
  intervalSeconds: started.intervalSeconds,
121
219
  expiresAt: started.expiresAt,
122
220
  timeoutMs: Math.max(0, new Date(started.expiresAt).getTime() - Date.now()),
@@ -124,20 +222,39 @@ export async function login(client, options) {
124
222
  if (result.status === "approved") {
125
223
  return result.me;
126
224
  }
225
+ if (result.status === "superseded") {
226
+ if (result.reason === "credential_operation_busy") {
227
+ throw new Error("Device login was approved, but another Apex credential operation was still in progress. Nothing was overwritten; retry the original command after it finishes.");
228
+ }
229
+ throw new Error(result.approvalReceived
230
+ ? "Device login was approved, but the Apex credential state changed before it could be saved. The current state was kept; retry the original command, or run `apex login` again only if authentication is still required."
231
+ : "The Apex credential state changed after device login started. The current state was kept; retry the original command, or run `apex login` again only if authentication is still required.");
232
+ }
127
233
  throw new Error("CLI login timed out before approval completed");
128
234
  }
129
- export async function logout(client) {
130
- const credentials = await client.getCredentials();
131
- if (!credentials) {
132
- return;
133
- }
134
- try {
135
- await client.request("/api/cli/v1/auth/logout", {
136
- method: "POST",
137
- auth: true,
138
- });
139
- }
140
- finally {
141
- await client.clearCredentials();
142
- }
235
+ export function logout(client, expectedCredentials) {
236
+ const expectedGeneration = getCredentialGeneration(expectedCredentials);
237
+ return withCredentialLifecycleLock(async () => {
238
+ const credentials = await client.getCredentials();
239
+ if (getCredentialGeneration(credentials) !== expectedGeneration) {
240
+ throw new Error("Apex credentials changed before logout completed. The current session was kept; retry `apex logout` so any guest trial can be confirmed explicitly.");
241
+ }
242
+ if (!credentials) {
243
+ return;
244
+ }
245
+ if (!credentials.accessToken.trim() && !credentials.refreshToken.trim()) {
246
+ // A proof-only onboarding stub is already logged out. Keep its claim proof
247
+ // locally and avoid an unauthenticated logout request that can only fail.
248
+ return;
249
+ }
250
+ try {
251
+ await client.request("/api/cli/v1/auth/logout", {
252
+ method: "POST",
253
+ auth: true,
254
+ });
255
+ }
256
+ finally {
257
+ await client.clearCredentials();
258
+ }
259
+ });
143
260
  }